Достижение: Добавлены скрипты и документация для релиза PDF Compressor.

- Добавлен release-body.md для подробных заметок о релизе на русском языке.
- Реализован release-gitea.ps1 для автоматизированного релиза Gitea с помощью PowerShell.
- Создан release-gitea.sh для автоматизированного релиза Gitea с помощью Bash.
- Добавлен release.sh для сборки и маркировки релизов с поддержкой нескольких платформ.
- Улучшен пользовательский интерфейс благодаря информативному логированию и обработке ошибок.
- Добавлена ​​поддержка переменных окружения и управления конфигурацией.
- Добавлена ​​функция создания архивов и загрузки ресурсов в Gitea.
This commit is contained in:
Dmitriy Fofanov
2025-11-05 09:33:12 +03:00
parent f328d67080
commit ec65cfd05a
43 changed files with 5792 additions and 2 deletions

41
.air.toml Normal file
View File

@@ -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

72
.dockerignore Normal file
View File

@@ -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

161
.env.example Normal file
View File

@@ -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=

27
.gitignore vendored
View File

@@ -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

103
Dockerfile Normal file
View File

@@ -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"

187
Makefile Normal file
View File

@@ -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

307
README.md
View File

@@ -1,3 +1,308 @@
# compress
# Universal File Compressor
Высокопроизводительный инструмент на 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 (1050% качество), 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 # Общий уровень (1090) влияет на стратегию
algorithm: "pdfcpu" # pdfcpu | unipdf
auto_start: false # Автозапуск при старте приложения
unipdf_license_key: "" # Ключ для UniPDF (опционально)
# Сжатие изображений
enable_jpeg: true # Включить JPEG
enable_png: true # Включить PNG
jpeg_quality: 30 # 1050 (шаг 5)
png_quality: 25 # 1050 (шаг 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 | 1090 | ErrInvalidCompressionLevel |
| jpeg_quality | 1050 (шаг 5) | ErrInvalidJPEGQuality |
| png_quality | 1050 (шаг 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 4060 |
| Максимальное уменьшение для рассылки | UniPDF + level 7085 |
| Много цветных сканов | UniPDF + включить сжатие JPEG 2535% |
| Оптимизация сайта (изображения + 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 (сред.) |
|------------|----------------|----------------|
| Снижение размера | 3045% | 5070% |
| Время (100 файлов) | 1× | 23× |
| Ошибки повторной попытки | Низко | Низко |

BIN
VERSION Normal file

Binary file not shown.

109
cmd/main.go Normal file
View File

@@ -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()
}

74
cmd/processor.go Normal file
View File

@@ -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()
}

23
config.yaml Normal file
View File

@@ -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

29
config.yaml.example Normal file
View File

@@ -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

143
docker-compose.yml Normal file
View File

@@ -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

39
go.mod Normal file
View File

@@ -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
)

107
go.sum Normal file
View File

@@ -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=

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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 файлы не найдены")
)

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 ""
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,
},
}
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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,
},
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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...)
}
}

73
scripts/release-body.md Normal file
View File

@@ -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!** 🙏

473
scripts/release-gitea.ps1 Normal file
View File

@@ -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

369
scripts/release-gitea.sh Normal file
View File

@@ -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 <<EOF
{
"tag_name": "$VERSION",
"name": "PDF Compressor $VERSION",
"body": "# 🔥 PDF Compressor $VERSION\n\n## ✨ Новые возможности\n- Обновления и улучшения\n- Оптимизация производительности\n\n## 🐛 Исправления\n- Различные багфиксы\n- Улучшения стабильности\n\n## 📦 Установка\n1. Скачайте архив для вашей платформы\n2. Распакуйте и запустите\n\n## 📖 Документация\nПолная документация доступна в README.md",
"draft": false,
"prerelease": false
}
EOF
)
# Создаем релиз через API
RESPONSE=$(curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$RELEASE_JSON" \
"$GITEA_SERVER/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases")
# Получаем ID релиза
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | cut -d':' -f2 | head -n1)
if [ -z "$RELEASE_ID" ]; then
error "Не удалось создать релиз. Ответ: $RESPONSE"
fi
log "Релиз создан с ID: $RELEASE_ID"
# Загружаем архивы
log "Загрузка архивов..."
for archive in "$BUILD_DIR/$VERSION"/*; do
if [ -f "$archive" ]; then
filename=$(basename "$archive")
log "Загрузка $filename..."
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@$archive" \
"$GITEA_SERVER/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases/$RELEASE_ID/assets"
if [ $? -eq 0 ]; then
log "$filename загружен"
else
warn "❌ Ошибка загрузки $filename"
fi
fi
done
}
# Функция очистки
cleanup() {
log "Очистка временных файлов..."
# Здесь можно добавить очистку при необходимости
}
# Функция показа справки
show_help() {
echo -e "${BLUE}Скрипт генерации релиза PDF Compressor${NC}"
echo ""
echo "Использование: $0 [версия]"
echo ""
echo "Параметры:"
echo " версия Версия релиза (например: v1.2.0)"
echo " Если не указана, используется VERSION файл или последний git тег"
echo ""
echo "Переменные окружения:"
echo " GITEA_SERVER URL Gitea сервера"
echo " GITEA_TOKEN Токен доступа Gitea"
echo " GITEA_OWNER Владелец репозитория"
echo ""
echo "Примеры:"
echo " $0 # Автоматическое определение версии"
echo " $0 v1.2.0 # Конкретная версия"
echo ""
}
# Основная функция
main() {
echo -e "${BLUE}🚀 PDF Compressor Release Generator${NC}"
echo ""
# Обработка аргументов
case "${1:-}" in
-h|--help|help)
show_help
exit 0
;;
esac
# Основной процесс
check_dependencies
check_env
get_version "$1"
check_git_status
run_tests
create_tag
build_binaries
create_archives
create_gitea_release
cleanup
log "🎉 Релиз $VERSION успешно создан!"
echo ""
echo -e "${GREEN}Релиз доступен по адресу:${NC}"
echo "$GITEA_SERVER/$GITEA_OWNER/$GITEA_REPO/releases/tag/$VERSION"
echo ""
}
# Обработка сигналов
trap cleanup EXIT
# Запуск основной функции
main "$@"

90
scripts/release.sh Normal file
View File

@@ -0,0 +1,90 @@
#!/bin/bash
# Скрипт для создания релиза PDF Compressor в Gitea
# Использование: ./scripts/release.sh v1.0.0
set -e
VERSION=$1
if [ -z "$VERSION" ]; then
echo "Использование: $0 <version>"
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/"