Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b0bcd298a | ||
|
|
f476d6cd14 | ||
|
|
138aa6756a | ||
|
|
ec5a71cbe8 | ||
|
|
baa7418cfd | ||
|
|
bbb05e361a | ||
|
|
3c0c3a2043 | ||
|
|
eb61a1b356 | ||
|
|
704d41e027 | ||
|
|
49171c93f9 |
@@ -28,7 +28,7 @@ Browser
|
||||
-> GET /api/csv/<sid> Browser downloads the mapping separately
|
||||
```
|
||||
|
||||
The browser sends files one at a time: it waits for the WebDAV `PUT`, then asks Flask to pull that file before moving to the next one. The buffer therefore holds files only during transit, not for the complete processing session. If the Flask pull fails, the buffer object may remain until manually removed or otherwise cleaned up; the current Nginx config does not define a TTL cleanup job.
|
||||
The browser sends files one at a time: it waits for the WebDAV `PUT`, then asks Flask to pull that file before moving to the next one. The buffer therefore holds files only during transit, not for the complete processing session. If the Flask pull fails, the buffer object may remain until manually removed or otherwise cleaned up; install the TTL cron cleanup described below on VM213.
|
||||
|
||||
## Application Components
|
||||
|
||||
@@ -40,7 +40,7 @@ The browser sends files one at a time: it waits for the WebDAV `PUT`, then asks
|
||||
- `upload/frontend/upload/put_to_vm.js` sends a raw file body with `PUT` and reports progress.
|
||||
- `upload/frontend/upload/upload_via_vm.js` coordinates the per-file `PUT` and `/api/upload_refs` requests.
|
||||
|
||||
The UI accepts `.pdf`, `.doc`, `.docx`, `.txt`, `.md`, and `.zip` inputs. ZIP archives are expanded by the browser picker, and their allowed inner files are uploaded individually. The backend `drhider/extractor.py` also expands ZIPs received directly (for example, through `POST /api/upload`). Per-file (50 MiB) and per-session (500 MiB) limits are enforced by the backend; the picker's entry/size/depth limits apply only while unpacking ZIP archives.
|
||||
The UI accepts `.pdf`, `.doc`, `.docx`, `.txt`, `.md`, and `.zip` inputs. CSV input is temporarily disabled in the picker and rejected by both upload endpoints; CSV files inside ZIP archives are not extracted. The CSV parsing and obfuscation code remains in `drhider/extractor.py` for internal use and possible re-enabling after reliability work on the other formats. This does not affect the separate mapping CSV generated as an output for each processing session. ZIP archives are expanded by the browser picker, and their allowed inner files are uploaded individually. The backend `drhider/extractor.py` also expands ZIPs received directly (for example, through `POST /api/upload`). Per-file (50 MiB) and per-session (500 MiB) limits are enforced by the backend; the picker's entry/size/depth limits apply only while unpacking ZIP archives.
|
||||
|
||||
### Flask composition and routes
|
||||
|
||||
@@ -58,7 +58,7 @@ Main endpoints:
|
||||
| `GET /upload/<path>`, `GET /dist/<path>` | Serve raw picker ES modules and dist assets |
|
||||
| `POST /api/upload_refs` | Pull browser-uploaded buffer objects into a Flask session |
|
||||
| `POST /api/upload` | Direct multipart upload endpoint |
|
||||
| `GET /api/session_files/<sid>` | Inspect files in a session (diagnostic endpoint) |
|
||||
| `GET /api/session_files/<sid>` | Inspect files in a session (diagnostic endpoint; disabled unless `ENABLE_SESSION_FILES=1`) |
|
||||
| `GET /api/process_stream/<sid>` | Process a session and stream progress using Server-Sent Events |
|
||||
| `POST /api/process/<sid>` | Synchronous/legacy processing endpoint |
|
||||
| `POST /api/cancel/<sid>` | Request a cooperative stop between processing steps |
|
||||
@@ -77,7 +77,7 @@ Main endpoints:
|
||||
|
||||
The core package is split by responsibility:
|
||||
|
||||
- `drhider/extractor.py` converts `.docx`, `.pdf`, and `.doc` content to text/Markdown and expands ZIP archives with ZIP-bomb protections. `.doc` conversion calls the configured conversion service.
|
||||
- `drhider/extractor.py` converts `.docx`, `.pdf`, and `.doc` content to text/Markdown, expands ZIP archives with ZIP-bomb protections, and retains internal CSV extraction/obfuscation code. CSV input is temporarily disabled at upload entry points while reliability work focuses on other formats; the separate output mapping CSV remains enabled. `.doc` conversion calls the configured conversion service.
|
||||
- `drhider/scanner.py` detects entities with regular expressions and LLM-based NER, building the original-to-replacement mapping.
|
||||
- `drhider/replacer.py` applies that mapping to extracted text.
|
||||
- `drhider/builder.py` builds the obfuscated ZIP and mapping CSV. The mapping CSV is deliberately not included inside the ZIP; it is downloaded through its separate endpoint.
|
||||
@@ -85,7 +85,7 @@ The core package is split by responsibility:
|
||||
|
||||
Processing runs in a worker thread for the SSE endpoint. Progress, per-file events, LLM activity, completion, cancellation, and errors are sent to the browser as SSE events. Cancellation is cooperative; the current operation/file may finish before the worker stops.
|
||||
|
||||
The LLM client reads `LLM_KEY` or `LLM_API_KEY`, with optional `LLM_URL` (default `https://api.aillm.ru/v1/chat/completions`) and `LLM_MODEL` (default `gpt-oss-120b`), from the Managed Flask environment. The `.doc` converter URL is configured with `CONVERT_SERVICE_URL` (default `https://liberta.containerk8s.dev.nubes.ru/convert`); `.doc` files are converted sequentially to avoid overloading the conversion service. Secrets belong in service environment settings, never in this repository.
|
||||
The LLM client reads `LLM_KEY` or `LLM_API_KEY`, with optional `LLM_URL` (default `https://api.aillm.ru/v1/chat/completions`) and `LLM_MODEL` (default `gpt-oss-120b`), from the Managed Flask environment. `LLM_RETRIES` sets the number of retries after the first request (default `3`), and `LLM_RETRY_DELAY` sets the base backoff in seconds (default `2.0`; delays grow linearly). The `.doc` converter URL is configured with `CONVERT_SERVICE_URL` (default `https://liberta.containerk8s.services.ngcloud.ru/convert`); `.doc` files are converted sequentially to avoid overloading the conversion service. Secrets belong in service environment settings, never in this repository.
|
||||
|
||||
## VM213 WebDAV Buffer
|
||||
|
||||
@@ -100,6 +100,12 @@ The source of the Obfuscator Nginx config is [`deploy/vm213/nginx/obfuscator-upl
|
||||
|
||||
The CORS allowlist is for the Obfuscator browser origin `https://obfuscator.pythonk8s.services.ngcloud.ru`. CORS is a browser policy, **not authentication**; direct HTTP clients are not blocked by CORS. The VM disk is temporary staging, not durable storage. A VM or disk failure can lose files that have not yet been pulled by Flask.
|
||||
|
||||
Install a cron job on VM213 to remove orphaned upload-buffer files older than 30 minutes. The cleanup command is:
|
||||
|
||||
```sh
|
||||
find /var/www/obfuscator-upload/ -type f -mmin +30 -delete
|
||||
```
|
||||
|
||||
To update the VM config, update this repository source, install the same file on VM213, run `nginx -t`, and reload Nginx only if validation succeeds. The Managed Flask deployment and Nginx deployment are independent; deploying one does not deploy the other.
|
||||
|
||||
## Repository Map
|
||||
|
||||
@@ -56,3 +56,7 @@ server {
|
||||
}
|
||||
}
|
||||
# End of Obfuscator's VM213 upload-buffer config.
|
||||
|
||||
# TTL cleanup (configure separately as a cron job on VM213; this file does not
|
||||
# install or schedule the job):
|
||||
# find /var/www/obfuscator-upload/ -type f -mmin +30 -delete
|
||||
+2
-16
@@ -8,13 +8,13 @@
|
||||
|
||||
import io
|
||||
import csv
|
||||
import os
|
||||
import zipfile
|
||||
import re
|
||||
import time
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from .config import ENTITY_PATTERNS, COMPANY_PATTERN
|
||||
from .names import resolve_name
|
||||
|
||||
|
||||
def build_zip(files: List[Tuple[str, bytes]], mapping_csv: str = "") -> bytes:
|
||||
@@ -39,24 +39,10 @@ def build_zip(files: List[Tuple[str, bytes]], mapping_csv: str = "") -> bytes:
|
||||
# Дедупликация имён: имя+одинаковый контент → пропуск; имя+разный контент → суффикс
|
||||
seen: Dict[str, bytes] = {}
|
||||
|
||||
def _resolve_name(name: str, content: bytes) -> str:
|
||||
if name not in seen:
|
||||
seen[name] = content
|
||||
return name
|
||||
if seen[name] == content:
|
||||
return None # точный дубль — пропускаем
|
||||
base, ext = os.path.splitext(name)
|
||||
n = 2
|
||||
while f"{base}_{n}{ext}" in seen:
|
||||
n += 1
|
||||
resolved = f"{base}_{n}{ext}"
|
||||
seen[resolved] = content
|
||||
return resolved
|
||||
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
# Добавляем обфусцированные файлы
|
||||
for fname, content in files:
|
||||
out_name = _resolve_name(fname, content)
|
||||
out_name = resolve_name(fname, content, seen)
|
||||
if out_name is None:
|
||||
continue
|
||||
info = zipfile.ZipInfo(out_name)
|
||||
|
||||
+102
-8
@@ -1,10 +1,11 @@
|
||||
"""
|
||||
Извлечение текста из документов — все форматы → Markdown.
|
||||
Извлечение текста из документов и подготовка CSV к обфускации.
|
||||
|
||||
Поддерживает:
|
||||
- .docx → MD (python-docx: стили, жирный/курсив, таблицы)
|
||||
- .pdf → MD (pdfplumber: текст + таблицы, без форматирования)
|
||||
- .doc → MD (LibreOffice headless: .doc → .docx → штатный docx_to_markdown)
|
||||
- .doc → HTTP-сервис конвертации (CONVERT_SERVICE_URL) → .docx → Markdown
|
||||
- .csv → текст ячеек для сканирования и обезличенный CSV на выходе
|
||||
- .txt и прочие → как есть
|
||||
|
||||
Также содержит:
|
||||
@@ -12,13 +13,16 @@
|
||||
"""
|
||||
|
||||
import io
|
||||
import csv
|
||||
import os
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
from typing import List, Tuple
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from . import replacer
|
||||
|
||||
log = logging.getLogger("drhider")
|
||||
|
||||
@@ -175,7 +179,7 @@ def doc_to_markdown(content: bytes) -> str:
|
||||
import httpx
|
||||
url = os.environ.get(
|
||||
"CONVERT_SERVICE_URL",
|
||||
"https://liberta.containerk8s.dev.nubes.ru/convert"
|
||||
"https://liberta.containerk8s.services.ngcloud.ru/convert"
|
||||
)
|
||||
try:
|
||||
with httpx.Client(timeout=120) as client:
|
||||
@@ -199,6 +203,89 @@ def doc_to_markdown(content: bytes) -> str:
|
||||
# Единая точка входа
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def decode_text(content: bytes) -> str:
|
||||
"""Декодировать текст как UTF-8, затем CP1251, с Latin-1 последним fallback."""
|
||||
try:
|
||||
return content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
# Блокнот и выгрузки 1С часто сохраняют кириллицу в CP1251.
|
||||
try:
|
||||
return content.decode("cp1251")
|
||||
except UnicodeDecodeError:
|
||||
return content.decode("latin-1", errors="replace")
|
||||
|
||||
|
||||
def detect_delimiter(text: str) -> str:
|
||||
"""Определить CSV-разделитель, используя Sniffer и детерминированный fallback."""
|
||||
delimiters = [",", ";", "\t"]
|
||||
nonempty_lines = [line for line in text.splitlines() if line.strip()]
|
||||
if nonempty_lines:
|
||||
sample = "\n".join(nonempty_lines[:10])
|
||||
try:
|
||||
return csv.Sniffer().sniff(sample, delimiters="".join(delimiters)).delimiter
|
||||
except csv.Error:
|
||||
first_line = nonempty_lines[0]
|
||||
return max(delimiters, key=first_line.count)
|
||||
return ","
|
||||
|
||||
|
||||
def csv_to_text(content: bytes) -> str:
|
||||
"""Распарсить CSV и склеить значения ячеек построчно для сканирования.
|
||||
|
||||
Входная загрузка CSV временно отключена, чтобы сосредоточить тестирование
|
||||
надёжности на остальных форматах. Функция оставлена для внутренней
|
||||
обработки и будущего повторного включения поддержки; не удалять её
|
||||
и связанную с ней логику вместе с UI/API-ограничением загрузки.
|
||||
"""
|
||||
text = decode_text(content).removeprefix("\ufeff")
|
||||
delimiter = detect_delimiter(text)
|
||||
reader = csv.reader(
|
||||
io.StringIO(text, newline=""),
|
||||
delimiter=delimiter,
|
||||
quotechar='"',
|
||||
strict=True,
|
||||
)
|
||||
# Каждая ячейка отделена переводом строки, чтобы сущности из соседних ячеек
|
||||
# не объединялись в строку, по которой работают regex и LLM.
|
||||
return "\n".join(cell for row in reader for cell in row)
|
||||
|
||||
|
||||
def obfuscate_csv(content: bytes, mapping: Dict[str, str],
|
||||
sorted_keys: List[str], compiled_re) -> bytes:
|
||||
"""Заменить сущности в CSV-ячейках, сохранив порядок строк и столбцов.
|
||||
|
||||
Входная загрузка CSV временно отключена для фокусировки на надёжности
|
||||
остальных форматов. Обработчик сохраняется для внутреннего использования
|
||||
и будущего повторного включения; CSV-файл mapping, выдаваемый как результат,
|
||||
является отдельной функцией и этой временной блокировкой не затрагивается.
|
||||
"""
|
||||
text = decode_text(content).removeprefix("\ufeff")
|
||||
delimiter = detect_delimiter(text)
|
||||
reader = csv.reader(
|
||||
io.StringIO(text, newline=""),
|
||||
delimiter=delimiter,
|
||||
quotechar='"',
|
||||
strict=True,
|
||||
)
|
||||
output = io.StringIO(newline="")
|
||||
writer = csv.writer(
|
||||
output,
|
||||
delimiter=delimiter,
|
||||
quotechar='"',
|
||||
lineterminator="\n",
|
||||
strict=True,
|
||||
)
|
||||
for row in reader:
|
||||
writer.writerow([
|
||||
replacer.apply_replacements(cell, mapping, sorted_keys, compiled_re)
|
||||
for cell in row
|
||||
])
|
||||
|
||||
# csv.writer нормализует кавычки/переводы строк; побайтовое сохранение
|
||||
# исходного формата не является целью. BOM помогает Excel открыть кириллицу.
|
||||
return ("\ufeff" + output.getvalue()).encode("utf-8")
|
||||
|
||||
|
||||
def extract_text(fname: str, content: bytes, ctype: str = "") -> str:
|
||||
"""Извлечь текст из одного файла → Markdown.
|
||||
|
||||
@@ -217,22 +304,26 @@ def extract_text(fname: str, content: bytes, ctype: str = "") -> str:
|
||||
try:
|
||||
return docx_to_markdown(content)
|
||||
except ImportError:
|
||||
return content.decode('utf-8', errors='replace')
|
||||
return decode_text(content)
|
||||
|
||||
# ── .pdf: текст + таблицы ──
|
||||
elif ext == '.pdf':
|
||||
try:
|
||||
return pdf_to_markdown(content)
|
||||
except ImportError:
|
||||
return content.decode('utf-8', errors='replace')
|
||||
return decode_text(content)
|
||||
|
||||
# ── .doc: бинарный → LibreOffice → DOCX → Markdown ──
|
||||
# ── .doc: бинарный → HTTP-сервис конвертации → DOCX → Markdown ──
|
||||
elif ext == '.doc':
|
||||
return doc_to_markdown(content)
|
||||
|
||||
# ── .csv: ячейки для обнаружения сущностей ──
|
||||
elif ext == '.csv':
|
||||
return csv_to_text(content)
|
||||
|
||||
# ── .txt и прочие ──
|
||||
else:
|
||||
return content.decode('utf-8', errors='replace')
|
||||
return decode_text(content)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -338,6 +429,9 @@ def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, s
|
||||
continue
|
||||
# Синхронизация с фронтом (listZipFiles): из zip берём ТОЛЬКО документы
|
||||
low = name.lower()
|
||||
# CSV-процессор сохранён, но пользовательский ввод CSV
|
||||
# временно отключён. Не извлекаем CSV из ZIP, чтобы обход
|
||||
# ограничения через архив не включал эту ветку обработки.
|
||||
if not (low.endswith('.zip') or low.endswith(('.pdf', '.doc', '.docx', '.txt', '.md'))):
|
||||
continue
|
||||
|
||||
|
||||
+39
-6
@@ -11,6 +11,8 @@ LLM-клиент для Obfuscator.
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
@@ -21,9 +23,12 @@ class LLMClient:
|
||||
LLM_API_KEY или LLM_KEY — ключ API
|
||||
LLM_URL — URL эндпоинта (по умолчанию https://api.aillm.ru/v1/chat/completions)
|
||||
LLM_MODEL — модель (по умолчанию gpt-oss-120b)
|
||||
LLM_RETRIES — число повторов после первой попытки (по умолчанию 3)
|
||||
LLM_RETRY_DELAY — базовая задержка backoff в секундах (по умолчанию 2)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, retries: Optional[int] = None,
|
||||
retry_delay: Optional[float] = None):
|
||||
"""Инициализировать клиент. httpx импортируется лениво.
|
||||
|
||||
Ведёт кумулятивные счётчики за всё время жизни клиента:
|
||||
@@ -36,6 +41,13 @@ class LLMClient:
|
||||
llm_started — time.time() начала текущего LLM-вызова
|
||||
"""
|
||||
self._httpx = httpx
|
||||
self.retries = int(os.environ.get("LLM_RETRIES", "3")) if retries is None else retries
|
||||
self.retry_delay = (
|
||||
float(os.environ.get("LLM_RETRY_DELAY", "2.0"))
|
||||
if retry_delay is None else retry_delay
|
||||
)
|
||||
if self.retries < 0 or self.retry_delay < 0:
|
||||
raise ValueError("LLM retries and retry delay must be non-negative")
|
||||
self.tokens_prompt = 0
|
||||
self.tokens_completion = 0
|
||||
self.llm_sec = 0.0
|
||||
@@ -52,6 +64,31 @@ class LLMClient:
|
||||
return time.time() - self.llm_started
|
||||
return 0.0
|
||||
|
||||
def _post_with_retries(self, url: str, json: dict, headers: dict,
|
||||
timeout: int) -> httpx.Response:
|
||||
"""Повторить только временные транспортные ошибки и выбранные HTTP-статусы."""
|
||||
for attempt in range(self.retries + 1):
|
||||
try:
|
||||
response = self._httpx.post(
|
||||
url, json=json, headers=headers, timeout=timeout
|
||||
)
|
||||
self.llm_sec += response.elapsed.total_seconds()
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except (self._httpx.TimeoutException, self._httpx.TransportError):
|
||||
if attempt >= self.retries:
|
||||
raise
|
||||
except self._httpx.HTTPStatusError as e:
|
||||
# Retry only throttling and server failures; 400/401/403 require
|
||||
# a corrected request or credentials and will not improve on retry.
|
||||
if e.response.status_code not in (429, 500, 502, 503, 504):
|
||||
raise
|
||||
if attempt >= self.retries:
|
||||
raise
|
||||
|
||||
# 120 секунд на попытку и backoff означают около 8 минут в худшем случае.
|
||||
time.sleep(self.retry_delay * (attempt + 1))
|
||||
|
||||
def complete(self, prompt: str) -> str:
|
||||
"""Отправить промпт в LLM и вернуть текст ответа.
|
||||
|
||||
@@ -76,7 +113,7 @@ class LLMClient:
|
||||
self.llm_active = True
|
||||
self.llm_started = time.time()
|
||||
try:
|
||||
r = self._httpx.post(
|
||||
r = self._post_with_retries(
|
||||
url,
|
||||
json={
|
||||
"model": model,
|
||||
@@ -90,10 +127,6 @@ class LLMClient:
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
# Время запроса (реальное время HTTP-вызова LLM)
|
||||
self.llm_sec += r.elapsed.total_seconds()
|
||||
|
||||
data = r.json()
|
||||
# Счётчики токенов из usage (могут отсутствовать у некоторых прокси)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Helpers for assigning stable, collision-free output filenames."""
|
||||
|
||||
import os
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
def resolve_name(name: str, content: bytes, seen: Dict[str, bytes]) -> Optional[str]:
|
||||
"""Return a unique name, or None when the same name and content were seen.
|
||||
|
||||
Name collisions with different content receive numeric suffixes before
|
||||
the extension, while exact duplicates are omitted.
|
||||
"""
|
||||
if name not in seen:
|
||||
seen[name] = content
|
||||
return name
|
||||
if seen[name] == content:
|
||||
return None
|
||||
|
||||
base, ext = os.path.splitext(name)
|
||||
index = 2
|
||||
while f"{base}_{index}{ext}" in seen:
|
||||
index += 1
|
||||
resolved = f"{base}_{index}{ext}"
|
||||
seen[resolved] = content
|
||||
return resolved
|
||||
+69
-34
@@ -17,13 +17,22 @@ from . import extractor
|
||||
from . import scanner
|
||||
from . import replacer
|
||||
from . import builder
|
||||
from .names import resolve_name
|
||||
|
||||
log = logging.getLogger("drhider")
|
||||
|
||||
|
||||
class LLMIncompleteError(Exception):
|
||||
"""Processing cannot safely return files when one or more LLM chunks failed."""
|
||||
|
||||
def __init__(self, llm_errors: int):
|
||||
self.llm_errors = llm_errors
|
||||
super().__init__("LLM processing incomplete")
|
||||
|
||||
|
||||
def _dedupe_file_names(
|
||||
files: List[Tuple[str, bytes, str]]
|
||||
) -> List[Tuple[str, bytes, str]]:
|
||||
) -> Tuple[List[Tuple[str, bytes, str]], List[List[int]]]:
|
||||
"""Уникализировать имена файлов перед обработкой.
|
||||
|
||||
Точный дубль (одинаковое имя + одинаковое содержимое) пропускается.
|
||||
@@ -33,25 +42,24 @@ def _dedupe_file_names(
|
||||
files: [(filename, content_bytes, content_type), ...]
|
||||
|
||||
Returns:
|
||||
Новый список с уникальными именами (порядок сохранён).
|
||||
Новый список с уникальными именами (порядок сохранён) и исходные индексы,
|
||||
соответствующие каждому файлу. Точные дубликаты ссылаются на один результат.
|
||||
"""
|
||||
seen: Dict[str, bytes] = {}
|
||||
out: List[Tuple[str, bytes, str]] = []
|
||||
source_indices: List[List[int]] = []
|
||||
source_to_output: Dict[Tuple[str, bytes], int] = {}
|
||||
|
||||
for fname, content, ctype in files:
|
||||
candidate = fname
|
||||
if candidate in seen:
|
||||
if seen[candidate] == content:
|
||||
continue # точный дубль — пропускаем
|
||||
base, ext = os.path.splitext(fname)
|
||||
n = 2
|
||||
while f"{base}_{n}{ext}" in seen:
|
||||
n += 1
|
||||
candidate = f"{base}_{n}{ext}"
|
||||
seen[candidate] = content
|
||||
out.append((candidate, content, ctype))
|
||||
for source_idx, (fname, content, ctype) in enumerate(files):
|
||||
candidate = resolve_name(fname, content, seen)
|
||||
if candidate is not None:
|
||||
out.append((candidate, content, ctype))
|
||||
source_to_output[(fname, content)] = len(out) - 1
|
||||
source_indices.append([source_idx])
|
||||
else:
|
||||
source_indices[source_to_output[(fname, content)]].append(source_idx)
|
||||
|
||||
return out
|
||||
return out, source_indices
|
||||
|
||||
|
||||
class TwoPassObfuscator:
|
||||
@@ -88,7 +96,7 @@ class TwoPassObfuscator:
|
||||
|
||||
def obfuscate(
|
||||
self, files: List[Tuple[str, bytes, str]],
|
||||
progress_cb: Optional[Callable[[str, int, int, str], None]] = None,
|
||||
progress_cb: Optional[Callable[[str, int, int, str, float, List[int]], None]] = None,
|
||||
cancel_event=None, file_progress=None
|
||||
) -> Tuple[bytes, str, Optional[dict]]:
|
||||
"""Обфусцировать список файлов.
|
||||
@@ -97,14 +105,18 @@ class TwoPassObfuscator:
|
||||
|
||||
Args:
|
||||
files: [(filename, content_bytes, content_type), ...]
|
||||
progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed),
|
||||
где phase ∈ {"start", "done"}, idx — 0-based индекс,
|
||||
elapsed — время обработки конкретного файла (сек).
|
||||
progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed, source_idxs),
|
||||
phase — extract_start/extract_done/extract_failed или
|
||||
replace_start/replace_done/replace_failed; idx — 0-based индекс,
|
||||
elapsed — время обработки конкретного файла (сек),
|
||||
source_idxs — индексы строк исходного списка, включая дубликаты.
|
||||
cancel_event: threading.Event — мягкая остановка. Проверяется между
|
||||
файлами LLM и между файлами замены; текущий файл добирается.
|
||||
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам.
|
||||
События: extract_done{total_chars}, file_start{chars,chunks,idx},
|
||||
file_chunk{chunks_done,chunks_total,idx}, file_done{elapsed,idx}.
|
||||
События: extract_complete{total_chars},
|
||||
file_start{chars,chunks,idx,source_idxs},
|
||||
file_chunk{chunks_done,chunks_total,idx,source_idxs},
|
||||
file_done{elapsed,idx,source_idxs}.
|
||||
|
||||
Returns:
|
||||
(zip_bytes, csv_string, meta):
|
||||
@@ -115,7 +127,7 @@ class TwoPassObfuscator:
|
||||
"""
|
||||
# ── Предобработка: распаковать ZIP + уникализировать имена ──
|
||||
files = extractor.expand_zips(files)
|
||||
files = _dedupe_file_names(files)
|
||||
files, source_indices = _dedupe_file_names(files)
|
||||
|
||||
# имя файла (внутр. ключ all_texts) -> idx в files (для событий file_progress)
|
||||
name_to_idx = {fname: i for i, (fname, _, _) in enumerate(files)}
|
||||
@@ -125,7 +137,9 @@ class TwoPassObfuscator:
|
||||
if event == "file_done":
|
||||
llm_done.add(fname)
|
||||
if file_progress:
|
||||
fields["idx"] = name_to_idx.get(fname)
|
||||
idx = name_to_idx.get(fname)
|
||||
fields["idx"] = idx
|
||||
fields["source_idxs"] = source_indices[idx] if idx is not None else []
|
||||
file_progress(event, fname, **fields)
|
||||
|
||||
try:
|
||||
@@ -149,7 +163,7 @@ class TwoPassObfuscator:
|
||||
break
|
||||
display_name = os.path.basename(fname) or fname
|
||||
if progress_cb:
|
||||
progress_cb("start", i, total, display_name, 0.0)
|
||||
progress_cb("extract_start", i, total, display_name, 0.0, source_indices[i])
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
@@ -158,9 +172,11 @@ class TwoPassObfuscator:
|
||||
log.warning("obfuscate: skip file %r: %r", fname, e)
|
||||
skipped.add(fname)
|
||||
if progress_cb:
|
||||
progress_cb("done", i, total, display_name, 0.0)
|
||||
progress_cb("extract_failed", i, total, display_name, 0.0, source_indices[i])
|
||||
continue
|
||||
all_texts[fname] = text
|
||||
if progress_cb:
|
||||
progress_cb("extract_done", i, total, display_name, 0.0, source_indices[i])
|
||||
|
||||
# Regex-сканирование (быстрое, локальное)
|
||||
if text and not text.startswith("[DOC binary"):
|
||||
@@ -170,20 +186,25 @@ class TwoPassObfuscator:
|
||||
# Объём извлечённого текста — для оценки времени (глобальной ETA)
|
||||
total_chars = sum(len(t) for t in all_texts.values())
|
||||
if file_progress:
|
||||
file_progress("extract_done", None, total_chars=total_chars,
|
||||
file_progress("extract_complete", None, total_chars=total_chars,
|
||||
per_file={fname: len(t) for fname, t in all_texts.items()})
|
||||
|
||||
# LLM-сканирование (получает уже найденное regex'ом чтобы не дублировать)
|
||||
cancelled_llm = cancelled_extract
|
||||
if not cancelled_llm and self._llm_client:
|
||||
try:
|
||||
scanner.scan_llm_ner(
|
||||
llm_failures = scanner.scan_llm_ner(
|
||||
all_texts, self._mapping, self._llm_client, self._counters,
|
||||
cancel_event=cancel_event, file_progress=_fp,
|
||||
)
|
||||
except scanner.CancelRequested:
|
||||
except scanner.CancelRequested as e:
|
||||
if e.llm_failures:
|
||||
raise LLMIncompleteError(e.llm_failures)
|
||||
log.info("obfuscate: LLM cancelled, llm_done=%d", len(llm_done))
|
||||
cancelled_llm = True
|
||||
else:
|
||||
if llm_failures:
|
||||
raise LLMIncompleteError(llm_failures)
|
||||
|
||||
# Предсортировать ключи один раз (по убыванию длины)
|
||||
self._sorted_keys = sorted(
|
||||
@@ -202,8 +223,6 @@ class TwoPassObfuscator:
|
||||
|
||||
# Битый файл (не удалось извлечь текст) — пропускаем
|
||||
if fname in skipped:
|
||||
if progress_cb:
|
||||
progress_cb("done", i, total, display_name, 0.0)
|
||||
continue
|
||||
|
||||
# При прерывании в LLM — в результат идут ТОЛЬКО файлы с завершённым LLM
|
||||
@@ -217,10 +236,23 @@ class TwoPassObfuscator:
|
||||
cancelled = True
|
||||
break
|
||||
|
||||
if progress_cb:
|
||||
progress_cb("replace_start", i, total, display_name, 0.0, source_indices[i])
|
||||
t0 = time.time()
|
||||
obf_content = content # По умолчанию — без изменений
|
||||
|
||||
if fname.endswith('.doc'):
|
||||
if fname.lower().endswith('.csv'):
|
||||
try:
|
||||
obf_content = extractor.obfuscate_csv(
|
||||
content, self._mapping, self._sorted_keys, self._compiled_re
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning("obfuscate: skip CSV %r during replacement: %r", fname, e)
|
||||
skipped.add(fname)
|
||||
if progress_cb:
|
||||
progress_cb("replace_failed", i, total, display_name, 0.0, source_indices[i])
|
||||
continue
|
||||
elif fname.endswith('.doc'):
|
||||
# .doc — конвертируется через liberta, обрабатывается как .docx
|
||||
md_text = all_texts.get(fname, '')
|
||||
replaced = replacer.apply_replacements(
|
||||
@@ -243,7 +275,10 @@ class TwoPassObfuscator:
|
||||
file_times[i] += time.time() - t0
|
||||
results.append((fname, obf_content))
|
||||
if progress_cb:
|
||||
progress_cb("done", i, total, display_name, round(file_times[i], 2))
|
||||
progress_cb(
|
||||
"replace_done", i, total, display_name,
|
||||
round(file_times[i], 2), source_indices[i],
|
||||
)
|
||||
|
||||
# ── Сборка результата ──
|
||||
csv_str = builder.build_mapping_csv(self._mapping)
|
||||
@@ -269,7 +304,7 @@ class TwoPassObfuscator:
|
||||
|
||||
def obfuscate_files(
|
||||
files: List[Tuple[str, bytes, str]], llm_client=None,
|
||||
progress_cb: Optional[Callable[[str, int, int, str], None]] = None,
|
||||
progress_cb: Optional[Callable[[str, int, int, str, float, List[int]], None]] = None,
|
||||
cancel_event=None, file_progress=None
|
||||
) -> Tuple[bytes, str, Optional[dict]]:
|
||||
"""Обфусцировать список файлов — удобная функция.
|
||||
@@ -279,7 +314,7 @@ def obfuscate_files(
|
||||
Args:
|
||||
files: [(filename, content_bytes, content_type), ...]
|
||||
llm_client: Опциональный LLM-клиент
|
||||
progress_cb: Опциональный коллбек (phase, idx, total, fname)
|
||||
progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed, source_idxs)
|
||||
cancel_event: threading.Event — мягкая остановка (см. TwoPassObfuscator.obfuscate)
|
||||
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам
|
||||
|
||||
|
||||
+73
-21
@@ -14,7 +14,7 @@ import json
|
||||
import random
|
||||
import time
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .config import (
|
||||
@@ -115,6 +115,9 @@ def scan_regex(text: str, mapping: Dict[str, str], counters: Dict[str, int]) ->
|
||||
class CancelRequested(Exception):
|
||||
"""Обработка прервана пользователем (мягкая остановка между файлами LLM)."""
|
||||
|
||||
def __init__(self, llm_failures: int = 0):
|
||||
self.llm_failures = llm_failures
|
||||
|
||||
# Приоритет границ для чанков (от предпочтительных к жёстким)
|
||||
_CHUNK_BOUNDARIES = ['\n\n', '\n', '. ', '? ', '! ', '; ', ', ', ' ']
|
||||
_CHUNK_SIZE = 6000 # символов на чанк (≈1500-2000 токенов RU — NER-качество)
|
||||
@@ -202,26 +205,53 @@ def _build_llm_prompt(text: str, already_found: List[str]) -> str:
|
||||
|
||||
|
||||
def _call_llm(text: str, llm_client) -> List[dict]:
|
||||
"""Один вызов LLM по фрагменту → список сущностей (пустой при ошибке)."""
|
||||
"""Один вызов LLM по фрагменту → проверенный список сущностей."""
|
||||
raw = llm_client.complete(text).strip()
|
||||
if raw.startswith("```"):
|
||||
lines = raw.splitlines()
|
||||
if lines and lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
raw = "\n".join(lines).strip()
|
||||
|
||||
try:
|
||||
raw = llm_client.complete(text)
|
||||
raw = raw.strip()
|
||||
if raw.startswith("```"):
|
||||
raw = raw.split("\n", 1)[1]
|
||||
if raw.endswith("```"):
|
||||
raw = raw[:-3]
|
||||
entities = json.loads(raw)
|
||||
if not isinstance(entities, list):
|
||||
return []
|
||||
return entities
|
||||
except Exception as e:
|
||||
log.warning("LLM NER failed: %s", e)
|
||||
return []
|
||||
except json.JSONDecodeError as original_error:
|
||||
# Модель иногда добавляет текст вокруг массива; raw_decode корректно
|
||||
# учитывает вложенные массивы и квадратные скобки внутри JSON-строк.
|
||||
decoder = json.JSONDecoder()
|
||||
entities = None
|
||||
for index, char in enumerate(raw):
|
||||
if char != "[":
|
||||
continue
|
||||
try:
|
||||
candidate, _ = decoder.raw_decode(raw, index)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(candidate, list):
|
||||
entities = candidate
|
||||
break
|
||||
if entities is None:
|
||||
raise ValueError("LLM response does not contain a JSON array") from original_error
|
||||
|
||||
if not isinstance(entities, list):
|
||||
raise ValueError("LLM response must be a JSON array")
|
||||
for entity in entities:
|
||||
if not isinstance(entity, dict):
|
||||
raise ValueError("LLM entity must be a JSON object")
|
||||
value = entity.get("value")
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError("LLM entity value must be a non-empty string")
|
||||
entity_type = entity.get("type")
|
||||
if entity_type is not None and not isinstance(entity_type, str):
|
||||
raise ValueError("LLM entity type must be a string")
|
||||
return entities
|
||||
|
||||
|
||||
def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
|
||||
llm_client, counters: Dict[str, int],
|
||||
cancel_event=None, file_progress=None) -> None:
|
||||
cancel_event=None, file_progress=None) -> int:
|
||||
"""Универсальное LLM-обнаружение приватных данных (пофайлово, целиком).
|
||||
|
||||
Каждый файл обрабатывается ПОЛНОСТЬЮ: текст разбивается на чанки
|
||||
@@ -243,11 +273,12 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
|
||||
file_items = sorted(
|
||||
all_texts.items(), key=lambda kv: len(kv[1]), reverse=True
|
||||
)
|
||||
llm_failures = 0
|
||||
|
||||
for fname, full_text in file_items:
|
||||
# Мягкая остановка ТОЛЬКО между файлами — текущий файл добирается до конца
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise CancelRequested()
|
||||
raise CancelRequested(llm_failures)
|
||||
|
||||
if not full_text or full_text.startswith("[DOC binary"):
|
||||
continue
|
||||
@@ -263,13 +294,30 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
|
||||
with ThreadPoolExecutor(max_workers=_LLM_CONCURRENCY) as _ex:
|
||||
futures = [_ex.submit(_call_llm, _build_llm_prompt(c, mapping.keys()), llm_client)
|
||||
for c in chunks]
|
||||
all_ent = []
|
||||
for fut in futures:
|
||||
all_ent.extend(fut.result())
|
||||
chunk_results = [None] * len(futures)
|
||||
future_indexes = {future: index for index, future in enumerate(futures)}
|
||||
completed = 0
|
||||
for fut in as_completed(futures):
|
||||
index = future_indexes[fut]
|
||||
try:
|
||||
chunk_results[index] = fut.result()
|
||||
except Exception as e:
|
||||
llm_failures += 1
|
||||
log.warning("LLM NER chunk failed in %r: %s", fname, e)
|
||||
chunk_results[index] = []
|
||||
completed += 1
|
||||
if file_progress:
|
||||
file_progress("file_chunk", fname,
|
||||
chunks_done=completed, chunks_total=len(chunks))
|
||||
all_ent = [ent for chunk_result in chunk_results for ent in chunk_result]
|
||||
else:
|
||||
all_ent = []
|
||||
for k, c in enumerate(chunks):
|
||||
all_ent.extend(_call_llm(_build_llm_prompt(c, mapping.keys()), llm_client))
|
||||
try:
|
||||
all_ent.extend(_call_llm(_build_llm_prompt(c, mapping.keys()), llm_client))
|
||||
except Exception as e:
|
||||
llm_failures += 1
|
||||
log.warning("LLM NER chunk failed in %r: %s", fname, e)
|
||||
if file_progress:
|
||||
file_progress("file_chunk", fname,
|
||||
chunks_done=k + 1, chunks_total=len(chunks))
|
||||
@@ -287,8 +335,12 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
|
||||
if not _verify_entity(val, full_text):
|
||||
log.debug("LLM hallucination dropped: %r", val)
|
||||
continue
|
||||
ent_type = ent.get("type", "").strip().lower().replace(" ", "_")
|
||||
raw_type = ent.get("type")
|
||||
ent_type = raw_type.strip().lower().replace(" ", "_") if raw_type else ""
|
||||
ent_type = ent_type or "other_id"
|
||||
mapping[val] = _next_token(ent_type, counters)
|
||||
|
||||
if file_progress:
|
||||
file_progress("file_done", fname, elapsed=round(time.time() - t0, 2))
|
||||
|
||||
return llm_failures
|
||||
|
||||
+2
-4
@@ -22,7 +22,7 @@ if _sys_path_root not in sys.path:
|
||||
sys.path.insert(0, _sys_path_root)
|
||||
|
||||
# Версия приложения (меняется при изменениях)
|
||||
VERSION = "1.0.2"
|
||||
VERSION = "1.1.5"
|
||||
|
||||
|
||||
def setup_logging():
|
||||
@@ -73,6 +73,7 @@ def create_app():
|
||||
# Конфигурация
|
||||
app.config["VERSION"] = VERSION
|
||||
app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB (лимит на один файл)
|
||||
app.config["ENABLE_SESSION_FILES"] = os.environ.get("ENABLE_SESSION_FILES", "0") == "1"
|
||||
|
||||
# Регистрируем blueprint'ы
|
||||
from routes import register_routes
|
||||
@@ -97,6 +98,3 @@ app = create_app()
|
||||
if __name__ == "__main__":
|
||||
from waitress import serve
|
||||
serve(app, host="0.0.0.0", port=5000, threads=8)
|
||||
|
||||
|
||||
|
||||
|
||||
+56
-19
@@ -17,12 +17,13 @@ import queue
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
import traceback
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, request, send_file, jsonify, Response, stream_with_context
|
||||
from flask import (Blueprint, current_app, request, send_file, jsonify, Response,
|
||||
stream_with_context)
|
||||
|
||||
from drhider import obfuscate_files, LLMClient
|
||||
from drhider.obfuscator import LLMIncompleteError
|
||||
from upload.backend.session import (create_session, add_file, get_files, store_result,
|
||||
get_result, store_csv, get_csv, cleanup, file_count,
|
||||
MAX_FILE_BYTES, pause_ttl, resume_ttl,
|
||||
@@ -41,13 +42,20 @@ def _disconnect_exceptions():
|
||||
def upload():
|
||||
"""Upload files to session (один или несколько)."""
|
||||
sid = request.form.get("session", "")
|
||||
if not sid:
|
||||
sid = create_session()
|
||||
uploaded = request.files.getlist("files")
|
||||
if not uploaded:
|
||||
log.warning("upload: no files, sid=%s", sid)
|
||||
return jsonify({"ok": False, "error": "No file"}), 400
|
||||
|
||||
# CSV input is temporarily disabled so reliability testing can focus on the
|
||||
# other document formats. Keep the CSV processing code in the core intact.
|
||||
if any(safe_name(f.filename).lower().endswith(".csv") for f in uploaded):
|
||||
log.warning("upload: CSV input is disabled")
|
||||
return jsonify({"ok": False, "error": "CSV uploads are temporarily disabled"}), 400
|
||||
|
||||
if not sid:
|
||||
sid = create_session()
|
||||
|
||||
added = 0
|
||||
had_unnamed = False
|
||||
for f in uploaded:
|
||||
@@ -55,6 +63,12 @@ def upload():
|
||||
if not name:
|
||||
had_unnamed = True
|
||||
continue
|
||||
# Ранняя проверка размера по Content-Length позволяет не читать в память
|
||||
# тело файла, заведомо превышающего лимит. Проверка выполняется до f.read().
|
||||
if f.content_length is not None and f.content_length > MAX_FILE_BYTES:
|
||||
log.warning("upload: file exceeds %dMB, skipped sid=%s file=%r size=%d",
|
||||
MAX_FILE_BYTES // (1024 * 1024), sid, name, f.content_length)
|
||||
continue
|
||||
data = f.read()
|
||||
log.info("upload: sid=%s file=%r size=%d", sid, name, len(data))
|
||||
if len(data) > MAX_FILE_BYTES:
|
||||
@@ -78,6 +92,8 @@ def upload():
|
||||
@api_bp.route("/session_files/<sid>", methods=["GET"])
|
||||
def session_files(sid):
|
||||
"""Отладка: список файлов сессии с размерами (для теста загрузки)."""
|
||||
if not current_app.config.get("ENABLE_SESSION_FILES"):
|
||||
return jsonify({"ok": False, "error": "Not found"}), 404
|
||||
files = get_files(sid)
|
||||
if files is None:
|
||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||
@@ -118,6 +134,9 @@ def process_stream(sid):
|
||||
|
||||
all_files = [(fname, content, "") for fname, content in files]
|
||||
log.info("process_stream: start sid=%s files=%d", sid, len(all_files))
|
||||
# A failed rerun must not leave an older ZIP or mapping downloadable.
|
||||
store_result(sid, None)
|
||||
store_csv(sid, None)
|
||||
# Сессия живёт, пока идёт обработка (TTL возобновляется в finally генератора)
|
||||
pause_ttl(sid)
|
||||
|
||||
@@ -131,8 +150,8 @@ def process_stream(sid):
|
||||
eta = {"total_chars": 0, "done_chars": 0, "cur_chars": 0, "cur_total": 0, "cur_done": 0}
|
||||
_TOKENS_PER_CHAR = 8.0 # эмпирический коэффициент символов -> токенов LLM
|
||||
|
||||
def progress(phase, idx, total_, name, elapsed):
|
||||
q.put(("progress", phase, idx, name, total_, elapsed))
|
||||
def progress(phase, idx, total_, name, elapsed, source_idxs):
|
||||
q.put(("progress", phase, idx, name, total_, elapsed, source_idxs))
|
||||
|
||||
# Состояние для per-file ETA (чанки)
|
||||
fstate = {"t0": 0.0, "total": 0, "done": 0}
|
||||
@@ -172,9 +191,16 @@ def process_stream(sid):
|
||||
log.info("worker: done sid=%s in %.1fs tokens=%d llm_sec=%.1f zip_len=%d",
|
||||
sid, dt, llm.tokens_total, llm.llm_sec, len(zip_data))
|
||||
q.put(("result", zip_data, csv_str, stats))
|
||||
except Exception as e:
|
||||
log.error("worker: exception sid=%s: %r\n%s", sid, e, traceback.format_exc())
|
||||
q.put(("error", repr(e)))
|
||||
except LLMIncompleteError as e:
|
||||
log.exception("worker: LLM incomplete sid=%s chunks=%d", sid, e.llm_errors)
|
||||
q.put(("error", {
|
||||
"error": "Processing stopped because the LLM could not process all fragments",
|
||||
"llm_incomplete": True,
|
||||
"llm_errors": e.llm_errors,
|
||||
}))
|
||||
except Exception:
|
||||
log.exception("worker: processing failed sid=%s", sid)
|
||||
q.put(("error", {"error": "Processing failed"}))
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
log.debug("process_stream: worker thread started sid=%s", sid)
|
||||
@@ -215,13 +241,13 @@ def process_stream(sid):
|
||||
kind = evt[0]
|
||||
|
||||
if kind == "progress":
|
||||
_, phase, idx, name, total_, elapsed = evt
|
||||
_, phase, idx, name, total_, elapsed, source_idxs = evt
|
||||
log.debug("process_stream: event=%s idx=%d name=%r elapsed=%s sid=%s",
|
||||
phase, idx, name, elapsed, sid)
|
||||
try:
|
||||
yield (
|
||||
f"event: {phase}\n"
|
||||
f"data: {json.dumps({'idx': idx, 'name': name, 'total': total_, 'elapsed': elapsed})}\n\n"
|
||||
f"data: {json.dumps({'idx': idx, 'source_idxs': source_idxs, 'name': name, 'total': total_, 'elapsed': elapsed})}\n\n"
|
||||
)
|
||||
except _disconnect_exceptions() as e:
|
||||
log.warning("process_stream: disconnect on progress sid=%s phase=%s err=%r", sid, phase, e)
|
||||
@@ -233,7 +259,7 @@ def process_stream(sid):
|
||||
elif kind == "file":
|
||||
_, event, fname, fields = evt
|
||||
# Обновляем состояние для глобальной ETA
|
||||
if event == "extract_done":
|
||||
if event == "extract_complete":
|
||||
eta["total_chars"] = fields.get("total_chars", 0)
|
||||
elif event == "file_start":
|
||||
eta["cur_chars"] = fields.get("chars", 0)
|
||||
@@ -266,7 +292,7 @@ def process_stream(sid):
|
||||
store_csv(sid, csv_str)
|
||||
count = 0
|
||||
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
||||
count = len([n for n in zf.namelist() if n != "mapping.csv"])
|
||||
count = len(zf.namelist())
|
||||
log.info("process_stream: complete sid=%s count=%d stats=%r", sid, count, stats)
|
||||
try:
|
||||
yield (
|
||||
@@ -295,10 +321,11 @@ def process_stream(sid):
|
||||
return
|
||||
|
||||
elif kind == "error":
|
||||
_, msg = evt
|
||||
log.error("process_stream: error event sid=%s msg=%r", sid, msg)
|
||||
_, error_data = evt
|
||||
log.error("process_stream: error event sid=%s llm_incomplete=%s",
|
||||
sid, error_data.get("llm_incomplete", False))
|
||||
try:
|
||||
yield f"event: proc_error\ndata: {json.dumps({'error': msg})}\n\n"
|
||||
yield f"event: proc_error\ndata: {json.dumps(error_data)}\n\n"
|
||||
except _disconnect_exceptions() as e:
|
||||
log.warning("process_stream: disconnect on error sid=%s err=%r", sid, e)
|
||||
return
|
||||
@@ -322,6 +349,8 @@ def process(sid):
|
||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||
if not files:
|
||||
return jsonify({"ok": False, "error": "No files"}), 400
|
||||
store_result(sid, None)
|
||||
store_csv(sid, None)
|
||||
try:
|
||||
llm = LLMClient()
|
||||
all_files = [(fname, content, "") for fname, content in files]
|
||||
@@ -334,9 +363,17 @@ def process(sid):
|
||||
if csv_str:
|
||||
store_csv(sid, csv_str)
|
||||
return jsonify({"ok": True, "status": "done"})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
except LLMIncompleteError as e:
|
||||
log.exception("process: LLM incomplete sid=%s chunks=%d", sid, e.llm_errors)
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"error": "Processing stopped because the LLM could not process all fragments",
|
||||
"llm_incomplete": True,
|
||||
"llm_errors": e.llm_errors,
|
||||
}), 500
|
||||
except Exception:
|
||||
log.exception("process: processing failed sid=%s", sid)
|
||||
return jsonify({"ok": False, "error": "Processing failed"}), 500
|
||||
|
||||
|
||||
@api_bp.route("/download/<sid>", methods=["GET"])
|
||||
|
||||
Vendored
+224
-7
@@ -1,4 +1,4 @@
|
||||
// node_modules/fflate/esm/browser.js
|
||||
// .agent-tmp/node_modules/fflate/esm/browser.js
|
||||
var u8 = Uint8Array;
|
||||
var u16 = Uint16Array;
|
||||
var i32 = Int32Array;
|
||||
@@ -641,14 +641,15 @@ function renderNode(node2, depth, cfg) {
|
||||
const name = esc(node2.name);
|
||||
const action = isGroup ? `<button class="tree-name tree-group tree-${node2.kind}" style="padding-left:${padding}ch" data-toggle="${node2.id}" aria-expanded="${node2.expanded}"><span class="tree-chevron">${node2.expanded ? "\u25BC" : "\u25B6"}</span>${name}</button>` : `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
|
||||
const remove = `<button class="remove-btn" type="button" data-remove="${node2.id}" aria-label="${esc(cfg.labels.remove)} ${name}">\xD7</button>`;
|
||||
const pathAttribute = node2.kind === "file" ? ` data-path="${esc(node2.path)}"` : "";
|
||||
const 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) : node2.kind === "file" ? esc(cfg.labels.statusReady) : ""
|
||||
};
|
||||
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||||
const row = `<tr class="tree-row tree-${node2.kind}"${pathAttribute}>` + columns.map((column) => `<td>${cells[column] || ""}</td>`).join("") + `<td>${remove}</td></tr>`;
|
||||
const row = `<tr class="tree-row tree-${node2.kind}"${pathAttribute}${fileIdAttribute}>` + columns.map((column) => `<td>${cells[column] || ""}</td>`).join("") + `<td>${remove}</td></tr>`;
|
||||
if (!isGroup || !node2.expanded) return row;
|
||||
return row + node2.children.map((child) => renderNode(child, depth + 1, cfg)).join("");
|
||||
}
|
||||
@@ -675,7 +676,7 @@ function findNode(nodes, id) {
|
||||
}
|
||||
function flattenFiles(nodes, result = []) {
|
||||
nodes.forEach((node2) => {
|
||||
if (node2.kind === "file") result.push({ path: node2.path, name: node2.name, size: node2.file.size, file: node2.file });
|
||||
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;
|
||||
@@ -717,6 +718,7 @@ 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 {
|
||||
@@ -726,14 +728,225 @@ function onFilesChange(state, cfg, elements) {
|
||||
};
|
||||
}
|
||||
|
||||
// upload/frontend/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 };
|
||||
}
|
||||
|
||||
// upload/frontend/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();
|
||||
for (const file of Array.from(elements.folderInputEl.files)) {
|
||||
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();
|
||||
@@ -1071,11 +1284,13 @@ function buildMarkup(root, cfg) {
|
||||
const control = (name, id, label, className = "") => controls.includes(name) ? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : "";
|
||||
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
|
||||
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join("");
|
||||
const fileAccept = esc([.../* @__PURE__ */ new Set([...cfg.allowedExt, ".zip"])].join(","));
|
||||
const folderAccept = esc([.../* @__PURE__ */ new Set([...cfg.allowedExt, ".zip", ".lst"])].join(","));
|
||||
root.innerHTML = `
|
||||
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ""}">
|
||||
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
|
||||
<input class="fp-file-input" type="file" accept="${esc([...cfg.allowedExt, ".zip"].join(","))}" multiple hidden>
|
||||
<input class="fp-folder-input" type="file" accept="${esc([...cfg.allowedExt, ".zip"].join(","))}" webkitdirectory directory multiple hidden>
|
||||
<input class="fp-file-input" type="file" accept="${fileAccept}" multiple hidden>
|
||||
<input class="fp-folder-input" type="file" accept="${folderAccept}" webkitdirectory directory multiple hidden>
|
||||
${control("files", "fp-files-btn", cfg.labels.pickFiles)}
|
||||
${control("folder", "fp-folder-btn", cfg.labels.pickFolder, "secondary")}
|
||||
${control("clear", "fp-clear-btn", cfg.labels.clear, "quiet")}
|
||||
@@ -1153,6 +1368,7 @@ function initFilePicker(config) {
|
||||
if (root.querySelector("#fp-clear-btn")) listen(root.querySelector("#fp-clear-btn"), "click", () => {
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
if (elements.statusEl) elements.statusEl.textContent = "";
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
});
|
||||
@@ -1174,6 +1390,7 @@ function initFilePicker(config) {
|
||||
clear: () => {
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
if (elements.statusEl) elements.statusEl.textContent = "";
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
},
|
||||
|
||||
Vendored
+224
-7
@@ -26,7 +26,7 @@ var FilePicker = (() => {
|
||||
uploadViaVM: () => uploadViaVM
|
||||
});
|
||||
|
||||
// node_modules/fflate/esm/browser.js
|
||||
// .agent-tmp/node_modules/fflate/esm/browser.js
|
||||
var u8 = Uint8Array;
|
||||
var u16 = Uint16Array;
|
||||
var i32 = Int32Array;
|
||||
@@ -669,14 +669,15 @@ var FilePicker = (() => {
|
||||
const name = esc(node2.name);
|
||||
const action = isGroup ? `<button class="tree-name tree-group tree-${node2.kind}" style="padding-left:${padding}ch" data-toggle="${node2.id}" aria-expanded="${node2.expanded}"><span class="tree-chevron">${node2.expanded ? "\u25BC" : "\u25B6"}</span>${name}</button>` : `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
|
||||
const remove = `<button class="remove-btn" type="button" data-remove="${node2.id}" aria-label="${esc(cfg.labels.remove)} ${name}">\xD7</button>`;
|
||||
const pathAttribute = node2.kind === "file" ? ` data-path="${esc(node2.path)}"` : "";
|
||||
const 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) : node2.kind === "file" ? esc(cfg.labels.statusReady) : ""
|
||||
};
|
||||
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||||
const row = `<tr class="tree-row tree-${node2.kind}"${pathAttribute}>` + columns.map((column) => `<td>${cells[column] || ""}</td>`).join("") + `<td>${remove}</td></tr>`;
|
||||
const row = `<tr class="tree-row tree-${node2.kind}"${pathAttribute}${fileIdAttribute}>` + columns.map((column) => `<td>${cells[column] || ""}</td>`).join("") + `<td>${remove}</td></tr>`;
|
||||
if (!isGroup || !node2.expanded) return row;
|
||||
return row + node2.children.map((child) => renderNode(child, depth + 1, cfg)).join("");
|
||||
}
|
||||
@@ -703,7 +704,7 @@ var FilePicker = (() => {
|
||||
}
|
||||
function flattenFiles(nodes, result = []) {
|
||||
nodes.forEach((node2) => {
|
||||
if (node2.kind === "file") result.push({ path: node2.path, name: node2.name, size: node2.file.size, file: node2.file });
|
||||
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;
|
||||
@@ -745,6 +746,7 @@ var FilePicker = (() => {
|
||||
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 {
|
||||
@@ -754,14 +756,225 @@ var FilePicker = (() => {
|
||||
};
|
||||
}
|
||||
|
||||
// upload/frontend/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 };
|
||||
}
|
||||
|
||||
// upload/frontend/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();
|
||||
for (const file of Array.from(elements.folderInputEl.files)) {
|
||||
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();
|
||||
@@ -1099,11 +1312,13 @@ var FilePicker = (() => {
|
||||
const control = (name, id, label, className = "") => controls.includes(name) ? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : "";
|
||||
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
|
||||
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join("");
|
||||
const fileAccept = esc([.../* @__PURE__ */ new Set([...cfg.allowedExt, ".zip"])].join(","));
|
||||
const folderAccept = esc([.../* @__PURE__ */ new Set([...cfg.allowedExt, ".zip", ".lst"])].join(","));
|
||||
root.innerHTML = `
|
||||
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ""}">
|
||||
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
|
||||
<input class="fp-file-input" type="file" accept="${esc([...cfg.allowedExt, ".zip"].join(","))}" multiple hidden>
|
||||
<input class="fp-folder-input" type="file" accept="${esc([...cfg.allowedExt, ".zip"].join(","))}" webkitdirectory directory multiple hidden>
|
||||
<input class="fp-file-input" type="file" accept="${fileAccept}" multiple hidden>
|
||||
<input class="fp-folder-input" type="file" accept="${folderAccept}" webkitdirectory directory multiple hidden>
|
||||
${control("files", "fp-files-btn", cfg.labels.pickFiles)}
|
||||
${control("folder", "fp-folder-btn", cfg.labels.pickFolder, "secondary")}
|
||||
${control("clear", "fp-clear-btn", cfg.labels.clear, "quiet")}
|
||||
@@ -1181,6 +1396,7 @@ var FilePicker = (() => {
|
||||
if (root.querySelector("#fp-clear-btn")) listen(root.querySelector("#fp-clear-btn"), "click", () => {
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
if (elements.statusEl) elements.statusEl.textContent = "";
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
});
|
||||
@@ -1202,6 +1418,7 @@ var FilePicker = (() => {
|
||||
clear: () => {
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
if (elements.statusEl) elements.statusEl.textContent = "";
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
},
|
||||
|
||||
+1
-25
@@ -1,25 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Creator: CorelDRAW -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="82.3711mm" height="18.2443mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
|
||||
viewBox="0 0 8221.93 1821.07"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
.fil0 {fill:#001C34}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Слой_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<g id="_2283355517888">
|
||||
<path class="fil0" d="M477.49 479.69l-413.51 0 -63.98 276.48 178.44 0 1.17 1028.71 0 26.75 0.03 0 276.39 0 0 -871.72c0,-101.28 82.43,-183.69 183.75,-183.69l589.25 3.44c101.34,0 183.76,82.46 183.76,183.74l0 871.72 276.44 0 0 -871.72c0,-253.77 -206.46,-460.15 -460.05,-460.15l-589.52 -3.53 -162.17 0.45 0 -0.48z"/>
|
||||
<path class="fil0" d="M6664.65 1813.37l1181.04 0c212.14,0 376.24,-175.01 376.24,-387.08 0,-212.13 -172.55,-384.68 -384.69,-384.68l-653.66 0c-60.22,0 -109.16,-48.94 -109.16,-109.18l0 -66.18c0,-60.19 48.94,-109.14 109.16,-109.14l695.76 0 63.74 -275.49 -759.5 0c-212.13,0 -384.66,172.53 -384.66,384.63l0 66.18c0,212.13 172.53,384.67 384.66,384.67l653.66 0c60.2,0 109.2,49 109.2,109.19 0,60.13 -49,116.1 -109.2,116.1l-1118.9 0 -53.68 270.98z"/>
|
||||
<path class="fil0" d="M6200.79 483.5l-723.21 0c-215.88,0 -391.47,175.6 -391.47,391.51l0 485.5c0,248.38 202.05,450.4 450.4,450.4l989.38 0 62.13 -268.51 -1051.51 0c-96.41,0 -174.95,-85.37 -174.95,-181.88l-1.5 -39.46 923.62 0 308.51 -1.84 0 -237.95 0 -206.26c0,-215.91 -175.59,-391.51 -391.41,-391.51zm115.96 560.28l-955.19 0 0 -168.77c0,-63.96 52.07,-116.05 116.02,-116.05l723.21 0c63.91,0 115.96,52.08 115.96,116.05l0 168.77z"/>
|
||||
<path class="fil0" d="M4683.49 1194.24l0 168.28c0,100.92 -81.7,178.37 -182.67,178.37l-589.87 1.23c-93.18,0 -170.28,-69.96 -181.63,-160.09l0 -458.13c11.36,-90.1 88.46,-160.07 181.63,-160.07l589.44 3.5c100.97,0 183.1,82.18 183.1,183.1l0 30.42 0 213.4zm-1230.2 -345.53l-0.89 -795.03 276.91 -53.68 0 526.07c55.74,-24.16 117.03,-37.74 181.5,-37.74l589.71 3.5c252.69,0 458.42,205.72 458.42,458.59l0 30.42 0 213.4 0 168.28c0,252.86 -205.73,458.54 -458.42,458.54l-589.71 -3.5c-252.7,0 -458.41,-205.67 -458.41,-458.56l0 -171.61 0 -240.48 0.89 -98.2z"/>
|
||||
<path class="fil0" d="M3041.25 1150.84l0 204.28c0,100.93 -82.15,183.05 -183.11,183.05l-582.11 -3.46c-100.96,0 -183.11,-82.16 -183.11,-183.08l0 -67.42 1.48 0.24 0 -862.65 -276.91 53.68 0.12 103.41 -0.12 0.04 0 772.69c0,252.87 205.72,458.54 458.39,458.54l582.4 3.5c252.67,0 458.4,-205.68 458.4,-458.55l0 -70.79 0.71 0.12 0 -862.65 -276.91 53.68 0.76 675.35z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="57" height="57" xml:space="preserve" overflow="hidden"><defs><clipPath id="c0"><rect x="652" y="420" width="64" height="75"/></clipPath><clipPath id="c1"><rect x="652" y="420" width="64" height="70"/></clipPath></defs><g clip-path="url(#c0)" transform="matrix(1.01357 0 0 1.01357 -664.97 -440.306)"><g clip-path="url(#c1)"><path d="m114.028 43.149v7.659c0 3.785-3.08 6.863-6.866 6.863h-21.825c-3.786 0-6.866-3.08-6.866-6.864v-2.528l0.056.009V15.815l-10.382 2.013 0.004 3.877v28.971c0 9.481 7.713 17.192 17.187 17.192l21.836.132c9.474 0 17.187-7.712 17.187-17.193v-2.654l0.027.004V15.815l-10.382 2.012 0.028 25.322z" fill="#001c34" fill-rule="evenodd" transform="matrix(1 0 0 1.01337 587.92 420.059)"/></g></g></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 763 B |
Binary file not shown.
+603
-290
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,8 @@ from typing import Optional
|
||||
from .state import _sessions, _lock
|
||||
|
||||
|
||||
def store_csv(sid: str, csv_str: str) -> bool:
|
||||
"""Сохранить CSV с таблицей замен.
|
||||
def store_csv(sid: str, csv_str: Optional[str]) -> bool:
|
||||
"""Сохранить mapping CSV или инвалидировать его, передав None.
|
||||
|
||||
Returns:
|
||||
True если сохранено, False если сессии нет.
|
||||
|
||||
@@ -5,8 +5,8 @@ from typing import Optional
|
||||
from .state import _sessions, _lock
|
||||
|
||||
|
||||
def store_result(sid: str, zip_data: bytes) -> bool:
|
||||
"""Сохранить результат обработки (ZIP-архив).
|
||||
def store_result(sid: str, zip_data: Optional[bytes]) -> bool:
|
||||
"""Сохранить ZIP-результат или инвалидировать его, передав None.
|
||||
|
||||
Returns:
|
||||
True если сохранено, False если сессии нет.
|
||||
|
||||
@@ -74,10 +74,17 @@ def create_upload_refs_blueprint(cfg: dict = None) -> Blueprint:
|
||||
if not refs:
|
||||
log.warning("upload_refs: no files, sid=%s", sid)
|
||||
return jsonify({"ok": False, "error": "No files"}), 400
|
||||
# Reject CSV refs before pulling any objects. The browser picker also
|
||||
# excludes CSV; this guard covers direct callers of the upload bridge.
|
||||
if any(safe_name(ref.get("name") or "").lower().endswith(".csv") for ref in refs):
|
||||
log.warning("upload_refs: CSV input is disabled, sid=%s", sid)
|
||||
return jsonify({"ok": False, "error": "CSV uploads are temporarily disabled"}), 400
|
||||
|
||||
added = 0
|
||||
transport = cfg.get("httpxTransport") or current_app.config.get("UPLOAD_HTTPX_TRANSPORT")
|
||||
try:
|
||||
with httpx.Client(transport=transport, timeout=pull_timeout, follow_redirects=True) as client:
|
||||
# Redirects could bypass validation against the trusted VM-buffer prefix.
|
||||
with httpx.Client(transport=transport, timeout=pull_timeout, follow_redirects=False) as client:
|
||||
for ref in refs:
|
||||
name = safe_name(ref.get("name") or "")
|
||||
url = ref.get("url") or ""
|
||||
|
||||
@@ -76,11 +76,17 @@ function buildMarkup(root, cfg) {
|
||||
? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : '';
|
||||
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
|
||||
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join('');
|
||||
// Обычный file input не принимает manifest: `.lst` ему не нужен.
|
||||
const fileAccept = esc([...new Set([...cfg.allowedExt, '.zip'])].join(','));
|
||||
// Folder input дополнительно объявляет `.lst`: при webkitdirectory Chromium
|
||||
// обычно игнорирует accept, но явное расширение служит страховкой, чтобы
|
||||
// `obscure.lst` попал в FileList. Сам файл в дерево не добавляется.
|
||||
const folderAccept = esc([...new Set([...cfg.allowedExt, '.zip', '.lst'])].join(','));
|
||||
root.innerHTML = `
|
||||
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ''}">
|
||||
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
|
||||
<input class="fp-file-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" multiple hidden>
|
||||
<input class="fp-folder-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" webkitdirectory directory multiple hidden>
|
||||
<input class="fp-file-input" type="file" accept="${fileAccept}" multiple hidden>
|
||||
<input class="fp-folder-input" type="file" accept="${folderAccept}" webkitdirectory directory multiple hidden>
|
||||
${control('files', 'fp-files-btn', cfg.labels.pickFiles)}
|
||||
${control('folder', 'fp-folder-btn', cfg.labels.pickFolder, 'secondary')}
|
||||
${control('clear', 'fp-clear-btn', cfg.labels.clear, 'quiet')}
|
||||
@@ -156,6 +162,7 @@ export function initFilePicker(config) {
|
||||
if (root.querySelector('#fp-clear-btn')) listen(root.querySelector('#fp-clear-btn'), 'click', () => {
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
if (elements.statusEl) elements.statusEl.textContent = '';
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
});
|
||||
@@ -174,6 +181,7 @@ export function initFilePicker(config) {
|
||||
clear: () => {
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
if (elements.statusEl) elements.statusEl.textContent = '';
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* lst_filter — фильтрация файлов каталога по manifest-файлу `obscure.lst`.
|
||||
*
|
||||
* Модуль намеренно чистый: не обращается к DOM, сети, upload или Flask.
|
||||
* На вход получает список File из folder picker и набор поддерживаемых
|
||||
* расширений (включая '.zip'), на выходе отдаёт отфильтрованный список File
|
||||
* в исходном порядке и диагностику для интерфейса.
|
||||
*
|
||||
* Формат manifest:
|
||||
* - одна строка — одно правило;
|
||||
* - пустые строки игнорируются;
|
||||
* - строки, начинающиеся с '#', — комментарии;
|
||||
* - '!' в начале строки задаёт исключение;
|
||||
* - правила задают пути относительно выбранной папки;
|
||||
* - положительные правила собирают кандидатов, исключения удаляют совпадения.
|
||||
*
|
||||
* Glob-семантика:
|
||||
* - '*' — ноль или больше символов в одном сегменте (не '/');
|
||||
* - '?' — один символ, кроме '/';
|
||||
* - '**' — целый сегмент: рекурсивное совпадение через каталоги;
|
||||
* внутри сегмента ('a**b') эквивалентен '*';
|
||||
* - '[abc]', '[a-z]' — классы и диапазоны символов;
|
||||
* - '[!a]', '[^a]' — отрицательные классы символов;
|
||||
* - 'reports/' эквивалентно 'reports/**';
|
||||
* - путь без glob-символов — точное совпадение;
|
||||
* - сравнение всего пути без учёта регистра.
|
||||
*/
|
||||
|
||||
/** Фиксированное имя manifest-файла (ищется только в корне выбранной папки). */
|
||||
const MANIFEST_NAME = 'obscure.lst';
|
||||
|
||||
/**
|
||||
* Возвращает true, если имя файла оканчивается одним из разрешённых расширений.
|
||||
*
|
||||
* Сравнение без учёта регистра: 'A.PDF' и '.pdf' считаются одинаковыми.
|
||||
*
|
||||
* @param {string} name Имя файла.
|
||||
* @param {string[]} allowedExt Список расширений (с точкой) включая '.zip'.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function extensionAllowed(name, allowedExt) {
|
||||
const lowerName = String(name || '').toLowerCase();
|
||||
return allowedExt.some((extension) => lowerName.endsWith(String(extension).toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычисляет путь файла относительно корня выбранной папки.
|
||||
*
|
||||
* Браузер для folder picker кладёт в `webkitRelativePath` путь вида
|
||||
* `<корень>/подпапка/файл`; первый сегмент (имя выбранной папки) отбрасываем.
|
||||
* Обратные слеши нормализуются в '/', чтобы правило можно было писать
|
||||
* в любом стиле.
|
||||
*
|
||||
* @param {{webkitRelativePath?: string, name: string}} file Файл или его фейк.
|
||||
* @returns {string} Путь относительно выбранной папки.
|
||||
*/
|
||||
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] || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ищет ровно корневой `obscure.lst` (без учёта регистра).
|
||||
*
|
||||
* Manifest — это файл на глубине 1 от выбранной папки. Вложенный
|
||||
* `subdir/obscure.lst` манифестом не считается; другие `*.lst` тоже.
|
||||
*
|
||||
* @param {Array} files Список файлов folder picker.
|
||||
* @returns {object|null} Найденный manifest либо null.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Экранирует символы регулярного выражения, чтобы glob-литералы
|
||||
* не превращались в regex-метасимволы.
|
||||
*
|
||||
* @param {string} text Произвольная строка.
|
||||
* @returns {string} Строка, безопасная для вставки в RegExp.
|
||||
*/
|
||||
function escapeRegExp(text) {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит закрывающую ']' для класса символов.
|
||||
*
|
||||
* Учитывает, что ']' сразу после '[' или '[!' / '[^' является литералом,
|
||||
* а не закрывающей скобкой.
|
||||
*
|
||||
* @param {string} glob Исходный шаблон.
|
||||
* @param {number} openIndex Позиция открывающей '['.
|
||||
* @returns {number} Индекс закрывающей ']' либо -1, если класс не закрыт.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Компилирует glob в регистронезависимый RegExp.
|
||||
*
|
||||
* @param {string} glob Нормализованный шаблон правила.
|
||||
* @returns {RegExp|null} null, если шаблон синтаксически некорректен
|
||||
* (например, незакрытый класс символов).
|
||||
*/
|
||||
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 {
|
||||
// 'a**b' внутри сегмента эквивалентно 'a*b' и не пересекает '/'.
|
||||
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);
|
||||
// '[!a]' — традиционная запись отрицательного класса, '[^a]' — regex-стиль.
|
||||
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) {
|
||||
// Некорректный класс (например, '[]') Regexp не компилирует — правило
|
||||
// отклоняется, остальные правила продолжают обрабатываться.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует одну строку manifest в путь правила.
|
||||
*
|
||||
* @param {string} line Исходная строка.
|
||||
* @returns {null|{error: string}|{path: string, negative: boolean}}
|
||||
* null — пустая строка или комментарий;
|
||||
* {error} — некорректное правило;
|
||||
* {path, negative} — нормализованное правило.
|
||||
*/
|
||||
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: 'пустое правило после "!"' };
|
||||
}
|
||||
|
||||
// Единый разделитель, схлопывание повторных '/' и устранение ведущих './'.
|
||||
// Схлопывание идёт до снятия './', чтобы './/reports' превращалось в
|
||||
// 'reports', а не в абсолютный '/reports'.
|
||||
text = text.replace(/\\/g, '/');
|
||||
text = text.replace(/\/{2,}/g, '/');
|
||||
text = text.replace(/^(?:\.\/)+/, '');
|
||||
|
||||
// Абсолютные пути (POSIX и Windows drive) недопустимы.
|
||||
if (text.startsWith('/') || /^[a-zA-Z]:\//.test(text)) {
|
||||
return { error: 'абсолютный путь не допускается' };
|
||||
}
|
||||
|
||||
// Выход за пределы выбранной папки запрещён.
|
||||
if (text.split('/').some((segment) => segment === '..')) {
|
||||
return { error: 'сегмент ".." не допускается' };
|
||||
}
|
||||
|
||||
if (!text) return { error: 'пустое правило' };
|
||||
|
||||
// Правило 'reports/' означает всё поддерево reports.
|
||||
if (text.endsWith('/')) text += '**';
|
||||
|
||||
return { path: text, negative };
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбирает текст manifest в массив правил.
|
||||
*
|
||||
* Некорректные строки не прерывают разбор: они попадают в diagnostics
|
||||
* с номером строки, остальные правила продолжают работать.
|
||||
*
|
||||
* @param {string} text Содержимое manifest (BOM уже снят).
|
||||
* @param {Array} diagnostics Аккумулятор диагностики.
|
||||
* @returns {Array<{line: number, source: string, negative: boolean, regex: RegExp}>}
|
||||
*/
|
||||
function parseRules(text, diagnostics) {
|
||||
// Поддерживаем CRLF, LF и одиночный CR.
|
||||
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: `некорректный glob: "${line.trim()}"`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
rules.push({
|
||||
line: lineNumber,
|
||||
source: line.trim(),
|
||||
negative: normalized.negative,
|
||||
regex,
|
||||
});
|
||||
});
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Фильтрует файлы каталога по manifest-файлу `obscure.lst`.
|
||||
*
|
||||
* Поведение:
|
||||
* - нет корневого manifest — возвращаются все файлы (прежнее поведение);
|
||||
* - manifest есть — результат строится только по его правилам, fallback нет;
|
||||
* - сам manifest никогда не попадает в результат;
|
||||
* - файлы неподдерживаемых типов не попадают в кандидаты;
|
||||
* - ZIP фильтруется по имени архива, внутренние имена не анализируются.
|
||||
*
|
||||
* @param {FileList|File[]} files Файлы, выбранные через folder picker.
|
||||
* @param {string[]} allowedExt Поддерживаемые расширения, включая '.zip'.
|
||||
* @returns {Promise<{
|
||||
* manifest: object|null,
|
||||
* files: Array,
|
||||
* diagnostics: Array<{level: string, line: number, message: string}>
|
||||
* }>}
|
||||
*/
|
||||
export async function lstFilter(files, allowedExt) {
|
||||
const list = Array.from(files || []);
|
||||
const manifest = findManifest(list);
|
||||
|
||||
// Manifest отсутствует: сохраняем прежнее поведение без какой-либо фильтрации.
|
||||
if (!manifest) {
|
||||
return { manifest: null, files: list, diagnostics: [] };
|
||||
}
|
||||
|
||||
const diagnostics = [];
|
||||
|
||||
// Ошибка чтения или UTF-8-декодирования: предупреждение и пустой результат.
|
||||
// TextDecoder с fatal:true не подменяет битые байты на U+FFFD молча.
|
||||
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: `не удалось прочитать ${MANIFEST_NAME}: ${error.message}`,
|
||||
});
|
||||
return { manifest, files: [], diagnostics };
|
||||
}
|
||||
|
||||
// BOM в начале снимаем вручную, иначе он попадёт в первое правило.
|
||||
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
|
||||
|
||||
const rules = parseRules(text, diagnostics);
|
||||
const positives = rules.filter((rule) => !rule.negative);
|
||||
const negatives = rules.filter((rule) => rule.negative);
|
||||
|
||||
// Нет ни одного корректного включающего правила — пустой результат без fallback.
|
||||
if (positives.length === 0) {
|
||||
diagnostics.push({
|
||||
level: 'warning',
|
||||
line: 0,
|
||||
message: `в ${MANIFEST_NAME} нет корректных включающих правил`,
|
||||
});
|
||||
return { manifest, files: [], diagnostics };
|
||||
}
|
||||
|
||||
// Кандидаты: только поддерживаемые типы, без самого manifest.
|
||||
// ZIP остаётся обычным кандидатом — include/exclude применяются к его имени.
|
||||
const candidates = list.filter((file) => file !== manifest
|
||||
&& extensionAllowed(file.name, allowedExt));
|
||||
|
||||
// Проход по файлам (а не по правилам) сохраняет исходный порядок и
|
||||
// исключает появление дублей при пересекающихся масках.
|
||||
const matched = 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: `правило не совпало ни с одним файлом: "${rule.source}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.length === 0) {
|
||||
diagnostics.push({
|
||||
level: 'warning',
|
||||
line: 0,
|
||||
message: 'manifest не выбрал ни одного файла',
|
||||
});
|
||||
}
|
||||
|
||||
return { manifest, files: selected, diagnostics };
|
||||
}
|
||||
@@ -61,6 +61,9 @@ export 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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { listZipFiles } from '../zip/list_zip_files.js';
|
||||
import { addFileWithDedup } from './add_file_with_dedup.js';
|
||||
import { rebaseTree } from './rebase_tree.js';
|
||||
import { render } from './render.js';
|
||||
import { lstFilter } from './lst_filter.js';
|
||||
|
||||
/**
|
||||
* Создаёт async-обработчик выбора каталога через webkitdirectory.
|
||||
@@ -20,9 +21,25 @@ export function onFolderChange(state, cfg, elements) {
|
||||
return async () => {
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
// Сброс статуса перед новым выбором: иначе сообщение об ошибке предыдущего
|
||||
// выбора (например, «Ошибка чтения архива») останется висеть в интерфейсе.
|
||||
if (elements.statusEl) elements.statusEl.textContent = '';
|
||||
try {
|
||||
const roots = new Map();
|
||||
for (const file of Array.from(elements.folderInputEl.files)) {
|
||||
// Manifest-фильтр: при наличии корневого `obscure.lst` набор файлов
|
||||
// ограничивается его правилами, а сам manifest в дерево не попадает.
|
||||
// Без manifest модуль возвращает исходный список без изменений.
|
||||
const filtered = await lstFilter(elements.folderInputEl.files, [...cfg.allowedExt, '.zip']);
|
||||
if (filtered.manifest) {
|
||||
// Диагностику показываем в statusEl: ошибки строк и предупреждения правил.
|
||||
const messages = filtered.diagnostics.map((item) => (
|
||||
item.line ? `строка ${item.line}: ${item.message}` : item.message
|
||||
));
|
||||
if (messages.length && elements.statusEl) {
|
||||
elements.statusEl.textContent = messages.join('; ');
|
||||
}
|
||||
}
|
||||
for (const file of filtered.files) {
|
||||
// Для webkitdirectory путь начинается с выбранной корневой папки.
|
||||
const parts = (file.webkitRelativePath || file.name).split('/');
|
||||
const relativePath = parts.slice(1).join('/') || file.name;
|
||||
@@ -30,8 +47,10 @@ export function onFolderChange(state, cfg, elements) {
|
||||
const rootName = parts[0] || file.name;
|
||||
if (!roots.has(rootName)) {
|
||||
// Один root на выбранный каталог позволяет сохранить дерево целиком.
|
||||
roots.set(rootName, { id: crypto.randomUUID(), kind: 'folder', name: rootName,
|
||||
path: rootName, children: [], expanded: false });
|
||||
roots.set(rootName, {
|
||||
id: crypto.randomUUID(), kind: 'folder', name: rootName,
|
||||
path: rootName, children: [], expanded: false
|
||||
});
|
||||
}
|
||||
const root = roots.get(rootName);
|
||||
// Вставляет узел по его пути, создавая отсутствующие промежуточные папки.
|
||||
@@ -42,9 +61,11 @@ export function onFolderChange(state, cfg, elements) {
|
||||
const last = index === nodeParts.length - 1;
|
||||
let child = current.children.find((item) => item.name === part);
|
||||
if (!child) {
|
||||
child = last ? node : { id: crypto.randomUUID(), kind: 'folder', name: part,
|
||||
child = last ? node : {
|
||||
id: crypto.randomUUID(), kind: 'folder', name: part,
|
||||
path: `${rootName}/${nodeParts.slice(0, index + 1).join('/')}`,
|
||||
children: [], expanded: false };
|
||||
children: [], expanded: false
|
||||
};
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
@@ -68,8 +89,10 @@ export function onFolderChange(state, cfg, elements) {
|
||||
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 });
|
||||
addToFolder({
|
||||
id: crypto.randomUUID(), kind: 'file', name: parts.at(-1),
|
||||
path: `${rootName}/${relativePath}`, file, children: [], expanded: true
|
||||
});
|
||||
}
|
||||
}
|
||||
// Дедупликация выполняется после сборки каждого корня каталога.
|
||||
|
||||
@@ -46,7 +46,8 @@ function renderNode(node, depth, cfg) {
|
||||
+ `aria-expanded="${node.expanded}"><span class="tree-chevron">${node.expanded ? '▼' : '▶'}</span>${name}</button>`
|
||||
: `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
|
||||
const remove = `<button class="remove-btn" type="button" data-remove="${node.id}" aria-label="${esc(cfg.labels.remove)} ${name}">×</button>`;
|
||||
const pathAttribute = node.kind === 'file' ? ` data-path="${esc(node.path)}"` : '';
|
||||
const pathAttribute = ` data-path="${esc(node.path)}"`;
|
||||
const fileIdAttribute = node.kind === 'file' ? ` data-file-id="${esc(node.id)}"` : '';
|
||||
const cells = {
|
||||
path: action,
|
||||
size: node.kind === 'file' ? fs(node.file.size) : '',
|
||||
@@ -55,7 +56,7 @@ function renderNode(node, depth, cfg) {
|
||||
const columns = cfg.layout.columns?.length
|
||||
? cfg.layout.columns
|
||||
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||||
const row = `<tr class="tree-row tree-${node.kind}"${pathAttribute}>`
|
||||
const row = `<tr class="tree-row tree-${node.kind}"${pathAttribute}${fileIdAttribute}>`
|
||||
+ columns.map((column) => `<td>${cells[column] || ''}</td>`).join('')
|
||||
+ `<td>${remove}</td></tr>`;
|
||||
if (!isGroup || !node.expanded) return row;
|
||||
@@ -113,11 +114,11 @@ export function findNode(nodes, id) {
|
||||
*
|
||||
* @param {Array} nodes Узлы любого уровня.
|
||||
* @param {Array} result Внутренний аккумулятор рекурсивного вызова.
|
||||
* @returns {Array<{path: string, name: string, size: number, file: File}>} Файлы для приложения.
|
||||
* @returns {Array<{id: string, path: string, name: string, size: number, file: File}>} Файлы для приложения.
|
||||
*/
|
||||
export function flattenFiles(nodes, result = []) {
|
||||
nodes.forEach((node) => {
|
||||
if (node.kind === 'file') result.push({ path: node.path, name: node.name, size: node.file.size, file: node.file });
|
||||
if (node.kind === 'file') result.push({ id: node.id, path: node.path, name: node.name, size: node.file.size, file: node.file });
|
||||
else flattenFiles(node.children, result);
|
||||
});
|
||||
return result;
|
||||
|
||||
Reference in New Issue
Block a user