From d0a106159d90dc24ec4919f3ba54bc4b7d2923a5 Mon Sep 17 00:00:00 2001 From: F4ilji Date: Sat, 21 Mar 2026 00:53:49 +0500 Subject: [PATCH] changes --- .../Actions/ProcessMixedFilesAction.php | 156 ++++++++- .../Dashboard/Tasks/UnpackArchiveTask.php | 303 ++++++++++++++++++ .../UI/WEB/Requests/StoreFilesRequest.php | 17 +- resources/js/Pages/Dashboard/Main.vue | 7 +- 4 files changed, 477 insertions(+), 6 deletions(-) create mode 100644 app/Containers/Dashboard/Tasks/UnpackArchiveTask.php diff --git a/app/Containers/Dashboard/Actions/ProcessMixedFilesAction.php b/app/Containers/Dashboard/Actions/ProcessMixedFilesAction.php index 4ec9659..26fb0bf 100644 --- a/app/Containers/Dashboard/Actions/ProcessMixedFilesAction.php +++ b/app/Containers/Dashboard/Actions/ProcessMixedFilesAction.php @@ -10,17 +10,21 @@ use App\Containers\Dashboard\Tasks\CreatePostFromAiDataTask; use App\Containers\Dashboard\Tasks\ExtractTextFromDocumentTask; use App\Containers\Dashboard\Tasks\ExtractTextFragmentTask; use App\Containers\Dashboard\Tasks\FindMainNewsFileTask; +use App\Containers\Dashboard\Tasks\UnpackArchiveTask; use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; class ProcessMixedFilesAction { + private array $extractPathsToCleanup = []; + public function __construct( private readonly FindMainNewsFileTask $findMainNewsFileTask, private readonly ExtractTextFromDocumentTask $extractTextFromDocumentTask, private readonly CallAiServiceTask $callAiServiceTask, private readonly CreatePostFromAiDataTask $createPostFromAiDataTask, + private readonly UnpackArchiveTask $unpackArchiveTask, ) {} /** @@ -36,6 +40,25 @@ class ProcessMixedFilesAction 'files' => $files->map(fn($f) => $f->getClientOriginalName())->toArray(), ]); + // Проверяем, есть ли архивы и нужно ли их распаковывать + $files = $this->processArchives($files); + + Log::info('[ProcessMixedFilesAction] Файлы после обработки архивов', [ + 'files_count' => $files->count(), + 'files' => $files->map(fn($f) => $f->getClientOriginalName())->toArray(), + ]); + + // После распаковки проверяем, что есть DOC/DOCX файл + $hasDocOrDocx = $files->contains(function ($file) { + $ext = strtolower($file->getClientOriginalExtension()); + return in_array($ext, ['doc', 'docx']); + }); + + if (!$hasDocOrDocx) { + Log::error('[ProcessMixedFilesAction] После распаковки не найден DOC/DOCX файл'); + throw new \RuntimeException('В архиве не найден DOC или DOCX файл для извлечения текста новости.'); + } + // Находим основной файл с текстом новости $mainFile = $this->findMainNewsFileTask->run($files); @@ -47,10 +70,25 @@ class ProcessMixedFilesAction Log::info('[ProcessMixedFilesAction] Основной файл найден', [ 'file' => $mainFile->getClientOriginalName(), 'extension' => $mainFile->getClientOriginalExtension(), + 'real_path' => $mainFile->getRealPath(), + 'exists' => file_exists($mainFile->getRealPath()), ]); // Извлекаем текст из основного файла (поддерживает и .doc, и .docx) - $extractedText = $this->extractTextFromDocumentTask->run($mainFile); + Log::info('[ProcessMixedFilesAction] Начало извлечения текста из документа'); + try { + $extractedText = $this->extractTextFromDocumentTask->run($mainFile); + Log::info('[ProcessMixedFilesAction] Текст извлечен', [ + 'text_length' => strlen($extractedText ?? ''), + 'text_preview' => substr($extractedText ?? '', 0, 100), + ]); + } catch (\Exception $e) { + Log::error('[ProcessMixedFilesAction] Ошибка при извлечении текста', [ + 'error' => $e->getMessage(), + 'file' => $mainFile->getClientOriginalName(), + ]); + throw $e; + } Log::info('[ProcessMixedFilesAction] Результат извлечения текста', [ 'text_length' => strlen($extractedText ?? ''), @@ -82,6 +120,9 @@ class ProcessMixedFilesAction // Создаём пост $post = $this->createPostFromAiDataTask->run($newsData, $documentPath, $mediaPaths, $attachedFiles); + // Очищаем временные директории после успешной обработки + $this->cleanupExtractPaths(); + // Возвращаем данные для отображения return $this->prepareResponse($post, $newsData); } @@ -106,6 +147,74 @@ class ProcessMixedFilesAction ]; } + /** + * Обрабатывает архивы: распаковывает, если загружены только архивы + */ + private function processArchives(Collection $files): Collection + { + $archiveExtensions = ['zip']; + + // Убедимся, что директория для распаковки существует + $tempPath = storage_path('app/temp'); + if (!file_exists($tempPath)) { + mkdir($tempPath, 0755, true); + } + + // Разделяем файлы на архивы и остальные + $archives = $files->filter(fn($file) => + in_array(strtolower($file->getClientOriginalExtension()), $archiveExtensions) + ); + + $nonArchives = $files->filter(fn($file) => + !in_array(strtolower($file->getClientOriginalExtension()), $archiveExtensions) + ); + + // Если есть только архивы (нет других файлов) — распаковываем + if ($archives->isNotEmpty() && $nonArchives->isEmpty()) { + Log::info('[ProcessMixedFilesAction] Обнаружены только архивы, начинаем распаковку', [ + 'archives_count' => $archives->count(), + ]); + + $unpackedFiles = collect(); + $extractPaths = []; + + foreach ($archives as $archive) { + try { + $result = $this->unpackArchiveTask->run($archive); + $extractedFiles = $result['files']; + $extractPaths[] = $result['extract_path']; + + $unpackedFiles = $unpackedFiles->merge($extractedFiles); + + Log::info('[ProcessMixedFilesAction] Архив распакован', [ + 'archive' => $archive->getClientOriginalName(), + 'extracted_count' => $extractedFiles->count(), + ]); + } catch (\Exception $e) { + Log::error('[ProcessMixedFilesAction] Ошибка при распаковке архива', [ + 'archive' => $archive->getClientOriginalName(), + 'error' => $e->getMessage(), + ]); + throw $e; + } + } + + // Сохраняем пути для очистки после обработки + $this->extractPathsToCleanup = $extractPaths; + + return $unpackedFiles; + } + + // Если есть не-архивные файлы — возвращаем все файлы как есть + // (архивы будут обработаны как обычные медиа-файлы) + Log::info('[ProcessMixedFilesAction] Обнаружены смешанные файлы, архивы не распаковываем', [ + 'archives_count' => $archives->count(), + 'non_archives_count' => $nonArchives->count(), + ]); + + return $files; + } + /** * Обрабатывает прикреплённые файлы (для добавления в контент) */ @@ -176,4 +285,49 @@ class ProcessMixedFilesAction return round($bytes, 2) . ' ' . $units[$pow]; } + + /** + * Очищает временные директории после распаковки + */ + private function cleanupExtractPaths(): void + { + foreach ($this->extractPathsToCleanup as $path) { + if (file_exists($path)) { + $this->cleanupDirectory($path); + } + } + $this->extractPathsToCleanup = []; + } + + /** + * Очищает директорию рекурсивно + */ + private function cleanupDirectory(string $path): void + { + if (!file_exists($path) || !is_dir($path)) { + return; + } + + try { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + @unlink($file->getPathname()); + } elseif ($file->isDir()) { + @rmdir($file->getPathname()); + } + } + + @rmdir($path); + } catch (\Exception $e) { + Log::warning('[ProcessMixedFilesAction] Не удалось очистить временную директорию', [ + 'error' => $e->getMessage(), + 'path' => $path, + ]); + } + } } diff --git a/app/Containers/Dashboard/Tasks/UnpackArchiveTask.php b/app/Containers/Dashboard/Tasks/UnpackArchiveTask.php new file mode 100644 index 0000000..1dca3b1 --- /dev/null +++ b/app/Containers/Dashboard/Tasks/UnpackArchiveTask.php @@ -0,0 +1,303 @@ +getClientOriginalExtension()); + + // Поддерживаем только zip для начала + if ($extension !== 'zip') { + throw new \Exception("Неподдерживаемый формат архива: {$extension}. Поддерживается только ZIP."); + } + + $extractPath = $extractPath ?? storage_path('app/temp/unpacked_' . uniqid()); + + // Создаем директорию если не существует + if (!file_exists($extractPath)) { + mkdir($extractPath, 0755, true); + } + + Log::info('[UnpackArchiveTask] Начало распаковки архива', [ + 'file' => $archive->getClientOriginalName(), + 'size' => $archive->getSize(), + 'extract_path' => $extractPath, + ]); + + try { + $zip = new \ZipArchive(); + + $openResult = $zip->open($archive->getRealPath()); + if ($openResult !== true) { + throw new \Exception('Не удалось открыть архив. Код ошибки: ' . $openResult); + } + + // Извлекаем все файлы с переименованием (кириллица → транслит) + Log::info('[UnpackArchiveTask] Количество файлов в архиве', ['numFiles' => $zip->numFiles]); + + for ($i = 0; $i < $zip->numFiles; $i++) { + $fileInfo = $zip->statIndex($i); + $originalName = $fileInfo['name']; + + Log::info('[UnpackArchiveTask] Обработка файла в архиве', [ + 'index' => $i, + 'original_name' => $originalName, + 'is_dir' => substr($originalName, -1) === '/', + ]); + + // Пропускаем директории + if (substr($originalName, -1) === '/') { + continue; + } + + // Преобразуем имя файла (кириллица → транслит) + $safeName = $this->transliterateFilename($originalName); + + Log::info('[UnpackArchiveTask] Транслитерация имени', [ + 'original' => $originalName, + 'safe' => $safeName, + ]); + + // Определяем директорию для файла + $dirname = dirname($safeName); + $targetDir = $extractPath; + if ($dirname !== '.' && $dirname !== '/') { + $targetDir = $extractPath . '/' . $dirname; + // Создаем поддиректории если нужно + if (!file_exists($targetDir)) { + mkdir($targetDir, 0755, true); + } + } + + // Получаем содержимое файла из архива + $content = $zip->getFromIndex($i); + + if ($content === false) { + Log::error('[UnpackArchiveTask] Не удалось прочитать содержимое файла', [ + 'name' => $originalName, + ]); + continue; + } + + // Сохраняем файл с безопасным именем + $targetPath = $targetDir . '/' . basename($safeName); + $writeResult = file_put_contents($targetPath, $content); + + if ($writeResult === false) { + Log::error('[UnpackArchiveTask] Не удалось записать файл', [ + 'path' => $targetPath, + ]); + continue; + } + + Log::info('[UnpackArchiveTask] Файл извлечен', [ + 'path' => $targetPath, + 'size' => $writeResult, + ]); + } + + $zip->close(); + + Log::info('[UnpackArchiveTask] Архив распакован', [ + 'files_count' => $this->countFilesInDirectory($extractPath), + ]); + + // Собираем все файлы из распакованной директории + $files = $this->collectFilesFromDirectory($extractPath); + + Log::info('[UnpackArchiveTask] Файлы собраны', [ + 'files' => $files->map(fn($f) => $f->getClientOriginalName())->toArray(), + ]); + + // Возвращаем файлы вместе с путем к директории для последующей очистки + return ['files' => $files, 'extract_path' => $extractPath]; + } catch (\Exception $e) { + Log::error('[UnpackArchiveTask] Ошибка при распаковке архива', [ + 'error' => $e->getMessage(), + 'file' => $archive->getClientOriginalName(), + ]); + + // Очищаем временную директорию при ошибке + if (file_exists($extractPath)) { + $this->cleanupDirectory($extractPath); + } + + throw new \Exception('Ошибка при распаковке архива: ' . $e->getMessage()); + } + } + + /** + * Преобразует имя файла (транслитерация кириллицы) + */ + private function transliterateFilename(string $filename): string + { + $translit = [ + 'а' => 'a', 'б' => 'b', 'в' => 'v', 'г' => 'g', 'д' => 'd', + 'е' => 'e', 'ё' => 'yo', 'ж' => 'zh', 'з' => 'z', 'и' => 'i', + 'й' => 'y', 'к' => 'k', 'л' => 'l', 'м' => 'm', 'н' => 'n', + 'о' => 'o', 'п' => 'p', 'р' => 'r', 'с' => 's', 'т' => 't', + 'у' => 'u', 'ф' => 'f', 'х' => 'h', 'ц' => 'c', 'ч' => 'ch', + 'ш' => 'sh', 'щ' => 'sch', 'ъ' => '', 'ы' => 'y', 'ь' => '', + 'э' => 'e', 'ю' => 'yu', 'я' => 'ya', + 'А' => 'A', 'Б' => 'B', 'В' => 'V', 'Г' => 'G', 'Д' => 'D', + 'Е' => 'E', 'Ё' => 'Yo', 'Ж' => 'Zh', 'З' => 'Z', 'И' => 'I', + 'Й' => 'Y', 'К' => 'K', 'Л' => 'L', 'М' => 'M', 'Н' => 'N', + 'О' => 'O', 'П' => 'P', 'Р' => 'R', 'С' => 'S', 'Т' => 'T', + 'У' => 'U', 'Ф' => 'F', 'Х' => 'H', 'Ц' => 'C', 'Ч' => 'Ch', + 'Ш' => 'Sh', 'Щ' => 'Sch', 'Ъ' => '', 'Ы' => 'Y', 'Ь' => '', + 'Э' => 'E', 'Ю' => 'Yu', 'Я' => 'Ya', + ]; + + $pathinfo = pathinfo($filename); + $dirname = $pathinfo['dirname'] ?? ''; + $filename = $pathinfo['filename']; + $extension = $pathinfo['extension'] ?? ''; + + // Транслитерируем имя файла + $transliterated = strtr($filename, $translit); + + // Заменяем пробелы и спецсимволы на подчеркивания + $transliterated = preg_replace('/[^A-Za-z0-9_\-]/', '_', $transliterated); + + // Собираем обратно + $result = $dirname !== '.' ? $dirname . '/' . $transliterated : $transliterated; + if ($extension) { + $result .= '.' . $extension; + } + + return $result; + } + + /** + * Собирает все файлы из директории рекурсивно + */ + private function collectFilesFromDirectory(string $path): Collection + { + $files = collect(); + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + // Определяем MIME-тип для правильного расширения + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $mimeType = $finfo->file($file->getPathname()); + + // Получаем расширение по MIME-типу + $extension = $this->getExtensionFromMimeType($mimeType); + + // Если не удалось определить по MIME, используем расширение из имени + if (!$extension) { + $extension = pathinfo($file->getFilename(), PATHINFO_EXTENSION); + } + + $uploadedFile = new UploadedFile( + $file->getPathname(), + $file->getFilename(), + $extension, + null, + true + ); + $files->push($uploadedFile); + } + } + + return $files; + } + + /** + * Возвращает расширение по MIME-типу + */ + private function getExtensionFromMimeType(string $mimeType): ?string + { + $map = [ + 'application/msword' => 'doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', + 'application/pdf' => 'pdf', + 'application/vnd.ms-excel' => 'xls', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'image/gif' => 'gif', + 'image/bmp' => 'bmp', + 'application/zip' => 'zip', + ]; + + return $map[$mimeType] ?? null; + } + + /** + * Подсчитывает количество файлов в директории + */ + private function countFilesInDirectory(string $path): int + { + $count = 0; + + if (!is_dir($path)) { + return 0; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + $count++; + } + } + + return $count; + } + + /** + * Очищает временную директорию + */ + private function cleanupDirectory(string $path): void + { + if (!file_exists($path) || !is_dir($path)) { + return; + } + + try { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + @unlink($file->getPathname()); + } elseif ($file->isDir()) { + @rmdir($file->getPathname()); + } + } + + @rmdir($path); + } catch (\Exception $e) { + Log::warning('[UnpackArchiveTask] Не удалось очистить временную директорию', [ + 'error' => $e->getMessage(), + 'path' => $path, + ]); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreFilesRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreFilesRequest.php index e905f4d..120bf41 100755 --- a/app/Containers/Dashboard/UI/WEB/Requests/StoreFilesRequest.php +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreFilesRequest.php @@ -26,11 +26,11 @@ class StoreFilesRequest extends FormRequest 'files.*' => [ 'required', 'file', - 'mimes:doc,docx,pdf,xls,xlsx,jpg,jpeg,png,webp,gif', + 'mimes:doc,docx,pdf,xls,xlsx,jpg,jpeg,png,webp,gif,zip', 'max:20480' // макс 20МБ на КАЖДЫЙ файл ], - // Обязательно наличие хотя бы одного DOC или DOCX файла + // Обязательно наличие хотя бы одного DOC или DOCX файла (или архива, который будет распакован) 'files' => [ 'required', 'array', @@ -40,7 +40,18 @@ class StoreFilesRequest extends FormRequest return in_array($ext, ['doc', 'docx']); }); - if (!$hasDocOrDocx) { + $hasArchive = collect($value)->contains(function ($file) { + $ext = strtolower($file->getClientOriginalExtension()); + return in_array($ext, ['zip']); + }); + + // Если есть только архивы — проверяем их содержимое после распаковки + if (!$hasDocOrDocx && $hasArchive) { + // Разрешаем, проверка будет после распаковки + return; + } + + if (!$hasDocOrDocx && !$hasArchive) { $fail('Должен быть загружен хотя бы один DOC или DOCX файл для извлечения текста новости.'); } }, diff --git a/resources/js/Pages/Dashboard/Main.vue b/resources/js/Pages/Dashboard/Main.vue index 4d53951..0ed2fc3 100755 --- a/resources/js/Pages/Dashboard/Main.vue +++ b/resources/js/Pages/Dashboard/Main.vue @@ -79,11 +79,14 @@

или перетащите их сюда

- DOCX, PDF, XLSX, JPG, PNG, WEBP (до 20MB каждый) + DOCX, PDF, XLSX, JPG, PNG, WEBP, ZIP (до 20MB каждый)

Система автоматически определит файл с текстом новости по названию или содержанию

+

+ 💡 Можно загрузить ZIP-архив — система автоматически распакует его и обработает содержимое +

@@ -95,7 +98,7 @@ ref="filesInput" class="hidden" @change="handleFileInput" - accept=".doc,.docx,.pdf,.xls,.xlsx,.jpg,.jpeg,.png,.webp,.gif" + accept=".doc,.docx,.pdf,.xls,.xlsx,.jpg,.jpeg,.png,.webp,.gif,.zip" />