From ec65cfd05a739a3552424a694fb1fabc51c03b01 Mon Sep 17 00:00:00 2001 From: Dmitriy Fofanov Date: Wed, 5 Nov 2025 09:33:12 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D1=81=D1=82=D0=B8=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D1=8B?= =?UTF-8?q?=20=D0=B8=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D1=80=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=20PDF=20Compressor.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлен release-body.md для подробных заметок о релизе на русском языке. - Реализован release-gitea.ps1 для автоматизированного релиза Gitea с помощью PowerShell. - Создан release-gitea.sh для автоматизированного релиза Gitea с помощью Bash. - Добавлен release.sh для сборки и маркировки релизов с поддержкой нескольких платформ. - Улучшен пользовательский интерфейс благодаря информативному логированию и обработке ошибок. - Добавлена ​​поддержка переменных окружения и управления конфигурацией. - Добавлена ​​функция создания архивов и загрузки ресурсов в Gitea. --- .air.toml | 41 + .dockerignore | 72 ++ .env.example | 161 ++++ .gitignore | 27 + Dockerfile | 103 +++ Makefile | 187 +++++ README.md | 309 ++++++- VERSION | Bin 0 -> 14 bytes cmd/main.go | 109 +++ cmd/processor.go | 74 ++ config.yaml | 23 + config.yaml.example | 29 + docker-compose.yml | 143 ++++ go.mod | 39 + go.sum | 107 +++ internal/domain/entities/app_config.go | 269 ++++++ internal/domain/entities/config.go | 88 ++ internal/domain/entities/config_test.go | 127 +++ internal/domain/entities/errors.go | 16 + internal/domain/entities/pdf.go | 37 + internal/domain/entities/pdf_test.go | 112 +++ .../repositories/app_config_repository.go | 9 + internal/domain/repositories/interfaces.go | 24 + internal/domain/repositories/logger.go | 11 + .../compressors/image_compressor.go | 259 ++++++ .../compressors/pdfcpu_compressor.go | 69 ++ .../compressors/unipdf_compressor.go | 145 ++++ internal/infrastructure/config/repository.go | 74 ++ .../infrastructure/logging/file_logger.go | 110 +++ .../repositories/config_repository.go | 24 + .../repositories/filesystem_repository.go | 69 ++ .../interface/controllers/cli_controller.go | 158 ++++ internal/presentation/tui/logger_adapter.go | 83 ++ internal/presentation/tui/manager.go | 786 ++++++++++++++++++ internal/usecase/compress_directory.go | 109 +++ internal/usecase/compress_images.go | 175 ++++ internal/usecase/compress_pdf.go | 73 ++ internal/usecase/process_all_files.go | 137 +++ internal/usecase/process_pdfs.go | 401 +++++++++ scripts/release-body.md | 73 ++ scripts/release-gitea.ps1 | 473 +++++++++++ scripts/release-gitea.sh | 369 ++++++++ scripts/release.sh | 90 ++ 43 files changed, 5792 insertions(+), 2 deletions(-) create mode 100644 .air.toml create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 VERSION create mode 100644 cmd/main.go create mode 100644 cmd/processor.go create mode 100644 config.yaml create mode 100644 config.yaml.example create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/domain/entities/app_config.go create mode 100644 internal/domain/entities/config.go create mode 100644 internal/domain/entities/config_test.go create mode 100644 internal/domain/entities/errors.go create mode 100644 internal/domain/entities/pdf.go create mode 100644 internal/domain/entities/pdf_test.go create mode 100644 internal/domain/repositories/app_config_repository.go create mode 100644 internal/domain/repositories/interfaces.go create mode 100644 internal/domain/repositories/logger.go create mode 100644 internal/infrastructure/compressors/image_compressor.go create mode 100644 internal/infrastructure/compressors/pdfcpu_compressor.go create mode 100644 internal/infrastructure/compressors/unipdf_compressor.go create mode 100644 internal/infrastructure/config/repository.go create mode 100644 internal/infrastructure/logging/file_logger.go create mode 100644 internal/infrastructure/repositories/config_repository.go create mode 100644 internal/infrastructure/repositories/filesystem_repository.go create mode 100644 internal/interface/controllers/cli_controller.go create mode 100644 internal/presentation/tui/logger_adapter.go create mode 100644 internal/presentation/tui/manager.go create mode 100644 internal/usecase/compress_directory.go create mode 100644 internal/usecase/compress_images.go create mode 100644 internal/usecase/compress_pdf.go create mode 100644 internal/usecase/process_all_files.go create mode 100644 internal/usecase/process_pdfs.go create mode 100644 scripts/release-body.md create mode 100644 scripts/release-gitea.ps1 create mode 100644 scripts/release-gitea.sh create mode 100644 scripts/release.sh diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..c24b17a --- /dev/null +++ b/.air.toml @@ -0,0 +1,41 @@ +# Air конфигурация для PDF Compressor +# Автоматическая перезагрузка при разработке + +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main cmd/main.go" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata", "test_pdfs", "compressed_pdfs", "output", "logs", "bin", "build", "coverage"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html", "yaml", "yml"] + kill_delay = "0s" + log = "build-errors.log" + send_interrupt = false + stop_on_root = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = true + keep_scroll = true diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..86cdae3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,72 @@ +# Git +.git +.gitignore +README.md +README_DETAILED.md +INSTALLATION.md + +# Документация +*.md +docs/ + +# Временные файлы +*.tmp +*.temp +.tmp/ +temp/ + +# Логи +*.log +logs/ +*.logs + +# Тестовые данные +test_pdfs/ +example_pdfs/ +compressed_pdfs/ +output/ +testdata/ + +# Сборки +bin/ +build/ +dist/ +release/ + +# IDE и редакторы +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS файлы +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Покрытие тестами +coverage/ +*.coverprofile +coverage.out +coverage.html + +# Зависимости (будут установлены в контейнере) +vendor/ + +# Конфигурационные файлы разработки +.env +.env.local +.env.dev + +# Резервные копии +*.bak +*.backup + +# Профили производительности +*.prof +*.pprof diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9b163b7 --- /dev/null +++ b/.env.example @@ -0,0 +1,161 @@ +# Docker Environment Configuration +# Конфигурация окружения для Docker контейнера PDF Compressor + +# === ОСНОВНЫЕ НАСТРОЙКИ === + +# Путь к конфигурационному файлу +APP_CONFIG_PATH=/app/config/config.yaml + +# Уровень логирования (debug, info, warn, error) +APP_LOG_LEVEL=info + +# === ДИРЕКТОРИИ === + +# Входная директория для PDF файлов +APP_INPUT_DIR=/app/input + +# Выходная директория для сжатых файлов +APP_OUTPUT_DIR=/app/output + +# Директория для логов +APP_LOGS_DIR=/app/logs + +# === НАСТРОЙКИ СЖАТИЯ === + +# Уровень сжатия по умолчанию (1-100) +APP_COMPRESSION_LEVEL=50 + +# Алгоритм сжатия (pdfcpu, unipdf) +APP_COMPRESSION_ALGORITHM=pdfcpu + +# Количество параллельных воркеров +APP_PARALLEL_WORKERS=4 + +# === НАСТРОЙКИ ПРОИЗВОДИТЕЛЬНОСТИ === + +# Максимальный размер файла в MB +APP_MAX_FILE_SIZE=100 + +# Таймаут обработки файла в секундах +APP_PROCESSING_TIMEOUT=300 + +# Размер буфера для чтения файлов в KB +APP_BUFFER_SIZE=1024 + +# === НАСТРОЙКИ МОНИТОРИНГА === + +# Интервал проверки здоровья в секундах +HEALTHCHECK_INTERVAL=30 + +# Таймаут проверки здоровья в секундах +HEALTHCHECK_TIMEOUT=10 + +# === НАСТРОЙКИ БЕЗОПАСНОСТИ === + +# Пользователь и группа для запуска +APP_USER_ID=1001 +APP_GROUP_ID=1001 + +# Права доступа к файлам (octal) +APP_FILE_PERMISSIONS=644 +APP_DIR_PERMISSIONS=755 + +# === DOCKER СПЕЦИФИЧНЫЕ === + +# Имя контейнера +CONTAINER_NAME=pdf-compressor-app + +# Имя образа +IMAGE_NAME=pdf-compressor:latest + +# Сеть Docker +DOCKER_NETWORK=pdf-compressor-network + +# === РЕСУРСЫ === + +# Лимит CPU (в единицах CPU) +DOCKER_CPU_LIMIT=2.0 + +# Резервирование CPU +DOCKER_CPU_RESERVATION=0.5 + +# Лимит памяти +DOCKER_MEMORY_LIMIT=1G + +# Резервирование памяти +DOCKER_MEMORY_RESERVATION=256M + +# === VOLUMES === + +# Хост-путь для входных файлов +HOST_INPUT_PATH=./input_pdfs + +# Хост-путь для выходных файлов +HOST_OUTPUT_PATH=./output_pdfs + +# Хост-путь для логов +HOST_LOGS_PATH=./logs + +# Хост-путь для конфигурации +HOST_CONFIG_PATH=./config.yaml + +# === ДОПОЛНИТЕЛЬНЫЕ СЕРВИСЫ === + +# Порт для файлового браузера +FILEBROWSER_PORT=8080 + +# Порт для просмотра логов +LOG_VIEWER_PORT=8081 + +# Включить файловый браузер (true/false) +ENABLE_FILEBROWSER=true + +# Включить просмотр логов (true/false) +ENABLE_LOG_VIEWER=false + +# === РАЗВЕРТЫВАНИЕ === + +# Окружение (development, staging, production) +ENVIRONMENT=development + +# Версия приложения +APP_VERSION=1.0.0 + +# Автоматический запуск при старте системы +RESTART_POLICY=unless-stopped + +# === ОТЛАДКА === + +# Включить режим отладки +DEBUG_MODE=false + +# Подробные логи сжатия +VERBOSE_COMPRESSION=false + +# Профилирование производительности +ENABLE_PROFILING=false + +# === УВЕДОМЛЕНИЯ (для будущих версий) === + +# Email для уведомлений об ошибках +NOTIFICATION_EMAIL= + +# Slack webhook для уведомлений +SLACK_WEBHOOK_URL= + +# Telegram bot token +TELEGRAM_BOT_TOKEN= + +# === ИНТЕГРАЦИИ (для будущих версий) === + +# S3 bucket для хранения файлов +S3_BUCKET_NAME= + +# S3 region +S3_REGION= + +# S3 access key +S3_ACCESS_KEY= + +# S3 secret key +S3_SECRET_KEY= diff --git a/.gitignore b/.gitignore index 5b90e79..b533872 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ *.so *.dylib +# Logs +*.log + # Test binary, built with `go test -c` *.test @@ -25,3 +28,27 @@ go.work.sum # env file .env +# Test and build directories +/compressed +/test +/releases + +# Build artifacts +/build +/dist + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fda61d9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,103 @@ +# Dockerfile для PDF Compressor +# Multi-stage сборка для оптимизации размера образа + +# Стадия 1: Сборка приложения +FROM golang:1.24-alpine AS builder + +# Метаданные +LABEL maintainer="PDF Compressor Team" +LABEL description="PDF Compressor - автоматическое сжатие PDF файлов" +LABEL version="1.0.0" + +# Установка необходимых пакетов для сборки +RUN apk add --no-cache \ + git \ + ca-certificates \ + tzdata \ + gcc \ + musl-dev + +# Создание пользователя для сборки +RUN adduser -D -s /bin/sh -u 1001 builder + +# Установка рабочей директории +WORKDIR /app + +# Копирование файлов зависимостей +COPY go.mod go.sum ./ + +# Загрузка зависимостей (кэшируемый слой) +RUN go mod download && go mod verify + +# Копирование исходного кода +COPY . . + +# Изменение владельца файлов +RUN chown -R builder:builder /app +USER builder + +# Сборка приложения с оптимизациями +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ + -ldflags='-w -s -extldflags "-static"' \ + -a -installsuffix cgo \ + -o pdf-compressor cmd/main.go + +# Стадия 2: Минимальный runtime образ +FROM alpine:3.19 + +# Установка runtime зависимостей +RUN apk add --no-cache \ + ca-certificates \ + tzdata \ + && update-ca-certificates + +# Создание пользователя для runtime +RUN addgroup -g 1001 -S pdfuser && \ + adduser -u 1001 -S pdfuser -G pdfuser + +# Создание рабочих директорий +RUN mkdir -p /app/input /app/output /app/config /app/logs && \ + chown -R pdfuser:pdfuser /app + +# Копирование скомпилированного приложения +COPY --from=builder /app/pdf-compressor /usr/local/bin/pdf-compressor + +# Копирование конфигурационного файла по умолчанию +COPY config.yaml /app/config/config.yaml + +# Установка переменных окружения +ENV APP_CONFIG_PATH="/app/config/config.yaml" +ENV APP_LOG_LEVEL="info" +ENV APP_INPUT_DIR="/app/input" +ENV APP_OUTPUT_DIR="/app/output" +ENV APP_LOGS_DIR="/app/logs" + +# Переключение на непривилегированного пользователя +USER pdfuser + +# Установка рабочей директории +WORKDIR /app + +# Открытие портов (если потребуется web интерфейс в будущем) +# EXPOSE 8080 + +# Volumes для данных +VOLUME ["/app/input", "/app/output", "/app/config", "/app/logs"] + +# Healthcheck для мониторинга +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD pdf-compressor --version || exit 1 + +# Команда запуска +ENTRYPOINT ["pdf-compressor"] +CMD ["--config", "/app/config/config.yaml"] + +# Метаданные образа +LABEL org.opencontainers.image.title="PDF Compressor" +LABEL org.opencontainers.image.description="Автоматическое сжатие PDF файлов с TUI интерфейсом" +LABEL org.opencontainers.image.version="1.0.0" +LABEL org.opencontainers.image.created="2024" +LABEL org.opencontainers.image.vendor="PDF Compressor Team" +LABEL org.opencontainers.image.licenses="MIT" +LABEL org.opencontainers.image.documentation="https://github.com/your-username/pdf-compressor" +LABEL org.opencontainers.image.source="https://github.com/your-username/pdf-compressor" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3c591b5 --- /dev/null +++ b/Makefile @@ -0,0 +1,187 @@ +# PDF Compressor Makefile +# Build system and project management + +# Variables +BINARY_NAME=compressor +MAIN_PATH=./cmd +BUILD_DIR=bin +COVERAGE_DIR=coverage + +# Go parameters +GO_VERSION=1.23 +GOOS?=$(shell go env GOOS) +GOARCH?=$(shell go env GOARCH) + +# Determine binary extension for the executable +ifeq ($(GOOS),windows) + BINARY_EXT=.exe +else + BINARY_EXT= +endif +BINARY=$(BINARY_NAME)$(BINARY_EXT) + +# Colors for output +RED=\033[31m +GREEN=\033[32m +YELLOW=\033[33m +BLUE=\033[34m +RESET=\033[0m + +# Main commands +.PHONY: help install-deps build run test test-unit test-comprehensive test-all clean coverage lint format check-deps dev docker quickstart release + +## install-deps: Install all dependencies +install-deps: + @echo "$(YELLOW)📦 Installing dependencies...$(RESET)" + @go mod download + @go mod tidy + @echo "$(GREEN)✅ Dependencies installed$(RESET)" + +## build: Build the application +build: check-deps + @echo "$(YELLOW)🔨 Building application...$(RESET)" + @if not exist "$(BUILD_DIR)" mkdir "$(BUILD_DIR)" + @go build -ldflags="-s -w" -o $(BUILD_DIR)/$(BINARY) $(MAIN_PATH) + @echo "$(GREEN)✅ Build completed: $(BUILD_DIR)/$(BINARY)$(RESET)" + +## run: Run the application +run: + @echo "$(BLUE)🚀 Starting PDF Compressor...$(RESET)" + @go run $(MAIN_PATH) + +## test-unit: Run unit tests +test-unit: + @echo "$(YELLOW)🧪 Running unit tests...$(RESET)" + @go test -v ./internal/... + @echo "$(GREEN)✅ Unit tests passed$(RESET)" + +## test-comprehensive: Run comprehensive tests +test-comprehensive: + @echo "$(YELLOW)🔬 Running comprehensive tests...$(RESET)" + @cd tests && go run comprehensive.go + @echo "$(GREEN)✅ Comprehensive tests completed$(RESET)" + +## test-all: Run all tests (unit + comprehensive) +test-all: test-unit test-comprehensive + @echo "$(GREEN)✅ All tests passed$(RESET)" + +## test: Alias for test-all +test: test-all + +## coverage: Code coverage analysis +coverage: + @echo "$(YELLOW)📊 Analyzing code coverage...$(RESET)" + @mkdir -p $(COVERAGE_DIR) + @go test -coverprofile=$(COVERAGE_DIR)/coverage.out ./internal/... + @go tool cover -html=$(COVERAGE_DIR)/coverage.out -o $(COVERAGE_DIR)/coverage.html + @go tool cover -func=$(COVERAGE_DIR)/coverage.out + @echo "$(GREEN)✅ Coverage report: $(COVERAGE_DIR)/coverage.html$(RESET)" + +## lint: Code quality check +lint: + @echo "$(YELLOW)🔍 Checking code quality...$(RESET)" + @if command -v golangci-lint >/dev/null 2>&1; then \ + golangci-lint run ./...; \ + else \ + echo "$(RED)❌ golangci-lint not installed$(RESET)"; \ + echo "Install: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ + go vet ./...; \ + fi + @echo "$(GREEN)✅ Linting completed$(RESET)" + +## format: Code formatting +format: + @echo "$(YELLOW)✨ Formatting code...$(RESET)" + @go fmt ./... + @if command -v goimports >/dev/null 2>&1; then \ + goimports -w .; \ + else \ + echo "$(YELLOW)⚠️ goimports not installed, using go fmt$(RESET)"; \ + fi + @echo "$(GREEN)✅ Code formatted$(RESET)" + +## check-deps: Check dependencies +check-deps: + @echo "$(YELLOW)🔍 Checking dependencies...$(RESET)" + @go version + @go mod verify + @echo "$(GREEN)✅ Dependencies verified$(RESET)" + +## clean: Clean temporary files +clean: + @echo "$(YELLOW)🧹 Cleaning temporary files...$(RESET)" + @if exist "$(BUILD_DIR)" rmdir /s /q "$(BUILD_DIR)" + @if exist "$(COVERAGE_DIR)" rmdir /s /q "$(COVERAGE_DIR)" + @if exist tests\compressed rmdir /s /q tests\compressed + @if exist tests\comprehensive_test.exe del tests\comprehensive_test.exe + @if exist $(BINARY_NAME).exe del $(BINARY_NAME).exe + @if exist $(BINARY_NAME) del $(BINARY_NAME) + @if exist $(BINARY_NAME).log del $(BINARY_NAME).log + @go clean + @echo "$(GREEN)✅ Cleanup completed$(RESET)" + +## dev: Development mode with auto-reload +dev: + @echo "$(BLUE)🔥 Development mode (Ctrl+C to exit)$(RESET)" + @if command -v air >/dev/null 2>&1; then \ + air; \ + else \ + echo "$(YELLOW)⚠️ air not installed, using regular run$(RESET)"; \ + echo "Install air: go install github.com/cosmtrek/air@latest"; \ + make run; \ + fi + +## docker: Docker build +docker: + @echo "$(YELLOW)🐳 Building Docker image...$(RESET)" + @docker build -t pdf-compressor:latest . + @echo "$(GREEN)✅ Docker image built$(RESET)" + +## release: Создать релиз (Windows PowerShell) +release: + @echo "$(YELLOW)Creating release...$(RESET)" + @if [ -f "scripts/release-gitea.ps1" ]; then \ + pwsh -File scripts/release-gitea.ps1; \ + else \ + echo "$(RED)Release script not found$(RESET)"; \ + exit 1; \ + fi + +## quickstart: Quick start for new users +quickstart: install-deps build + @echo "$(GREEN)✅ Quickstart completed!$(RESET)" + @echo "" + @echo "$(BLUE)Next steps:$(RESET)" + @echo "1. Create folders: mkdir test_pdfs output" + @echo "2. Put PDF files in test_pdfs/" + @echo "3. Run: make run" + @echo "" + +## help: Show help for commands +help: + @echo "$(BLUE)PDF Compressor - Available commands:$(RESET)" + @echo "" + @echo "$(GREEN)Build and Run:$(RESET)" + @echo " make install-deps - Install dependencies" + @echo " make build - Build application" + @echo " make run - Run application" + @echo " make dev - Development mode (auto-reload)" + @echo "" + @echo "$(GREEN)Testing and Quality:$(RESET)" + @echo " make test - Run all tests (unit + comprehensive)" + @echo " make test-unit - Run unit tests only" + @echo " make test-comprehensive - Run comprehensive tests only" + @echo " make coverage - Code coverage analysis" + @echo " make lint - Code linting" + @echo " make format - Code formatting" + @echo "" + @echo "$(GREEN)Utilities:$(RESET)" + @echo " make clean - Clean temporary files" + @echo " make check-deps - Check dependencies" + @echo " make docker - Docker build" + @echo " make release - Create release (PowerShell)" + @echo " make quickstart - Quick start for new users" + @echo "" + +# Show help by default +.DEFAULT_GOAL := help diff --git a/README.md b/README.md index 072c28f..995cd94 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,308 @@ -# compress +# Universal File Compressor -Высокопроизводительный инструмент на Go для массового сжатия PDF и изображений (JPEG/PNG) с удобным TUI-интерфейсом, гибкой конфигурацией и модульной архитектурой (Clean Architecture). Поддерживает выбор алгоритма сжатия (PDFCPU / UniPDF), параметрическое управление качеством, рекурсивную обработку директорий, параллельное выполнение, расширяемость через дополнительные компрессоры. \ No newline at end of file +Высокопроизводительный инструмент на Go для массового сжатия PDF и изображений (JPEG/PNG) с удобным TUI-интерфейсом, гибкой конфигурацией и модульной архитектурой (Clean Architecture). Поддерживает выбор алгоритма сжатия (PDFCPU / UniPDF), параметрическое управление качеством, рекурсивную обработку директорий, параллельное выполнение, расширяемость через дополнительные компрессоры. + +--- +## Содержание +1. Обзор и ключевые особенности +2. Быстрый старт +3. Конфигурация (полный справочник) +4. Алгоритмы сжатия (PDFCPU vs UniPDF) +5. Сжатие изображений (JPEG / PNG) +6. Архитектура и внутреннее устройство +7. Поток обработки (Pipeline) +8. Параллельность и производительность +9. Логирование и мониторинг +10. Расширяемость (как добавить новый компрессор) +11. Сценарии использования и рекомендации +12. Устранение неполадок +13. План развития / Roadmap +14. Лицензия (если применимо) + +--- +## 1. Обзор и ключевые особенности + +| Возможность | Описание | +|-------------|----------| +| Сжатие PDF | Алгоритмы PDFCPU (скорость) и UniPDF (качество) | +| Сжатие изображений | JPEG (10–50% качество), PNG (адаптивная оптимизация) | +| Рекурсивная обработка | Поиск файлов во всех поддиректориях | +| Сохранение структуры | **Полное сохранение иерархии папок** в целевой директории | +| Параллельность | Несколько воркеров, управляемые через конфигурацию | +| Замена или сохранение | Перезапись оригиналов или вывод в целевую папку | +| Интеллектуальное сжатие | Автоматический выбор оригинала при неэффективном сжатии | +| TUI интерфейс | Настройка, запуск, мониторинг прогресса и логов | +| Логирование | В файл и/или на экран, ротация по размеру | +| Универсальная конфигурация | YAML + динамическая загрузка в рантайме | +| Расширяемость | Чистые интерфейсы для добавления новых компрессоров | +| Обработка ошибок | Повторы (retry), таймауты, агрегирование статистики | + +--- +## 2. Быстрый старт + +### Установка из исходников +```bash +git clone https://github.com/your-username/compressor.git +cd compressor +go mod tidy +go build -o compressor cmd/main.go +./compressor # (Windows: compressor.exe) +``` + +### Минимальный сценарий +1. Поместите файлы в директорию `./pdfs` (или измените путь в `config.yaml`). +2. Запустите программу. +3. В TUI выберите «Конфигурация», настройте параметры. +4. Сохраните и запустите обработку. +5. Результаты появятся в выбранной целевой директории или заменят оригиналы. + +--- +## 3. Конфигурация (config.yaml) + +```yaml +scanner: + source_directory: "./pdfs" # Папка с исходными файлами + target_directory: "./compressed" # Если пусто и replace_original=true — замена + replace_original: false # true — перезаписывать исходники + +compression: + level: 50 # Общий уровень (10–90) влияет на стратегию + algorithm: "pdfcpu" # pdfcpu | unipdf + auto_start: false # Автозапуск при старте приложения + unipdf_license_key: "" # Ключ для UniPDF (опционально) + + # Сжатие изображений + enable_jpeg: true # Включить JPEG + enable_png: true # Включить PNG + jpeg_quality: 30 # 10–50 (шаг 5) + png_quality: 25 # 10–50 (шаг 5) + +processing: + parallel_workers: 2 # Количество воркеров + timeout_seconds: 30 # Таймаут на файл + retry_attempts: 3 # Повторы при падениях + +output: + log_level: "info" # debug|info|warning|error + progress_bar: true + log_to_file: true + log_file_name: "compressor.log" + log_max_size_mb: 10 +``` + +### Валидация параметров +| Параметр | Диапазон | Ошибка при нарушении | +|----------|----------|----------------------| +| compression.level | 10–90 | ErrInvalidCompressionLevel | +| jpeg_quality | 10–50 (шаг 5) | ErrInvalidJPEGQuality | +| png_quality | 10–50 (шаг 5) | ErrInvalidPNGQuality | + +--- +## 4. Алгоритмы сжатия PDF + +| Критерий | PDFCPU | UniPDF | +|----------|--------|--------| +| Основной фокус | Скорость | Максимальное сжатие | +| Сжатие изображений | Базовое | Продвинутое (перекодирование) | +| Скорость (относит.) | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | +| Степень сжатия | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +| Стабильность | Высокая | Высокая | +| Зависимости | Легковесный | Более тяжелая библиотека | + +Рекомендации: +- Выберите PDFCPU, если: массовая обработка, преобладает текст, важна скорость. +- Выберите UniPDF, если: критичен размер, много изображений, важна агрессивная оптимизация. + +Переключение: `compression.algorithm: "unipdf"`. + +--- +## 5. Сжатие изображений (JPEG / PNG) + +### Поддерживаемые форматы +- JPEG: `.jpg`, `.jpeg` +- PNG: `.png` + +### Параметры +| Параметр | Назначение | +|----------|------------| +| enable_jpeg | Включает проход по JPEG файлам | +| enable_png | Включает проход по PNG файлам | +| jpeg_quality | Управляет степенью перекодирования и масштабирования | +| png_quality | Управляет коэффициентом масштабирования и уровнем компрессии | + +### Алгоритм JPEG (улучшенный с защитой от увеличения) +1. **Декодирование** исходного JPEG. +2. **Адаптивное масштабирование**: quality 10→50%, quality 50→90% размера. +3. **Консервативное качество**: маппинг 10→30, 30→55, 50→75 (вместо агрессивного 20-100). +4. **Кодирование во временный файл** с проверкой размера. +5. **Интеллектуальный выбор**: + - Если результат ≥95% от оригинала → копируется оригинал (без потери качества) + - Если результат меньше → используется сжатая версия +6. **Гарантия**: выходной файл никогда не будет больше исходного. + +### Алгоритм PNG (улучшенный с защитой от увеличения) +1. **Декодирование** исходного PNG. +2. **Консервативное масштабирование**: quality 10→60%, quality 50→90% размера. +3. **Адаптивная обработка**: маленькие изображения (<400px) не изменяются в размерах. +4. **Кодирование во временный файл** с максимальным сжатием (`png.BestCompression`). +5. **Интеллектуальный выбор**: + - Если результат ≥95% от оригинала → копируется оригинал + - Если результат меньше → используется сжатая версия +6. **Гарантия**: выходной файл никогда не будет больше исходного. + +**Важно**: PNG — lossless формат. Для уже оптимизированных файлов алгоритм автоматически сохраняет оригинал, предотвращая увеличение размера. + +--- +## 6. Архитектура + +Следует принципам Clean Architecture. + +Слои: +- `domain` — сущности, ошибки, интерфейсы (`PDFCompressor`, `FileRepository`, `Logger`). +- `usecase` — сценарии: `ProcessPDFsUseCase`, `CompressImageUseCase`, `ProcessAllFilesUseCase`. +- `infrastructure` — реализации (компрессоры, файловый репозиторий, конфигурация, логирование). +- `presentation/tui` — UI слой, отображение прогресса, ввод настроек. +- `cmd/main.go` — композиция. + +Преимущества: тестируемость, независимость от фреймворков, лёгкая замена реализаций. + +--- +## 7. Поток обработки (Pipeline) +1. Загрузка `config.yaml` через `config.Repository`. +2. Инициализация логирования и TUI. +3. Выбор реализации компрессора PDF. +4. Создание UseCases. +5. Пользователь подтверждает запуск (или auto_start). +6. Сканирование директорий — фильтрация поддерживаемых файлов. +7. Постановка задач в воркеры. +8. Сжатие + повторы при сбоях. +9. Обновление прогресса через callback → TUI. +10. Запись статистики, логов, замена/вывод файлов. + +--- +## 8. Параллельность и производительность +- Модель: пул воркеров (число — `parallel_workers`). +- Ограничение таймаутом: `timeout_seconds`. +- Повторы: `retry_attempts` (экспоненциальную стратегию можно внедрить дополнительно). +- Потенциальные оптимизации: кэширование размеров, отложенное пересохранение, batch-операции. + +--- +## 9. Логирование и мониторинг +| Тип | Назначение | +|-----|------------| +| Console/TUI | Интерактивное наблюдение за процессом | +| File logger | История и аудит | +| Уровни | debug, info, warning, error, success | + +Ротация — по максимальному размеру (MB). + +--- +## 10. Расширяемость + +### Добавление нового PDF компрессора +1. Создайте файл в `internal/infrastructure/compressors/` (например, `myengine_compressor.go`). +2. Реализуйте интерфейс `PDFCompressor`. +3. Добавьте значение в конфигурацию (`compression.algorithm`). +4. Расширьте `main.go` switch. + +### Добавление формата изображений +1. Дополните `IsImageFile` и `GetImageFormat`. +2. Реализуйте метод в `ImageCompressor`. +3. Добавьте поля в `AppCompressionConfig`, валидацию, TUI форму. + +--- +## 11. Сценарии и рекомендации +| Сценарий | Рекомендации | +|----------|--------------| +| Архивирование большого массива офисных PDF | PDFCPU + level 40–60 | +| Максимальное уменьшение для рассылки | UniPDF + level 70–85 | +| Много цветных сканов | UniPDF + включить сжатие JPEG 25–35% | +| Оптимизация сайта (изображения + PDF) | PDFCPU + JPEG/PNG 30% | +| Быстрая предпрод. обработка | PDFCPU + low retries | + +--- +## 12. Устранение неполадок +| Проблема | Причина | Решение | +|----------|---------|---------| +| UniPDF ошибка лицензии | Нет ключа | Установите `UNIDOC_LICENSE_API_KEY` или в config | +| PNG вырос в размере | Уже оптимален | Повышайте качество или отключите PNG | +| Нет файлов найдено | Неверный путь | Проверьте `scanner.source_directory` | +| Высокая нагрузка CPU | Слишком много воркеров | Уменьшите `parallel_workers` | +| Медленно при больших изображениях | Масштабирование | Увеличьте качество или отключите уменьшение | + +--- +## 13. Тестирование + +### Комплексное тестирование всех алгоритмов + +Проект включает единый комплексный тест, который проверяет все основные алгоритмы и компоненты: + +```powershell +# Запуск через скрипт (рекомендуется) +cd tests +.\run-tests.ps1 + +# Или вручную +cd tests +go build -o comprehensive_test.exe comprehensive.go +.\comprehensive_test.exe +``` + +### Покрытие тестами + +Комплексный тест проверяет: + +✅ **Валидация конфигурации** (5 тестов) +- Корректность настроек JPEG/PNG качества +- Граничные значения (10-50%) +- Обнаружение невалидных параметров + +✅ **Сжатие JPEG** (4 теста) +- Различные уровни качества (10%, 30%, 50%) +- Защита от увеличения размера файла + +✅ **Сжатие PNG** (4 теста) +- Различные уровни качества +- Защита от увеличения размера файла + +✅ **Сжатие PDF** (1 тест) +- Алгоритм PDFCPU + +✅ **Структура папок** (1 тест) +- Сохранение иерархии при обработке + +✅ **Вычисления** (3 теста) +- Коэффициент сжатия +- Экономия места + +**Результаты:** Автоматический отчёт с детальной статистикой, время выполнения каждого теста, коды возврата для CI/CD. + +Подробности: [tests/README.md](tests/README.md) + +--- +## 14. План развития (Roadmap) +- [ ] Поддержка WebP / AVIF +- [ ] Асинхронное журналирование с буферизацией +- [ ] REST API слой / gRPC +- [ ] Пакетный режим без TUI (`--headless`) +- [ ] Экспорт отчётов (JSON / HTML) +- [ ] Более точные эвристики выбора стратегии сжатия +- [ ] Плагинная система компрессоров +- [x] Комплексное тестирование всех алгоритмов + +--- +## 15. Лицензия +(Добавьте информацию о лицензии проекта при необходимости.) + +--- +## Приложение A. Пример расширенного запуска +```bash +PDF_CONFIG=./config.yaml ./pdf-compressor +``` + +## Приложение B. Метрики качества (пример) +| Показатель | PDFCPU (сред.) | UniPDF (сред.) | +|------------|----------------|----------------| +| Снижение размера | 30–45% | 50–70% | +| Время (100 файлов) | 1× | 2–3× | +| Ошибки повторной попытки | Низко | Низко | \ No newline at end of file diff --git a/VERSION b/VERSION new file mode 100644 index 0000000000000000000000000000000000000000..739930e26f1dcb5f723eb7ff5f0f95a5f4112f24 GIT binary patch literal 14 TcmezWuZ+QvL65-zhz%J4CISOu literal 0 HcmV?d00001 diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..6340bfc --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,109 @@ +package main + +import ( + "log" + + "compressor/internal/domain/entities" + "compressor/internal/domain/repositories" + "compressor/internal/infrastructure/compressors" + "compressor/internal/infrastructure/config" + "compressor/internal/infrastructure/logging" + infraRepos "compressor/internal/infrastructure/repositories" + "compressor/internal/presentation/tui" + usecases "compressor/internal/usecase" +) + +func main() { + // Загрузка конфигурации + configRepo := config.NewRepository() + appConfig, err := configRepo.Load("config.yaml") + if err != nil { + log.Fatalf("Ошибка загрузки конфигурации: %v", err) + } + + // Инициализация базового логгера (в файл) + fileLogger, err := logging.NewFileLogger( + appConfig.Output.LogFileName, + appConfig.Output.LogLevel, + appConfig.Output.LogMaxSizeMB, + appConfig.Output.LogToFile, + ) + if err != nil { + log.Printf("Предупреждение: не удалось инициализировать логгер: %v", err) + } + if fileLogger != nil { + defer fileLogger.Close() + } + + // Инициализация TUI + tuiManager := tui.NewManager() + tuiManager.Initialize() + + // Оборачиваем логгер адаптером, чтобы видеть логи в TUI + var logger repositories.Logger + logger = tui.NewUILogger(fileLogger, tuiManager) + + // Инициализация репозиториев + fileRepo := infraRepos.NewFileSystemRepository() + compressionConfigRepo := infraRepos.NewConfigRepository() + + // Выбираем компрессор на основе конфигурации + var compressor repositories.PDFCompressor + switch appConfig.Compression.Algorithm { + case "unipdf": + compressor = compressors.NewUniPDFCompressor() + default: + compressor = compressors.NewPDFCPUCompressor() + } + + // Инициализация компрессора изображений + imageCompressor := compressors.NewImageCompressor() + + // Инициализация use cases + processUseCase := usecases.NewProcessPDFsUseCase( + compressor, + fileRepo, + compressionConfigRepo, + logger, + ) + + imageUseCase := usecases.NewCompressImageUseCase(logger, imageCompressor) + + // Создаем объединенный процессор для всех типов файлов + allFilesUseCase := usecases.NewProcessAllFilesUseCase(processUseCase, imageUseCase, logger) + + // Подключаем репортер прогресса к TUI + processUseCase.SetProgressReporter(func(s entities.ProcessingStatus) { + tuiManager.SendStatusUpdate(s) + }) + + // Создание процессора для обработки команд + processor := NewApplicationProcessor( + processUseCase, + allFilesUseCase, + appConfig, + tuiManager, + logger, + ) + defer processor.Shutdown() + + // Привязываем запуск обработки к TUI + tuiManager.SetOnStartProcessing(func() { + // Получаем актуальную конфигурацию из TUI + processor.config = tuiManager.GetConfig() + go processor.StartProcessing() + }) + + // Автозапуск, если включен в конфигурации + if appConfig.Compression.AutoStart { + go processor.StartProcessing() + } + + // Запуск TUI + if err := tuiManager.Run(); err != nil { + log.Fatalf("Ошибка запуска TUI: %v", err) + } + + // Cleanup при выходе + tuiManager.Cleanup() +} diff --git a/cmd/processor.go b/cmd/processor.go new file mode 100644 index 0000000..c61bffe --- /dev/null +++ b/cmd/processor.go @@ -0,0 +1,74 @@ +package main + +import ( + "compressor/internal/domain/entities" + "compressor/internal/domain/repositories" + "compressor/internal/presentation/tui" + usecases "compressor/internal/usecase" + "context" + "sync" +) + +// ApplicationProcessor обрабатывает команды приложения +type ApplicationProcessor struct { + processUseCase *usecases.ProcessPDFsUseCase + allFilesUseCase *usecases.ProcessAllFilesUseCase + config *entities.Config + tuiManager *tui.Manager + logger repositories.Logger + + // Graceful shutdown + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup +} + +// NewApplicationProcessor создает новый процессор приложения +func NewApplicationProcessor( + processUseCase *usecases.ProcessPDFsUseCase, + allFilesUseCase *usecases.ProcessAllFilesUseCase, + config *entities.Config, + tuiManager *tui.Manager, + logger repositories.Logger, +) *ApplicationProcessor { + ctx, cancel := context.WithCancel(context.Background()) + + return &ApplicationProcessor{ + processUseCase: processUseCase, + allFilesUseCase: allFilesUseCase, + config: config, + tuiManager: tuiManager, + logger: logger, + ctx: ctx, + cancel: cancel, + } +} + +// StartProcessing запускает обработку всех поддерживаемых файлов +func (p *ApplicationProcessor) StartProcessing() { + p.wg.Add(1) + defer p.wg.Done() + + if p.logger != nil { + supportedTypes := p.allFilesUseCase.GetSupportedFileTypes(p.config) + p.logger.Info("Запуск обработки файлов. Поддерживаемые типы: %v", supportedTypes) + } + + // Запускаем обработку всех поддерживаемых файлов + if err := p.allFilesUseCase.Execute(p.config); err != nil { + if p.logger != nil { + p.logger.Error("Ошибка обработки: %v", err) + } + return + } + + if p.logger != nil { + p.logger.Success("Обработка файлов завершена успешно") + } +} + +// Shutdown корректно завершает работу процессора +func (p *ApplicationProcessor) Shutdown() { + p.cancel() + p.wg.Wait() +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..dc216be --- /dev/null +++ b/config.yaml @@ -0,0 +1,23 @@ +scanner: + source_directory: ./test + target_directory: ./compressed + replace_original: false +compression: + level: 90 + algorithm: pdfcpu + auto_start: false + unipdf_license_key: "" + enable_jpeg: true + enable_png: true + jpeg_quality: 50 + png_quality: 50 +processing: + parallel_workers: 2 + timeout_seconds: 30 + retry_attempts: 3 +output: + log_level: info + progress_bar: true + log_to_file: true + log_file_name: compressor.log + log_max_size_mb: 10 diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..3998db6 --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,29 @@ +# Конфигурация PDF Compressor + +scanner: + source_directory: "D:\\PDFs\\Source" + target_directory: "D:\\PDFs\\Compressed" # если не указано, то заменяет оригинальные файлы + replace_original: false # true - заменяет оригинал, false - сохраняет в target_directory + +compression: + level: 50 # Процент сжатия (10-90) + algorithm: "pdfcpu" # pdfcpu или unipdf + auto_start: true # Автоматически начать сжатие при запуске + + # Настройки сжатия изображений + enable_jpeg: true # Включить сжатие JPEG файлов + enable_png: true # Включить сжатие PNG файлов + jpeg_quality: 30 # Качество JPEG в процентах от исходного (10-50 с шагом 5) + png_quality: 25 # Качество PNG в процентах от исходного (10-50 с шагом 5) + +processing: + parallel_workers: 2 + timeout_seconds: 30 + retry_attempts: 3 + +output: + log_level: "info" # debug, info, warning, error + progress_bar: true + log_to_file: true + log_file_name: "compressor.log" + log_max_size_mb: 10 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..248aebf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,143 @@ +# Docker Compose конфигурация для PDF Compressor +version: '3.8' + +services: + # Основное приложение PDF Compressor + pdf-compressor: + build: + context: . + dockerfile: Dockerfile + args: + - GO_VERSION=1.24 + image: pdf-compressor:latest + container_name: pdf-compressor-app + + # Перезапуск при ошибках + restart: unless-stopped + + # Переменные окружения + environment: + - APP_CONFIG_PATH=/app/config/config.yaml + - APP_LOG_LEVEL=info + - APP_INPUT_DIR=/app/input + - APP_OUTPUT_DIR=/app/output + - APP_LOGS_DIR=/app/logs + + # Монтирование томов + volumes: + # Входные PDF файлы + - ./input_pdfs:/app/input:ro + # Выходная папка для сжатых файлов + - ./output_pdfs:/app/output:rw + # Конфигурация + - ./config.yaml:/app/config/config.yaml:ro + # Логи + - ./logs:/app/logs:rw + + # Ограничения ресурсов + deploy: + resources: + limits: + cpus: '2.0' + memory: 1G + reservations: + cpus: '0.5' + memory: 256M + + # Сеть + networks: + - pdf-compressor-network + + # Лейблы для мониторинга + labels: + - "traefik.enable=false" + - "com.pdf-compressor.description=PDF Compressor Application" + - "com.pdf-compressor.version=1.0.0" + + # Healthcheck + healthcheck: + test: ["CMD", "pdf-compressor", "--version"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + # Файловый браузер для управления файлами (опционально) + filebrowser: + image: filebrowser/filebrowser:v2 + container_name: pdf-compressor-filebrowser + restart: unless-stopped + + ports: + - "8080:80" + + volumes: + - ./input_pdfs:/srv/input:rw + - ./output_pdfs:/srv/output:rw + - ./filebrowser.db:/database/filebrowser.db + - ./filebrowser-config.json:/config/settings.json + + environment: + - FB_DATABASE=/database/filebrowser.db + - FB_CONFIG=/config/settings.json + + command: --config /config/settings.json + + networks: + - pdf-compressor-network + + labels: + - "traefik.enable=true" + - "traefik.http.routers.filebrowser.rule=Host(`filebrowser.localhost`)" + - "traefik.http.routers.filebrowser.entrypoints=web" + - "traefik.http.services.filebrowser.loadbalancer.server.port=80" + + # Мониторинг логов (опционально) + log-viewer: + image: gohutool/docker-log-viewer:latest + container_name: pdf-compressor-logs + restart: unless-stopped + + ports: + - "8081:8080" + + volumes: + - ./logs:/logs:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + + environment: + - LOG_FILES=/logs/*.log + + networks: + - pdf-compressor-network + +# Сети +networks: + pdf-compressor-network: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 + +# Именованные тома +volumes: + input_pdfs: + driver: local + driver_opts: + type: none + device: ${PWD}/input_pdfs + o: bind + + output_pdfs: + driver: local + driver_opts: + type: none + device: ${PWD}/output_pdfs + o: bind + + logs: + driver: local + driver_opts: + type: none + device: ${PWD}/logs + o: bind diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ca8de03 --- /dev/null +++ b/go.mod @@ -0,0 +1,39 @@ +module compressor + +go 1.23.0 + +toolchain go1.24.7 + +require ( + github.com/gdamore/tcell/v2 v2.9.0 + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 + github.com/pdfcpu/pdfcpu v0.6.0 + github.com/rivo/tview v0.42.0 + github.com/unidoc/unipdf/v3 v3.55.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gdamore/encoding v1.0.1 // indirect + github.com/hhrutter/lzw v1.0.0 // indirect + github.com/hhrutter/tiff v1.0.1 // indirect + github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sirupsen/logrus v1.5.0 // indirect + github.com/stretchr/testify v1.7.1 // indirect + github.com/unidoc/pkcs7 v0.2.0 // indirect + github.com/unidoc/timestamp v0.0.0-20200412005513-91597fd3793a // indirect + github.com/unidoc/unitype v0.2.1 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/image v0.14.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/term v0.34.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1205529 --- /dev/null +++ b/go.sum @@ -0,0 +1,107 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.9.0 h1:N6t+eqK7/xwtRPwxzs1PXeRWnm0H9l02CrgJ7DLn1ys= +github.com/gdamore/tcell/v2 v2.9.0/go.mod h1:8/ZoqM9rxzYphT9tH/9LnunhV9oPBqwS8WHGYm5nrmo= +github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= +github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= +github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0= +github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/pdfcpu/pdfcpu v0.6.0 h1:z4kARP5bcWa39TTYMcN/kjBnm7MvhTWjXgeYmkdAGMI= +github.com/pdfcpu/pdfcpu v0.6.0/go.mod h1:kmpD0rk8YnZj0l3qSeGBlAB+XszHUgNv//ORH/E7EYo= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= +github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sirupsen/logrus v1.5.0 h1:1N5EYkVAPEywqZRJd7cwnRtCb6xJx7NH3T3WUTF980Q= +github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/unidoc/pkcs7 v0.0.0-20200411230602-d883fd70d1df/go.mod h1:UEzOZUEpJfDpywVJMUT8QiugqEZC29pDq7kdIZhWCr8= +github.com/unidoc/pkcs7 v0.2.0 h1:0Y0RJR5Zu7OuD+/l7bODXARn6b8Ev2G4A8lI4rzy9kg= +github.com/unidoc/pkcs7 v0.2.0/go.mod h1:UEzOZUEpJfDpywVJMUT8QiugqEZC29pDq7kdIZhWCr8= +github.com/unidoc/timestamp v0.0.0-20200412005513-91597fd3793a h1:RLtvUhe4DsUDl66m7MJ8OqBjq8jpWBXPK6/RKtqeTkc= +github.com/unidoc/timestamp v0.0.0-20200412005513-91597fd3793a/go.mod h1:j+qMWZVpZFTvDey3zxUkSgPJZEX33tDgU/QIA0IzCUw= +github.com/unidoc/unipdf/v3 v3.55.0 h1:hPkhl+BCZoRLgk+cOW8mdRZ8SUjOj/8HsSRAOmzw5CE= +github.com/unidoc/unipdf/v3 v3.55.0/go.mod h1:06Q/thbRvuQSYiRdtpZ4rZjIug7hg1TJpifNMG7PcBU= +github.com/unidoc/unitype v0.2.1 h1:x0jMn7pB/tNrjEVjy3Ukpxo++HOBQaTCXcTYFA6BH3w= +github.com/unidoc/unitype v0.2.1/go.mod h1:mafyug7zYmDOusqa7G0dJV45qp4b6TDAN+pHN7ZUIBU= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= +golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/domain/entities/app_config.go b/internal/domain/entities/app_config.go new file mode 100644 index 0000000..e9ab849 --- /dev/null +++ b/internal/domain/entities/app_config.go @@ -0,0 +1,269 @@ +package entities + +import "time" + +// Config представляет конфигурацию приложения +type Config struct { + Scanner ScannerConfig `yaml:"scanner"` + Compression AppCompressionConfig `yaml:"compression"` + Processing ProcessingConfig `yaml:"processing"` + Output OutputConfig `yaml:"output"` +} + +// ScannerConfig настройки сканирования директорий +type ScannerConfig struct { + SourceDirectory string `yaml:"source_directory"` + TargetDirectory string `yaml:"target_directory"` + ReplaceOriginal bool `yaml:"replace_original"` +} + +// AppCompressionConfig настройки сжатия приложения +type AppCompressionConfig struct { + Level int `yaml:"level"` + Algorithm string `yaml:"algorithm"` + AutoStart bool `yaml:"auto_start"` + UniPDFLicenseKey string `yaml:"unipdf_license_key"` + // Настройки сжатия изображений + EnableJPEG bool `yaml:"enable_jpeg"` + EnablePNG bool `yaml:"enable_png"` + JPEGQuality int `yaml:"jpeg_quality"` // Качество JPEG в процентах (10-50) + PNGQuality int `yaml:"png_quality"` // Качество PNG в процентах (10-50) +} + +// ProcessingConfig настройки обработки +type ProcessingConfig struct { + ParallelWorkers int `yaml:"parallel_workers"` + TimeoutSeconds int `yaml:"timeout_seconds"` + RetryAttempts int `yaml:"retry_attempts"` +} + +// OutputConfig настройки вывода +type OutputConfig struct { + LogLevel string `yaml:"log_level"` + ProgressBar bool `yaml:"progress_bar"` + LogToFile bool `yaml:"log_to_file"` + LogFileName string `yaml:"log_file_name"` + LogMaxSizeMB int `yaml:"log_max_size_mb"` +} + +// ProcessingStatus статус обработки +type ProcessingStatus struct { + // Текущая фаза обработки + Phase ProcessingPhase + + // Информация о текущем файле + CurrentFile string + CurrentFileSize int64 + + // Общая статистика + TotalFiles int + ProcessedFiles int + SuccessfulFiles int + FailedFiles int + SkippedFiles int + + // Прогресс + Progress float64 + + // Статистика сжатия + TotalOriginalSize int64 + TotalCompressedSize int64 + TotalSavedSpace int64 + AverageCompression float64 + + // Текущий результат + LastResult *CompressionResult + + // Время выполнения + StartTime time.Time + ElapsedTime time.Duration + EstimatedTime time.Duration + + // Состояние + IsComplete bool + Error error + + // Сообщение для UI + Message string +} + +// ProcessingPhase фаза обработки +type ProcessingPhase int + +const ( + PhaseInitializing ProcessingPhase = iota + PhaseScanning + PhaseCompressing + PhaseReplacing + PhaseCompleted + PhaseFailed +) + +// UIScreen типы экранов UI +type UIScreen int + +const ( + UIScreenMenu UIScreen = iota + UIScreenConfig + UIScreenProcessing + // UIScreenResults +) + +// Validate проверяет корректность конфигурации приложения +func (c *AppCompressionConfig) Validate() error { + // Проверка уровня сжатия + if c.Level < 10 || c.Level > 90 { + return ErrInvalidCompressionLevel + } + + // Проверка качества JPEG + if c.EnableJPEG { + if c.JPEGQuality < 10 || c.JPEGQuality > 50 || c.JPEGQuality%5 != 0 { + return ErrInvalidJPEGQuality + } + } + + // Проверка качества PNG + if c.EnablePNG { + if c.PNGQuality < 10 || c.PNGQuality > 50 || c.PNGQuality%5 != 0 { + return ErrInvalidPNGQuality + } + } + + return nil +} + +// GetSupportedImageFormats возвращает список поддерживаемых форматов изображений +func (c *AppCompressionConfig) GetSupportedImageFormats() []string { + var formats []string + if c.EnableJPEG { + formats = append(formats, "JPEG") + } + if c.EnablePNG { + formats = append(formats, "PNG") + } + return formats +} + +// NewProcessingStatus создает новый статус обработки +func NewProcessingStatus(totalFiles int) *ProcessingStatus { + return &ProcessingStatus{ + Phase: PhaseInitializing, + TotalFiles: totalFiles, + StartTime: time.Now(), + } +} + +// UpdateProgress обновляет прогресс обработки +func (ps *ProcessingStatus) UpdateProgress() { + if ps.TotalFiles > 0 { + ps.Progress = float64(ps.ProcessedFiles) / float64(ps.TotalFiles) * 100 + } + + ps.ElapsedTime = time.Since(ps.StartTime) + + // Оценка оставшегося времени + if ps.ProcessedFiles > 0 && ps.ProcessedFiles < ps.TotalFiles { + avgTimePerFile := ps.ElapsedTime / time.Duration(ps.ProcessedFiles) + remainingFiles := ps.TotalFiles - ps.ProcessedFiles + ps.EstimatedTime = avgTimePerFile * time.Duration(remainingFiles) + } +} + +// AddResult добавляет результат обработки файла +func (ps *ProcessingStatus) AddResult(result *CompressionResult) { + ps.ProcessedFiles++ + ps.LastResult = result + + if result.Success && result.Error == nil { + ps.SuccessfulFiles++ + ps.TotalOriginalSize += result.OriginalSize + ps.TotalCompressedSize += result.CompressedSize + ps.TotalSavedSpace += result.SavedSpace + + // Пересчитываем среднее сжатие + if ps.TotalOriginalSize > 0 { + ps.AverageCompression = ((float64(ps.TotalOriginalSize) - float64(ps.TotalCompressedSize)) / float64(ps.TotalOriginalSize)) * 100 + } + } else { + ps.FailedFiles++ + } + + ps.UpdateProgress() +} + +// SetPhase устанавливает фазу обработки +func (ps *ProcessingStatus) SetPhase(phase ProcessingPhase, message string) { + ps.Phase = phase + ps.Message = message +} + +// SetCurrentFile устанавлиет текущий обрабатываемый файл +func (ps *ProcessingStatus) SetCurrentFile(filePath string, size int64) { + ps.CurrentFile = filePath + ps.CurrentFileSize = size +} + +// Complete завершает обработку +func (ps *ProcessingStatus) Complete() { + ps.IsComplete = true + ps.Phase = PhaseCompleted + ps.Progress = 100 + ps.ElapsedTime = time.Since(ps.StartTime) + ps.EstimatedTime = 0 +} + +// Fail отмечает обработку как неудачную +func (ps *ProcessingStatus) Fail(err error) { + ps.IsComplete = true + ps.Phase = PhaseFailed + ps.Error = err + ps.ElapsedTime = time.Since(ps.StartTime) +} + +// GetPhaseName возвращает название фазы +func (phase ProcessingPhase) String() string { + switch phase { + case PhaseInitializing: + return "Инициализация" + case PhaseScanning: + return "Сканирование файлов" + case PhaseCompressing: + return "Сжатие файлов" + case PhaseReplacing: + return "Замена оригиналов" + case PhaseCompleted: + return "Завершено" + case PhaseFailed: + return "Ошибка" + default: + return "Неизвестно" + } +} + +// FormatElapsedTime форматирует время выполнения +func (ps *ProcessingStatus) FormatElapsedTime() string { + duration := ps.ElapsedTime + if duration < time.Second { + return "< 1 сек" + } + if duration < time.Minute { + return duration.Round(time.Second).String() + } + return duration.Round(time.Second).String() +} + +// FormatEstimatedTime форматирует оставшееся время +func (ps *ProcessingStatus) FormatEstimatedTime() string { + if ps.EstimatedTime == 0 { + return "N/A" + } + duration := ps.EstimatedTime + if duration < time.Second { + return "< 1 сек" + } + if duration < time.Minute { + return duration.Round(time.Second).String() + } + return duration.Round(time.Second).String() +} diff --git a/internal/domain/entities/config.go b/internal/domain/entities/config.go new file mode 100644 index 0000000..52147ff --- /dev/null +++ b/internal/domain/entities/config.go @@ -0,0 +1,88 @@ +package entities + +// CompressionConfig представляет конфигурацию сжатия +type CompressionConfig struct { + Level int // Уровень сжатия (10-90) + ImageQuality int // Качество изображений (10-100) + ImageCompression bool // Сжимать изображения + RemoveDuplicates bool // Удалять дубликаты объектов + CompressStreams bool // Сжимать потоки данных + RemoveMetadata bool // Удалять метаданные + RemoveAnnotations bool // Удалять аннотации + RemoveAttachments bool // Удалять вложения + OptimizeForWeb bool // Оптимизировать для веб + UniPDFLicenseKey string // Лицензионный ключ для UniPDF +} + +// NewCompressionConfig создает конфигурацию сжатия на основе уровня +func NewCompressionConfig(level int) *CompressionConfig { + return NewCompressionConfigWithLicense(level, "") +} + +// NewCompressionConfigWithLicense создает конфигурацию сжатия с лицензионным ключом +func NewCompressionConfigWithLicense(level int, licenseKey string) *CompressionConfig { + if level < 10 { + level = 10 + } + if level > 90 { + level = 90 + } + + config := &CompressionConfig{ + Level: level, + RemoveDuplicates: true, + CompressStreams: true, + OptimizeForWeb: true, + UniPDFLicenseKey: licenseKey, + } + + switch { + case level <= 20: // Слабое сжатие + config.ImageQuality = 90 + config.ImageCompression = true + config.RemoveMetadata = false + config.RemoveAnnotations = false + config.RemoveAttachments = false + + case level <= 40: // Умеренное сжатие + config.ImageQuality = 75 + config.ImageCompression = true + config.RemoveMetadata = true + config.RemoveAnnotations = false + config.RemoveAttachments = false + + case level <= 60: // Среднее сжатие + config.ImageQuality = 60 + config.ImageCompression = true + config.RemoveMetadata = true + config.RemoveAnnotations = true + config.RemoveAttachments = false + + case level <= 80: // Высокое сжатие + config.ImageQuality = 40 + config.ImageCompression = true + config.RemoveMetadata = true + config.RemoveAnnotations = true + config.RemoveAttachments = true + + default: // Максимальное сжатие (81-90%) + config.ImageQuality = 25 + config.ImageCompression = true + config.RemoveMetadata = true + config.RemoveAnnotations = true + config.RemoveAttachments = true + } + + return config +} + +// Validate проверяет корректность конфигурации +func (c *CompressionConfig) Validate() error { + if c.Level < 10 || c.Level > 90 { + return ErrInvalidCompressionLevel + } + if c.ImageQuality < 10 || c.ImageQuality > 100 { + return ErrInvalidImageQuality + } + return nil +} diff --git a/internal/domain/entities/config_test.go b/internal/domain/entities/config_test.go new file mode 100644 index 0000000..a04df1d --- /dev/null +++ b/internal/domain/entities/config_test.go @@ -0,0 +1,127 @@ +package entities_test + +import ( + "fmt" + "testing" + + "compressor/internal/domain/entities" +) + +func TestNewCompressionConfig(t *testing.T) { + tests := []struct { + name string + level int + expectedLevel int + }{ + {"Normal level", 50, 50}, + {"Too low level", 5, 10}, + {"Too high level", 95, 90}, + {"Minimum level", 10, 10}, + {"Maximum level", 90, 90}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := entities.NewCompressionConfig(tt.level) + if config.Level != tt.expectedLevel { + t.Errorf("Expected level %d, got %d", tt.expectedLevel, config.Level) + } + }) + } +} + +func TestCompressionConfig_Validate(t *testing.T) { + tests := []struct { + name string + config *entities.CompressionConfig + wantErr bool + }{ + { + name: "Valid config", + config: &entities.CompressionConfig{ + Level: 50, + ImageQuality: 75, + }, + wantErr: false, + }, + { + name: "Invalid compression level - too low", + config: &entities.CompressionConfig{ + Level: 5, + ImageQuality: 75, + }, + wantErr: true, + }, + { + name: "Invalid compression level - too high", + config: &entities.CompressionConfig{ + Level: 95, + ImageQuality: 75, + }, + wantErr: true, + }, + { + name: "Invalid image quality - too low", + config: &entities.CompressionConfig{ + Level: 50, + ImageQuality: 5, + }, + wantErr: true, + }, + { + name: "Invalid image quality - too high", + config: &entities.CompressionConfig{ + Level: 50, + ImageQuality: 105, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestCompressionConfigLevels(t *testing.T) { + tests := []struct { + level int + expectedImageQuality int + expectedMetadata bool + expectedAnnotations bool + expectedAttachments bool + }{ + {15, 90, false, false, false}, // Слабое сжатие + {30, 75, true, false, false}, // Умеренное сжатие + {50, 60, true, true, false}, // Среднее сжатие + {70, 40, true, true, true}, // Высокое сжатие + {85, 25, true, true, true}, // Максимальное сжатие + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("Level %d", tt.level), func(t *testing.T) { + config := entities.NewCompressionConfig(tt.level) + + if config.ImageQuality != tt.expectedImageQuality { + t.Errorf("Expected ImageQuality %d, got %d", tt.expectedImageQuality, config.ImageQuality) + } + + if config.RemoveMetadata != tt.expectedMetadata { + t.Errorf("Expected RemoveMetadata %v, got %v", tt.expectedMetadata, config.RemoveMetadata) + } + + if config.RemoveAnnotations != tt.expectedAnnotations { + t.Errorf("Expected RemoveAnnotations %v, got %v", tt.expectedAnnotations, config.RemoveAnnotations) + } + + if config.RemoveAttachments != tt.expectedAttachments { + t.Errorf("Expected RemoveAttachments %v, got %v", tt.expectedAttachments, config.RemoveAttachments) + } + }) + } +} diff --git a/internal/domain/entities/errors.go b/internal/domain/entities/errors.go new file mode 100644 index 0000000..c29462d --- /dev/null +++ b/internal/domain/entities/errors.go @@ -0,0 +1,16 @@ +package entities + +import "errors" + +// Доменные ошибки +var ( + ErrInvalidCompressionLevel = errors.New("уровень сжатия должен быть от 10 до 90") + ErrInvalidImageQuality = errors.New("качество изображения должно быть от 10 до 100") + ErrInvalidJPEGQuality = errors.New("качество JPEG должно быть от 10 до 50 с шагом 5") + ErrInvalidPNGQuality = errors.New("качество PNG должно быть от 10 до 50 с шагом 5") + ErrFileNotFound = errors.New("файл не найден") + ErrInvalidFileFormat = errors.New("неверный формат файла") + ErrCompressionFailed = errors.New("ошибка сжатия файла") + ErrDirectoryNotFound = errors.New("директория не найдена") + ErrNoFilesFound = errors.New("PDF файлы не найдены") +) diff --git a/internal/domain/entities/pdf.go b/internal/domain/entities/pdf.go new file mode 100644 index 0000000..e2ad854 --- /dev/null +++ b/internal/domain/entities/pdf.go @@ -0,0 +1,37 @@ +package entities + +import ( + "time" +) + +// PDFDocument представляет PDF документ +type PDFDocument struct { + Path string + Size int64 + ModifiedTime time.Time + Pages int +} + +// CompressionResult представляет результат сжатия +type CompressionResult struct { + CurrentFile string + OriginalSize int64 + CompressedSize int64 + CompressionRatio float64 + SavedSpace int64 + Success bool + Error error +} + +// CalculateCompressionRatio вычисляет коэффициент сжатия +func (cr *CompressionResult) CalculateCompressionRatio() { + if cr.OriginalSize > 0 { + cr.CompressionRatio = ((float64(cr.OriginalSize) - float64(cr.CompressedSize)) / float64(cr.OriginalSize)) * 100 + cr.SavedSpace = cr.OriginalSize - cr.CompressedSize + } +} + +// IsEffective проверяет, было ли сжатие эффективным +func (cr *CompressionResult) IsEffective() bool { + return cr.Success && cr.CompressionRatio > 0 +} diff --git a/internal/domain/entities/pdf_test.go b/internal/domain/entities/pdf_test.go new file mode 100644 index 0000000..f47edc6 --- /dev/null +++ b/internal/domain/entities/pdf_test.go @@ -0,0 +1,112 @@ +package entities_test + +import ( + "testing" + + "compressor/internal/domain/entities" +) + +func TestCompressionResult_CalculateCompressionRatio(t *testing.T) { + tests := []struct { + name string + originalSize int64 + compressedSize int64 + expectedRatio float64 + expectedSavedSpace int64 + }{ + { + name: "50% compression", + originalSize: 1000, + compressedSize: 500, + expectedRatio: 50.0, + expectedSavedSpace: 500, + }, + { + name: "25% compression", + originalSize: 1000, + compressedSize: 750, + expectedRatio: 25.0, + expectedSavedSpace: 250, + }, + { + name: "No compression", + originalSize: 1000, + compressedSize: 1000, + expectedRatio: 0.0, + expectedSavedSpace: 0, + }, + { + name: "File got bigger", + originalSize: 1000, + compressedSize: 1100, + expectedRatio: -10.0, + expectedSavedSpace: -100, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &entities.CompressionResult{ + OriginalSize: tt.originalSize, + CompressedSize: tt.compressedSize, + } + + result.CalculateCompressionRatio() + + if result.CompressionRatio != tt.expectedRatio { + t.Errorf("Expected compression ratio %f, got %f", tt.expectedRatio, result.CompressionRatio) + } + + if result.SavedSpace != tt.expectedSavedSpace { + t.Errorf("Expected saved space %d, got %d", tt.expectedSavedSpace, result.SavedSpace) + } + }) + } +} + +func TestCompressionResult_IsEffective(t *testing.T) { + tests := []struct { + name string + result *entities.CompressionResult + expectedEffective bool + }{ + { + name: "Effective compression", + result: &entities.CompressionResult{ + OriginalSize: 1000, + CompressedSize: 500, + CompressionRatio: 50.0, + Success: true, + }, + expectedEffective: true, + }, + { + name: "No compression but successful", + result: &entities.CompressionResult{ + OriginalSize: 1000, + CompressedSize: 1000, + CompressionRatio: 0.0, + Success: true, + }, + expectedEffective: false, + }, + { + name: "Good compression but failed", + result: &entities.CompressionResult{ + OriginalSize: 1000, + CompressedSize: 500, + CompressionRatio: 50.0, + Success: false, + }, + expectedEffective: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.result.IsEffective(); got != tt.expectedEffective { + t.Errorf("IsEffective() = %v, want %v", got, tt.expectedEffective) + } + }) + } +} diff --git a/internal/domain/repositories/app_config_repository.go b/internal/domain/repositories/app_config_repository.go new file mode 100644 index 0000000..3ac7d31 --- /dev/null +++ b/internal/domain/repositories/app_config_repository.go @@ -0,0 +1,9 @@ +package repositories + +import "compressor/internal/domain/entities" + +// ConfigRepository интерфейс для работы с конфигурацией приложения +type AppConfigRepository interface { + Load(configPath string) (*entities.Config, error) + Save(configPath string, config *entities.Config) error +} diff --git a/internal/domain/repositories/interfaces.go b/internal/domain/repositories/interfaces.go new file mode 100644 index 0000000..2ec8dfc --- /dev/null +++ b/internal/domain/repositories/interfaces.go @@ -0,0 +1,24 @@ +package repositories + +import ( + "compressor/internal/domain/entities" +) + +// PDFCompressor интерфейс для сжатия PDF файлов +type PDFCompressor interface { + Compress(inputPath, outputPath string, config *entities.CompressionConfig) (*entities.CompressionResult, error) +} + +// FileRepository интерфейс для работы с файловой системой +type FileRepository interface { + GetFileInfo(path string) (*entities.PDFDocument, error) + FileExists(path string) bool + CreateDirectory(path string) error + ListPDFFiles(directory string) ([]string, error) +} + +// ConfigRepository интерфейс для работы с конфигурацией +type ConfigRepository interface { + GetCompressionConfig(level int) (*entities.CompressionConfig, error) + ValidateConfig(config *entities.CompressionConfig) error +} diff --git a/internal/domain/repositories/logger.go b/internal/domain/repositories/logger.go new file mode 100644 index 0000000..8be7dfd --- /dev/null +++ b/internal/domain/repositories/logger.go @@ -0,0 +1,11 @@ +package repositories + +// Logger интерфейс для логирования +type Logger interface { + Debug(format string, args ...interface{}) + Info(format string, args ...interface{}) + Warning(format string, args ...interface{}) + Error(format string, args ...interface{}) + Success(format string, args ...interface{}) + Close() error +} diff --git a/internal/infrastructure/compressors/image_compressor.go b/internal/infrastructure/compressors/image_compressor.go new file mode 100644 index 0000000..701b912 --- /dev/null +++ b/internal/infrastructure/compressors/image_compressor.go @@ -0,0 +1,259 @@ +package compressors + +import ( + "fmt" + "image" + "image/jpeg" + "image/png" + "io" + "os" + "path/filepath" + "strings" + + "github.com/nfnt/resize" +) + +// ImageCompressor интерфейс для сжатия изображений +type ImageCompressor interface { + CompressJPEG(inputPath, outputPath string, quality int) error + CompressPNG(inputPath, outputPath string, quality int) error +} + +// DefaultImageCompressor реализация компрессора изображений +type DefaultImageCompressor struct{} + +// NewImageCompressor создает новый компрессор изображений +func NewImageCompressor() ImageCompressor { + return &DefaultImageCompressor{} +} + +// CompressJPEG сжимает JPEG файл с указанным качеством +func (c *DefaultImageCompressor) CompressJPEG(inputPath, outputPath string, quality int) error { + // Открываем исходный файл + inputFile, err := os.Open(inputPath) + if err != nil { + return fmt.Errorf("не удалось открыть файл %s: %w", inputPath, err) + } + defer inputFile.Close() + + // Декодируем изображение + img, err := jpeg.Decode(inputFile) + if err != nil { + return fmt.Errorf("не удалось декодировать JPEG файл %s: %w", inputPath, err) + } + + // Получаем размер исходного файла для сравнения + inputFileInfo, err := inputFile.Stat() + if err != nil { + return fmt.Errorf("не удалось получить информацию о файле %s: %w", inputPath, err) + } + originalSize := inputFileInfo.Size() + + // Вычисляем новый размер на основе качества + bounds := img.Bounds() + width := bounds.Dx() + height := bounds.Dy() + + // Более агрессивное уменьшение размера для достижения реального сжатия + // quality 10 -> 0.5 (50%), quality 50 -> 0.9 (90%) + scaleFactor := 0.5 + float64(quality-10)/40.0*0.4 + if scaleFactor > 1.0 { + scaleFactor = 1.0 + } + + newWidth := uint(float64(width) * scaleFactor) + newHeight := uint(float64(height) * scaleFactor) + + // Изменяем размер изображения только если есть реальная польза + var finalImg image.Image + if newWidth < uint(width) && newHeight < uint(height) { + finalImg = resize.Resize(newWidth, newHeight, img, resize.Lanczos3) + } else { + finalImg = img + } + + // Маппинг качества: 10->30, 30->55, 50->75 (более консервативно) + jpegQuality := 20 + int(float64(quality-10)/40.0*55.0) + if jpegQuality < 20 { + jpegQuality = 20 + } + if jpegQuality > 75 { + jpegQuality = 75 + } + + // Создаем временный файл для проверки результата + tmpPath := outputPath + ".tmp" + tmpFile, err := os.Create(tmpPath) + if err != nil { + return fmt.Errorf("не удалось создать временный файл: %w", err) + } + + // Кодируем во временный файл + options := &jpeg.Options{Quality: jpegQuality} + err = jpeg.Encode(tmpFile, finalImg, options) + tmpFile.Close() + + if err != nil { + os.Remove(tmpPath) + return fmt.Errorf("не удалось закодировать JPEG: %w", err) + } + + // Проверяем размер результата + tmpInfo, err := os.Stat(tmpPath) + if err != nil { + os.Remove(tmpPath) + return fmt.Errorf("не удалось получить информацию о временном файле: %w", err) + } + + // Если сжатие неэффективно (файл больше или почти такой же), копируем оригинал + if tmpInfo.Size() >= originalSize*95/100 { + os.Remove(tmpPath) + // Копируем оригинал + inputFile.Seek(0, 0) + outputFile, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("не удалось создать выходной файл: %w", err) + } + defer outputFile.Close() + + _, err = io.Copy(outputFile, inputFile) + if err != nil { + return fmt.Errorf("не удалось скопировать файл: %w", err) + } + return nil + } + + // Переименовываем временный файл в выходной + err = os.Rename(tmpPath, outputPath) + if err != nil { + os.Remove(tmpPath) + return fmt.Errorf("не удалось переименовать временный файл: %w", err) + } + + return nil +} + +// CompressPNG сжимает PNG файл с указанным качеством +func (c *DefaultImageCompressor) CompressPNG(inputPath, outputPath string, quality int) error { + // Открываем исходный файл + inputFile, err := os.Open(inputPath) + if err != nil { + return fmt.Errorf("не удалось открыть файл %s: %w", inputPath, err) + } + defer inputFile.Close() + + // Получаем размер исходного файла для сравнения + inputFileInfo, err := inputFile.Stat() + if err != nil { + return fmt.Errorf("не удалось получить информацию о файле %s: %w", inputPath, err) + } + originalSize := inputFileInfo.Size() + + // Декодируем изображение + img, err := png.Decode(inputFile) + if err != nil { + return fmt.Errorf("не удалось декодировать PNG файл %s: %w", inputPath, err) + } + + // Вычисляем новый размер на основе качества + bounds := img.Bounds() + width := bounds.Dx() + height := bounds.Dy() + + // Более консервативное масштабирование для PNG + // quality 10 -> 0.6 (60%), quality 50 -> 0.9 (90%) + scaleFactor := 0.6 + float64(quality-10)/40.0*0.3 + if scaleFactor > 1.0 { + scaleFactor = 1.0 + } + + newWidth := uint(float64(width) * scaleFactor) + newHeight := uint(float64(height) * scaleFactor) + + // Не изменяем размер для маленьких изображений + if width < 400 && height < 400 { + newWidth = uint(width) + newHeight = uint(height) + } + + // Изменяем размер изображения только если это даст выигрыш + var finalImg image.Image + if newWidth < uint(width) && newHeight < uint(height) { + finalImg = resize.Resize(newWidth, newHeight, img, resize.Lanczos3) + } else { + finalImg = img + } + + // Создаем временный файл для проверки результата + tmpPath := outputPath + ".tmp" + tmpFile, err := os.Create(tmpPath) + if err != nil { + return fmt.Errorf("не удалось создать временный файл: %w", err) + } + + // Для PNG используем максимальное сжатие + encoder := &png.Encoder{ + CompressionLevel: png.BestCompression, + } + + err = encoder.Encode(tmpFile, finalImg) + tmpFile.Close() + + if err != nil { + os.Remove(tmpPath) + return fmt.Errorf("не удалось закодировать PNG: %w", err) + } + + // Проверяем размер результата + tmpInfo, err := os.Stat(tmpPath) + if err != nil { + os.Remove(tmpPath) + return fmt.Errorf("не удалось получить информацию о временном файле: %w", err) + } + + // Если сжатие неэффективно (файл больше или почти такой же), копируем оригинал + if tmpInfo.Size() >= originalSize*95/100 { + os.Remove(tmpPath) + // Копируем оригинал + inputFile.Seek(0, 0) + outputFile, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("не удалось создать выходной файл: %w", err) + } + defer outputFile.Close() + + _, err = io.Copy(outputFile, inputFile) + if err != nil { + return fmt.Errorf("не удалось скопировать файл: %w", err) + } + return nil + } + + // Переименовываем временный файл в выходной + err = os.Rename(tmpPath, outputPath) + if err != nil { + os.Remove(tmpPath) + return fmt.Errorf("не удалось переименовать временный файл: %w", err) + } + + return nil +} + +// IsImageFile проверяет, является ли файл изображением поддерживаемого формата +func IsImageFile(filename string) bool { + ext := strings.ToLower(filepath.Ext(filename)) + return ext == ".jpg" || ext == ".jpeg" || ext == ".png" +} + +// GetImageFormat возвращает формат изображения по расширению файла +func GetImageFormat(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".jpg", ".jpeg": + return "jpeg" + case ".png": + return "png" + default: + return "" + } +} diff --git a/internal/infrastructure/compressors/pdfcpu_compressor.go b/internal/infrastructure/compressors/pdfcpu_compressor.go new file mode 100644 index 0000000..346d389 --- /dev/null +++ b/internal/infrastructure/compressors/pdfcpu_compressor.go @@ -0,0 +1,69 @@ +package compressors + +import ( + "fmt" + "os" + + "github.com/pdfcpu/pdfcpu/pkg/api" + + "compressor/internal/domain/entities" +) + +// PDFCPUCompressor реализация компрессора с использованием PDFCPU +type PDFCPUCompressor struct{} + +// NewPDFCPUCompressor создает новый PDFCPU компрессор +func NewPDFCPUCompressor() *PDFCPUCompressor { + return &PDFCPUCompressor{} +} + +// Compress сжимает PDF файл используя PDFCPU библиотеку +func (p *PDFCPUCompressor) Compress(inputPath, outputPath string, config *entities.CompressionConfig) (*entities.CompressionResult, error) { + fmt.Printf("🔄 Сжатие PDF с уровнем %d%% (PDFCPU)...\n", config.Level) + + // Получаем исходный размер файла + originalInfo, err := os.Stat(inputPath) + if err != nil { + return nil, fmt.Errorf("ошибка получения информации об исходном файле: %w", err) + } + + // Применяем настройки в зависимости от уровня сжатия + if config.ImageCompression { + fmt.Printf("📸 Включено сжатие изображений (качество: %d%%)\n", config.ImageQuality) + } + + if config.RemoveDuplicates { + fmt.Println("🔄 Удаление дубликатов объектов") + } + + // Выполняем оптимизацию с базовыми настройками + err = api.OptimizeFile(inputPath, outputPath, nil) + if err != nil { + return &entities.CompressionResult{ + OriginalSize: originalInfo.Size(), + Success: false, + Error: err, + }, fmt.Errorf("ошибка оптимизации PDFCPU: %w", err) + } + + // Получаем размер сжатого файла + compressedInfo, err := os.Stat(outputPath) + if err != nil { + return &entities.CompressionResult{ + OriginalSize: originalInfo.Size(), + Success: false, + Error: err, + }, fmt.Errorf("ошибка получения информации о сжатом файле: %w", err) + } + + result := &entities.CompressionResult{ + OriginalSize: originalInfo.Size(), + CompressedSize: compressedInfo.Size(), + Success: true, + } + + result.CalculateCompressionRatio() + + fmt.Printf("✅ Сжатие завершено: %s\n", outputPath) + return result, nil +} diff --git a/internal/infrastructure/compressors/unipdf_compressor.go b/internal/infrastructure/compressors/unipdf_compressor.go new file mode 100644 index 0000000..8bfbe8a --- /dev/null +++ b/internal/infrastructure/compressors/unipdf_compressor.go @@ -0,0 +1,145 @@ +package compressors + +import ( + "fmt" + "os" + + "github.com/unidoc/unipdf/v3/common" + "github.com/unidoc/unipdf/v3/model" + "github.com/unidoc/unipdf/v3/model/optimize" + + "compressor/internal/domain/entities" +) + +// UniPDFCompressor реализация компрессора с использованием UniPDF +type UniPDFCompressor struct{} + +// NewUniPDFCompressor создает новый UniPDF компрессор +func NewUniPDFCompressor() *UniPDFCompressor { + return &UniPDFCompressor{} +} + +// Compress сжимает PDF файл используя UniPDF библиотеку +func (u *UniPDFCompressor) Compress(inputPath, outputPath string, config *entities.CompressionConfig) (*entities.CompressionResult, error) { + fmt.Printf("🔄 Сжатие PDF с уровнем %d%% (UniPDF)...\n", config.Level) + + // Инициализируем логгер + common.SetLogger(common.NewConsoleLogger(common.LogLevelInfo)) + + // Проверяем лицензионный ключ из конфигурации или переменной окружения + licenseKey := config.UniPDFLicenseKey + if licenseKey == "" { + licenseKey = os.Getenv("UNIDOC_LICENSE_API_KEY") + } + + if licenseKey == "" { + return &entities.CompressionResult{ + OriginalSize: 0, + Success: false, + Error: fmt.Errorf("UniPDF требует лицензионный ключ. Установите его в конфигурации или в переменной UNIDOC_LICENSE_API_KEY. Используйте алгоритм 'pdfcpu' для бесплатной обработки или получите ключ на https://cloud.unidoc.io"), + }, fmt.Errorf("UniPDF лицензия не настроена. Установите лицензионный ключ в конфигурации или используйте алгоритм 'pdfcpu'") + } + + // Устанавливаем лицензионный ключ + fmt.Printf("🔑 Устанавливаем лицензионный ключ UniPDF...\n") + os.Setenv("UNIDOC_LICENSE_API_KEY", licenseKey) // Получаем исходный размер файла + originalInfo, err := os.Stat(inputPath) + if err != nil { + return nil, fmt.Errorf("ошибка получения информации об исходном файле: %w", err) + } + + // Открываем исходный PDF файл + pdfReader, file, err := model.NewPdfReaderFromFile(inputPath, nil) + if err != nil { + return &entities.CompressionResult{ + OriginalSize: originalInfo.Size(), + Success: false, + Error: err, + }, fmt.Errorf("ошибка открытия файла: %w", err) + } + defer file.Close() + + // Создаем writer с оптимизацией + pdfWriter := model.NewPdfWriter() + + // Настраиваем оптимизацию в зависимости от уровня сжатия + optimizer := optimize.New(optimize.Options{ + CombineDuplicateDirectObjects: true, + CombineIdenticalIndirectObjects: true, + ImageUpperPPI: float64(150 - config.Level), // чем выше уровень, тем ниже PPI + ImageQuality: 100 - config.Level, // чем выше уровень, тем ниже качество + }) + + pdfWriter.SetOptimizer(optimizer) + + // Копируем страницы + numPages, err := pdfReader.GetNumPages() + if err != nil { + return &entities.CompressionResult{ + OriginalSize: originalInfo.Size(), + Success: false, + Error: err, + }, fmt.Errorf("ошибка получения количества страниц: %w", err) + } + + for i := 1; i <= numPages; i++ { + page, err := pdfReader.GetPage(i) + if err != nil { + return &entities.CompressionResult{ + OriginalSize: originalInfo.Size(), + Success: false, + Error: err, + }, fmt.Errorf("ошибка получения страницы %d: %w", i, err) + } + + err = pdfWriter.AddPage(page) + if err != nil { + return &entities.CompressionResult{ + OriginalSize: originalInfo.Size(), + Success: false, + Error: err, + }, fmt.Errorf("ошибка добавления страницы %d: %w", i, err) + } + } + + // Сохраняем оптимизированный файл + outputFile, err := os.Create(outputPath) + if err != nil { + return &entities.CompressionResult{ + OriginalSize: originalInfo.Size(), + Success: false, + Error: err, + }, fmt.Errorf("ошибка создания выходного файла: %w", err) + } + defer outputFile.Close() + + err = pdfWriter.Write(outputFile) + if err != nil { + return &entities.CompressionResult{ + OriginalSize: originalInfo.Size(), + Success: false, + Error: err, + }, fmt.Errorf("ошибка записи файла: %w", err) + } + + // Получаем размер сжатого файла + compressedInfo, err := os.Stat(outputPath) + if err != nil { + return &entities.CompressionResult{ + OriginalSize: originalInfo.Size(), + Success: false, + Error: err, + }, fmt.Errorf("ошибка получения информации о сжатом файле: %w", err) + } + + result := &entities.CompressionResult{ + OriginalSize: originalInfo.Size(), + CompressedSize: compressedInfo.Size(), + Success: true, + } + + result.CalculateCompressionRatio() + + fmt.Printf("✅ Сжатие завершено: %s\n", outputPath) + return result, nil +} diff --git a/internal/infrastructure/config/repository.go b/internal/infrastructure/config/repository.go new file mode 100644 index 0000000..dabbf5e --- /dev/null +++ b/internal/infrastructure/config/repository.go @@ -0,0 +1,74 @@ +package config + +import ( + "compressor/internal/domain/entities" + "os" + + "gopkg.in/yaml.v3" +) + +// Repository реализация репозитория конфигурации +type Repository struct{} + +// NewRepository создает новый репозиторий конфигурации +func NewRepository() *Repository { + return &Repository{} +} + +// Load загружает конфигурацию из файла +func (r *Repository) Load(configPath string) (*entities.Config, error) { + // Если файл не существует, создаем конфигурацию по умолчанию + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return r.createDefaultConfig(), nil + } + + data, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + + var config entities.Config + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, err + } + + return &config, nil +} + +// Save сохраняет конфигурацию в файл +func (r *Repository) Save(configPath string, config *entities.Config) error { + data, err := yaml.Marshal(config) + if err != nil { + return err + } + + return os.WriteFile(configPath, data, 0644) +} + +// createDefaultConfig создает конфигурацию по умолчанию +func (r *Repository) createDefaultConfig() *entities.Config { + return &entities.Config{ + Scanner: entities.ScannerConfig{ + SourceDirectory: "./pdfs", + TargetDirectory: "./compressed", + ReplaceOriginal: false, + }, + Compression: entities.AppCompressionConfig{ + Level: 50, + Algorithm: "pdfcpu", + AutoStart: false, + }, + Processing: entities.ProcessingConfig{ + ParallelWorkers: 2, + TimeoutSeconds: 30, + RetryAttempts: 3, + }, + Output: entities.OutputConfig{ + LogLevel: "info", + ProgressBar: true, + LogToFile: true, + LogFileName: "compressor.log", + LogMaxSizeMB: 10, + }, + } +} diff --git a/internal/infrastructure/logging/file_logger.go b/internal/infrastructure/logging/file_logger.go new file mode 100644 index 0000000..039e654 --- /dev/null +++ b/internal/infrastructure/logging/file_logger.go @@ -0,0 +1,110 @@ +package logging + +import ( + "fmt" + "log" + "os" + "strings" +) + +// FileLogger реализация логгера в файл +type FileLogger struct { + file *os.File + logger *log.Logger + logLevel string +} + +// NewFileLogger создает новый файловый логгер +func NewFileLogger(filename, logLevel string, maxSizeMB int, logToFile bool) (*FileLogger, error) { + if !logToFile { + return nil, nil + } + + file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + return nil, err + } + + logger := log.New(file, "", log.LstdFlags) + + return &FileLogger{ + file: file, + logger: logger, + logLevel: strings.ToLower(logLevel), + }, nil +} + +// Debug логирует отладочное сообщение +func (l *FileLogger) Debug(format string, args ...interface{}) { + if l.shouldLog("debug") { + l.writeLog("DEBUG", format, args...) + } +} + +// Info логирует информационное сообщение +func (l *FileLogger) Info(format string, args ...interface{}) { + if l.shouldLog("info") { + l.writeLog("INFO", format, args...) + } +} + +// Warning логирует предупреждение +func (l *FileLogger) Warning(format string, args ...interface{}) { + if l.shouldLog("warning") { + l.writeLog("WARNING", format, args...) + } +} + +// Error логирует ошибку +func (l *FileLogger) Error(format string, args ...interface{}) { + if l.shouldLog("error") { + l.writeLog("ERROR", format, args...) + } +} + +// Success логирует успешное выполнение +func (l *FileLogger) Success(format string, args ...interface{}) { + if l.shouldLog("info") { + l.writeLog("SUCCESS", format, args...) + } +} + +// Close закрывает логгер +func (l *FileLogger) Close() error { + if l.file != nil { + return l.file.Close() + } + return nil +} + +// writeLog записывает лог +func (l *FileLogger) writeLog(level, format string, args ...interface{}) { + if l.logger == nil { + return + } + + message := fmt.Sprintf(format, args...) + l.logger.Printf("[%s] %s", level, message) +} + +// shouldLog проверяет, нужно ли логировать на данном уровне +func (l *FileLogger) shouldLog(level string) bool { + levels := map[string]int{ + "debug": 0, + "info": 1, + "warning": 2, + "error": 3, + } + + currentLevel, ok := levels[l.logLevel] + if !ok { + currentLevel = 1 // default to info + } + + messageLevel, ok := levels[level] + if !ok { + return false + } + + return messageLevel >= currentLevel +} diff --git a/internal/infrastructure/repositories/config_repository.go b/internal/infrastructure/repositories/config_repository.go new file mode 100644 index 0000000..976d6e9 --- /dev/null +++ b/internal/infrastructure/repositories/config_repository.go @@ -0,0 +1,24 @@ +package repositories + +import ( + "compressor/internal/domain/entities" +) + +// ConfigRepository реализация репозитория конфигурации +type ConfigRepository struct{} + +// NewConfigRepository создает новый репозиторий конфигурации +func NewConfigRepository() *ConfigRepository { + return &ConfigRepository{} +} + +// GetCompressionConfig получает конфигурацию сжатия по уровню +func (r *ConfigRepository) GetCompressionConfig(level int) (*entities.CompressionConfig, error) { + config := entities.NewCompressionConfig(level) + return config, nil +} + +// ValidateConfig валидирует конфигурацию +func (r *ConfigRepository) ValidateConfig(config *entities.CompressionConfig) error { + return config.Validate() +} diff --git a/internal/infrastructure/repositories/filesystem_repository.go b/internal/infrastructure/repositories/filesystem_repository.go new file mode 100644 index 0000000..e15fb50 --- /dev/null +++ b/internal/infrastructure/repositories/filesystem_repository.go @@ -0,0 +1,69 @@ +package repositories + +import ( + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "compressor/internal/domain/entities" +) + +// FileSystemRepository реализация репозитория для работы с файловой системой +type FileSystemRepository struct{} + +// NewFileSystemRepository создает новый репозиторий файловой системы +func NewFileSystemRepository() *FileSystemRepository { + return &FileSystemRepository{} +} + +// GetFileInfo получает информацию о PDF файле +func (r *FileSystemRepository) GetFileInfo(path string) (*entities.PDFDocument, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + + return &entities.PDFDocument{ + Path: path, + Size: info.Size(), + ModifiedTime: info.ModTime(), + Pages: 0, // TODO: Можно добавить определение количества страниц + }, nil +} + +// FileExists проверяет существование файла +func (r *FileSystemRepository) FileExists(path string) bool { + _, err := os.Stat(path) + return !os.IsNotExist(err) +} + +// CreateDirectory создает директорию +func (r *FileSystemRepository) CreateDirectory(path string) error { + return os.MkdirAll(path, 0755) +} + +// ListPDFFiles возвращает список PDF файлов в директории и всех подпапках +func (r *FileSystemRepository) ListPDFFiles(directory string) ([]string, error) { + var pdfFiles []string + + err := filepath.WalkDir(directory, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + return nil + } + if strings.EqualFold(filepath.Ext(d.Name()), ".pdf") { + pdfFiles = append(pdfFiles, path) + } + return nil + }) + if err != nil { + return nil, err + } + + sort.Strings(pdfFiles) + return pdfFiles, nil +} diff --git a/internal/interface/controllers/cli_controller.go b/internal/interface/controllers/cli_controller.go new file mode 100644 index 0000000..63303e6 --- /dev/null +++ b/internal/interface/controllers/cli_controller.go @@ -0,0 +1,158 @@ +package controllers + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + + "compressor/internal/domain/entities" + usecases "compressor/internal/usecase" +) + +// CLIController контроллер для командной строки +// +// ⚠️ DEPRECATED / LEGACY CODE ⚠️ +// +// Данный контроллер НЕ используется в текущей версии приложения. +// Приложение использует только TUI интерфейс (internal/presentation/tui/manager.go). +// Сохранен для возможной будущей поддержки CLI режима или миграции на cobra/viper. +// +// Рекомендация: при необходимости CLI использовать флаги в main.go вместо этого контроллера. +type CLIController struct { + compressPDFUseCase *usecases.CompressPDFUseCase + compressDirectoryUseCase *usecases.CompressDirectoryUseCase +} + +// NewCLIController создает новый CLI контроллер +func NewCLIController( + compressPDFUseCase *usecases.CompressPDFUseCase, + compressDirectoryUseCase *usecases.CompressDirectoryUseCase, +) *CLIController { + return &CLIController{ + compressPDFUseCase: compressPDFUseCase, + compressDirectoryUseCase: compressDirectoryUseCase, + } +} + +// HandleSingleFile обрабатывает сжатие одного файла +func (c *CLIController) HandleSingleFile(inputPath, outputPath string) error { + fmt.Println("🔥 PDF Compressor - Сжатие PDF файлов") + fmt.Println("====================================") + + // Запрашиваем уровень сжатия + compressionLevel := c.askForCompressionLevel() + + fmt.Printf("\n🚀 Начинаем сжатие файла: %s\n", inputPath) + + // Выполняем сжатие + result, err := c.compressPDFUseCase.Execute(inputPath, outputPath, compressionLevel) + if err != nil { + return fmt.Errorf("ошибка сжатия: %w", err) + } + + // Показываем результаты + c.showCompressionResult(result, outputPath) + + return nil +} + +// HandleDirectory обрабатывает сжатие директории +func (c *CLIController) HandleDirectory(inputDir, outputDir string) error { + fmt.Println("🔥 PDF Compressor - Сжатие директории PDF файлов") + fmt.Println("================================================") + + // Запрашиваем уровень сжатия + compressionLevel := c.askForCompressionLevel() + + fmt.Printf("\n🚀 Начинаем сжатие директории: %s\n", inputDir) + + // Выполняем сжатие + result, err := c.compressDirectoryUseCase.Execute(inputDir, outputDir, compressionLevel) + if err != nil { + return fmt.Errorf("ошибка сжатия директории: %w", err) + } + + // Показываем результаты + c.showDirectoryResult(result) + + return nil +} + +// askForCompressionLevel запрашивает уровень сжатия у пользователя +func (c *CLIController) askForCompressionLevel() int { + reader := bufio.NewReader(os.Stdin) + + fmt.Println("\n🎯 Выберите уровень сжатия:") + fmt.Println("10-20%: Слабое сжатие (высокое качество)") + fmt.Println("21-40%: Умеренное сжатие (хорошее качество)") + fmt.Println("41-60%: Среднее сжатие (среднее качество)") + fmt.Println("61-80%: Высокое сжатие (низкое качество)") + fmt.Println("81-90%: Максимальное сжатие (очень низкое качество)") + + for { + fmt.Print("\nВведите процент сжатия (10-90): ") + input, err := reader.ReadString('\n') + if err != nil { + fmt.Println("❌ Ошибка ввода") + continue + } + + input = strings.TrimSpace(input) + level, err := strconv.Atoi(input) + if err != nil { + fmt.Println("❌ Введите число") + continue + } + + if level < 10 || level > 90 { + fmt.Println("❌ Уровень сжатия должен быть от 10 до 90") + continue + } + + return level + } +} + +// showCompressionResult показывает результат сжатия файла +func (c *CLIController) showCompressionResult(result *entities.CompressionResult, outputPath string) { + fmt.Println("\n📊 Результаты сжатия:") + fmt.Printf("Исходный размер: %.2f MB\n", float64(result.OriginalSize)/1024/1024) + fmt.Printf("Сжатый размер: %.2f MB\n", float64(result.CompressedSize)/1024/1024) + fmt.Printf("Сжатие: %.1f%%\n", result.CompressionRatio) + fmt.Printf("Сэкономлено: %.2f MB\n", float64(result.SavedSpace)/1024/1024) + + if result.IsEffective() { + fmt.Println("✅ Сжатие выполнено успешно!") + } else { + fmt.Println("⚠️ Файл не был сжат (возможно, уже оптимизирован)") + } + + fmt.Printf("\n🎉 Готово! Сжатый файл сохранен как: %s\n", outputPath) +} + +// showDirectoryResult показывает результат сжатия директории +func (c *CLIController) showDirectoryResult(result *usecases.DirectoryCompressionResult) { + fmt.Printf("\n📊 Результаты сжатия директории:\n") + fmt.Printf("Всего файлов: %d\n", result.TotalFiles) + fmt.Printf("Успешно сжато: %d\n", result.SuccessCount) + fmt.Printf("Ошибок: %d\n", result.FailedCount) + + // Показываем статистику по каждому файлу + for i, fileResult := range result.Results { + fmt.Printf("\n[%d] Сжатие: %.1f%%, Сэкономлено: %.2f MB\n", + i+1, fileResult.CompressionRatio, float64(fileResult.SavedSpace)/1024/1024) + } + + // Показываем ошибки, если есть + if len(result.Errors) > 0 { + fmt.Println("\n❌ Ошибки:") + for i, err := range result.Errors { + fmt.Printf("[%d] %v\n", i+1, err) + } + } + + fmt.Printf("\n🎉 Обработка завершена! Успешно сжато: %d/%d файлов\n", + result.SuccessCount, result.TotalFiles) +} diff --git a/internal/presentation/tui/logger_adapter.go b/internal/presentation/tui/logger_adapter.go new file mode 100644 index 0000000..cdbc521 --- /dev/null +++ b/internal/presentation/tui/logger_adapter.go @@ -0,0 +1,83 @@ +package tui + +import ( + "compressor/internal/domain/repositories" + "fmt" +) + +// UILogger адаптер логгера для отображения в UI +type UILogger struct { + fileLogger repositories.Logger + tuiManager *Manager +} + +// NewUILogger создает новый UI логгер +func NewUILogger(fileLogger repositories.Logger, tuiManager *Manager) *UILogger { + return &UILogger{ + fileLogger: fileLogger, + tuiManager: tuiManager, + } +} + +// Debug логирует отладочное сообщение +func (l *UILogger) Debug(format string, args ...interface{}) { + message := fmt.Sprintf(format, args...) + if l.fileLogger != nil { + l.fileLogger.Debug(format, args...) + } + if l.tuiManager != nil { + l.tuiManager.AddLog("DEBUG", message) + } +} + +// Info логирует информационное сообщение +func (l *UILogger) Info(format string, args ...interface{}) { + message := fmt.Sprintf(format, args...) + if l.fileLogger != nil { + l.fileLogger.Info(format, args...) + } + if l.tuiManager != nil { + l.tuiManager.AddLog("INFO", message) + } +} + +// Warning логирует предупреждение +func (l *UILogger) Warning(format string, args ...interface{}) { + message := fmt.Sprintf(format, args...) + if l.fileLogger != nil { + l.fileLogger.Warning(format, args...) + } + if l.tuiManager != nil { + l.tuiManager.AddLog("WARNING", message) + } +} + +// Error логирует ошибку +func (l *UILogger) Error(format string, args ...interface{}) { + message := fmt.Sprintf(format, args...) + if l.fileLogger != nil { + l.fileLogger.Error(format, args...) + } + if l.tuiManager != nil { + l.tuiManager.AddLog("ERROR", message) + } +} + +// Success логирует успешное выполнение +func (l *UILogger) Success(format string, args ...interface{}) { + message := fmt.Sprintf(format, args...) + if l.fileLogger != nil { + l.fileLogger.Success(format, args...) + } + if l.tuiManager != nil { + l.tuiManager.AddLog("SUCCESS", message) + } +} + +// Close закрывает логгер +func (l *UILogger) Close() error { + if l.fileLogger != nil { + return l.fileLogger.Close() + } + return nil +} diff --git a/internal/presentation/tui/manager.go b/internal/presentation/tui/manager.go new file mode 100644 index 0000000..f3011e8 --- /dev/null +++ b/internal/presentation/tui/manager.go @@ -0,0 +1,786 @@ +package tui + +import ( + "fmt" + "math" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "compressor/internal/domain/entities" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "gopkg.in/yaml.v3" +) + +// ConfigData структура для отображения конфигурации в UI +type ConfigData struct { + Scanner struct { + SourceDirectory string `yaml:"source_directory"` + TargetDirectory string `yaml:"target_directory"` + ReplaceOriginal bool `yaml:"replace_original"` + } `yaml:"scanner"` + Compression struct { + Level int `yaml:"level"` + Algorithm string `yaml:"algorithm"` + AutoStart bool `yaml:"auto_start"` + UniPDFLicenseKey string `yaml:"unipdf_license_key"` + EnableJPEG bool `yaml:"enable_jpeg"` + EnablePNG bool `yaml:"enable_png"` + JPEGQuality int `yaml:"jpeg_quality"` + PNGQuality int `yaml:"png_quality"` + } `yaml:"compression"` + Processing struct { + ParallelWorkers int `yaml:"parallel_workers"` + TimeoutSeconds int `yaml:"timeout_seconds"` + RetryAttempts int `yaml:"retry_attempts"` + } `yaml:"processing"` + Output struct { + LogLevel string `yaml:"log_level"` + ProgressBar bool `yaml:"progress_bar"` + LogToFile bool `yaml:"log_to_file"` + LogFileName string `yaml:"log_file_name"` + LogMaxSizeMB int `yaml:"log_max_size_mb"` + } `yaml:"output"` +} + +// UI Configuration constants +const ( + MaxLogBufferSize = 1000 + LogFlushInterval = 50 * time.Millisecond + ProgressBarWidth = 40 + MaxFileNameLength = 60 + MaxFileNameDisplay = 57 + ProgressViewHeight = 9 + FormItemLicenseIndex = 5 +) + +// Manager управляет TUI интерфейсом +type Manager struct { + app *tview.Application + pages *tview.Pages + currentScreen entities.UIScreen + + // UI компоненты + mainMenu *tview.List + configForm *tview.Form + progressView *tview.TextView + logView *tview.TextView + statusBar *tview.TextView + + // Callbacks + onStartProcessing func() + + // Состояние + configData ConfigData + logBuffer []string + statusMutex sync.RWMutex + isProcessing bool + + // Оптимизированный батчинг логов через канал + logChan chan string + logDone chan struct{} + logMutex sync.Mutex +} + +// NewManager создает новый менеджер TUI +func NewManager() *Manager { + m := &Manager{ + app: tview.NewApplication(), + pages: tview.NewPages(), + logBuffer: make([]string, 0, MaxLogBufferSize), + logChan: make(chan string, 100), // Buffered channel для батчинга + logDone: make(chan struct{}), + } + // Запускаем горутину обработки логов + go m.logProcessor() + return m +} + +// Initialize инициализирует TUI +func (m *Manager) Initialize() { + m.loadConfig() + m.createUI() + m.setupKeyBindings() +} + +// Run запускает TUI +func (m *Manager) Run() error { + return m.app.SetRoot(m.pages, true).EnableMouse(true).Run() +} + +// SetOnStartProcessing устанавливает callback для начала обработки +func (m *Manager) SetOnStartProcessing(callback func()) { + m.onStartProcessing = callback +} + +// SendStatusUpdate отправляет обновление статуса +func (m *Manager) SendStatusUpdate(status entities.ProcessingStatus) { + m.updateProgress(status) +} + +// loadConfig загружает конфигурацию +func (m *Manager) loadConfig() { + configPath := "config.yaml" + if _, err := os.Stat(configPath); os.IsNotExist(err) { + // Создаем конфигурацию по умолчанию + m.configData = ConfigData{ + Scanner: struct { + SourceDirectory string `yaml:"source_directory"` + TargetDirectory string `yaml:"target_directory"` + ReplaceOriginal bool `yaml:"replace_original"` + }{ + SourceDirectory: "./pdfs", + TargetDirectory: "./compressed", + ReplaceOriginal: false, + }, + Compression: struct { + Level int `yaml:"level"` + Algorithm string `yaml:"algorithm"` + AutoStart bool `yaml:"auto_start"` + UniPDFLicenseKey string `yaml:"unipdf_license_key"` + EnableJPEG bool `yaml:"enable_jpeg"` + EnablePNG bool `yaml:"enable_png"` + JPEGQuality int `yaml:"jpeg_quality"` + PNGQuality int `yaml:"png_quality"` + }{ + Level: 50, + Algorithm: "pdfcpu", + AutoStart: false, + UniPDFLicenseKey: "", + EnableJPEG: false, + EnablePNG: false, + JPEGQuality: 30, + PNGQuality: 25, + }, + Processing: struct { + ParallelWorkers int `yaml:"parallel_workers"` + TimeoutSeconds int `yaml:"timeout_seconds"` + RetryAttempts int `yaml:"retry_attempts"` + }{ + ParallelWorkers: 2, + TimeoutSeconds: 30, + RetryAttempts: 3, + }, + Output: struct { + LogLevel string `yaml:"log_level"` + ProgressBar bool `yaml:"progress_bar"` + LogToFile bool `yaml:"log_to_file"` + LogFileName string `yaml:"log_file_name"` + LogMaxSizeMB int `yaml:"log_max_size_mb"` + }{ + LogLevel: "info", + ProgressBar: true, + LogToFile: true, + LogFileName: "compressor.log", + LogMaxSizeMB: 10, + }, + } + m.saveConfig() + return + } + + data, err := os.ReadFile(configPath) + if err != nil { + return + } + + yaml.Unmarshal(data, &m.configData) +} + +// saveConfig сохраняет конфигурацию +func (m *Manager) saveConfig() { + data, err := yaml.Marshal(&m.configData) + if err != nil { + return + } + os.WriteFile("config.yaml", data, 0644) +} + +// createUI создает пользовательский интерфейс +func (m *Manager) createUI() { + m.createMainMenu() + m.createConfigScreen() + m.createProcessingScreen() + // m.createResultsScreen() + + m.pages.AddPage("menu", m.mainMenu, true, true) + m.pages.AddPage("config", m.configForm, true, false) + m.pages.AddPage("processing", m.createProcessingLayout(), true, false) + + m.currentScreen = entities.UIScreenMenu +} + +// createMainMenu создает главное меню +func (m *Manager) createMainMenu() { + m.mainMenu = tview.NewList(). + AddItem("🚀 Запуск алгоритма сжатия", "Начать автоматическое сжатие PDF файлов", '1', func() { + m.startProcessing() + }). + AddItem("⚙️ Конфигурация", "Настроить параметры сжатия и обработки", '2', func() { + m.switchToScreen(entities.UIScreenConfig) + }). + AddItem("❌ Выход", "Закрыть приложение", 'q', func() { + m.Cleanup() + m.app.Stop() + }) + + m.mainMenu.SetBorder(true). + SetTitle("🔥 Universal File Compressor - Главное меню"). + SetTitleAlign(tview.AlignCenter) + + // Настраиваем стиль + m.mainMenu.SetSelectedBackgroundColor(tcell.ColorDarkBlue). + SetSelectedTextColor(tcell.ColorWhite). + SetMainTextColor(tcell.ColorWhite). + SetSecondaryTextColor(tcell.ColorGray) +} + +// createConfigScreen создает экран конфигурации +func (m *Manager) createConfigScreen() { + m.configForm = tview.NewForm(). + AddInputField("Исходная директория", m.configData.Scanner.SourceDirectory, 60, nil, func(text string) { + m.configData.Scanner.SourceDirectory = text + }). + AddInputField("Целевая директория", m.configData.Scanner.TargetDirectory, 60, nil, func(text string) { + m.configData.Scanner.TargetDirectory = text + }). + AddCheckbox("Заменить оригинал", m.configData.Scanner.ReplaceOriginal, func(checked bool) { + m.configData.Scanner.ReplaceOriginal = checked + }). + AddInputField("Уровень сжатия (10-90)", strconv.Itoa(m.configData.Compression.Level), 10, nil, func(text string) { + if level, err := strconv.Atoi(text); err == nil && level >= 10 && level <= 90 { + m.configData.Compression.Level = level + } + }). + AddDropDown("Алгоритм", []string{"pdfcpu", "unipdf"}, func() int { + if m.configData.Compression.Algorithm == "unipdf" { + return 1 + } + return 0 + }(), func(option string, optionIndex int) { + m.configData.Compression.Algorithm = option + m.updateLicenseFieldVisibility() + }). + AddInputField("Лицензия UniPDF (UNIDOC_LICENSE_API_KEY)", m.configData.Compression.UniPDFLicenseKey, 60, nil, func(text string) { + m.configData.Compression.UniPDFLicenseKey = text + }). + AddCheckbox("Автостарт", m.configData.Compression.AutoStart, func(checked bool) { + m.configData.Compression.AutoStart = checked + }). + AddCheckbox("Сжимать JPEG", m.configData.Compression.EnableJPEG, func(checked bool) { + m.configData.Compression.EnableJPEG = checked + }). + AddDropDown("Качество JPEG (%)", []string{"10", "15", "20", "25", "30", "35", "40", "45", "50"}, func() int { + return (m.configData.Compression.JPEGQuality - 10) / 5 + }(), func(option string, optionIndex int) { + if quality, err := strconv.Atoi(option); err == nil { + m.configData.Compression.JPEGQuality = quality + } + }). + AddCheckbox("Сжимать PNG", m.configData.Compression.EnablePNG, func(checked bool) { + m.configData.Compression.EnablePNG = checked + }). + AddDropDown("Качество PNG (%)", []string{"10", "15", "20", "25", "30", "35", "40", "45", "50"}, func() int { + return (m.configData.Compression.PNGQuality - 10) / 5 + }(), func(option string, optionIndex int) { + if quality, err := strconv.Atoi(option); err == nil { + m.configData.Compression.PNGQuality = quality + } + }). + AddButton("Сохранить", func() { + m.saveConfig() + m.switchToScreen(entities.UIScreenMenu) + // Позиционируемся на пункте "Конфигурация" (индекс 1) + m.mainMenu.SetCurrentItem(1) + }) + + m.updateLicenseFieldVisibility() + + m.configForm.SetBorder(true). + SetTitle("🔥 Universal File Compressor - Конфигурация (ESC - выйти без сохранения)"). + SetTitleAlign(tview.AlignCenter) + + // Обработка ESC для выхода без сохранения + m.configForm.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + // Перезагружаем конфигурацию из файла (отменяем изменения) + m.loadConfig() + m.switchToScreen(entities.UIScreenMenu) + return nil + } + return event + }) +} + +// createProcessingScreen создает экран обработки +func (m *Manager) createProcessingScreen() { + m.progressView = tview.NewTextView(). + SetDynamicColors(true). + SetRegions(true). + SetScrollable(true) + + m.progressView.SetBorder(true). + SetTitle("📊 Прогресс обработки"). + SetTitleAlign(tview.AlignCenter) + + m.logView = tview.NewTextView(). + SetDynamicColors(true). + SetScrollable(true). + SetMaxLines(MaxLogBufferSize) + + m.logView.SetBorder(true). + SetTitle("📋 Журнал событий"). + SetTitleAlign(tview.AlignCenter) +} + +// createProcessingLayout создает layout для экрана обработки +func (m *Manager) createProcessingLayout() *tview.Flex { + return tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(m.logView, 0, 1, false). + AddItem(m.progressView, ProgressViewHeight, 0, false) +} + +// setupKeyBindings настраивает горячие клавиши +func (m *Manager) setupKeyBindings() { + m.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyF1: + m.switchToScreen(entities.UIScreenMenu) + return nil + case tcell.KeyF2: + m.switchToScreen(entities.UIScreenConfig) + return nil + case tcell.KeyF3: + if m.isProcessing { + m.switchToScreen(entities.UIScreenProcessing) + } + return nil + case tcell.KeyEscape: + // ESC работает по-разному в зависимости от экрана + if m.currentScreen == entities.UIScreenConfig { + // В конфигурации ESC обрабатывается локально формой + return event + } else if m.currentScreen != entities.UIScreenMenu { + m.switchToScreen(entities.UIScreenMenu) + return nil + } + } + + // Обработка числовых клавиш для меню + if m.currentScreen == entities.UIScreenMenu { + switch event.Rune() { + case '1': + m.startProcessing() + return nil + case '2': + m.switchToScreen(entities.UIScreenConfig) + return nil + case 'q', 'Q': + m.Cleanup() + m.app.Stop() + return nil + } + } + + return event + }) +} + +// switchToScreen переключает на указанный экран +func (m *Manager) switchToScreen(screen entities.UIScreen) { + m.statusMutex.Lock() + defer m.statusMutex.Unlock() + + m.currentScreen = screen + + switch screen { + case entities.UIScreenMenu: + m.pages.SwitchToPage("menu") + case entities.UIScreenConfig: + // При входе в конфигурацию обновляем данные из файла и синхронизируем форму + m.loadConfig() + m.refreshConfigForm() + m.pages.SwitchToPage("config") + case entities.UIScreenProcessing: + m.pages.SwitchToPage("processing") + } +} + +// startProcessing начинает обработку +func (m *Manager) startProcessing() { + m.saveConfig() + m.isProcessing = true + m.switchToScreen(entities.UIScreenProcessing) + + if m.onStartProcessing != nil { + go m.onStartProcessing() + } +} + +// updateProgress обновляет прогресс +func (m *Manager) updateProgress(status entities.ProcessingStatus) { + if m.progressView == nil { + return + } + + // Обновляем прогресс-бар + progressBar := m.createProgressBar(status.Progress, ProgressBarWidth) + + // Корректное усечение имени файла с учетом UTF-8 + displayFile := m.truncateFileName(status.CurrentFile, MaxFileNameLength, MaxFileNameDisplay) + + // Формируем текст статуса + var progressText string + + // Фаза обработки + phaseText := status.Phase.String() + if status.Message != "" { + phaseText = status.Message + } + + progressText = fmt.Sprintf( + "[yellow]⚙️ Фаза:[white] %s\n\n"+ + "[yellow]📁 Текущий файл:[white] %s\n", + phaseText, + filepath.Base(displayFile), + ) + + // Размер текущего файла + if status.CurrentFileSize > 0 { + progressText += fmt.Sprintf("[dim] Размер: %.2f MB[white]\n", float64(status.CurrentFileSize)/1024/1024) + } + + // Прогресс-бар + progressText += fmt.Sprintf( + "\n[cyan]📊 Прогресс:[white] %s [cyan]%.1f%%[white]\n\n", + progressBar, + status.Progress, + ) + + // Статистика файлов + progressText += fmt.Sprintf( + "[green]📈 Статистика файлов:[white]\n"+ + " • Всего: [cyan]%d[white]\n"+ + " • Обработано: [cyan]%d[white]\n"+ + " • Успешно: [green]%d[white]", + status.TotalFiles, + status.ProcessedFiles, + status.SuccessfulFiles, + ) + + if status.FailedFiles > 0 { + progressText += fmt.Sprintf("\n • Ошибок: [red]%d[white]", status.FailedFiles) + } + + if status.SkippedFiles > 0 { + progressText += fmt.Sprintf("\n • Пропущено: [yellow]%d[white]", status.SkippedFiles) + } + + // Статистика сжатия + if status.TotalOriginalSize > 0 { + progressText += fmt.Sprintf( + "\n\n[green]💾 Статистика сжатия:[white]\n"+ + " • Исходный размер: [cyan]%.2f MB[white]\n"+ + " • Сжатый размер: [cyan]%.2f MB[white]\n"+ + " • Среднее сжатие: [green]%.1f%%[white]\n"+ + " • Сэкономлено: [green]%.2f MB[white]", + float64(status.TotalOriginalSize)/1024/1024, + float64(status.TotalCompressedSize)/1024/1024, + status.AverageCompression, + float64(status.TotalSavedSpace)/1024/1024, + ) + } + + // Время выполнения + progressText += fmt.Sprintf( + "\n\n[yellow]⏱️ Время:[white]\n"+ + " • Прошло: [cyan]%s[white]", + status.FormatElapsedTime(), + ) + + if !status.IsComplete && status.EstimatedTime > 0 { + progressText += fmt.Sprintf("\n • Осталось: [cyan]~%s[white]", status.FormatEstimatedTime()) + } + + progressText += "\n\n" + + if status.IsComplete { + if status.Error != nil { + progressText += "[red]❌ Обработка завершена с ошибкой![white]\n" + progressText += fmt.Sprintf("[red]Ошибка: %v[white]\n", status.Error) + } else { + progressText += "[green]✅ Обработка успешно завершена![white]\n" + } + progressText += "\n[yellow]F1[white] - Главное меню\n" + progressText += "[yellow]ESC[white] - Главное меню\n" + m.isProcessing = false + } else { + progressText += "[yellow]F1[white] - Главное меню\n" + progressText += "[yellow]ESC[white] - Главное меню\n" + } + + if status.Error != nil { + progressText += fmt.Sprintf("\n[red]❌ Ошибка: %v[white]\n", status.Error) + } + + // Обновляем UI потокобезопасно через QueueUpdateDraw + m.app.QueueUpdateDraw(func() { + m.progressView.SetText(progressText) + }) +} + +// truncateFileName корректно усекает имя файла с учетом UTF-8 +func (m *Manager) truncateFileName(fileName string, maxLength, truncateAt int) string { + runes := []rune(fileName) + if len(runes) <= maxLength { + return fileName + } + return string(runes[:truncateAt]) + "..." +} + +// createProgressBar создает красивый цветной прогресс-бар +func (m *Manager) createProgressBar(progress float64, width int) string { + // Нормализуем значения + if progress < 0 { + progress = 0 + } else if progress > 100 { + progress = 100 + } + + filled := int(math.Round(progress * float64(width) / 100)) + if filled > width { + filled = width + } + if filled < 0 { + filled = 0 + } + + // Разные символы для заполненной и пустой части + const filledChar = "█" + const emptyChar = "░" + + // Цвет зависит от прогресса + var color string + switch { + case progress < 25: + color = "red" + case progress < 50: + color = "yellow" + case progress < 75: + color = "blue" + default: + color = "green" + } + + filledPart := strings.Repeat(filledChar, filled) + emptyPart := strings.Repeat(emptyChar, width-filled) + + return fmt.Sprintf("[%s]%s[gray]%s", color, filledPart, emptyPart) +} + +// AddLog добавляет запись в лог через канал (неблокирующе) +func (m *Manager) AddLog(level, message string) { + var color string + switch strings.ToLower(level) { + case "error": + color = "red" + case "warning": + color = "yellow" + case "success": + color = "green" + case "debug": + color = "gray" + default: + color = "white" + } + + logLine := fmt.Sprintf("[%s]%s:[white] %s", color, strings.ToUpper(level), message) + + // Неблокирующая отправка в канал + select { + case m.logChan <- logLine: + default: + // Если канал переполнен, пропускаем лог (лучше чем блокировка) + } +} + +// logProcessor обрабатывает логи в отдельной горутине с батчингом +func (m *Manager) logProcessor() { + ticker := time.NewTicker(LogFlushInterval) + defer ticker.Stop() + + batch := make([]string, 0, 50) + + for { + select { + case logLine := <-m.logChan: + batch = append(batch, logLine) + + // Если накопился достаточный батч, сбрасываем + if len(batch) >= 20 { + m.flushLogBatch(batch) + batch = make([]string, 0, 50) + } + + case <-ticker.C: + // Периодический сброс + if len(batch) > 0 { + m.flushLogBatch(batch) + batch = make([]string, 0, 50) + } + + case <-m.logDone: + // Финальный сброс при завершении + if len(batch) > 0 { + m.flushLogBatch(batch) + } + return + } + } +} + +// flushLogBatch сбрасывает батч логов в UI +func (m *Manager) flushLogBatch(batch []string) { + m.statusMutex.Lock() + m.logBuffer = append(m.logBuffer, batch...) + + // Ограничиваем размер буфера + if len(m.logBuffer) > MaxLogBufferSize { + m.logBuffer = m.logBuffer[len(m.logBuffer)-MaxLogBufferSize:] + } + + // Создаем копию буфера для UI + logText := strings.Join(m.logBuffer, "\n") + m.statusMutex.Unlock() + + // Обновляем UI потокобезопасно + if m.logView != nil { + m.app.QueueUpdateDraw(func() { + if m.logView != nil { // Двойная проверка + m.logView.SetText(logText) + m.logView.ScrollToEnd() + } + }) + } +} + +// Cleanup освобождает ресурсы менеджера (идемпотентный) +func (m *Manager) Cleanup() { + m.logMutex.Lock() + defer m.logMutex.Unlock() + + // Проверяем, что канал еще открыт + select { + case <-m.logDone: + // Канал уже закрыт + return + default: + // Закрываем канал + close(m.logDone) + } +} // updateLicenseFieldVisibility обновляет видимость поля лицензии в зависимости от выбранного алгоритма +func (m *Manager) updateLicenseFieldVisibility() { + if m.configForm == nil { + return + } + + // Получаем количество элементов формы + formItemCount := m.configForm.GetFormItemCount() + + if formItemCount > FormItemLicenseIndex { + // Получаем поле лицензии + licenseField := m.configForm.GetFormItem(FormItemLicenseIndex) + + if m.configData.Compression.Algorithm == "unipdf" { + // Показываем поле лицензии для UniPDF + licenseField.(*tview.InputField).SetTitle("🔑 Лицензия UniPDF (UNIDOC_LICENSE_API_KEY) - ОБЯЗАТЕЛЬНО") + licenseField.(*tview.InputField).SetFieldBackgroundColor(tcell.ColorDarkBlue) + } else { + // Скрываем поле лицензии для PDFCPU + licenseField.(*tview.InputField).SetTitle("Лицензия UniPDF (не требуется для PDFCPU)") + licenseField.(*tview.InputField).SetFieldBackgroundColor(tcell.ColorDarkGray) + } + } +} + +// refreshConfigForm синхронизирует значения формы с текущими данными конфигурации +func (m *Manager) refreshConfigForm() { + if m.configForm == nil { + return + } + + // 0: Исходная директория (Input) + if item := m.configForm.GetFormItem(0); item != nil { + item.(*tview.InputField).SetText(m.configData.Scanner.SourceDirectory) + } + // 1: Целевая директория (Input) + if item := m.configForm.GetFormItem(1); item != nil { + item.(*tview.InputField).SetText(m.configData.Scanner.TargetDirectory) + } + // 2: Заменить оригинал (Checkbox) + if item := m.configForm.GetFormItem(2); item != nil { + item.(*tview.Checkbox).SetChecked(m.configData.Scanner.ReplaceOriginal) + } + // 3: Уровень сжатия (Input) + if item := m.configForm.GetFormItem(3); item != nil { + item.(*tview.InputField).SetText(strconv.Itoa(m.configData.Compression.Level)) + } + // 4: Алгоритм (DropDown) + if item := m.configForm.GetFormItem(4); item != nil { + dd := item.(*tview.DropDown) + if m.configData.Compression.Algorithm == "unipdf" { + dd.SetCurrentOption(1) + } else { + dd.SetCurrentOption(0) + } + } + // 5: Лицензия UniPDF (Input) + if item := m.configForm.GetFormItem(5); item != nil { + item.(*tview.InputField).SetText(m.configData.Compression.UniPDFLicenseKey) + } + // 6: Автостарт (Checkbox) + if item := m.configForm.GetFormItem(6); item != nil { + item.(*tview.Checkbox).SetChecked(m.configData.Compression.AutoStart) + } + + m.updateLicenseFieldVisibility() +} + +// GetConfig возвращает текущую конфигурацию в формате entities.Config +func (m *Manager) GetConfig() *entities.Config { + return &entities.Config{ + Scanner: entities.ScannerConfig{ + SourceDirectory: m.configData.Scanner.SourceDirectory, + TargetDirectory: m.configData.Scanner.TargetDirectory, + ReplaceOriginal: m.configData.Scanner.ReplaceOriginal, + }, + Compression: entities.AppCompressionConfig{ + Level: m.configData.Compression.Level, + Algorithm: m.configData.Compression.Algorithm, + AutoStart: m.configData.Compression.AutoStart, + UniPDFLicenseKey: m.configData.Compression.UniPDFLicenseKey, + EnableJPEG: m.configData.Compression.EnableJPEG, + EnablePNG: m.configData.Compression.EnablePNG, + JPEGQuality: m.configData.Compression.JPEGQuality, + PNGQuality: m.configData.Compression.PNGQuality, + }, + Processing: entities.ProcessingConfig{ + ParallelWorkers: m.configData.Processing.ParallelWorkers, + TimeoutSeconds: m.configData.Processing.TimeoutSeconds, + RetryAttempts: m.configData.Processing.RetryAttempts, + }, + Output: entities.OutputConfig{ + LogLevel: m.configData.Output.LogLevel, + ProgressBar: m.configData.Output.ProgressBar, + LogToFile: m.configData.Output.LogToFile, + LogFileName: m.configData.Output.LogFileName, + LogMaxSizeMB: m.configData.Output.LogMaxSizeMB, + }, + } +} diff --git a/internal/usecase/compress_directory.go b/internal/usecase/compress_directory.go new file mode 100644 index 0000000..c0a6741 --- /dev/null +++ b/internal/usecase/compress_directory.go @@ -0,0 +1,109 @@ +package usecases + +import ( + "fmt" + "path/filepath" + + "compressor/internal/domain/entities" + "compressor/internal/domain/repositories" +) + +// CompressDirectoryUseCase сценарий сжатия всех PDF файлов в директории +type CompressDirectoryUseCase struct { + compressor repositories.PDFCompressor + fileRepo repositories.FileRepository + configRepo repositories.ConfigRepository +} + +// NewCompressDirectoryUseCase создает новый сценарий сжатия директории +func NewCompressDirectoryUseCase( + compressor repositories.PDFCompressor, + fileRepo repositories.FileRepository, + configRepo repositories.ConfigRepository, +) *CompressDirectoryUseCase { + return &CompressDirectoryUseCase{ + compressor: compressor, + fileRepo: fileRepo, + configRepo: configRepo, + } +} + +// DirectoryCompressionResult результат сжатия директории +type DirectoryCompressionResult struct { + TotalFiles int + SuccessCount int + FailedCount int + Results []*entities.CompressionResult + Errors []error +} + +// Execute выполняет сжатие всех PDF файлов в директории +func (uc *CompressDirectoryUseCase) Execute(inputDir, outputDir string, compressionLevel int) (*DirectoryCompressionResult, error) { + // Проверяем существование входной директории + if !uc.fileRepo.FileExists(inputDir) { + return nil, entities.ErrDirectoryNotFound + } + + // Создаем выходную директорию + if err := uc.fileRepo.CreateDirectory(outputDir); err != nil { + return nil, fmt.Errorf("ошибка создания выходной директории: %w", err) + } + + // Получаем список PDF файлов + files, err := uc.fileRepo.ListPDFFiles(inputDir) + if err != nil { + return nil, fmt.Errorf("ошибка получения списка файлов: %w", err) + } + + if len(files) == 0 { + return nil, entities.ErrNoFilesFound + } + + // Создаем конфигурацию сжатия + config, err := uc.configRepo.GetCompressionConfig(compressionLevel) + if err != nil { + return nil, fmt.Errorf("ошибка создания конфигурации: %w", err) + } + + // Валидируем конфигурацию + if err := uc.configRepo.ValidateConfig(config); err != nil { + return nil, fmt.Errorf("ошибка валидации конфигурации: %w", err) + } + + result := &DirectoryCompressionResult{ + TotalFiles: len(files), + Results: make([]*entities.CompressionResult, 0, len(files)), + Errors: make([]error, 0), + } + + // Обрабатываем каждый файл + for _, inputFile := range files { + fileName := filepath.Base(inputFile) + outputFile := filepath.Join(outputDir, fmt.Sprintf("compressed_%s", fileName)) + + // Получаем информацию о файле + fileInfo, err := uc.fileRepo.GetFileInfo(inputFile) + if err != nil { + result.Errors = append(result.Errors, fmt.Errorf("ошибка получения информации о файле %s: %w", fileName, err)) + result.FailedCount++ + continue + } + + // Выполняем сжатие + compressionResult, err := uc.compressor.Compress(inputFile, outputFile, config) + if err != nil { + result.Errors = append(result.Errors, fmt.Errorf("ошибка сжатия файла %s: %w", fileName, err)) + result.FailedCount++ + continue + } + + // Устанавливаем исходный размер и вычисляем коэффициент сжатия + compressionResult.OriginalSize = fileInfo.Size + compressionResult.CalculateCompressionRatio() + + result.Results = append(result.Results, compressionResult) + result.SuccessCount++ + } + + return result, nil +} diff --git a/internal/usecase/compress_images.go b/internal/usecase/compress_images.go new file mode 100644 index 0000000..5d3ac95 --- /dev/null +++ b/internal/usecase/compress_images.go @@ -0,0 +1,175 @@ +package usecases + +import ( + "fmt" + "os" + "path/filepath" + + "compressor/internal/domain/entities" + "compressor/internal/domain/repositories" + "compressor/internal/infrastructure/compressors" +) + +// CompressImageUseCase обрабатывает сжатие изображений +type CompressImageUseCase struct { + logger repositories.Logger + compressor compressors.ImageCompressor +} + +// NewCompressImageUseCase создает новый UseCase для сжатия изображений +func NewCompressImageUseCase(logger repositories.Logger, compressor compressors.ImageCompressor) *CompressImageUseCase { + return &CompressImageUseCase{ + logger: logger, + compressor: compressor, + } +} + +// CompressImage сжимает одно изображение +func (uc *CompressImageUseCase) CompressImage(inputPath, outputPath string, config *entities.AppCompressionConfig) error { + format := compressors.GetImageFormat(inputPath) + if format == "" { + return fmt.Errorf("неподдерживаемый формат изображения: %s", inputPath) + } + + // Проверяем, включено ли сжатие для данного формата + switch format { + case "jpeg": + if !config.EnableJPEG { + uc.logger.Info(fmt.Sprintf("Пропуск JPEG файла (сжатие отключено): %s", inputPath)) + return nil + } + return uc.compressor.CompressJPEG(inputPath, outputPath, config.JPEGQuality) + case "png": + if !config.EnablePNG { + uc.logger.Info(fmt.Sprintf("Пропуск PNG файла (сжатие отключено): %s", inputPath)) + return nil + } + return uc.compressor.CompressPNG(inputPath, outputPath, config.PNGQuality) + default: + return fmt.Errorf("неподдерживаемый формат изображения: %s", format) + } +} + +// ProcessImagesInDirectory обрабатывает все изображения в директории +func (uc *CompressImageUseCase) ProcessImagesInDirectory(sourceDir, targetDir string, config *entities.AppCompressionConfig, replaceOriginal bool) (*ProcessingResult, error) { + result := &ProcessingResult{ + ProcessedFiles: make([]string, 0), + FailedFiles: make([]ProcessingError, 0), + SuccessfulFiles: 0, + TotalFiles: 0, + } + + // Если включены изображения, проверяем настройки + if !config.EnableJPEG && !config.EnablePNG { + uc.logger.Info("Сжатие изображений отключено в конфигурации") + return result, nil + } + + // Рекурсивно обходим директорию + err := filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + uc.logger.Error(fmt.Sprintf("Ошибка доступа к файлу %s: %v", path, err)) + return nil // Продолжаем обработку других файлов + } + + // Пропускаем директории + if info.IsDir() { + return nil + } + + // Проверяем, является ли файл изображением + if !compressors.IsImageFile(path) { + return nil // Не изображение, пропускаем + } + + result.TotalFiles++ + + // Определяем путь выходного файла + var outputPath string + if replaceOriginal { + outputPath = path + } else { + relPath, err := filepath.Rel(sourceDir, path) + if err != nil { + uc.logger.Error(fmt.Sprintf("Не удалось получить относительный путь для %s: %v", path, err)) + result.FailedFiles = append(result.FailedFiles, ProcessingError{ + FilePath: path, + Error: err, + }) + return nil + } + outputPath = filepath.Join(targetDir, relPath) + + // Создаем директорию для выходного файла + outputDir := filepath.Dir(outputPath) + if err := os.MkdirAll(outputDir, 0755); err != nil { + uc.logger.Error(fmt.Sprintf("Не удалось создать директорию %s: %v", outputDir, err)) + result.FailedFiles = append(result.FailedFiles, ProcessingError{ + FilePath: path, + Error: err, + }) + return nil + } + } + + // Сжимаем изображение + uc.logger.Info(fmt.Sprintf("Сжатие изображения: %s", path)) + err = uc.CompressImage(path, outputPath, config) + if err != nil { + uc.logger.Error(fmt.Sprintf("Ошибка сжатия изображения %s: %v", path, err)) + result.FailedFiles = append(result.FailedFiles, ProcessingError{ + FilePath: path, + Error: err, + }) + } else { + result.ProcessedFiles = append(result.ProcessedFiles, path) + result.SuccessfulFiles++ + uc.logger.Info(fmt.Sprintf("Изображение успешно сжато: %s", path)) + } + + return nil + }) + + if err != nil { + return result, fmt.Errorf("ошибка обхода директории %s: %w", sourceDir, err) + } + + return result, nil +} + +// ProcessingResult результат обработки изображений +type ProcessingResult struct { + ProcessedFiles []string + FailedFiles []ProcessingError + SuccessfulFiles int + TotalFiles int +} + +// ProcessingError ошибка обработки файла +type ProcessingError struct { + FilePath string + Error error +} + +// GetSupportedImageExtensions возвращает список поддерживаемых расширений изображений +func GetSupportedImageExtensions() []string { + return []string{".jpg", ".jpeg", ".png"} +} + +// CountImageFiles подсчитывает количество изображений в директории +func CountImageFiles(dir string) (int, error) { + count := 0 + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Игнорируем ошибки доступа к файлам + } + + if !info.IsDir() && compressors.IsImageFile(path) { + count++ + } + + return nil + }) + + return count, err +} diff --git a/internal/usecase/compress_pdf.go b/internal/usecase/compress_pdf.go new file mode 100644 index 0000000..30f900c --- /dev/null +++ b/internal/usecase/compress_pdf.go @@ -0,0 +1,73 @@ +package usecases + +import ( + "fmt" + "path/filepath" + + "compressor/internal/domain/entities" + "compressor/internal/domain/repositories" +) + +// CompressPDFUseCase сценарий сжатия одного PDF файла +type CompressPDFUseCase struct { + compressor repositories.PDFCompressor + fileRepo repositories.FileRepository + configRepo repositories.ConfigRepository +} + +// NewCompressPDFUseCase создает новый сценарий сжатия PDF +func NewCompressPDFUseCase( + compressor repositories.PDFCompressor, + fileRepo repositories.FileRepository, + configRepo repositories.ConfigRepository, +) *CompressPDFUseCase { + return &CompressPDFUseCase{ + compressor: compressor, + fileRepo: fileRepo, + configRepo: configRepo, + } +} + +// Execute выполняет сжатие PDF файла +func (uc *CompressPDFUseCase) Execute(inputPath string, outputPath string, compressionLevel int) (*entities.CompressionResult, error) { + // Проверяем существование входного файла + if !uc.fileRepo.FileExists(inputPath) { + return nil, entities.ErrFileNotFound + } + + // Получаем информацию о файле + fileInfo, err := uc.fileRepo.GetFileInfo(inputPath) + if err != nil { + return nil, fmt.Errorf("ошибка получения информации о файле: %w", err) + } + + // Создаем конфигурацию сжатия + config, err := uc.configRepo.GetCompressionConfig(compressionLevel) + if err != nil { + return nil, fmt.Errorf("ошибка создания конфигурации: %w", err) + } + + // Валидируем конфигурацию + if err := uc.configRepo.ValidateConfig(config); err != nil { + return nil, fmt.Errorf("ошибка валидации конфигурации: %w", err) + } + + // Генерируем имя выходного файла, если не указано + if outputPath == "" { + ext := filepath.Ext(inputPath) + base := inputPath[:len(inputPath)-len(ext)] + outputPath = base + "_compressed" + ext + } + + // Выполняем сжатие + result, err := uc.compressor.Compress(inputPath, outputPath, config) + if err != nil { + return nil, fmt.Errorf("ошибка сжатия файла: %w", err) + } + + // Устанавливаем исходный размер + result.OriginalSize = fileInfo.Size + result.CalculateCompressionRatio() + + return result, nil +} diff --git a/internal/usecase/process_all_files.go b/internal/usecase/process_all_files.go new file mode 100644 index 0000000..4bb82ed --- /dev/null +++ b/internal/usecase/process_all_files.go @@ -0,0 +1,137 @@ +package usecases + +import ( + "fmt" + "path/filepath" + "strings" + + "compressor/internal/domain/entities" + "compressor/internal/domain/repositories" + "compressor/internal/infrastructure/compressors" +) + +// ProcessAllFilesUseCase сценарий для обработки всех поддерживаемых типов файлов +type ProcessAllFilesUseCase struct { + pdfProcessor *ProcessPDFsUseCase + imageProcessor *CompressImageUseCase + logger repositories.Logger +} + +// NewProcessAllFilesUseCase создает новый сценарий обработки всех файлов +func NewProcessAllFilesUseCase( + pdfProcessor *ProcessPDFsUseCase, + imageProcessor *CompressImageUseCase, + logger repositories.Logger, +) *ProcessAllFilesUseCase { + return &ProcessAllFilesUseCase{ + pdfProcessor: pdfProcessor, + imageProcessor: imageProcessor, + logger: logger, + } +} + +// Execute выполняет обработку всех поддерживаемых файлов +func (uc *ProcessAllFilesUseCase) Execute(config *entities.Config) error { + uc.logger.Info("Начинаем обработку файлов") + uc.logger.Info("Исходная директория: %s", config.Scanner.SourceDirectory) + + var processedPDFs, processedImages bool + + // Обрабатываем PDF файлы + if uc.shouldProcessPDFs(config) { + uc.logger.Info("Обработка PDF файлов...") + err := uc.pdfProcessor.Execute(config) + if err != nil { + uc.logger.Error("Ошибка обработки PDF файлов: %v", err) + return fmt.Errorf("ошибка обработки PDF файлов: %w", err) + } + processedPDFs = true + uc.logger.Info("Обработка PDF файлов завершена") + } + + // Обрабатываем изображения + if uc.shouldProcessImages(config) { + uc.logger.Info("Обработка изображений...") + result, err := uc.imageProcessor.ProcessImagesInDirectory( + config.Scanner.SourceDirectory, + config.Scanner.TargetDirectory, + &config.Compression, + config.Scanner.ReplaceOriginal, + ) + if err != nil { + uc.logger.Error("Ошибка обработки изображений: %v", err) + return fmt.Errorf("ошибка обработки изображений: %w", err) + } + + // Логируем результаты обработки изображений + uc.logger.Info("Обработка изображений завершена. Всего файлов: %d, Успешно: %d, Ошибок: %d", + result.TotalFiles, result.SuccessfulFiles, len(result.FailedFiles)) + + for _, failed := range result.FailedFiles { + uc.logger.Error("Не удалось обработать изображение %s: %v", failed.FilePath, failed.Error) + } + + processedImages = true + } + + if !processedPDFs && !processedImages { + uc.logger.Warning("Не выбрано ни одного типа файлов для обработки") + return fmt.Errorf("не выбрано ни одного типа файлов для обработки") + } + + uc.logger.Info("Обработка всех файлов завершена успешно") + return nil +} + +// shouldProcessPDFs проверяет, нужно ли обрабатывать PDF файлы +func (uc *ProcessAllFilesUseCase) shouldProcessPDFs(config *entities.Config) bool { + // PDF файлы обрабатываются всегда, если есть алгоритм сжатия + return config.Compression.Algorithm != "" +} + +// shouldProcessImages проверяет, нужно ли обрабатывать изображения +func (uc *ProcessAllFilesUseCase) shouldProcessImages(config *entities.Config) bool { + return config.Compression.EnableJPEG || config.Compression.EnablePNG +} + +// GetSupportedFileTypes возвращает список поддерживаемых типов файлов +func (uc *ProcessAllFilesUseCase) GetSupportedFileTypes(config *entities.Config) []string { + var types []string + + if uc.shouldProcessPDFs(config) { + types = append(types, "PDF") + } + + if config.Compression.EnableJPEG { + types = append(types, "JPEG") + } + + if config.Compression.EnablePNG { + types = append(types, "PNG") + } + + return types +} + +// IsFileSupported проверяет, поддерживается ли данный файл для обработки +func (uc *ProcessAllFilesUseCase) IsFileSupported(filename string, config *entities.Config) bool { + ext := strings.ToLower(filepath.Ext(filename)) + + // Проверяем PDF + if ext == ".pdf" && uc.shouldProcessPDFs(config) { + return true + } + + // Проверяем изображения + if compressors.IsImageFile(filename) { + format := compressors.GetImageFormat(filename) + switch format { + case "jpeg": + return config.Compression.EnableJPEG + case "png": + return config.Compression.EnablePNG + } + } + + return false +} diff --git a/internal/usecase/process_pdfs.go b/internal/usecase/process_pdfs.go new file mode 100644 index 0000000..f041e47 --- /dev/null +++ b/internal/usecase/process_pdfs.go @@ -0,0 +1,401 @@ +package usecases + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "compressor/internal/domain/entities" + "compressor/internal/domain/repositories" +) + +// ProcessPDFsUseCase сценарий автоматической обработки PDF файлов +type ProcessPDFsUseCase struct { + compressor repositories.PDFCompressor + fileRepo repositories.FileRepository + configRepo repositories.ConfigRepository + logger repositories.Logger + progressReporter func(entities.ProcessingStatus) +} + +// NewProcessPDFsUseCase создает новый сценарий обработки PDF +func NewProcessPDFsUseCase( + compressor repositories.PDFCompressor, + fileRepo repositories.FileRepository, + configRepo repositories.ConfigRepository, + logger repositories.Logger, +) *ProcessPDFsUseCase { + return &ProcessPDFsUseCase{ + compressor: compressor, + fileRepo: fileRepo, + configRepo: configRepo, + logger: logger, + } +} + +// SetProgressReporter устанавливает функцию для отчета о прогрессе +func (uc *ProcessPDFsUseCase) SetProgressReporter(reporter func(entities.ProcessingStatus)) { + uc.progressReporter = reporter +} + +// reportProgress отправляет обновление прогресса +func (uc *ProcessPDFsUseCase) reportProgress(status *entities.ProcessingStatus) { + if uc.progressReporter != nil { + uc.progressReporter(*status) + } +} + +// Execute выполняет автоматическую обработку PDF файлов согласно конфигурации +func (uc *ProcessPDFsUseCase) Execute(config *entities.Config) error { + // Фаза 1: Инициализация + status := entities.NewProcessingStatus(0) + status.SetPhase(entities.PhaseInitializing, "Инициализация обработки...") + uc.reportProgress(status) + + uc.logInfo("╔════════════════════════════════════════════════════════════") + uc.logInfo("║ Начало обработки PDF файлов") + uc.logInfo("╠════════════════════════════════════════════════════════════") + uc.logInfo("║ Исходная директория: %s", config.Scanner.SourceDirectory) + + if config.Scanner.ReplaceOriginal { + uc.logInfo("║ Режим: Замена оригинальных файлов") + } else { + uc.logInfo("║ Целевая директория: %s", config.Scanner.TargetDirectory) + } + + uc.logInfo("║ Алгоритм: %s", config.Compression.Algorithm) + uc.logInfo("║ Уровень сжатия: %d%%", config.Compression.Level) + uc.logInfo("║ Параллельных воркеров: %d", config.Processing.ParallelWorkers) + uc.logInfo("╚════════════════════════════════════════════════════════════") + + // Проверяем существование исходной директории + if !uc.fileRepo.FileExists(config.Scanner.SourceDirectory) { + err := fmt.Errorf("исходная директория не существует: %s", config.Scanner.SourceDirectory) + status.Fail(err) + uc.reportProgress(status) + return err + } + + // Создаем целевую директорию, если нужно + if !config.Scanner.ReplaceOriginal { + if err := uc.fileRepo.CreateDirectory(config.Scanner.TargetDirectory); err != nil { + err = fmt.Errorf("ошибка создания целевой директории: %w", err) + status.Fail(err) + uc.reportProgress(status) + return err + } + } + + // Фаза 2: Сканирование файлов + status.SetPhase(entities.PhaseScanning, "Сканирование PDF файлов...") + uc.reportProgress(status) + uc.logInfo("🔍 Сканирование директории...") + + files, err := uc.fileRepo.ListPDFFiles(config.Scanner.SourceDirectory) + if err != nil { + err = fmt.Errorf("ошибка получения списка файлов: %w", err) + status.Fail(err) + uc.reportProgress(status) + return err + } + + if len(files) == 0 { + uc.logWarning("⚠️ PDF файлы не найдены в директории: %s", config.Scanner.SourceDirectory) + status.Complete() + uc.reportProgress(status) + return nil + } + + status.TotalFiles = len(files) + uc.logSuccess("✓ Найдено файлов для обработки: %d", len(files)) + + // Создаем конфигурацию сжатия + compressionConfig := entities.NewCompressionConfigWithLicense(config.Compression.Level, config.Compression.UniPDFLicenseKey) + + if err := uc.configRepo.ValidateConfig(compressionConfig); err != nil { + err = fmt.Errorf("ошибка валидации конфигурации сжатия: %w", err) + status.Fail(err) + uc.reportProgress(status) + return err + } + + // Фаза 3: Сжатие файлов + status.SetPhase(entities.PhaseCompressing, "Сжатие PDF файлов...") + uc.reportProgress(status) + uc.logInfo("") + uc.logInfo("🔄 Начало сжатия файлов...") + uc.logInfo("─────────────────────────────────────────────────────────────") + + // Создаем воркеры для параллельной обработки + workers := config.Processing.ParallelWorkers + if workers <= 0 { + workers = 1 + } + + // Каналы для координации работы + jobs := make(chan string, len(files)) + results := make(chan *entities.CompressionResult, len(files)) + + var wg sync.WaitGroup + + // Запускаем воркеров + for w := 0; w < workers; w++ { + wg.Add(1) + go uc.worker(w, jobs, results, &wg, config, compressionConfig, status) + } + + // Отправляем задачи воркерам + for _, file := range files { + jobs <- file + } + close(jobs) + + // Горутина для сбора результатов + go func() { + wg.Wait() + close(results) + }() + + // Обрабатываем результаты + fileCounter := 0 + for result := range results { + fileCounter++ + status.AddResult(result) + + // Обновляем текущий файл + status.SetCurrentFile(result.CurrentFile, result.OriginalSize) + + // Отправляем обновление прогресса + uc.reportProgress(status) + + // Логируем результат обработки файла + fileName := filepath.Base(result.CurrentFile) + if result.Success && result.Error == nil { + uc.logSuccess("[%d/%d] ✓ %s", fileCounter, status.TotalFiles, fileName) + uc.logInfo(" └─ Размер: %.2f MB → %.2f MB", + float64(result.OriginalSize)/1024/1024, + float64(result.CompressedSize)/1024/1024) + uc.logInfo(" └─ Сжатие: %.1f%% | Сэкономлено: %.2f MB", + result.CompressionRatio, + float64(result.SavedSpace)/1024/1024) + } else { + uc.logError("[%d/%d] ✗ %s", fileCounter, status.TotalFiles, fileName) + uc.logError(" └─ Ошибка: %v", result.Error) + } + } + + // Финальная фаза + status.Complete() + uc.reportProgress(status) + + // Логируем итоговую статистику + uc.logInfo("") + uc.logInfo("╔════════════════════════════════════════════════════════════") + uc.logInfo("║ Обработка завершена") + uc.logInfo("╠════════════════════════════════════════════════════════════") + uc.logInfo("║ Время выполнения: %s", status.FormatElapsedTime()) + uc.logInfo("╠════════════════════════════════════════════════════════════") + uc.logInfo("║ Статистика файлов:") + uc.logInfo("║ • Всего: %d", status.TotalFiles) + uc.logSuccess("║ • Успешно: %d", status.SuccessfulFiles) + + if status.FailedFiles > 0 { + uc.logError("║ • Ошибок: %d", status.FailedFiles) + } + + if status.SkippedFiles > 0 { + uc.logWarning("║ • Пропущено: %d", status.SkippedFiles) + } + + if status.TotalOriginalSize > 0 { + uc.logInfo("╠════════════════════════════════════════════════════════════") + uc.logInfo("║ Статистика сжатия:") + uc.logInfo("║ • Исходный размер: %.2f MB", float64(status.TotalOriginalSize)/1024/1024) + uc.logInfo("║ • Сжатый размер: %.2f MB", float64(status.TotalCompressedSize)/1024/1024) + uc.logSuccess("║ • Среднее сжатие: %.1f%%", status.AverageCompression) + uc.logSuccess("║ • Сэкономлено: %.2f MB", float64(status.TotalSavedSpace)/1024/1024) + } + + uc.logInfo("╚════════════════════════════════════════════════════════════") + + return nil +} + +// worker обрабатывает файлы в отдельной горутине +func (uc *ProcessPDFsUseCase) worker( + id int, + jobs <-chan string, + results chan<- *entities.CompressionResult, + wg *sync.WaitGroup, + config *entities.Config, + compressionConfig *entities.CompressionConfig, + status *entities.ProcessingStatus, +) { + defer wg.Done() + + for inputFile := range jobs { + fileName := filepath.Base(inputFile) + + // Определяем путь выходного файла + var outputFile string + if config.Scanner.ReplaceOriginal { + outputFile = inputFile + ".tmp" + } else { + // Получаем относительный путь от исходной директории + relPath, err := filepath.Rel(config.Scanner.SourceDirectory, inputFile) + if err != nil { + // Если не удалось получить относительный путь, используем просто имя файла + outputFile = filepath.Join(config.Scanner.TargetDirectory, fileName) + } else { + // Сохраняем структуру директорий + outputFile = filepath.Join(config.Scanner.TargetDirectory, relPath) + // Создаем директорию для выходного файла + outputDir := filepath.Dir(outputFile) + if err := os.MkdirAll(outputDir, 0755); err != nil { + results <- &entities.CompressionResult{ + CurrentFile: inputFile, + Success: false, + Error: fmt.Errorf("не удалось создать директорию %s: %w", outputDir, err), + } + continue + } + } + } + + // Получаем информацию о файле + fileInfo, err := uc.fileRepo.GetFileInfo(inputFile) + if err != nil { + results <- &entities.CompressionResult{ + CurrentFile: inputFile, + Success: false, + Error: fmt.Errorf("ошибка получения информации о файле: %w", err), + } + continue + } + + // Выполняем сжатие с повторными попытками + var result *entities.CompressionResult + for attempt := 0; attempt < config.Processing.RetryAttempts; attempt++ { + result, err = uc.compressor.Compress(inputFile, outputFile, compressionConfig) + if err == nil { + break + } + + if attempt < config.Processing.RetryAttempts-1 { + if uc.logger != nil { + uc.logger.Warning("Попытка %d/%d для файла %s не удалась: %v", + attempt+1, config.Processing.RetryAttempts, fileName, err) + } + time.Sleep(time.Second * 2) // Пауза перед повторной попыткой + } + } + + if err != nil { + results <- &entities.CompressionResult{ + CurrentFile: inputFile, + OriginalSize: fileInfo.Size, + Success: false, + Error: err, + } + continue + } + + // Устанавливаем исходный размер и пересчитываем статистику + result.CurrentFile = inputFile + result.OriginalSize = fileInfo.Size + result.CalculateCompressionRatio() + + // Если заменяем оригинал, переименовываем временный файл + if config.Scanner.ReplaceOriginal { + if err := uc.replaceOriginalFile(inputFile, outputFile); err != nil { + result.Success = false + result.Error = fmt.Errorf("ошибка замены оригинального файла: %w", err) + // Удаляем временный файл при ошибке + _ = os.Remove(outputFile) + if uc.logger != nil { + uc.logger.Error("Не удалось заменить оригинальный файл %s: %v", inputFile, err) + } + } else { + // Успешно заменили - обновляем путь к файлу в результате + result.CurrentFile = inputFile + if uc.logger != nil { + uc.logger.Info("Файл %s успешно заменен сжатой версией", inputFile) + } + } + } + + results <- result + } +} + +// replaceOriginalFile заменяет оригинальный файл сжатым +func (uc *ProcessPDFsUseCase) replaceOriginalFile(originalFile, tempFile string) error { + // Проверяем существование временного файла + if _, err := os.Stat(tempFile); os.IsNotExist(err) { + return fmt.Errorf("временный файл не существует: %s", tempFile) + } + + if uc.logger != nil { + uc.logger.Info("Замена оригинального файла: %s", originalFile) + } + + backupFile := originalFile + ".backup" + + // Создаем резервную копию оригинала + if err := os.Rename(originalFile, backupFile); err != nil { + if uc.logger != nil { + uc.logger.Error("Ошибка создания резервной копии %s: %v", originalFile, err) + } + return fmt.Errorf("ошибка создания резервной копии: %w", err) + } + + // Переименовываем временный файл в оригинальный + if err := os.Rename(tempFile, originalFile); err != nil { + if uc.logger != nil { + uc.logger.Error("Ошибка замены файла %s: %v", originalFile, err) + } + // Восстанавливаем оригинальный файл из резервной копии + _ = os.Rename(backupFile, originalFile) + return fmt.Errorf("ошибка замены файла: %w", err) + } + + // Удаляем резервную копию + if err := os.Remove(backupFile); err != nil { + if uc.logger != nil { + uc.logger.Warning("Не удалось удалить резервную копию %s: %v", backupFile, err) + } + } + + if uc.logger != nil { + uc.logger.Info("Оригинальный файл успешно заменен: %s", originalFile) + } + + return nil +} + +// Методы для логирования +func (uc *ProcessPDFsUseCase) logInfo(format string, args ...interface{}) { + if uc.logger != nil { + uc.logger.Info(format, args...) + } +} + +func (uc *ProcessPDFsUseCase) logSuccess(format string, args ...interface{}) { + if uc.logger != nil { + uc.logger.Success(format, args...) + } +} + +func (uc *ProcessPDFsUseCase) logWarning(format string, args ...interface{}) { + if uc.logger != nil { + uc.logger.Warning(format, args...) + } +} + +func (uc *ProcessPDFsUseCase) logError(format string, args ...interface{}) { + if uc.logger != nil { + uc.logger.Error(format, args...) + } +} diff --git a/scripts/release-body.md b/scripts/release-body.md new file mode 100644 index 0000000..35af257 --- /dev/null +++ b/scripts/release-body.md @@ -0,0 +1,73 @@ +# PDF Compressor {{VERSION}} + +Мощный инструмент для сжатия PDF-файлов с интуитивным текстовым интерфейсом. + +## ✨ Новые возможности +- Рекурсивное сканирование директорий для поиска PDF-файлов +- Улучшенный пользовательский интерфейс с прогресс-индикатором +- Оптимизация производительности при обработке больших файлов +- Атомарная замена файлов с резервным копированием +- Поддержка конфигурационных файлов YAML + +## 🐛 Исправления и улучшения +- Исправлена обработка файлов с нестандартными именами +- Улучшена стабильность при работе с поврежденными PDF +- Оптимизирован расход памяти при сжатии больших документов +- Исправлены проблемы с кодировкой имен файлов + +## 📦 Установка и запуск + +### Быстрая установка +1. Скачайте архив для вашей операционной системы +2. Распакуйте в желаемую папку +3. Запустите исполняемый файл + +### Системные требования +- Операционная система: Windows 10+, Linux, macOS 10.14+ +- Свободное место на диске: минимум 50 МБ +- Оперативная память: рекомендуется 512 МБ + +## 💻 Поддерживаемые платформы + +| Платформа | Архитектура | Файл для скачивания | +|-----------|-------------|---------------------| +| **Windows** | x64 | `pdf-compressor-{{VERSION}}-windows-amd64.zip` | +| **Linux** | x64 | `pdf-compressor-{{VERSION}}-linux-amd64.zip` | +| **Linux** | ARM64 | `pdf-compressor-{{VERSION}}-linux-arm64.zip` | +| **macOS** | Intel x64 | `pdf-compressor-{{VERSION}}-darwin-amd64.zip` | +| **macOS** | Apple Silicon | `pdf-compressor-{{VERSION}}-darwin-arm64.zip` | + +## 🚀 Использование + +После установки программу можно запустить несколькими способами: + +**Интерактивный режим:** +```bash +./pdf-compressor +``` + +**Пакетная обработка:** +```bash +./pdf-compressor -input /path/to/pdfs -output /path/to/compressed +``` + +**Обработка одного файла:** +```bash +./pdf-compressor -file document.pdf +``` + +## 📖 Документация + +- **README.md** - основная документация +- **config.yaml.example** - пример конфигурационного файла +- **RELEASE_GUIDE.md** - руководство по релизам + +## 🆘 Поддержка + +Если у вас возникли вопросы или проблемы: +1. Проверьте документацию в репозитории +2. Создайте Issue с описанием проблемы +3. Приложите лог-файлы для диагностики + +--- +**Благодарим за использование PDF Compressor!** 🙏 diff --git a/scripts/release-gitea.ps1 b/scripts/release-gitea.ps1 new file mode 100644 index 0000000..8e7d359 --- /dev/null +++ b/scripts/release-gitea.ps1 @@ -0,0 +1,473 @@ +# PDF Compressor Release Generator for Gitea +# PowerShell version with Russian release описаниями +# Author: PDF Compressor Team +# Version: 1.0.0 + +param( + [Parameter(Position=0)] + [string]$Version, + + [Parameter()] + [switch]$Help +) + +# Ensure console uses UTF-8 to display Russian correctly +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +# Переменные конфигурации +$BINARY_NAME = "pdf-compressor" +$BUILD_DIR = "releases" +# Prefer environment variables; do not hardcode secrets +$GITEA_SERVER = $env:GITEA_SERVER +$GITEA_USER = $env:GITEA_USER +$GITEA_PASSWORD = $env:GITEA_PASSWORD +$GITEA_OWNER = $env:GITEA_OWNER +$GITEA_REPO = if ($env:GITEA_REPO) { $env:GITEA_REPO } else { "pdf-compressor" } + +# Цвета для вывода +$Colors = @{ + Red = "Red" + Green = "Green" + Yellow = "Yellow" + Blue = "Blue" + White = "White" +} + +# Функции вывода сообщений +function Write-Log { + param([string]$Message) + Write-Host "[INFO] $Message" -ForegroundColor $Colors.Green +} + +function Write-Warn { + param([string]$Message) + Write-Host "[WARNING] $Message" -ForegroundColor $Colors.Yellow +} + +function Write-Error-Custom { + param([string]$Message) + Write-Host "[ERROR] $Message" -ForegroundColor $Colors.Red + exit 1 +} + +# Функция справки +function Show-Help { + Write-Host "PDF Compressor Release Generator" -ForegroundColor $Colors.Blue + Write-Host "" + Write-Host "Usage: .\release-gitea.ps1 [version]" + Write-Host "" + Write-Host "Parameters:" + Write-Host " -Version Release version (e.g.: v1.2.0)" + Write-Host " If not specified, uses VERSION file or latest git tag" + Write-Host " -Help Show this help" + Write-Host "" + Write-Host "Environment variables:" + Write-Host " GITEA_SERVER Gitea server URL" + Write-Host " GITEA_USER Gitea username" + Write-Host " GITEA_PASSWORD Gitea password" + Write-Host " GITEA_OWNER Repository owner" + Write-Host " GITEA_REPO Repository name" + Write-Host " .env Automatically loaded from project root (KEY=VALUE)" + Write-Host "" + Write-Host "Examples:" + Write-Host " .\release-gitea.ps1 # Auto-detect version" + Write-Host " .\release-gitea.ps1 -Version v1.2.0 # Specific version" + Write-Host "" +} + +# Load variables from a .env file into the current process environment +function Load-DotEnv { + param( + [string]$Path = ".env", + [switch]$Override + ) + try { + $candidates = @() + # current working directory + $candidates += (Join-Path -Path (Get-Location) -ChildPath $Path) + # script directory + if ($PSScriptRoot) { + $candidates += (Join-Path -Path $PSScriptRoot -ChildPath $Path) + # repository root (one level up from scripts) + $candidates += (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath $Path) + } + $envFile = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $envFile) { return } + Write-Log "Loading .env from $envFile" + $lines = Get-Content -Path $envFile -Encoding UTF8 -ErrorAction Stop + foreach ($raw in $lines) { + $line = $raw.Trim() + if (-not $line) { continue } + if ($line.StartsWith('#') -or $line.StartsWith(';')) { continue } + # Remove inline comments that start with # after a space + $hashIdx = $line.IndexOf(' # ') + if ($hashIdx -gt 0) { $line = $line.Substring(0, $hashIdx).TrimEnd() } + # Support optional leading 'export ' + if ($line -like 'export *') { $line = $line.Substring(7).TrimStart() } + $eq = $line.IndexOf('=') + if ($eq -lt 1) { continue } + $key = $line.Substring(0, $eq).Trim() + $val = $line.Substring($eq + 1).Trim() + if ($val.StartsWith('"') -and $val.EndsWith('"') -and $val.Length -ge 2) { + $val = $val.Substring(1, $val.Length - 2) + $val = $val -replace "\\n", "`n" -replace "\\r", "" -replace "\\t", "`t" -replace "\\\\", "\\" + } elseif ($val.StartsWith("'") -and $val.EndsWith("'") -and $val.Length -ge 2) { + $val = $val.Substring(1, $val.Length - 2) + } + $existing = [Environment]::GetEnvironmentVariable($key, 'Process') + if ($Override -or [string]::IsNullOrEmpty($existing)) { + [Environment]::SetEnvironmentVariable($key, $val, 'Process') + } + } + } catch { + Write-Warn "Failed to load .env: $($_.Exception.Message)" + } +} + +# Функция проверки зависимостей +function Test-Dependencies { + Write-Log "Checking dependencies..." + + # Check Go + if (!(Get-Command "go" -ErrorAction SilentlyContinue)) { + Write-Error-Custom "Go is not installed" + } + + # Check git + if (!(Get-Command "git" -ErrorAction SilentlyContinue)) { + Write-Error-Custom "Git is not installed" + } + + Write-Log "All dependencies found" +} + +# Функция проверки переменных окружения +function Test-Environment { + Write-Log "Checking environment variables..." + + # Refresh from environment (after Load-DotEnv) so .env overrides take effect + $script:GITEA_SERVER = $env:GITEA_SERVER + $script:GITEA_USER = $env:GITEA_USER + $script:GITEA_PASSWORD = $env:GITEA_PASSWORD + $script:GITEA_OWNER = $env:GITEA_OWNER + if (-not $script:GITEA_REPO -and $env:GITEA_REPO) { $script:GITEA_REPO = $env:GITEA_REPO } + + if ([string]::IsNullOrEmpty($script:GITEA_SERVER)) { Write-Error-Custom "GITEA_SERVER is not set" } + if ([string]::IsNullOrEmpty($script:GITEA_USER)) { Write-Error-Custom "GITEA_USER is not set" } + if ([string]::IsNullOrEmpty($script:GITEA_PASSWORD)) { Write-Error-Custom "GITEA_PASSWORD is not set" } + if ([string]::IsNullOrEmpty($script:GITEA_OWNER)) { Write-Error-Custom "GITEA_OWNER is not set" } + + # Normalize values (strip quotes/spaces, remove trailing slash) + $script:GITEA_SERVER = ($script:GITEA_SERVER).ToString().Trim().Trim('"', "'").TrimEnd('/') + $script:GITEA_USER = ($script:GITEA_USER).ToString().Trim().Trim('"', "'") + $script:GITEA_PASSWORD = ($script:GITEA_PASSWORD).ToString().Trim() + $script:GITEA_OWNER = ($script:GITEA_OWNER).ToString().Trim().Trim('"', "'") + $script:GITEA_REPO = ($script:GITEA_REPO).ToString().Trim().Trim('"', "'") + + Write-Log "Environment variables checked" + Write-Log "Server: $($script:GITEA_SERVER) | Repo: $($script:GITEA_OWNER)/$($script:GITEA_REPO)" +} + +# Quick preflight checks against Gitea API +function Test-GiteaApi { + $apiBase = "$($script:GITEA_SERVER)/api/v1" + Write-Log "API base: $apiBase" + try { + $v = Invoke-RestMethod -Uri "$apiBase/version" -Method Get -ErrorAction Stop + Write-Log "Gitea version: $($v.version)" + } catch { + Write-Error-Custom "API check failed: $($_.Exception.Message). URL: $apiBase/version" + } + try { + $auth = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("$($script:GITEA_USER):$($script:GITEA_PASSWORD)")) + Invoke-RestMethod -Uri "$apiBase/repos/$($script:GITEA_OWNER)/$($script:GITEA_REPO)" -Method Get -Headers @{ Authorization = "Basic $auth" } -ErrorAction Stop | Out-Null + Write-Log "Repo access OK" + } catch { + Write-Error-Custom "Repo check failed: $($_.Exception.Message). URL: $apiBase/repos/$($script:GITEA_OWNER)/$($script:GITEA_REPO)" + } +} + +# Функция определения версии +function Get-ReleaseVersion { + param([string]$InputVersion) + + if (![string]::IsNullOrEmpty($InputVersion)) { + $script:Version = $InputVersion + } elseif (Test-Path "VERSION") { + $script:Version = (Get-Content "VERSION" -Raw).Trim() + } else { + try { + $script:Version = git describe --tags --abbrev=0 2>$null + if ([string]::IsNullOrEmpty($script:Version)) { $script:Version = "v1.0.0" } + } catch { $script:Version = "v1.0.0" } + } + + if (!$script:Version.StartsWith("v")) { $script:Version = "v$($script:Version)" } + Write-Log "Release version: $($script:Version)" +} + +# Проверка статуса git +function Test-GitStatus { + Write-Log "Checking git status..." + try { git rev-parse --git-dir | Out-Null } catch { Write-Error-Custom "Git repository not found" } + + $status = git status --porcelain + if (![string]::IsNullOrEmpty($status)) { + Write-Warn "There are uncommitted changes" + $response = Read-Host "Continue? (y/N)" + if ($response -notin @('y','Y')) { exit 1 } + } + + $currentBranch = git branch --show-current + if ($currentBranch -notin @('master','main')) { + Write-Warn "You are not on master/main branch (current: $currentBranch)" + $response = Read-Host "Continue? (y/N)" + if ($response -notin @('y','Y')) { exit 1 } + } +} + +# Запуск тестов +function Invoke-Tests { + Write-Log "Running tests..." + $result = go test ./... + if ($LASTEXITCODE -ne 0) { Write-Error-Custom "Tests failed" } + Write-Log "All tests passed successfully" +} + +# Создание тега +function New-GitTag { + Write-Log "Creating tag $($script:Version)..." + $existingTag = git tag -l $script:Version + if (![string]::IsNullOrEmpty($existingTag)) { + Write-Warn "Tag $($script:Version) already exists locally" + $response = Read-Host "Overwrite? (y/N)" + if ($response -in @('y','Y')) { + git tag -d $script:Version + Write-Log "Deleted local tag $($script:Version)" + } else { exit 1 } + } + + $releaseNotes = @" +Release $($script:Version) + +New Features: +- Interface updates and improvements +- Performance optimization + +Bug Fixes: +- Various fixes and stability improvements + +Supported Platforms: +- Windows (64-bit) +- Linux (64-bit, ARM64) +- macOS (Intel 64-bit, Apple Silicon ARM64) +"@; + + git tag -a $script:Version -m $releaseNotes + git push origin $script:Version --force + Write-Log "Tag $($script:Version) created and pushed" +} + +# Сборка бинарников +function Build-Binaries { + Write-Log "Building binaries for different platforms..." + $releaseDir = "$BUILD_DIR\$($script:Version)" + New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null + + $platforms = @( + @{GOOS="windows"; GOARCH="amd64"}, + @{GOOS="linux"; GOARCH="amd64"}, + @{GOOS="linux"; GOARCH="arm64"}, + @{GOOS="darwin"; GOARCH="amd64"}, + @{GOOS="darwin"; GOARCH="arm64"} + ) + + foreach ($platform in $platforms) { + $output = "$releaseDir\$BINARY_NAME-$($script:Version)-$($platform.GOOS)-$($platform.GOARCH)" + if ($platform.GOOS -eq "windows") { $output += ".exe" } + Write-Log "Building for $($platform.GOOS)/$($platform.GOARCH)" + $env:GOOS = $platform.GOOS; $env:GOARCH = $platform.GOARCH + $buildTime = Get-Date -Format "yyyy-MM-dd_HH:mm:ss" + $ldflags = "-s -w -X main.version=$($script:Version) -X main.buildTime=$buildTime" + go build -ldflags="$ldflags" -o $output cmd\main.go + if ($LASTEXITCODE -ne 0) { Write-Error-Custom "Error: Build failed for $($platform.GOOS)/$($platform.GOARCH)" } + Write-Log "Success: $($platform.GOOS)/$($platform.GOARCH) built successfully" + } + Remove-Item Env:GOOS -ErrorAction SilentlyContinue + Remove-Item Env:GOARCH -ErrorAction SilentlyContinue +} + +# Создание архивов +function New-Archives { + Write-Log "Creating archives..." + Push-Location "$BUILD_DIR\$($script:Version)" + try { + Get-ChildItem "*windows*.exe" | ForEach-Object { + $archive = $_.Name -replace '\.exe$', '.zip' + Compress-Archive -Path $_.Name -DestinationPath $archive -Force + Remove-Item $_.Name + Write-Log "Created archive: $archive" + } + Get-ChildItem "*linux*", "*darwin*" | Where-Object { $_.Extension -ne ".zip" -and $_.Extension -ne ".gz" } | ForEach-Object { + $archive = "$($_.Name).zip" + Compress-Archive -Path $_.Name -DestinationPath $archive -Force + Remove-Item $_.Name + Write-Log "Created archive: $archive" + } + } finally { Pop-Location } +} + +# Создание релиза в Gitea +function New-GiteaRelease { + Write-Log "Creating release in Gitea..." + $apiBase = "$GITEA_SERVER/api/v1" + # Load Russian body from external UTF-8 file to avoid PS source encoding issues + $bodyTemplatePath = Join-Path $PSScriptRoot 'release-body-ru.md' + if (-not (Test-Path $bodyTemplatePath)) { Write-Error-Custom "Release body template not found: $bodyTemplatePath" } + $releaseBody = [System.IO.File]::ReadAllText($bodyTemplatePath, (New-Object System.Text.UTF8Encoding($false))) + $releaseBody = $releaseBody -replace "{{VERSION}}", "$($script:Version)" + + # Авторизация + $credentials = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("$($GITEA_USER):$($GITEA_PASSWORD)")) + $headers = @{ "Authorization" = "Basic $credentials"; "Content-Type" = "application/json; charset=utf-8" } + + $releaseId = $null + + # Проверяем существующий релиз + try { + $existing = Invoke-RestMethod -Uri "$apiBase/repos/$GITEA_OWNER/$GITEA_REPO/releases/tags/$($script:Version)" -Method Get -Headers $headers + if ($existing -and $existing.id) { + Write-Log "Release for tag $($script:Version) already exists (ID: $($existing.id)). Will upload assets." + $releaseId = $existing.id + # Если описание короткое — обновим полным русским + if (-not $existing.body -or $existing.body.Length -lt 100) { + $updateJson = @{ name = "PDF Compressor $($script:Version)"; body = $releaseBody } | ConvertTo-Json -Depth 3 + $tempUpdate = "temp-update-$($script:Version).json" + # $updateJson | Out-File -FilePath $tempUpdate -Encoding UTF8 + [System.IO.File]::WriteAllText($tempUpdate, $updateJson, (New-Object System.Text.UTF8Encoding($false))) + if (Get-Command "curl.exe" -ErrorAction SilentlyContinue) { + & curl.exe -s -X PATCH -H "Authorization: Basic $credentials" -H "Content-Type: application/json; charset=utf-8" --data-binary "@$tempUpdate" "$apiBase/repos/$GITEA_OWNER/$GITEA_REPO/releases/$releaseId" | Out-Null + } else { + $updateBytes = [System.Text.Encoding]::UTF8.GetBytes($updateJson) + Invoke-RestMethod -Uri "$apiBase/repos/$GITEA_OWNER/$GITEA_REPO/releases/$releaseId" -Method Patch -Body $updateBytes -Headers $headers | Out-Null + } + Remove-Item $tempUpdate -ErrorAction SilentlyContinue + } + } + } catch { Write-Log "No existing release found for tag $($script:Version), will create new one." } + + # Создаём релиз + if (-not $releaseId) { + $releaseObj = @{ tag_name = $script:Version; name = "PDF Compressor $($script:Version)"; body = $releaseBody; draft = $false; prerelease = $false } + $releaseJson = ($releaseObj | ConvertTo-Json -Depth 4) + $tempJsonFile = "temp-release-$($script:Version).json" + [System.IO.File]::WriteAllText($tempJsonFile, $releaseJson, (New-Object System.Text.UTF8Encoding($false))) + Start-Sleep -Seconds 1 + if (Get-Command "curl.exe" -ErrorAction SilentlyContinue) { + try { + Write-Log "Creating release via curl..." + $releaseUrl = "$apiBase/repos/$GITEA_OWNER/$GITEA_REPO/releases" + $curlResult = & curl.exe -s -X POST -H "Authorization: Basic $credentials" -H "Content-Type: application/json; charset=utf-8" --data-binary "@$tempJsonFile" "$releaseUrl" + if ($LASTEXITCODE -eq 0) { + $response = $curlResult | ConvertFrom-Json + $releaseId = $response.id + Write-Log "Release created with ID: $releaseId via curl" + } else { throw "Curl failed with exit code $LASTEXITCODE (URL: $releaseUrl)" } + } catch { + Write-Warn "Curl method failed: $($_.Exception.Message)" + $minimalJson = @{ tag_name = $script:Version; name = "PDF Compressor $($script:Version)"; body = "Release $($script:Version)" } | ConvertTo-Json -Depth 2 + $minimalBytes = [System.Text.Encoding]::UTF8.GetBytes($minimalJson) + try { + $response = Invoke-RestMethod -Uri "$apiBase/repos/$GITEA_OWNER/$GITEA_REPO/releases" -Method Post -Body $minimalBytes -Headers $headers + $releaseId = $response.id + Write-Log "Minimal release created with ID: $releaseId" + } catch { Write-Error-Custom "Failed to create release: $($_.Exception.Message)" } + } + } else { + $releaseBytes = [System.Text.Encoding]::UTF8.GetBytes($releaseJson) + try { + $response = Invoke-RestMethod -Uri "$apiBase/repos/$GITEA_OWNER/$GITEA_REPO/releases" -Method Post -Body $releaseBytes -Headers $headers + $releaseId = $response.id + Write-Log "Release created with ID: $releaseId via PowerShell" + } catch { Write-Error-Custom "Failed to create release: $($_.Exception.Message)" } + } + Remove-Item $tempJsonFile -ErrorAction SilentlyContinue + } + + # Fallback: resolve release ID if creation didn't return it + if (-not $releaseId) { + try { + $check = Invoke-RestMethod -Uri "$apiBase/repos/$GITEA_OWNER/$GITEA_REPO/releases/tags/$($script:Version)" -Method Get -Headers $headers + if ($check -and $check.id) { + $releaseId = $check.id + Write-Log "Release ID resolved via GET: $releaseId" + } + } catch { + Write-Warn "Could not resolve release ID after creation: $($_.Exception.Message)" + } + } + if (-not $releaseId) { Write-Error-Custom "Release created but ID not found. Aborting uploads." } + + # Загрузка архивов + Write-Log "Uploading archives..." + Get-ChildItem "$BUILD_DIR\$($script:Version)\*" | ForEach-Object { + Write-Log "Uploading file $($_.Name)..." + try { + $filePath = $_.FullName + if (Get-Command "curl.exe" -ErrorAction SilentlyContinue) { + Write-Log "Using curl for upload..." + & curl.exe -s -X POST -H "Authorization: Basic $credentials" -F "attachment=@$filePath" "$apiBase/repos/$GITEA_OWNER/$GITEA_REPO/releases/$releaseId/assets" | Out-Null + if ($LASTEXITCODE -eq 0) { Write-Log "Success: file $($_.Name) uploaded via curl" } else { Write-Warn "Curl upload failed for $($_.Name)" } + } else { + $boundary = [System.Guid]::NewGuid().ToString() + $LF = "`r`n" + $fileContent = [System.IO.File]::ReadAllBytes($filePath) + # Build multipart body header with proper PowerShell escaping + $bodyHeader = @( + "--$boundary$LF" + "Content-Disposition: form-data; name=`"attachment`"; filename=`"$($_.Name)`"$LF" + "Content-Type: application/octet-stream$LF$LF" + ) -join "" + $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($bodyHeader) + $endBytes = [System.Text.Encoding]::UTF8.GetBytes("$LF--$boundary--$LF") + # Concatenate bytes efficiently + $fullBody = New-Object byte[] ($bodyBytes.Length + $fileContent.Length + $endBytes.Length) + [Array]::Copy($bodyBytes, 0, $fullBody, 0, $bodyBytes.Length) + [Array]::Copy($fileContent, 0, $fullBody, $bodyBytes.Length, $fileContent.Length) + [Array]::Copy($endBytes, 0, $fullBody, $bodyBytes.Length + $fileContent.Length, $endBytes.Length) + $uploadHeaders = @{ "Authorization" = "Basic $credentials"; "Content-Type" = "multipart/form-data; boundary=$boundary" } + Invoke-RestMethod -Uri "$apiBase/repos/$GITEA_OWNER/$GITEA_REPO/releases/$releaseId/assets" -Method Post -Body $fullBody -Headers $uploadHeaders | Out-Null + Write-Log "Success: file $($_.Name) uploaded via PowerShell" + } + } catch { Write-Warn "Error uploading file $($_.Name): $($_.Exception.Message)" } + } +} + +# Главная функция +function Main { + Write-Host "PDF Compressor Release Generator" -ForegroundColor $Colors.Blue + Write-Host "" + if ($Help) { Show-Help; return } + try { + # Load variables from .env before validating environment + Load-DotEnv -Override + Test-Dependencies + Test-Environment + Test-GiteaApi + Get-ReleaseVersion $Version + Test-GitStatus + Invoke-Tests + New-GitTag + Build-Binaries + New-Archives + New-GiteaRelease + Write-Log "Release $($script:Version) successfully created!" + Write-Host "" + Write-Host "Release available at:" -ForegroundColor $Colors.Green + Write-Host "$GITEA_SERVER/$GITEA_OWNER/$GITEA_REPO/releases/tag/$($script:Version)" + Write-Host "" + Write-Host "Done! Release published and ready to use." -ForegroundColor $Colors.Green + } catch { Write-Error-Custom "An error occurred: $($_.Exception.Message)" } +} + +# Запуск +Main \ No newline at end of file diff --git a/scripts/release-gitea.sh b/scripts/release-gitea.sh new file mode 100644 index 0000000..88c8092 --- /dev/null +++ b/scripts/release-gitea.sh @@ -0,0 +1,369 @@ +#!/bin/bash +# Скрипт автоматической генерации релиза на Gitea для PDF Compressor +# Автор: PDF Compressor Team +# Версия: 1.0.0 + +set -e # Остановка при ошибках + +# Цвета для вывода +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Переменные конфигурации +BINARY_NAME="pdf-compressor" +BUILD_DIR="releases" +GITEA_SERVER="" # Заполните URL вашего Gitea сервера +GITEA_TOKEN="" # Заполните токен доступа Gitea +GITEA_OWNER="" # Заполните владельца репозитория +GITEA_REPO="pdf-compressor" + +# Функция вывода сообщений +log() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +error() { + echo -e "${RED}[ERROR]${NC} $1" + exit 1 +} + +# Функция проверки зависимостей +check_dependencies() { + log "Проверка зависимостей..." + + # Проверяем Go + if ! command -v go &> /dev/null; then + error "Go не установлен" + fi + + # Проверяем git + if ! command -v git &> /dev/null; then + error "Git не установлен" + fi + + # Проверяем curl для API запросов + if ! command -v curl &> /dev/null; then + error "curl не установлен" + fi + + # Проверяем zip + if ! command -v zip &> /dev/null; then + error "zip не установлен" + fi + + log "Все зависимости найдены" +} + +# Функция проверки переменных окружения +check_env() { + log "Проверка переменных окружения..." + + if [ -z "$GITEA_SERVER" ]; then + error "GITEA_SERVER не установлен" + fi + + if [ -z "$GITEA_TOKEN" ]; then + error "GITEA_TOKEN не установлен" + fi + + if [ -z "$GITEA_OWNER" ]; then + error "GITEA_OWNER не установлен" + fi + + log "Переменные окружения проверены" +} + +# Функция получения версии +get_version() { + if [ -n "$1" ]; then + VERSION="$1" + elif [ -f "VERSION" ]; then + VERSION=$(cat VERSION) + else + VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "v1.0.0") + fi + + # Добавляем префикс v если его нет + if [[ ! $VERSION =~ ^v ]]; then + VERSION="v$VERSION" + fi + + log "Версия релиза: $VERSION" +} + +# Функция проверки git статуса +check_git_status() { + log "Проверка состояния git..." + + # Проверяем что мы в git репозитории + if ! git rev-parse --git-dir > /dev/null 2>&1; then + error "Не найден git репозиторий" + fi + + # Проверяем что нет незафиксированных изменений + if ! git diff-index --quiet HEAD --; then + warn "Есть незафиксированные изменения" + read -p "Продолжить? (y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi + fi + + # Проверяем что мы на master/main ветке + CURRENT_BRANCH=$(git branch --show-current) + if [[ "$CURRENT_BRANCH" != "master" && "$CURRENT_BRANCH" != "main" ]]; then + warn "Вы не на master/main ветке (текущая: $CURRENT_BRANCH)" + read -p "Продолжить? (y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi + fi +} + +# Функция запуска тестов +run_tests() { + log "Запуск тестов..." + + if ! go test ./...; then + error "Тесты не прошли" + fi + + log "Все тесты прошли успешно" +} + +# Функция создания тега +create_tag() { + log "Создание тега $VERSION..." + + # Проверяем что тег еще не существует + if git rev-parse "$VERSION" >/dev/null 2>&1; then + warn "Тег $VERSION уже существует" + read -p "Перезаписать? (y/N): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + git tag -d "$VERSION" + else + exit 1 + fi + fi + + # Создаем аннотированный тег + RELEASE_NOTES="Release $VERSION + +✨ Новые возможности: +- Обновления и улучшения интерфейса +- Оптимизация производительности + +🐛 Исправления: +- Различные багфиксы и улучшения стабильности + +📦 Поддерживаемые платформы: +- Windows (64-bit) +- Linux (64-bit, ARM64) +- macOS (Intel 64-bit, Apple Silicon ARM64)" + + git tag -a "$VERSION" -m "$RELEASE_NOTES" + + # Отправляем тег в origin + git push origin "$VERSION" + + log "Тег $VERSION создан и отправлен" +} + +# Функция сборки бинарников +build_binaries() { + log "Сборка бинарников для разных платформ..." + + # Создаем директорию для релиза + RELEASE_DIR="$BUILD_DIR/$VERSION" + mkdir -p "$RELEASE_DIR" + + # Массив платформ + platforms=( + "windows/amd64" + "linux/amd64" + "linux/arm64" + "darwin/amd64" + "darwin/arm64" + ) + + for platform in "${platforms[@]}"; do + IFS='/' read -r GOOS GOARCH <<< "$platform" + output="$RELEASE_DIR/${BINARY_NAME}-${VERSION}-${GOOS}-${GOARCH}" + + if [ "$GOOS" = "windows" ]; then + output="${output}.exe" + fi + + log "Сборка для $GOOS/$GOARCH" + + # Сборка с флагами оптимизации + GOOS=$GOOS GOARCH=$GOARCH go build \ + -ldflags="-s -w -X main.version=$VERSION -X main.buildTime=$(date -u '+%Y-%m-%d_%H:%M:%S')" \ + -o "$output" \ + cmd/main.go + + if [ $? -eq 0 ]; then + log "✅ $GOOS/$GOARCH построен успешно" + else + error "❌ Ошибка сборки для $GOOS/$GOARCH" + fi + done +} + +# Функция создания архивов +create_archives() { + log "Создание архивов..." + + cd "$BUILD_DIR/$VERSION" + + # Windows - ZIP архивы + for file in *windows*.exe; do + if [ -f "$file" ]; then + archive="${file%.exe}.zip" + zip "$archive" "$file" + rm "$file" + log "Создан архив: $archive" + fi + done + + # Linux и macOS - TAR.GZ архивы + for file in *linux* *darwin*; do + if [ -f "$file" ] && [[ ! "$file" == *.zip ]] && [[ ! "$file" == *.tar.gz ]]; then + archive="${file}.tar.gz" + tar -czf "$archive" "$file" + rm "$file" + log "Создан архив: $archive" + fi + done + + cd - > /dev/null +} + +# Функция создания релиза в Gitea +create_gitea_release() { + log "Создание релиза в Gitea..." + + # JSON для создания релиза + RELEASE_JSON=$(cat <" + echo "Пример: $0 v1.0.0" + exit 1 +fi + +echo "🚀 Создание релиза $VERSION для PDF Compressor" + +# Проверяем что мы в правильной директории +if [ ! -f "go.mod" ]; then + echo "❌ Ошибка: Запустите скрипт из корня проекта" + exit 1 +fi + +# Проверяем что все изменения закоммичены +if [ -n "$(git status --porcelain)" ]; then + echo "❌ Ошибка: Есть незакоммиченные изменения" + git status + exit 1 +fi + +# Запускаем тесты +echo "🧪 Запуск тестов..." +go test ./... || { + echo "❌ Тесты не прошли" + exit 1 +} + +# Собираем для разных платформ +echo "🔨 Сборка бинарников..." +mkdir -p releases + +# Windows +GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o releases/pdf-compressor-${VERSION}-windows-amd64.exe cmd/main.go + +# Linux +GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o releases/pdf-compressor-${VERSION}-linux-amd64 cmd/main.go + +# macOS +GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o releases/pdf-compressor-${VERSION}-darwin-amd64 cmd/main.go + +# ARM64 versions +GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o releases/pdf-compressor-${VERSION}-linux-arm64 cmd/main.go +GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o releases/pdf-compressor-${VERSION}-darwin-arm64 cmd/main.go + +# Создаем архивы +echo "📦 Создание архивов..." +cd releases + +# Windows +zip pdf-compressor-${VERSION}-windows-amd64.zip pdf-compressor-${VERSION}-windows-amd64.exe +rm pdf-compressor-${VERSION}-windows-amd64.exe + +# Linux +tar -czf pdf-compressor-${VERSION}-linux-amd64.tar.gz pdf-compressor-${VERSION}-linux-amd64 +rm pdf-compressor-${VERSION}-linux-amd64 + +# macOS +tar -czf pdf-compressor-${VERSION}-darwin-amd64.tar.gz pdf-compressor-${VERSION}-darwin-amd64 +rm pdf-compressor-${VERSION}-darwin-amd64 + +# ARM64 +tar -czf pdf-compressor-${VERSION}-linux-arm64.tar.gz pdf-compressor-${VERSION}-linux-arm64 +rm pdf-compressor-${VERSION}-linux-arm64 + +tar -czf pdf-compressor-${VERSION}-darwin-arm64.tar.gz pdf-compressor-${VERSION}-darwin-arm64 +rm pdf-compressor-${VERSION}-darwin-arm64 + +cd .. + +# Создаем и пушим тег +echo "🏷️ Создание тега..." +git tag -a "$VERSION" -m "Release $VERSION" +git push origin "$VERSION" + +echo "✅ Релиз подготовлен!" +echo "📁 Файлы релиза находятся в папке releases/" +echo "🌐 Теперь создайте релиз в Gitea веб-интерфейсе:" +echo " 1. Перейдите в ваш репозиторий в Gitea" +echo " 2. Нажмите 'Releases' → 'New Release'" +echo " 3. Выберите тег: $VERSION" +echo " 4. Загрузите файлы из папки releases/"