diff --git a/docs/service-how-it-works.md b/docs/service-how-it-works.md index f826a94..653521b 100644 --- a/docs/service-how-it-works.md +++ b/docs/service-how-it-works.md @@ -21,13 +21,15 @@ ## 2. Загрузка файлов -Фронтенд начинает обработку в [`site/static/files.js`](../site/static/files.js): +Выбор файлов реализован в [`upload/frontend/index.js`](../upload/frontend/index.js) и модулях `table/` и `zip/`; собранный IIFE подключается на странице через `/file-picker/file-picker.iife.js`. Picker строит дерево, раскрывает ZIP, а при выборе папки применяет корневой `obscure.lst`. -- `onFilesSelected()` принимает выбранные файлы, обрабатывает ZIP и обычные документы, запускает подготовку загрузки и сохраняет связь `zip_source`. -- `uploadFile()` запускает загрузку через VM-буфер; фактические PUT и отправка ссылок выполняются функциями `uploadViaVM()` и `putToVm()` в [`upload/frontend/upload/upload_via_vm.js`](../upload/frontend/upload/upload_via_vm.js) и [`upload/frontend/upload/put_to_vm.js`](../upload/frontend/upload/put_to_vm.js). -- `finalizeUpload()` завершает загрузку и обновляет состояние. -- `renderFiles(state)` отображает список файлов, группируя вложения по `zip_source`. -- `syncDB()` синхронизирует состав файлов в БД. +Связка с приложением находится в [`site/static/app.js`](../site/static/app.js) и [`site/static/files.js`](../site/static/files.js): + +- `onPickerFilesChange()` синхронизирует leaf-файлы дерева с `state.files`, вычисляет `zip_source` как путь до последнего ZIP в цепочке и ставит новые файлы в последовательную очередь. +- `uploadPickerEntry()` вызывает `FilePicker.uploadViaVM()` для одного файла: PUT в VM-буфер, затем POST `/api/upload_refs` с `batch_id`, `contract_id` и `zip_source`. +- Результат sink (`doc_id`, `parsed` или duplicate/error) отображается в строке дерева; детали и распарсенный текст доступны из действий строки. +- `finalizeUpload()` ожидает завершения очереди и синхронизации, затем включает классификацию. +- `syncDB()` синхронизирует только текущий `batch_id`; перед удалением документа ждёт активную очередь загрузки. Из-за ограничения managed-шлюза большие файлы сначала передаются на ВМ, а затем бэкенд забирает их исходящим запросом. Общий переиспользуемый транспорт находится в [`upload/backend/upload_refs/blueprint.py`](../upload/backend/upload_refs/blueprint.py), включая обработчик `POST /api/upload_refs`; конкретная бизнес-логика передаётся через `sink`. diff --git a/site/db/connection.py b/site/db/connection.py index cae8207..0407fbd 100644 --- a/site/db/connection.py +++ b/site/db/connection.py @@ -34,17 +34,9 @@ def transaction(): def init_db(): - """Создать/пересоздать БД + схему. Вызывается при старте и после cleanup.""" + """Create the database schema without deleting persisted data.""" global _db_session_key - _db_session_key = time.time() - - # Удалить старый файл + WAL-сателлиты - for f in (DB_PATH, DB_PATH + "-wal", DB_PATH + "-shm"): - try: - os.remove(f) - except FileNotFoundError: - pass - + _db_session_key = time.time_ns() conn = get_conn() _create_schema(conn) return conn @@ -147,8 +139,20 @@ def _create_schema(conn): def cleanup_db(): - """Удалить БД полностью. Вызывает init_db() для создания новой.""" - init_db() + """Explicitly delete the database and recreate an empty schema.""" + global _db_session_key + conn = getattr(_local, "conn", None) + if conn is not None: + conn.close() + _local.conn = None + _local.session_key = None + _db_session_key = time.time_ns() + for path in (DB_PATH, DB_PATH + "-wal", DB_PATH + "-shm"): + try: + os.remove(path) + except FileNotFoundError: + pass + return init_db() def get_conn(): diff --git a/site/routes/api_bp.py b/site/routes/api_bp.py index 0323e16..c99a21a 100644 --- a/site/routes/api_bp.py +++ b/site/routes/api_bp.py @@ -1,7 +1,7 @@ """API blueprint — groups, documents, supplements, sync, cleanup, spec-current, chat.""" from flask import Blueprint, request, jsonify from db import documents, supplements, spec_current -from db.connection import execute, query +from db.connection import execute, query, transaction from services.grouping import group_documents, apply_groups from config import LLM_URL, LLM_KEY, LLM_MODEL import httpx @@ -38,47 +38,65 @@ def api_document(doc_id): @api_bp.route("/api/documents/", methods=["DELETE"]) def api_document_delete(doc_id): + if not documents.get(doc_id): + return jsonify(ok=False, error="not found"), 404 + with transaction(): + contract_ids = _delete_document(doc_id) + _delete_orphan_contracts(contract_ids) + return jsonify(ok=True) + + +def _delete_document(doc_id): supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (doc_id,)) - for s in (supps or []): + contract_ids = {row["contract_id"] for row in supps} + for supp in supps: execute( """DELETE FROM spec_current WHERE contract_id = %s AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""", - (s["contract_id"], s["id"]), + (supp["contract_id"], supp["id"]), ) - execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],)) - execute("DELETE FROM supplements WHERE id = %s", (s["id"],)) + execute("DELETE FROM spec_events WHERE supplement_id = %s", (supp["id"],)) + execute("DELETE FROM supplements WHERE id = %s", (supp["id"],)) execute("DELETE FROM documents WHERE id = %s", (doc_id,)) - return jsonify(ok=True) + return contract_ids + + +def _delete_orphan_contracts(contract_ids): + for contract_id in contract_ids: + remaining = query( + "SELECT id FROM supplements WHERE contract_id = %s LIMIT 1", + (contract_id,), + ) + if not remaining: + execute("DELETE FROM contracts WHERE id = %s", (contract_id,)) # ── Sync ───────────────────────────────────────────────────────── @api_bp.route("/api/sync", methods=["POST"]) def api_sync(): - """Удалить документы, не входящие в keep_ids.""" - body = request.get_json() - keep_ids = set(body.get("keep_ids", [])) + """Remove documents absent from keep_ids in the specified batch only.""" + body = request.get_json(silent=True) or {} + batch_id = body.get("batch_id") + keep_ids = body.get("keep_ids", []) + if not isinstance(batch_id, str) or not batch_id.strip(): + return jsonify(ok=False, error="batch_id required"), 400 + if not isinstance(keep_ids, list) or any(not isinstance(doc_id, str) for doc_id in keep_ids): + return jsonify(ok=False, error="keep_ids must be a list of document IDs"), 400 if len(keep_ids) > 1000: return jsonify(ok=False, error="too many keep_ids (max 1000)"), 400 - docs = query("SELECT id FROM documents", ()) + docs = query("SELECT id FROM documents WHERE batch_id = %s", (batch_id,)) deleted = 0 - for d in (docs or []): - if d["id"] in keep_ids: - continue - supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (d["id"],)) - for s in (supps or []): - execute( - """DELETE FROM spec_current WHERE contract_id = %s - AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""", - (s["contract_id"], s["id"]), - ) - execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],)) - execute("DELETE FROM supplements WHERE id = %s", (s["id"],)) - execute("DELETE FROM documents WHERE id = %s", (d["id"],)) - deleted += 1 - - execute("DELETE FROM contracts WHERE id NOT IN (SELECT DISTINCT contract_id FROM supplements)") + contract_ids = set() + keep_ids = set(keep_ids) + with transaction(): + for doc in docs: + if doc["id"] in keep_ids: + continue + contract_ids.update(_delete_document(doc["id"])) + deleted += 1 + _delete_orphan_contracts(contract_ids) return jsonify(ok=True, deleted=deleted) @@ -170,13 +188,3 @@ def chat(): return jsonify(OK=True, ANSWER=answer) except Exception as e: return jsonify(OK=False, ERROR=f"LLM error: {e}") - - -# ── Cleanup (атомарное удаление БД) ────────────────────────────── - -@api_bp.route("/api/cleanup", methods=["POST"]) -def api_cleanup(): - """Полная очистка: os.remove(DB) + init новой. Данные гарантированно стёрты.""" - from db.connection import cleanup_db - cleanup_db() - return jsonify(ok=True, message="all data cleaned") diff --git a/site/routes/pages_bp.py b/site/routes/pages_bp.py index 07f91d0..e0cc3bf 100644 --- a/site/routes/pages_bp.py +++ b/site/routes/pages_bp.py @@ -9,6 +9,10 @@ _UPLOAD_FRONTEND = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "upload", "frontend", ) +_FILE_PICKER_DIST = os.path.join( + os.path.dirname(os.path.dirname(_UPLOAD_FRONTEND)), + "site", "static", "dist", +) @pages_bp.route("/") @@ -25,3 +29,9 @@ def architect(): def upload_frontend(filename): """Отдать статику ES-модулей upload/frontend (для клиентского ZIP).""" return send_from_directory(_UPLOAD_FRONTEND, filename) + + +@pages_bp.route("/file-picker/") +def file_picker_bundle(filename): + """Отдать собранный picker bundle.""" + return send_from_directory(_FILE_PICKER_DIST, filename) diff --git a/site/static/app.js b/site/static/app.js index f392a48..ad5c7d8 100644 --- a/site/static/app.js +++ b/site/static/app.js @@ -1,45 +1,51 @@ // ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ -// Contracts App v2.0 — всё на Flask, ВМ больше нет +// Contracts App v2.0 — backend на Flask; ВМ используется только как upload buffer. var VM_API = ''; // ВМ-буфер загрузки (паттерн drhider): браузер кладёт файл сюда (WebDAV, мимо шлюза), // бэк сам тянет его по /api/upload_refs. Origin должен быть в CORS на nginx ВМ. var VM_UPLOAD_URL = 'https://contracts.kube5s.ru/contracts-upload/'; // SITE_URL удалён (Фаза 4) — не использовался -var fileInput = document.getElementById('fileInput'); -var fileTable = document.getElementById('fileTable'); // state.files — теперь в state.js (Фаза 0: state + render) // state.contractId — теперь в state.js (Фаза 0: state + render) // state.batchId — теперь в state.js (Фаза 0: state + render) // (batchId = crypto.randomUUID() — уникальный ID сессии для классификации) -// Прогрев upstream + автоочистка при загрузке страницы +// Прогрев приложения при загрузке страницы. // ⚠️ Без прогрева первый POST может упасть с ERR_CONNECTION_RESET (MTU/Geneve) (async function init() { try { await fetch('/health'); } catch(e) { /* ignore */ } - try { await fetch(VM_API + '/api/cleanup', { method: 'POST' }); } catch(e) { /* ignore */ } })(); /** - * render(state) — Главная функция рендеринга (Фаза 0, decoupling-final-plan.md). - * - * ПАТТЕРН: action → мутация state → render(state) - * - * Фаза 0: вызывает существующий renderTable(). - * renderTable читает state.files напрямую (state — глобальный объект). - * Фаза 1+: будет вызывать renderFiles(state), renderGroups(state), renderStepper(state) и т.д. - * - * ПРАВИЛО: любое изменение state ДОЛЖНО завершаться вызовом render(state). - * Никакой прямой манипуляции DOM в обход render(). - * console.log(state) показывает ВСЁ состояние в любой момент. + * Обновить отображение picker и текущий шаг pipeline после изменения state. */ function render(state) { renderFiles(state); renderStepper(state); } -// syncDB, statusToHTML, renderFiles, toggleClassifyDetail, uploadFile, refreshSupps -// → вынесены в files.js (Фаза 1) +if (!window.FilePicker) throw new Error('Не загружен бандл FilePicker'); +filePicker = window.FilePicker.initFilePicker({ + mount: '#filePicker', + allowedExt: ['.pdf', '.doc', '.docx'], + limits: FILE_PICKER_LIMITS, + onChange: onPickerFilesChange, + onError: function(error, file) { + filePicker.setMessage('Ошибка чтения ' + file.name + ': ' + error.message); + }, + onDetails: function(id) { + var index = state.files.findIndex(function(file) { return file.pickerId === id; }); + if (index >= 0) toggleClassifyDetail(index); + }, + onText: function(id) { + var index = state.files.findIndex(function(file) { return file.pickerId === id; }); + if (index >= 0) showText(index); + } +}); + +// Picker selection, upload queue, syncDB, document details and finalization +// are wired by files.js. // ── Pipeline stepper (Фаза 4: state-driven) ────────────────── @@ -97,36 +103,13 @@ function resetStepper(fromId) { } // Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) — // вынесены в app_utils.js для облегчения анализа и отладки. -// statusToHTML, renderFiles → files.js (Фаза 1) +// Picker rows are rendered by the FilePicker integration in files.js. // ── Раскрыть/скрыть результат классификации под строкой файла ── // toggleClassifyDetail → files.js (Фаза 1) // moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js -// ── Автозагрузка при выборе файлов ────────────────────────── -// Фаза 1 (decoupling-final-plan.md): fileInput handler разбит на чистые функции -// onFilesSelected → reconcileSelection → addZipFile/addRegularFile → finalizeUpload -// Вся логика — в files.js. Здесь только тонкая обёртка. -fileInput.addEventListener('change', async function() { - var newFiles = Array.from(fileInput.files); - onFilesSelected(newFiles); -}); - -// Выбор папки (webkitdirectory) — та же обработка, что и файлы -var folderInput = document.getElementById('folderInput'); -var folderBtn = document.getElementById('folderBtn'); -if (folderBtn && folderInput) { - folderBtn.addEventListener('click', function() { folderInput.click(); }); - folderInput.addEventListener('change', function() { - var files = Array.from(folderInput.files).filter(function(f) { - var n = f.name.toLowerCase(); - return n.endsWith('.pdf') || n.endsWith('.doc') || n.endsWith('.docx') || n.endsWith('.zip'); - }); - if (files.length) onFilesSelected(files); - }); -} - // ── Классификация ────────────────────────────────────────── function showClassifyBtn() { var btn = document.getElementById('classifyBtn'); diff --git a/site/static/app_utils.js b/site/static/app_utils.js index d65a8a5..dea64fd 100644 --- a/site/static/app_utils.js +++ b/site/static/app_utils.js @@ -6,7 +6,7 @@ /** * Форматирует размер файла в человекочитаемый вид. - * Используется в renderTable() для колонки "Размер". + * Используется в модальном окне документа. */ function formatSize(bytes) { if (!bytes || bytes === 0) return '—'; @@ -17,7 +17,7 @@ function formatSize(bytes) { /** * Форматирует timestamp в российскую дату + время. - * Используется в renderTable() для колонки "Изменён". + * Используется в модальном окне документа. */ function formatDate(ts) { if (!ts) return '—'; @@ -33,15 +33,8 @@ function formatDate(ts) { * Если очередь опустела — сбрасывает state.contractId. */ function removeFile(i) { - state.files.splice(i, 1); - if (state.files.length === 0) state.contractId = null; - render(state); - // Синхронизировать БД с таблицей - if (typeof syncDB === 'function') syncDB(); - // Сбросить прогресс при изменении состава файлов - if (typeof resetStepper === 'function') resetStepper('stepUpload'); - // Разблокировать кнопку классификации при изменении состава файлов - if (typeof showClassifyBtn === 'function') showClassifyBtn(); + var file = state.files[i]; + if (file && filePicker) filePicker.remove(file.pickerId); } window.removeFile = removeFile; diff --git a/site/static/dist/fflate-LICENSE.txt b/site/static/dist/fflate-LICENSE.txt new file mode 100644 index 0000000..bd36874 --- /dev/null +++ b/site/static/dist/fflate-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Arjun Barrett + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/site/static/dist/file-picker.iife.js b/site/static/dist/file-picker.iife.js new file mode 100644 index 0000000..03a0d6d --- /dev/null +++ b/site/static/dist/file-picker.iife.js @@ -0,0 +1,1513 @@ +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); + + // index.js + var index_exports = {}; + __export(index_exports, { + DEFAULTS: () => DEFAULTS, + initFilePicker: () => initFilePicker, + putToVm: () => putToVm, + uploadViaVM: () => uploadViaVM + }); + + // ../../../../drhider/OBFUSCATOR/.agent-tmp/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; + } + + // table/rebase_tree.js + function rebaseTree(root, prefix) { + root.path = `${prefix}/${root.path}`; + if (root.file) root.file = new File([root.file], root.file.name, { lastModified: root.file.lastModified }); + root.children.forEach((child) => rebaseTree(child, prefix)); + return root; + } + + // 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.split("/").at(-1)); + } + 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: kind === "file" }; + } + 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"); + const lowerName = entry.name.toLowerCase(); + if (!extensionAllowed(lowerName, allowedExt) && !lowerName.endsWith(".zip")) return false; + 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 }); + } + + // 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; + if (accepted.kind !== "file") { + const existing = state.nodes.find((node2) => node2.kind !== "file" && node2.path === accepted.path); + if (existing) { + mergeChildren(existing, accepted); + return true; + } + } + state.nodes.push(accepted); + return true; + } + function mergeChildren(target, incoming) { + incoming.children.forEach((child) => { + if (child.kind === "file") { + const duplicate = target.children.some((existing2) => existing2.kind === "file" && existing2.path === child.path && existing2.file.size === child.file.size); + if (!duplicate) target.children.push(child); + return; + } + const existing = target.children.find((candidate) => candidate.kind !== "file" && candidate.path === child.path); + if (existing) mergeChildren(existing, child); + else target.children.push(child); + }); + } + + // table/esc.js + function esc(value) { + return String(value).replace(/[&<>"']/g, (character) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + })[character]); + } + + // table/fs.js + function fs(bytes) { + return bytes < 1024 ? `${bytes} B` : bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(1)} MB`; + } + + // 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 leafStatuses(node2, state, result = []) { + if (node2.kind === "file") { + result.push(state.statuses.get(node2.id) || { kind: "ready", text: "" }); + } else { + node2.children.forEach((child) => leafStatuses(child, state, result)); + } + return result; + } + function renderStatus(node2, state, cfg) { + if (node2.kind === "file") { + const status = state.statuses.get(node2.id) || { kind: "ready", text: cfg.labels.statusReady }; + const kind = ["uploading", "parsing", "parsed", "duplicate", "error"].includes(status.kind) ? status.kind : "ready"; + return `${esc(status.text || cfg.labels.statusReady)}`; + } + const statuses = leafStatuses(node2, state); + const errors = statuses.filter((status) => status.kind === "error").length; + const uploading = statuses.filter((status) => status.kind === "uploading" || status.kind === "parsing").length; + const parsed = statuses.filter((status) => ["parsed", "duplicate"].includes(status.kind)).length; + if (uploading) return `${uploading} \u0437\u0430\u0433\u0440\u0443\u0436\u0430\u0435\u0442\u0441\u044F/\u043E\u0431\u0440\u0430\u0431\u0430\u0442\u044B\u0432\u0430\u0435\u0442\u0441\u044F`; + if (errors) return `${errors} \u043E\u0448\u0438\u0431\u043E\u043A`; + if (parsed === statuses.length) return `${statuses.length} \u0433\u043E\u0442\u043E\u0432\u043E`; + return `${parsed}/${statuses.length} \u043E\u0431\u0440\u0430\u0431\u043E\u0442\u0430\u043D\u043E`; + } + function renderNode(node2, depth, cfg, state) { + const isGroup = node2.kind !== "file"; + const padding = depth * 4; + const name = esc(node2.name); + const action = isGroup ? `` : `${name}`; + const documentId = state.documents.get(node2.id); + const details = node2.kind === "file" && documentId ? `` : ""; + const remove = ``; + const pathAttribute = ` data-path="${esc(node2.path)}"`; + const fileIdAttribute = node2.kind === "file" ? ` data-file-id="${esc(node2.id)}"` : ""; + const cells = { + path: action, + size: node2.kind === "file" ? fs(node2.file.size) : "", + status: node2.error ? esc(node2.error) : renderStatus(node2, state, cfg) + }; + const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(DEFAULT_RENDER_CONFIG.labels.columns); + const row = `` + columns.map((column) => `${cells[column] || ""}`).join("") + `${details}${remove}`; + if (!isGroup || !node2.expanded) return row; + return row + node2.children.map((child) => renderNode(child, depth + 1, cfg, state)).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, state)).join("") : `${esc(normalized.labels.empty)}`; + 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({ id: node2.id, path: node2.path, name: node2.name, size: node2.file.size, file: node2.file }); + else flattenFiles(node2.children, result); + }); + return result; + } + + // 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); + } else if (elements.statusEl) { + elements.statusEl.textContent = `\u041E\u0448\u0438\u0431\u043A\u0430 \u0447\u0442\u0435\u043D\u0438\u044F \u0430\u0440\u0445\u0438\u0432\u0430 ${file.name}: ${error.message}`; + } + continue; + } + } + render(state, elements, cfg); + } + function onFilesChange(state, cfg, elements) { + return async () => { + if (state.busy) return; + state.busy = true; + if (elements.statusEl) elements.statusEl.textContent = ""; + try { + await addFiles(state, cfg, elements.fileInputEl.files, elements); + } finally { + elements.fileInputEl.value = ""; + state.busy = false; + } + }; + } + + // table/lst_filter.js + var MANIFEST_NAME = "obscure.lst"; + function extensionAllowed2(name, allowedExt) { + const lowerName = String(name || "").toLowerCase(); + return allowedExt.some((extension) => lowerName.endsWith(String(extension).toLowerCase())); + } + function relativePathOf(file) { + const raw = String(file.webkitRelativePath || file.name || "").replace(/\\/g, "/"); + const parts = raw.split("/"); + return parts.length > 1 ? parts.slice(1).join("/") : parts[0] || ""; + } + function findManifest(files) { + return files.find((file) => { + const raw = String(file.webkitRelativePath || file.name || "").replace(/\\/g, "/"); + const parts = raw.split("/"); + return parts.length === 2 && parts[1].toLowerCase() === MANIFEST_NAME; + }) || null; + } + function escapeRegExp(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + function findClosingBracket(glob, openIndex) { + let index = openIndex + 1; + if (glob[index] === "!" || glob[index] === "^") index += 1; + if (glob[index] === "]") index += 1; + for (; index < glob.length; index += 1) { + if (glob[index] === "]") return index; + } + return -1; + } + function compileGlob(glob) { + let pattern = "^"; + let index = 0; + while (index < glob.length) { + const character = glob[index]; + if (character === "*") { + const isDouble = glob[index + 1] === "*"; + if (!isDouble) { + pattern += "[^/]*"; + index += 1; + continue; + } + const atSegmentStart = index === 0 || glob[index - 1] === "/"; + const atSegmentEnd = index + 2 === glob.length || glob[index + 2] === "/"; + if (atSegmentStart && atSegmentEnd) { + if (glob[index + 2] === "/") { + pattern += "(?:[^/]+/)*"; + index += 3; + } else { + pattern += ".*"; + index += 2; + } + } else { + pattern += "[^/]*"; + index += 2; + } + continue; + } + if (character === "?") { + pattern += "[^/]"; + index += 1; + continue; + } + if (character === "[") { + const close = findClosingBracket(glob, index); + if (close === -1) return null; + let body = glob.slice(index + 1, close); + if (body.startsWith("!")) body = `^${body.slice(1)}`; + pattern += `[${body.replace(/[\\\]]/g, (symbol) => `\\${symbol}`)}]`; + index = close + 1; + continue; + } + pattern += escapeRegExp(character); + index += 1; + } + pattern += "$"; + try { + return new RegExp(pattern, "i"); + } catch (error) { + return null; + } + } + function normalizeRule(line) { + let text = line.trim(); + if (!text || text.startsWith("#")) return null; + let negative = false; + if (text.startsWith("!")) { + negative = true; + text = text.slice(1).trim(); + if (!text) return { error: '\u043F\u0443\u0441\u0442\u043E\u0435 \u043F\u0440\u0430\u0432\u0438\u043B\u043E \u043F\u043E\u0441\u043B\u0435 "!"' }; + } + text = text.replace(/\\/g, "/"); + text = text.replace(/\/{2,}/g, "/"); + text = text.replace(/^(?:\.\/)+/, ""); + if (text.startsWith("/") || /^[a-zA-Z]:\//.test(text)) { + return { error: "\u0430\u0431\u0441\u043E\u043B\u044E\u0442\u043D\u044B\u0439 \u043F\u0443\u0442\u044C \u043D\u0435 \u0434\u043E\u043F\u0443\u0441\u043A\u0430\u0435\u0442\u0441\u044F" }; + } + if (text.split("/").some((segment) => segment === "..")) { + return { error: '\u0441\u0435\u0433\u043C\u0435\u043D\u0442 ".." \u043D\u0435 \u0434\u043E\u043F\u0443\u0441\u043A\u0430\u0435\u0442\u0441\u044F' }; + } + if (!text) return { error: "\u043F\u0443\u0441\u0442\u043E\u0435 \u043F\u0440\u0430\u0432\u0438\u043B\u043E" }; + if (text.endsWith("/")) text += "**"; + return { path: text, negative }; + } + function parseRules(text, diagnostics) { + const lines = text.split(/\r\n|\r|\n/); + const rules = []; + lines.forEach((line, index) => { + const lineNumber = index + 1; + const normalized = normalizeRule(line); + if (normalized === null) return; + if (normalized.error) { + diagnostics.push({ + level: "error", + line: lineNumber, + message: `${normalized.error}: "${line.trim()}"` + }); + return; + } + const regex = compileGlob(normalized.path); + if (!regex) { + diagnostics.push({ + level: "error", + line: lineNumber, + message: `\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 glob: "${line.trim()}"` + }); + return; + } + rules.push({ + line: lineNumber, + source: line.trim(), + negative: normalized.negative, + regex + }); + }); + return rules; + } + async function lstFilter(files, allowedExt) { + const list = Array.from(files || []); + const manifest = findManifest(list); + if (!manifest) { + return { manifest: null, files: list, diagnostics: [] }; + } + const diagnostics = []; + let text; + try { + const buffer = await manifest.arrayBuffer(); + text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); + } catch (error) { + diagnostics.push({ + level: "error", + line: 0, + message: `\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u0440\u043E\u0447\u0438\u0442\u0430\u0442\u044C ${MANIFEST_NAME}: ${error.message}` + }); + return { manifest, files: [], diagnostics }; + } + if (text.charCodeAt(0) === 65279) text = text.slice(1); + const rules = parseRules(text, diagnostics); + const positives = rules.filter((rule) => !rule.negative); + const negatives = rules.filter((rule) => rule.negative); + if (positives.length === 0) { + diagnostics.push({ + level: "warning", + line: 0, + message: `\u0432 ${MANIFEST_NAME} \u043D\u0435\u0442 \u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0445 \u0432\u043A\u043B\u044E\u0447\u0430\u044E\u0449\u0438\u0445 \u043F\u0440\u0430\u0432\u0438\u043B` + }); + return { manifest, files: [], diagnostics }; + } + const candidates = list.filter((file) => file !== manifest && extensionAllowed2(file.name, allowedExt)); + const matched = /* @__PURE__ */ new Set(); + const selected = []; + for (const file of candidates) { + const path = relativePathOf(file); + let included = false; + for (const rule of positives) { + if (rule.regex.test(path)) { + matched.add(rule); + included = true; + } + } + if (!included) continue; + const excluded = negatives.some((rule) => rule.regex.test(path)); + if (!excluded) selected.push(file); + } + for (const rule of positives) { + if (!matched.has(rule)) { + diagnostics.push({ + level: "warning", + line: rule.line, + message: `\u043F\u0440\u0430\u0432\u0438\u043B\u043E \u043D\u0435 \u0441\u043E\u0432\u043F\u0430\u043B\u043E \u043D\u0438 \u0441 \u043E\u0434\u043D\u0438\u043C \u0444\u0430\u0439\u043B\u043E\u043C: "${rule.source}"` + }); + } + } + if (selected.length === 0) { + diagnostics.push({ + level: "warning", + line: 0, + message: "manifest \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043B \u043D\u0438 \u043E\u0434\u043D\u043E\u0433\u043E \u0444\u0430\u0439\u043B\u0430" + }); + } + return { manifest, files: selected, diagnostics }; + } + + // table/on_folder_change.js + function onFolderChange(state, cfg, elements) { + return async () => { + if (state.busy) return; + state.busy = true; + if (elements.statusEl) elements.statusEl.textContent = ""; + try { + const roots = /* @__PURE__ */ new Map(); + const filtered = await lstFilter(elements.folderInputEl.files, [...cfg.allowedExt, ".zip"]); + if (filtered.manifest) { + const messages = filtered.diagnostics.map((item) => item.line ? `\u0441\u0442\u0440\u043E\u043A\u0430 ${item.line}: ${item.message}` : item.message); + if (messages.length && elements.statusEl) { + elements.statusEl.textContent = messages.join("; "); + } + } + for (const file of filtered.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: false + }); + } + 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: false + }; + 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); + } else if (elements.statusEl) { + elements.statusEl.textContent = `\u041E\u0448\u0438\u0431\u043A\u0430 \u0447\u0442\u0435\u043D\u0438\u044F \u0430\u0440\u0445\u0438\u0432\u0430 ${file.name}: ${error.message}`; + } + 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/put_to_vm.js + function putToVm(file, url, options = {}) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + if (options.onXHR) { + options.onXHR(xhr); + } + const abortHandler = () => { + xhr.abort(); + reject(new DOMException("Upload aborted", "AbortError")); + }; + if (options.signal) { + if (options.signal.aborted) { + reject(new DOMException("Upload aborted", "AbortError")); + return; + } + options.signal.addEventListener("abort", abortHandler, { once: true }); + } + xhr.open("PUT", url); + xhr.timeout = options.timeoutMs || 3e5; + xhr.upload.onprogress = (e) => { + if (e.lengthComputable && options.onProgress) { + const pct = Math.round(e.loaded / e.total * 100); + options.onProgress(pct, e.loaded, e.total); + } + }; + xhr.onload = () => { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject(new Error(`\u0411\u0443\u0444\u0435\u0440: HTTP ${xhr.status} ${xhr.statusText}`)); + } + }; + xhr.onerror = () => { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + reject(new Error("\u0421\u0435\u0442\u0435\u0432\u0430\u044F \u043E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438 \u043E\u0442\u043F\u0440\u0430\u0432\u043A\u0435 \u0432 \u0431\u0443\u0444\u0435\u0440")); + }; + xhr.ontimeout = () => { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + reject(new Error(`\u0422\u0430\u0439\u043C\u0430\u0443\u0442 \u043E\u0436\u0438\u0434\u0430\u043D\u0438\u044F \u043E\u0442\u0432\u0435\u0442\u0430 \u0431\u0443\u0444\u0435\u0440\u0430 (${Math.round(xhr.timeout / 1e3)}\u0441)`)); + }; + xhr.onabort = () => { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + reject(new DOMException("Upload aborted", "AbortError")); + }; + xhr.send(file); + }); + } + + // upload/upload_via_vm.js + function generateUuid() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = Math.random() * 16 | 0; + const v = c === "x" ? r : r & 3 | 8; + return v.toString(16); + }); + } + function normalizeFileEntry(item) { + if (item instanceof File || item instanceof Blob) { + return { name: item.name || "unnamed", size: item.size, file: item, path: item.name || "", source: item }; + } + if (item && item.file) { + return { + name: item.name || item.file.name || "unnamed", + size: item.size ?? item.file.size ?? 0, + file: item.file, + path: item.path || item.name || "", + source: item + }; + } + throw new Error("\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 \u043E\u0431\u044A\u0435\u043A\u0442 \u0444\u0430\u0439\u043B\u0430"); + } + async function uploadViaVM(files, options = {}) { + if (!options.vmUploadUrl) { + return { ok: false, error: "\u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 options.vmUploadUrl \u043E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u0435\u043D" }; + } + const normalizedFiles = Array.from(files).map(normalizeFileEntry); + const total = normalizedFiles.length; + if (total === 0) { + return { ok: true, session: options.session || "", count: 0 }; + } + const backendUrl = options.backendUploadUrl || (options.apiBase || "") + "/api/upload_refs"; + const vmBase = options.vmUploadUrl.endsWith("/") ? options.vmUploadUrl : `${options.vmUploadUrl}/`; + let currentSession = options.session || ""; + let totalAdded = 0; + for (let k = 0; k < total; k++) { + if (options.signal?.aborted) { + return { ok: false, error: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession }; + } + const { name, size, file, source } = normalizedFiles[k]; + const fileKey = `${generateUuid()}_${k}`; + const fileUrl = `${vmBase}${fileKey}`; + if (options.onStatus) { + options.onStatus(`\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u0432 \u0431\u0443\u0444\u0435\u0440 (${k + 1}/${total}): ${name}`); + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u23F3 \u0412 \u0431\u0443\u0444\u0435\u0440..."); + } + const t0 = performance.now(); + try { + await putToVm(file, fileUrl, { + signal: options.signal, + onXHR: options.onXHR, + onProgress(pct, loaded, fileTotal) { + const elapsed = (performance.now() - t0) / 1e3; + const speed = elapsed > 0 ? loaded / elapsed : 0; + if (options.onFileStatus) { + options.onFileStatus(k, `\u23F3 ${pct}% (${fs(speed)}/s)`); + } + if (options.onProgress) { + options.onProgress({ index: k, total, pct, speed, loaded, fileTotal, name }); + } + } + }); + } catch (err2) { + if (options.signal?.aborted) { + return { ok: false, error: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession }; + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u2717 \u041E\u0448\u0438\u0431\u043A\u0430 \u0431\u0443\u0444\u0435\u0440\u0430"); + } + return { + ok: false, + error: `\u041E\u0448\u0438\u0431\u043A\u0430 \u043E\u0442\u043F\u0440\u0430\u0432\u043A\u0438 \u0444\u0430\u0439\u043B\u0430 ${name} \u0432 \u0431\u0443\u0444\u0435\u0440: ${err2.message}`, + failedIndex: k, + session: currentSession + }; + } + if (options.onStatus) { + options.onStatus(`\u041F\u0440\u0438\u0451\u043C \u0441\u0435\u0440\u0432\u0438\u0441\u043E\u043C (${k + 1}/${total}): ${name}`); + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u26A1 \u0412 \u0441\u0435\u0441\u0441\u0438\u044E..."); + } + try { + const resp = await fetch(backendUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...typeof options.getExtraPayload === "function" ? options.getExtraPayload(source, k) || {} : options.extraPayload || {}, + session: currentSession, + files: [{ name, size, url: fileUrl }] + }), + signal: options.signal + }); + if (!resp.ok) { + throw new Error(`HTTP ${resp.status} ${resp.statusText}`); + } + const data = await resp.json(); + if (!data.ok) { + throw new Error(data.error || "\u0411\u044D\u043A\u0435\u043D\u0434 \u0432\u0435\u0440\u043D\u0443\u043B \u043E\u0448\u0438\u0431\u043A\u0443"); + } + const result = Array.isArray(data.results) ? data.results[0] : void 0; + if (Array.isArray(data.results) && !result) { + throw new Error("\u0411\u044D\u043A\u0435\u043D\u0434 \u043D\u0435 \u0432\u0435\u0440\u043D\u0443\u043B \u0440\u0435\u0437\u0443\u043B\u044C\u0442\u0430\u0442 \u043E\u0431\u0440\u0430\u0431\u043E\u0442\u043A\u0438 \u0444\u0430\u0439\u043B\u0430"); + } + currentSession = data.session || currentSession; + totalAdded = data.count ?? totalAdded + 1; + if (options.onFileStatus) { + options.onFileStatus(k, "\u2713 \u0414\u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D"); + } + if (options.onFileComplete) { + options.onFileComplete({ + fileIndex: k, + name, + size, + session: currentSession, + totalAdded, + result, + source + }); + } + } catch (err2) { + if (options.signal?.aborted) { + return { ok: false, error: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession }; + } + if (options.onFileStatus) { + options.onFileStatus(k, "\u2717 \u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438\u0451\u043C\u0430"); + } + return { + ok: false, + error: `\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438\u0451\u043C\u0430 \u0444\u0430\u0439\u043B\u0430 ${name} \u0431\u044D\u043A\u0435\u043D\u0434\u043E\u043C: ${err2.message}`, + failedIndex: k, + session: currentSession + }; + } + } + if (options.onStatus) { + options.onStatus(`\u0417\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u043E. \u0423\u0441\u043F\u0435\u0448\u043D\u043E \u043F\u0435\u0440\u0435\u0434\u0430\u043D\u043E \u0444\u0430\u0439\u043B\u043E\u0432: ${total}`); + } + return { ok: true, session: currentSession, count: totalAdded }; + } + + // 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", + details: "\u041A\u043B\u0430\u0441\u0441\u0438\u0444\u0438\u043A\u0430\u0446\u0438\u044F", + text: "\u0422\u0435\u043A\u0441\u0442 \u0434\u043E\u043A\u0443\u043C\u0435\u043D\u0442\u0430", + 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) ? `` : ""; + const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns); + const headings = columns.map((column) => `${esc(cfg.labels.columns[column] || column)}`).join(""); + const fileAccept = esc([.../* @__PURE__ */ new Set([...cfg.allowedExt, ".zip"])].join(",")); + const folderAccept = esc([.../* @__PURE__ */ new Set([...cfg.allowedExt, ".zip", ".lst"])].join(",")); + root.innerHTML = ` +
+
+ + + ${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")} +
+

+
${headings}
+

+
`; + } + 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}`); + state.statuses.delete(node2.id); + state.documents.delete(node2.id); + 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"), + statusEl: root.querySelector(".fp-status"), + tableBodyEl: root.querySelector(".fp-table-body"), + countEl: root.querySelector(".fp-count") + }; + const state = { + nodes: [], + fileKeys: /* @__PURE__ */ new Set(), + busy: false, + statuses: /* @__PURE__ */ new Map(), + documents: /* @__PURE__ */ new Map() + }; + 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 details = event.target.closest("[data-details]"); + if (details) { + if (typeof cfg.onDetails === "function") cfg.onDetails(details.dataset.details); + return; + } + const text = event.target.closest("[data-text]"); + if (text) { + if (typeof cfg.onText === "function") cfg.onText(text.dataset.text); + 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(); + state.statuses.clear(); + state.documents.clear(); + if (elements.statusEl) elements.statusEl.textContent = ""; + 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)], + setStatus: (id, status) => { + if (!findNode(state.nodes, id)) return false; + state.statuses.set(id, { + kind: String(status?.kind || "ready"), + text: String(status?.text || "") + }); + redraw(); + return true; + }, + setDocument: (id, documentId) => { + if (!findNode(state.nodes, id)) return false; + if (documentId) state.documents.set(id, String(documentId)); + else state.documents.delete(id); + redraw(); + return true; + }, + setMessage: (message) => { + if (elements.statusEl) elements.statusEl.textContent = String(message || ""); + }, + remove: (id) => { + if (removeNode(state, id)) { + redraw(); + notify(cfg, state); + } + }, + clear: () => { + state.nodes = []; + state.fileKeys.clear(); + state.statuses.clear(); + state.documents.clear(); + if (elements.statusEl) elements.statusEl.textContent = ""; + redraw(); + notify(cfg, state); + }, + render: redraw, + destroy: () => { + listeners.forEach((removeListener) => removeListener()); + state.nodes = []; + state.fileKeys.clear(); + state.statuses.clear(); + state.documents.clear(); + mount.replaceChildren(); + } + }; + } + return __toCommonJS(index_exports); +})(); diff --git a/site/static/files.js b/site/static/files.js index 616fbc6..0980e8c 100644 --- a/site/static/files.js +++ b/site/static/files.js @@ -3,134 +3,48 @@ * * ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔⛔⛔ * - * v2.0.3: честный счётчик ⏳ соединение... Nс вместо фейкового ↑N% - * Проверено в бою 2026-07-16. Любое изменение = риск сломать загрузку. - * - * КЛЮЧЕВЫЕ ФУНКЦИИ (не трогать): - * - uploadFile() — fetch-загрузка с имитацией прогресса - * - onFilesSelected() — все строки в таблицу сразу, потом загрузка по одной - * - statusToHTML() — рендер статуса (↑ N%, ✓) - * - renderFiles() — рендер всей таблицы - -/** - * statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1). - * - * Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега. - * kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | '' | 'connecting' - * Выход: безопасная HTML-строка (статусы не содержат пользовательских данных). - * - * ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления. - * state хранит чистые данные, statusToHTML — чистая функция рендеринга. + * FilePicker owns selection/tree rendering; this module connects it to the + * Contracts batch, upload sink, and document pipeline. + * + * Состав и обработка документов синхронизируются с FilePicker в app.js. */ -function statusToHTML(st) { - if (!st || !st.kind) return ''; - switch (st.kind) { - case 'connecting': return '⏳ соединение... ' + (st.elapsed || 0) + 'с'; - case 'converting': return '⏳ конвертация... ' + (st.elapsed || 0) + 'с'; - case 'uploading': return '⏳ отправка... ' + (st.elapsed || 0) + 'с'; - case 'uploaded': return '✓'; - case 'unzipping': return '⏳ распаковка...'; - case 'parsing': return '⏳ парсинг...'; - case 'parsed': return '✓ ' + (st.count || 0) + ' эл.' + (st.elapsed ? ' (' + st.elapsed + 'с)' : '') + ''; - case 'error': return '✗ ' + escHtml(st.text || 'Неизвестная ошибка') + ''; - case 'duplicate': return '⤿ дубликат'; - case 'warning': return '⚠ ' + escHtml(st.text || 'пустой') + ''; - case 'no_format': return '✗ формат не поддерживается'; - default: return ''; - } -} + +var filePicker = null; +var uploadQueue = Promise.resolve(); +var uploadTasks = new Map(); +var queuedUploads = new Set(); +var pendingUploads = 0; +var FILE_PICKER_LIMITS = { + maxEntries: 1000, + maxTotalBytes: 500 * 1024 * 1024, + maxEntryBytes: 50 * 1024 * 1024, + maxDepth: 20, +}; /** - * renderFiles(state) — Рендер таблицы файлов (бывший renderTable, Фаза 1). - * - * Читает state.files, генерирует HTML для #fileTable. - * Использует statusToHTML() для рендеринга статусов (чистые данные → HTML). - * Вызывает lucide.createIcons() для иконок. - * - * Вход: state (весь объект состояния, но читает только state.files). - * Выход: мутирует DOM (fileTable.innerHTML). + * renderFiles(state) — обновить отображение дерева и статусов picker. */ function renderFiles(state) { - if (state.files.length === 0) { - fileTable.innerHTML = 'Нет файлов — выберите .doc / .docx / .pdf'; - lucide.createIcons(); - return; - } - - // Сгруппировать файлы по zip_source - var groups = []; // [{zip: "name.zip"|null, files: [f, ...]}] - var seen = {}; - for (var i = 0; i < state.files.length; i++) { - var f = state.files[i]; - var zip = f.zip_source || null; - var key = zip || '__naked__'; - if (!seen[key]) { - seen[key] = { zip: zip, files: [] }; - groups.push(seen[key]); - } - // Сохраняем оригинальный индекс для id строк - f._idx = i; - seen[key].files.push(f); - } - - // Рендер: заголовок ZIP → файлы с отступом - var rows = []; - for (var gi = 0; gi < groups.length; gi++) { - var g = groups[gi]; - - if (g.zip) { - // Заголовок-секция ZIP - rows.push( - '' + - '' + - '📦 ' + escHtml(g.zip) + ' — ' + g.files.length + ' файл.' + - (g.files.length === 1 ? '' : 'ов') + - '' - ); - } - - // Файлы внутри группы (с отступом если из ZIP) - for (var fi = 0; fi < g.files.length; fi++) { - var f = g.files[fi]; - var i = f._idx; - var indent = g.zip ? 'padding-left:24px;' : ''; - - rows.push( - '' + - '' + - (f.doc_id ? '▸' : '') + - escHtml(f.name) + '' + - '' + formatDate(f.lastModified) + '' + - '' + formatSize(f.size) + '' + - '' + statusToHTML(f.status) + '' + - '' + - '' + - '' + - 'Загрузка...' - ); - } - } - - fileTable.innerHTML = rows.join(''); - lucide.createIcons(); + if (filePicker) filePicker.render(); } /** * syncDB() — Синхронизация БД с текущим состоянием файлов. * - * Отправляет на бэкенд список doc_id, которые должны остаться в БД. - * Всё, чего нет в state.files, будет удалено из БД (cascade). - * Вызывается после каждого изменения состава файлов (removeFile, upload). + * Синхронизирует keep_ids только для текущего batch_id. */ async function syncDB() { - var keepIds = state.files.map(function(f) { return f.doc_id; }).filter(Boolean); - try { - await fetch(VM_API + '/api/sync', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({keep_ids: keepIds}) - }); - } catch(e) { /* сервер недоступен — не критично */ } + var keepIds = Array.from(new Set(state.files.map(function(f) { return f.doc_id; }).filter(Boolean))); + var response = await fetch(VM_API + '/api/sync', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({batch_id: state.batchId, keep_ids: keepIds}) + }); + var data = await response.json(); + if (!response.ok || !data.ok) { + throw new Error(data.error || 'Не удалось синхронизировать документы'); + } + return data; } /** @@ -146,7 +60,47 @@ async function syncDB() { window.toggleClassifyDetail = async function(i) { var detailRow = document.getElementById('detail_' + i); var expandIcon = document.getElementById('expand_' + i); - if (!detailRow) return; + if (!detailRow) { + var file = state.files[i]; + if (!file || !file.doc_id) return; + document.getElementById('modalTitle').textContent = 'Классификация — ' + file.name; + document.getElementById('modalBody').innerHTML = 'Загрузка...'; + document.getElementById('modalOverlay').classList.add('open'); + try { + var detailResponse = await fetch(VM_API + '/api/documents/' + encodeURIComponent(file.doc_id)); + var detailData = await detailResponse.json(); + if (!detailResponse.ok || !detailData.ok) { + throw new Error(detailData.error || 'Ошибка загрузки документа'); + } + var detailHtml = '
Результат классификации
'; + [['Тип', detailData.doc_type], ['Номер', detailData.own_number], + ['Родительский', detailData.parent_number], ['Дата', detailData.doc_date], + ['Контрагент', detailData.counterparty]].forEach(function(pair) { + if (pair[1]) detailHtml += '
' + pair[0] + + '' + escHtml(pair[1]) + '
'; + }); + if (detailData.classify_input) { + detailHtml += '
Текст, отправленный в LLM
'
+          + escHtml(detailData.classify_input) + '
'; + } + if (detailData.classify_raw) { + detailHtml += '
Сырой ответ LLM
'
+          + escHtml(detailData.classify_raw) + '
'; + } + if (detailData.elements_json) { + var parsedElements = typeof detailData.elements_json === 'string' + ? detailData.elements_json : JSON.stringify(detailData.elements_json, null, 2); + detailHtml += '
Распарсенные элементы
'
+          + escHtml(parsedElements.substring(0, 5000))
+          + (parsedElements.length > 5000 ? '\n... (обрезано)' : '')
+          + '
'; + } + document.getElementById('modalBody').innerHTML = detailHtml; + } catch (error) { + document.getElementById('modalBody').textContent = 'Ошибка: ' + error.message; + } + return; + } if (detailRow.style.display !== 'none') { detailRow.style.display = 'none'; @@ -204,65 +158,6 @@ window.toggleClassifyDetail = async function(i) { } }; -/** - * ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ uploadFile — загрузка через ВМ-буфер (паттерн drhider). - * - * Фаза 1: PUT файла на ВМ (WebDAV /contracts-upload/, мимо шлюза кластера ~64КБ). - * Фаза 2: POST /api/upload_refs {files:[{name,size,url}]} — бэк сам тянет файл с ВМ. - * Честный счётчик времени: ⏳ соединение... Nс → ⏳ отправка... Nс (fetch не даёт progress). - */ -function uploadFile(file, onProgress, zipSource) { - var startTime = Date.now(); - var phase = 'connecting'; // connecting → uploading - if (onProgress) onProgress({ kind: 'connecting', elapsed: 0 }); - var token = crypto.randomUUID(); - var vmUrl = VM_UPLOAD_URL + token + '_0'; - - // Честный счётчик: каждую секунду обновляем elapsed - var timer = setInterval(function() { - var elapsed = Math.floor((Date.now() - startTime) / 1000); - if (onProgress) onProgress({ kind: phase, elapsed: elapsed }); - }, 1000); - - // Фаза 1: PUT файла на ВМ-буфер - return fetch(vmUrl, { method: 'PUT', body: file, headers: { 'Content-Type': 'application/octet-stream' } }) - .then(function(r) { - if (!r.ok) throw new Error('VM upload HTTP ' + r.status); - phase = 'uploading'; - // Фаза 2: refs на бэкенд → pull с ВМ - var body = { batch_id: state.batchId, files: [{ name: file.name, size: file.size, url: vmUrl }] }; - if (state.contractId) body.contract_id = state.contractId; - if (zipSource) body.zip_source = zipSource; - return fetch('/api/upload_refs?_=' + Date.now(), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }); - }) - .then(function(r) { - if (!r.ok) throw new Error('HTTP ' + r.status); - return r.json(); - }) - .then(function(data) { - clearInterval(timer); - if (data.ok && data.results && data.results.length === 1) { - var res = data.results[0]; - if (!res.ok) throw new Error(res.error || 'Неизвестная ошибка'); - var elapsed = Math.floor((Date.now() - startTime) / 1000); - if (onProgress) onProgress({ kind: 'uploading', elapsed: elapsed }); - return res; // {ok, doc_id, contract_id, parsed} - } - throw new Error(data.error || 'Неизвестная ошибка'); - }) - .catch(function(e) { - clearInterval(timer); - if (e.message === 'Failed to fetch' || e.name === 'TypeError') { - throw new Error('Сеть'); - } - throw e; - }); -} - /** * refreshSupps() — Обновить supplement_id/type для файлов в state.files. * @@ -291,25 +186,6 @@ async function refreshSupps() { } catch(e) { console.log('refreshSupps error:', e); } } -/** - * reconcileSelection(files, newFiles) — Чистая функция (Фаза 1). - * - * Согласование состава: оставляет в массиве files только те, - * имена которых есть в newFiles. Остальные удаляются. - * - * Вход: files — текущий state.files (массив) - * newFiles — FileList от (массив File-объектов) - * Выход: новый массив (НЕ мутирует входной) - * - * Вызывается из onFilesSelected() ПЕРЕД обработкой каждого файла. - */ -function reconcileSelection(files, newFiles) { - var newNames = new Set(newFiles.map(function(f) { return f.name; })); - return files.filter(function(f) { - return newNames.has(f.name); - }); -} - /** * applyParseResult(entry, parsed, elapsed) — Чистая функция (Фаза 1). * @@ -338,7 +214,7 @@ function applyParseResult(entry, parsed, elapsed) { /** * finalizeUpload() — Завершение цикла загрузки (Фаза 1). * - * Вызывается ПОСЛЕ обработки всех файлов в onFilesSelected(). + * Вызывается после завершения очереди выбранных файлов. * 1. Обновляет supplement_id/type через refreshSupps() * 2. Отмечает шаг «Загрузка» как готовый * 3. Активирует шаг «Классификация» @@ -346,14 +222,18 @@ function applyParseResult(entry, parsed, elapsed) { * 5. Показывает кнопки (LLM, классификация) */ async function finalizeUpload() { + if (!state.files.some(function(file) { return file.doc_id; })) { + var llmButton = document.getElementById('llmBtn'); + var classifyButton = document.getElementById('classifyBtn'); + if (llmButton) llmButton.style.display = 'none'; + if (classifyButton) classifyButton.style.display = 'none'; + return; + } await refreshSupps(); + await syncDB(); stepDone('stepUpload'); stepActive('stepClassify'); - syncDB(); - - fileInput.value = ''; - fileInput.disabled = false; - + var llmBtn = document.getElementById('llmBtn'); llmBtn.style.display = 'flex'; llmBtn.disabled = false; @@ -361,79 +241,188 @@ async function finalizeUpload() { lucide.createIcons(); } -/** - * expandZipClient(f) — клиентское рекурсивное раскрытие ZIP (drhider listZipFiles). - * Каждый документ из архива (включая вложенные zip) добавляется в state.files - * с zip_source = имя архива. Fallback: если раскрыть не удалось — архив как есть. - */ -async function expandZipClient(f) { - var nested = []; +function zipSourceFromPath(path) { + var parts = String(path || '').split('/'); + var lastZip = -1; + parts.forEach(function(part, index) { + if (part.toLowerCase().endsWith('.zip')) lastZip = index; + }); + return lastZip < 0 ? null : parts.slice(0, lastZip + 1).join('/'); +} + +function isPickerEntrySelected(entry) { + return state.files.some(function(file) { return file.pickerId === entry.pickerId; }); +} + +function setPickerFileStatus(entry, kind, text) { + entry.status = { kind: kind, text: text }; + if (filePicker) filePicker.setStatus(entry.pickerId, entry.status); +} + +function showPickerError(error) { + if (filePicker) filePicker.setMessage(error.message || String(error)); +} + +async function uploadPickerEntry(entry) { + if (!isPickerEntrySelected(entry) || entry.uploadBlocked) return; + + var task = { controller: new AbortController(), phase: 'put' }; + uploadTasks.set(entry.pickerId, task); + setPickerFileStatus(entry, 'uploading', '⏳ Подготовка к загрузке'); + var sinkResult; try { - if (window.listZipFiles) { - nested = await window.listZipFiles(f, ['.pdf', '.doc', '.docx']); + var response = await window.FilePicker.uploadViaVM([{ + name: entry.file.name, + size: entry.file.size, + file: entry.file, + path: entry.pickerPath + }], { + vmUploadUrl: VM_UPLOAD_URL, + backendUploadUrl: VM_API + '/api/upload_refs', + signal: task.controller.signal, + getExtraPayload: function() { + var payload = { batch_id: state.batchId }; + if (state.contractId) payload.contract_id = state.contractId; + if (entry.zip_source) payload.zip_source = entry.zip_source; + return payload; + }, + onFileStatus: function(index, message) { + if (message.indexOf('⚡') === 0) task.phase = 'backend'; + var kind = message.indexOf('✗') === 0 ? 'error' + : message.indexOf('✓') === 0 ? 'parsing' + : task.phase === 'backend' ? 'parsing' : 'uploading'; + if (isPickerEntrySelected(entry)) setPickerFileStatus(entry, kind, message); + }, + onFileComplete: function(fileResult) { + sinkResult = fileResult.result; + } + }); + + if (!response.ok) throw new Error(response.error || 'Ошибка загрузки файла'); + if (!sinkResult) throw new Error('Сервис не вернул результат обработки файла'); + if (sinkResult.ok && !sinkResult.doc_id) { + throw new Error('Сервис обработал файл без идентификатора документа'); } - } catch (e) { - nested = []; - } - if (!nested || nested.length === 0) { - state.files.push({ name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: { kind: 'connecting', elapsed: 0 }, zip_source: null }); - state.files[state.files.length - 1]._pendingFile = f; - return; - } - for (var z = 0; z < nested.length; z++) { - state.files.push({ name: nested[z].name, lastModified: nested[z].lastModified, size: nested[z].size, file: nested[z], status: { kind: 'connecting', elapsed: 0 }, zip_source: f.name }); - state.files[state.files.length - 1]._pendingFile = nested[z]; + + if (sinkResult.error === 'duplicate' && sinkResult.doc_id) { + entry.doc_id = sinkResult.doc_id; + entry.uploaded = true; + entry.status = { kind: 'duplicate', text: '⤿ дубликат' }; + } else if (sinkResult.ok) { + entry.doc_id = sinkResult.doc_id; + entry.uploaded = true; + if (sinkResult.contract_id) state.contractId = sinkResult.contract_id; + applyParseResult(entry, sinkResult.parsed); + } else { + throw new Error(sinkResult.error || 'Не удалось обработать файл'); + } + + if (isPickerEntrySelected(entry)) { + filePicker.setDocument(entry.pickerId, entry.doc_id); + setPickerFileStatus(entry, entry.status.kind, entry.status.text + || (entry.status.kind === 'parsed' + ? '✓ ' + (entry.status.count || 0) + ' эл.' + : entry.status.text || 'Ошибка обработки')); + } + } catch (error) { + if (!(task.controller.signal.aborted && !isPickerEntrySelected(entry))) { + setPickerFileStatus(entry, 'error', '✗ ' + error.message); + } + } finally { + uploadTasks.delete(entry.pickerId); } } -/** - * ⛔ НЕ МЕНЯТЬ ⛔ onFilesSelected — все строки сразу, потом загрузка. - * - * v2.0.2: Фаза 1 — все строки в таблицу. Фаза 2 — загрузка по одной. - * Юзер видит таблицу целиком, каждая строка обновляется независимо. - */ -async function onFilesSelected(newFiles) { - if (newFiles.length === 0) return; - - fileInput.disabled = true; - resetStepper('stepUpload'); - - // Фаза 1: ВСЕ строки в таблицу сразу - for (var i = 0; i < newFiles.length; i++) { - var f = newFiles[i]; - if (f.name.toLowerCase().endsWith('.zip')) { - await expandZipClient(f); - continue; +function enqueuePickerUpload(entry) { + if (queuedUploads.has(entry.pickerId) || uploadTasks.has(entry.pickerId)) return; + queuedUploads.add(entry.pickerId); + pendingUploads += 1; + var run = function() { return uploadPickerEntry(entry); }; + uploadQueue = uploadQueue.then(run, run).finally(async function() { + queuedUploads.delete(entry.pickerId); + pendingUploads -= 1; + if (pendingUploads === 0) { + try { + await finalizeUpload(); + } catch (error) { + showPickerError(error); + } } - state.files.push({ name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: { kind: 'connecting', elapsed: 0 }, zip_source: null }); - state.files[state.files.length - 1]._pendingFile = f; + }); +} + +function scheduleBatchSync() { + var sync = async function() { + await syncDB(); + if (state.files.some(function(file) { return file.doc_id; })) { + showClassifyBtn(); + var compareButton = document.getElementById('llmBtn'); + if (compareButton) compareButton.style.display = 'flex'; + } + }; + uploadQueue = uploadQueue.then(sync, sync).catch(showPickerError); +} + +function onPickerFilesChange(pickerFiles) { + var previous = new Map(state.files.map(function(file) { return [file.pickerId, file]; })); + var incomingIds = new Set(pickerFiles.map(function(file) { return file.id; })); + var added = []; + var nextFiles = []; + var toQueue = []; + var selectedBytes = 0; + pickerFiles.forEach(function(pickerFile) { + selectedBytes += pickerFile.size; + var entry = previous.get(pickerFile.id); + if (!entry) { + entry = { + pickerId: pickerFile.id, + pickerPath: pickerFile.path, + name: pickerFile.name, + size: pickerFile.size, + lastModified: pickerFile.file.lastModified, + file: pickerFile.file, + zip_source: zipSourceFromPath(pickerFile.path), + status: { kind: 'connecting' } + }; + added.push(entry); + } + if (!entry.doc_id && !uploadTasks.has(entry.pickerId)) { + var limitError = entry.size > FILE_PICKER_LIMITS.maxEntryBytes + ? 'Файл превышает лимит 50 МБ' + : selectedBytes > FILE_PICKER_LIMITS.maxTotalBytes ? 'Превышен общий лимит 500 МБ' : ''; + if (limitError) { + entry.uploadBlocked = true; + entry.status = { kind: 'error', text: limitError }; + filePicker.setStatus(entry.pickerId, entry.status); + } else if (entry.uploadBlocked) { + delete entry.uploadBlocked; + entry.status = { kind: 'connecting' }; + if (!queuedUploads.has(entry.pickerId)) toQueue.push(entry); + } else if (added.indexOf(entry) !== -1) { + toQueue.push(entry); + } + } + nextFiles.push(entry); + }); + + var removed = state.files.filter(function(file) { return !incomingIds.has(file.pickerId); }); + removed.forEach(function(file) { + var task = uploadTasks.get(file.pickerId); + if (task && task.phase === 'put') task.controller.abort(); + }); + + var changed = added.length > 0 || removed.length > 0 || toQueue.length > 0; + state.files = nextFiles; + if (!state.files.some(function(file) { return file.doc_id; })) state.contractId = null; + if (changed) { + resetStepper('stepUpload'); + var classifyButton = document.getElementById('classifyBtn'); + if (classifyButton) classifyButton.style.display = 'none'; + var compareButton = document.getElementById('llmBtn'); + if (compareButton) compareButton.style.display = 'none'; } render(state); - // Фаза 2: загрузка по одному с обновлением строки - for (var j = 0; j < state.files.length; j++) { - var entry = state.files[j]; - var pendingFile = entry._pendingFile; - if (!pendingFile) continue; - delete entry._pendingFile; - var f = pendingFile; - try { - var resp = await uploadFile(f, function(st) { - entry.status = st; - render(state); - }, entry.zip_source); - if (resp && resp.contract_id) state.contractId = resp.contract_id; - entry.doc_id = resp.doc_id; - entry.status = { kind: 'uploaded' }; - entry.uploaded = true; - var pr = resp.parsed; - var elapsed = pr && pr.status === 'parsed' ? '0.0' : null; - applyParseResult(entry, pr, elapsed); - } catch(err) { - entry.status = { kind: 'error', text: err.message }; - } - render(state); - } - - await finalizeUpload(); + toQueue.forEach(enqueuePickerUpload); + if (removed.length) scheduleBatchSync(); } diff --git a/site/templates/index.html b/site/templates/index.html index 859d3c4..2b1cfe4 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -68,6 +68,26 @@ .text-pane th { font-size: 10px; padding: 4px 6px; } .text-pane td { padding: 3px 6px; font-size: 11px; } .text-pane .pane-title { font-weight: 600; font-size: 12px; margin-bottom: 8px; color: var(--muted); text-transform: uppercase; } + #filePicker .file-picker { color: var(--foreground); font-family: system-ui, sans-serif; } + #filePicker .fp-toolbar { margin: 0 0 8px; } + #filePicker .fp-toolbar button { min-height: 36px; border: 1px solid var(--brand-gray); border-radius: 6px; padding: 0 14px; color: var(--foreground); background: var(--background); font: inherit; } + #filePicker .fp-toolbar button:first-of-type { color: #fff; background: var(--brand-primary); border-color: var(--brand-primary); } + #filePicker .fp-toolbar button.secondary { color: var(--foreground); background: var(--background); } + #filePicker .fp-toolbar button.quiet { color: var(--muted); } + #filePicker .fp-table-wrap { max-height: 50vh; overflow: auto; border: 1px solid var(--brand-gray); border-radius: 6px; } + #filePicker .fp-table { font: 13px system-ui, sans-serif; } + #filePicker .fp-table th, #filePicker .fp-table td { padding: 6px 10px; border-color: var(--brand-gray); } + #filePicker .fp-table th { color: var(--muted); letter-spacing: 0; } + #filePicker .tree-name { min-width: 0; padding-top: 0; padding-bottom: 0; color: var(--foreground); font-family: inherit; } + #filePicker .tree-file .tree-name { overflow: hidden; text-overflow: ellipsis; } + #filePicker .fp-actions { white-space: nowrap; } + #filePicker .fp-action { margin-right: 4px; padding: 3px 6px; border: 0; border-radius: 4px; color: var(--brand-primary); background: transparent; cursor: pointer; font: 11px system-ui, sans-serif; } + #filePicker .fp-status-uploading { color: var(--brand-primary); } + #filePicker .fp-status-parsing { color: var(--brand-primary); } + #filePicker .fp-status-parsed { color: var(--green); } + #filePicker .fp-status-duplicate { color: var(--muted); } + #filePicker .fp-status-error { color: var(--destructive); } + #filePicker .fp-status, #filePicker .fp-count { color: var(--muted); font: 12px system-ui, sans-serif; } @@ -90,22 +110,9 @@ Загрузка договоров/приложений/спецификаций
- - -
- -
Порядок определяется автоматически при классификации
-
⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.
- -
- - - - - -
ИмяИзменёнРазмерПарсингТекст✕
-
+
Выберите PDF, DOC или DOCX. ZIP раскрываются рекурсивно; правила из корневого obscure.lst применяются при выборе папки.
+
- + diff --git a/tests/test_routes.py b/tests/test_routes.py index 8be456a..f44854c 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -46,3 +46,51 @@ class TestGroups: def test_missing_batch(self, client): r = client.get("/api/groups") assert r.status_code == 400 + + +class TestSync: + def test_requires_batch_id(self, client): + response = client.post("/api/sync", json={"keep_ids": []}) + assert response.status_code == 400 + assert response.get_json()["error"] == "batch_id required" + + def test_only_deletes_documents_in_requested_batch(self, client): + from db import documents + from db.connection import execute, query + + doc_a = documents.insert("a.pdf", "application/pdf", "", batch_id="batch-a") + doc_b = documents.insert("b.pdf", "application/pdf", "", batch_id="batch-b") + execute( + "INSERT INTO prompts (id, role, name, body) VALUES (%s, %s, %s, %s)", + ("prompt-1", "classify", "custom", "keep me"), + ) + + response = client.post( + "/api/sync", + json={"batch_id": "batch-a", "keep_ids": []}, + ) + + assert response.status_code == 200 + assert response.get_json()["deleted"] == 1 + assert documents.get(doc_a["id"]) is None + assert documents.get(doc_b["id"]) is not None + assert query("SELECT body FROM prompts WHERE id = %s", ("prompt-1",)) == [ + {"body": "keep me"} + ] + + def test_cleanup_endpoint_is_not_exposed(self, client): + response = client.post("/api/cleanup") + assert response.status_code == 404 + + def test_file_picker_bundle_is_served(self, client): + response = client.get("/file-picker/file-picker.iife.js") + assert response.status_code == 200 + assert b"initFilePicker" in response.data + + def test_application_startup_preserves_database(self, client): + from app import create_app + from db import documents + + doc = documents.insert("keep.pdf", "application/pdf", "", batch_id="batch-a") + create_app() + assert documents.get(doc["id"]) is not None diff --git a/tests/test_upload_refs.py b/tests/test_upload_refs.py new file mode 100644 index 0000000..ad535cd --- /dev/null +++ b/tests/test_upload_refs.py @@ -0,0 +1,69 @@ +import os +import sys + +from flask import Flask + +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + + +def test_sink_receives_contract_metadata_without_creating_session(monkeypatch): + from upload.backend.upload_refs import blueprint + + calls = [] + + class FakeHttpClient: + def __init__(self, **_kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def delete(self, _url): + return None + + def sink(name, content, **metadata): + calls.append((name, content, metadata)) + return {"ok": True, "doc_id": "doc-1"} + + def unexpected_create_session(): + raise AssertionError("sink mode must not create a session") + + monkeypatch.setattr(blueprint.httpx, "Client", FakeHttpClient) + monkeypatch.setattr(blueprint, "pull_file", lambda *_args, **_kwargs: b"document") + monkeypatch.setattr(blueprint, "create_session", unexpected_create_session) + + app = Flask(__name__) + app.register_blueprint(blueprint.create_upload_refs_blueprint( + {"apiPrefix": "/api", "vmUploadPrefix": "https://vm.example/uploads/"}, + sink=sink, + )) + + response = app.test_client().post("/api/upload_refs", json={ + "batch_id": "batch-1", + "contract_id": "contract-1", + "zip_source": "folder/archive.zip", + "files": [{ + "name": "agreement.pdf", + "size": 8, + "url": "https://vm.example/uploads/token", + }], + }) + + assert response.status_code == 200 + assert response.get_json()["results"] == [ + {"name": "agreement.pdf", "ok": True, "doc_id": "doc-1"} + ] + assert calls == [( + "agreement.pdf", + b"document", + { + "batch_id": "batch-1", + "contract_id": "contract-1", + "zip_source": "folder/archive.zip", + }, + )] diff --git a/upload/README.md b/upload/README.md index f0826d5..f660f86 100644 --- a/upload/README.md +++ b/upload/README.md @@ -30,49 +30,49 @@ upload/ ## Подключение фронта -Подключить ES-модули (`' }]]), + documents: new Map([['file-1', 'doc-1']]), + }; + const elements = { tableBodyEl: { innerHTML: '' }, countEl: { textContent: '' } }; + render(state, elements); + assert.match(elements.tableBodyEl.innerHTML, /data-details="file-1"/); + assert.match(elements.tableBodyEl.innerHTML, /data-text="file-1"/); + assert.match(elements.tableBodyEl.innerHTML, /<script>bad<\/script>/); + assert.doesNotMatch(elements.tableBodyEl.innerHTML, /