Достижение: Добавлены скрипты и документация для релиза PDF Compressor.
- Добавлен release-body.md для подробных заметок о релизе на русском языке. - Реализован release-gitea.ps1 для автоматизированного релиза Gitea с помощью PowerShell. - Создан release-gitea.sh для автоматизированного релиза Gitea с помощью Bash. - Добавлен release.sh для сборки и маркировки релизов с поддержкой нескольких платформ. - Улучшен пользовательский интерфейс благодаря информативному логированию и обработке ошибок. - Добавлена поддержка переменных окружения и управления конфигурацией. - Добавлена функция создания архивов и загрузки ресурсов в Gitea.
This commit is contained in:
41
.air.toml
Normal file
41
.air.toml
Normal 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
72
.dockerignore
Normal 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
161
.env.example
Normal 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
27
.gitignore
vendored
@@ -9,6 +9,9 @@
|
|||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
# Test binary, built with `go test -c`
|
||||||
*.test
|
*.test
|
||||||
|
|
||||||
@@ -25,3 +28,27 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.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
103
Dockerfile
Normal 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
187
Makefile
Normal 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
307
README.md
@@ -1,3 +1,308 @@
|
|||||||
# compress
|
# Universal File Compressor
|
||||||
|
|
||||||
Высокопроизводительный инструмент на Go для массового сжатия PDF и изображений (JPEG/PNG) с удобным TUI-интерфейсом, гибкой конфигурацией и модульной архитектурой (Clean Architecture). Поддерживает выбор алгоритма сжатия (PDFCPU / UniPDF), параметрическое управление качеством, рекурсивную обработку директорий, параллельное выполнение, расширяемость через дополнительные компрессоры.
|
Высокопроизводительный инструмент на Go для массового сжатия PDF и изображений (JPEG/PNG) с удобным TUI-интерфейсом, гибкой конфигурацией и модульной архитектурой (Clean Architecture). Поддерживает выбор алгоритма сжатия (PDFCPU / UniPDF), параметрическое управление качеством, рекурсивную обработку директорий, параллельное выполнение, расширяемость через дополнительные компрессоры.
|
||||||
|
|
||||||
|
---
|
||||||
|
## Содержание
|
||||||
|
1. Обзор и ключевые особенности
|
||||||
|
2. Быстрый старт
|
||||||
|
3. Конфигурация (полный справочник)
|
||||||
|
4. Алгоритмы сжатия (PDFCPU vs UniPDF)
|
||||||
|
5. Сжатие изображений (JPEG / PNG)
|
||||||
|
6. Архитектура и внутреннее устройство
|
||||||
|
7. Поток обработки (Pipeline)
|
||||||
|
8. Параллельность и производительность
|
||||||
|
9. Логирование и мониторинг
|
||||||
|
10. Расширяемость (как добавить новый компрессор)
|
||||||
|
11. Сценарии использования и рекомендации
|
||||||
|
12. Устранение неполадок
|
||||||
|
13. План развития / Roadmap
|
||||||
|
14. Лицензия (если применимо)
|
||||||
|
|
||||||
|
---
|
||||||
|
## 1. Обзор и ключевые особенности
|
||||||
|
|
||||||
|
| Возможность | Описание |
|
||||||
|
|-------------|----------|
|
||||||
|
| Сжатие PDF | Алгоритмы PDFCPU (скорость) и UniPDF (качество) |
|
||||||
|
| Сжатие изображений | JPEG (10–50% качество), PNG (адаптивная оптимизация) |
|
||||||
|
| Рекурсивная обработка | Поиск файлов во всех поддиректориях |
|
||||||
|
| Сохранение структуры | **Полное сохранение иерархии папок** в целевой директории |
|
||||||
|
| Параллельность | Несколько воркеров, управляемые через конфигурацию |
|
||||||
|
| Замена или сохранение | Перезапись оригиналов или вывод в целевую папку |
|
||||||
|
| Интеллектуальное сжатие | Автоматический выбор оригинала при неэффективном сжатии |
|
||||||
|
| TUI интерфейс | Настройка, запуск, мониторинг прогресса и логов |
|
||||||
|
| Логирование | В файл и/или на экран, ротация по размеру |
|
||||||
|
| Универсальная конфигурация | YAML + динамическая загрузка в рантайме |
|
||||||
|
| Расширяемость | Чистые интерфейсы для добавления новых компрессоров |
|
||||||
|
| Обработка ошибок | Повторы (retry), таймауты, агрегирование статистики |
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2. Быстрый старт
|
||||||
|
|
||||||
|
### Установка из исходников
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/your-username/compressor.git
|
||||||
|
cd compressor
|
||||||
|
go mod tidy
|
||||||
|
go build -o compressor cmd/main.go
|
||||||
|
./compressor # (Windows: compressor.exe)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Минимальный сценарий
|
||||||
|
1. Поместите файлы в директорию `./pdfs` (или измените путь в `config.yaml`).
|
||||||
|
2. Запустите программу.
|
||||||
|
3. В TUI выберите «Конфигурация», настройте параметры.
|
||||||
|
4. Сохраните и запустите обработку.
|
||||||
|
5. Результаты появятся в выбранной целевой директории или заменят оригиналы.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 3. Конфигурация (config.yaml)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
scanner:
|
||||||
|
source_directory: "./pdfs" # Папка с исходными файлами
|
||||||
|
target_directory: "./compressed" # Если пусто и replace_original=true — замена
|
||||||
|
replace_original: false # true — перезаписывать исходники
|
||||||
|
|
||||||
|
compression:
|
||||||
|
level: 50 # Общий уровень (10–90) влияет на стратегию
|
||||||
|
algorithm: "pdfcpu" # pdfcpu | unipdf
|
||||||
|
auto_start: false # Автозапуск при старте приложения
|
||||||
|
unipdf_license_key: "" # Ключ для UniPDF (опционально)
|
||||||
|
|
||||||
|
# Сжатие изображений
|
||||||
|
enable_jpeg: true # Включить JPEG
|
||||||
|
enable_png: true # Включить PNG
|
||||||
|
jpeg_quality: 30 # 10–50 (шаг 5)
|
||||||
|
png_quality: 25 # 10–50 (шаг 5)
|
||||||
|
|
||||||
|
processing:
|
||||||
|
parallel_workers: 2 # Количество воркеров
|
||||||
|
timeout_seconds: 30 # Таймаут на файл
|
||||||
|
retry_attempts: 3 # Повторы при падениях
|
||||||
|
|
||||||
|
output:
|
||||||
|
log_level: "info" # debug|info|warning|error
|
||||||
|
progress_bar: true
|
||||||
|
log_to_file: true
|
||||||
|
log_file_name: "compressor.log"
|
||||||
|
log_max_size_mb: 10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Валидация параметров
|
||||||
|
| Параметр | Диапазон | Ошибка при нарушении |
|
||||||
|
|----------|----------|----------------------|
|
||||||
|
| compression.level | 10–90 | ErrInvalidCompressionLevel |
|
||||||
|
| jpeg_quality | 10–50 (шаг 5) | ErrInvalidJPEGQuality |
|
||||||
|
| png_quality | 10–50 (шаг 5) | ErrInvalidPNGQuality |
|
||||||
|
|
||||||
|
---
|
||||||
|
## 4. Алгоритмы сжатия PDF
|
||||||
|
|
||||||
|
| Критерий | PDFCPU | UniPDF |
|
||||||
|
|----------|--------|--------|
|
||||||
|
| Основной фокус | Скорость | Максимальное сжатие |
|
||||||
|
| Сжатие изображений | Базовое | Продвинутое (перекодирование) |
|
||||||
|
| Скорость (относит.) | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
|
||||||
|
| Степень сжатия | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||||
|
| Стабильность | Высокая | Высокая |
|
||||||
|
| Зависимости | Легковесный | Более тяжелая библиотека |
|
||||||
|
|
||||||
|
Рекомендации:
|
||||||
|
- Выберите PDFCPU, если: массовая обработка, преобладает текст, важна скорость.
|
||||||
|
- Выберите UniPDF, если: критичен размер, много изображений, важна агрессивная оптимизация.
|
||||||
|
|
||||||
|
Переключение: `compression.algorithm: "unipdf"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 5. Сжатие изображений (JPEG / PNG)
|
||||||
|
|
||||||
|
### Поддерживаемые форматы
|
||||||
|
- JPEG: `.jpg`, `.jpeg`
|
||||||
|
- PNG: `.png`
|
||||||
|
|
||||||
|
### Параметры
|
||||||
|
| Параметр | Назначение |
|
||||||
|
|----------|------------|
|
||||||
|
| enable_jpeg | Включает проход по JPEG файлам |
|
||||||
|
| enable_png | Включает проход по PNG файлам |
|
||||||
|
| jpeg_quality | Управляет степенью перекодирования и масштабирования |
|
||||||
|
| png_quality | Управляет коэффициентом масштабирования и уровнем компрессии |
|
||||||
|
|
||||||
|
### Алгоритм JPEG (улучшенный с защитой от увеличения)
|
||||||
|
1. **Декодирование** исходного JPEG.
|
||||||
|
2. **Адаптивное масштабирование**: quality 10→50%, quality 50→90% размера.
|
||||||
|
3. **Консервативное качество**: маппинг 10→30, 30→55, 50→75 (вместо агрессивного 20-100).
|
||||||
|
4. **Кодирование во временный файл** с проверкой размера.
|
||||||
|
5. **Интеллектуальный выбор**:
|
||||||
|
- Если результат ≥95% от оригинала → копируется оригинал (без потери качества)
|
||||||
|
- Если результат меньше → используется сжатая версия
|
||||||
|
6. **Гарантия**: выходной файл никогда не будет больше исходного.
|
||||||
|
|
||||||
|
### Алгоритм PNG (улучшенный с защитой от увеличения)
|
||||||
|
1. **Декодирование** исходного PNG.
|
||||||
|
2. **Консервативное масштабирование**: quality 10→60%, quality 50→90% размера.
|
||||||
|
3. **Адаптивная обработка**: маленькие изображения (<400px) не изменяются в размерах.
|
||||||
|
4. **Кодирование во временный файл** с максимальным сжатием (`png.BestCompression`).
|
||||||
|
5. **Интеллектуальный выбор**:
|
||||||
|
- Если результат ≥95% от оригинала → копируется оригинал
|
||||||
|
- Если результат меньше → используется сжатая версия
|
||||||
|
6. **Гарантия**: выходной файл никогда не будет больше исходного.
|
||||||
|
|
||||||
|
**Важно**: PNG — lossless формат. Для уже оптимизированных файлов алгоритм автоматически сохраняет оригинал, предотвращая увеличение размера.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 6. Архитектура
|
||||||
|
|
||||||
|
Следует принципам Clean Architecture.
|
||||||
|
|
||||||
|
Слои:
|
||||||
|
- `domain` — сущности, ошибки, интерфейсы (`PDFCompressor`, `FileRepository`, `Logger`).
|
||||||
|
- `usecase` — сценарии: `ProcessPDFsUseCase`, `CompressImageUseCase`, `ProcessAllFilesUseCase`.
|
||||||
|
- `infrastructure` — реализации (компрессоры, файловый репозиторий, конфигурация, логирование).
|
||||||
|
- `presentation/tui` — UI слой, отображение прогресса, ввод настроек.
|
||||||
|
- `cmd/main.go` — композиция.
|
||||||
|
|
||||||
|
Преимущества: тестируемость, независимость от фреймворков, лёгкая замена реализаций.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 7. Поток обработки (Pipeline)
|
||||||
|
1. Загрузка `config.yaml` через `config.Repository`.
|
||||||
|
2. Инициализация логирования и TUI.
|
||||||
|
3. Выбор реализации компрессора PDF.
|
||||||
|
4. Создание UseCases.
|
||||||
|
5. Пользователь подтверждает запуск (или auto_start).
|
||||||
|
6. Сканирование директорий — фильтрация поддерживаемых файлов.
|
||||||
|
7. Постановка задач в воркеры.
|
||||||
|
8. Сжатие + повторы при сбоях.
|
||||||
|
9. Обновление прогресса через callback → TUI.
|
||||||
|
10. Запись статистики, логов, замена/вывод файлов.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 8. Параллельность и производительность
|
||||||
|
- Модель: пул воркеров (число — `parallel_workers`).
|
||||||
|
- Ограничение таймаутом: `timeout_seconds`.
|
||||||
|
- Повторы: `retry_attempts` (экспоненциальную стратегию можно внедрить дополнительно).
|
||||||
|
- Потенциальные оптимизации: кэширование размеров, отложенное пересохранение, batch-операции.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 9. Логирование и мониторинг
|
||||||
|
| Тип | Назначение |
|
||||||
|
|-----|------------|
|
||||||
|
| Console/TUI | Интерактивное наблюдение за процессом |
|
||||||
|
| File logger | История и аудит |
|
||||||
|
| Уровни | debug, info, warning, error, success |
|
||||||
|
|
||||||
|
Ротация — по максимальному размеру (MB).
|
||||||
|
|
||||||
|
---
|
||||||
|
## 10. Расширяемость
|
||||||
|
|
||||||
|
### Добавление нового PDF компрессора
|
||||||
|
1. Создайте файл в `internal/infrastructure/compressors/` (например, `myengine_compressor.go`).
|
||||||
|
2. Реализуйте интерфейс `PDFCompressor`.
|
||||||
|
3. Добавьте значение в конфигурацию (`compression.algorithm`).
|
||||||
|
4. Расширьте `main.go` switch.
|
||||||
|
|
||||||
|
### Добавление формата изображений
|
||||||
|
1. Дополните `IsImageFile` и `GetImageFormat`.
|
||||||
|
2. Реализуйте метод в `ImageCompressor`.
|
||||||
|
3. Добавьте поля в `AppCompressionConfig`, валидацию, TUI форму.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 11. Сценарии и рекомендации
|
||||||
|
| Сценарий | Рекомендации |
|
||||||
|
|----------|--------------|
|
||||||
|
| Архивирование большого массива офисных PDF | PDFCPU + level 40–60 |
|
||||||
|
| Максимальное уменьшение для рассылки | UniPDF + level 70–85 |
|
||||||
|
| Много цветных сканов | UniPDF + включить сжатие JPEG 25–35% |
|
||||||
|
| Оптимизация сайта (изображения + PDF) | PDFCPU + JPEG/PNG 30% |
|
||||||
|
| Быстрая предпрод. обработка | PDFCPU + low retries |
|
||||||
|
|
||||||
|
---
|
||||||
|
## 12. Устранение неполадок
|
||||||
|
| Проблема | Причина | Решение |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| UniPDF ошибка лицензии | Нет ключа | Установите `UNIDOC_LICENSE_API_KEY` или в config |
|
||||||
|
| PNG вырос в размере | Уже оптимален | Повышайте качество или отключите PNG |
|
||||||
|
| Нет файлов найдено | Неверный путь | Проверьте `scanner.source_directory` |
|
||||||
|
| Высокая нагрузка CPU | Слишком много воркеров | Уменьшите `parallel_workers` |
|
||||||
|
| Медленно при больших изображениях | Масштабирование | Увеличьте качество или отключите уменьшение |
|
||||||
|
|
||||||
|
---
|
||||||
|
## 13. Тестирование
|
||||||
|
|
||||||
|
### Комплексное тестирование всех алгоритмов
|
||||||
|
|
||||||
|
Проект включает единый комплексный тест, который проверяет все основные алгоритмы и компоненты:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Запуск через скрипт (рекомендуется)
|
||||||
|
cd tests
|
||||||
|
.\run-tests.ps1
|
||||||
|
|
||||||
|
# Или вручную
|
||||||
|
cd tests
|
||||||
|
go build -o comprehensive_test.exe comprehensive.go
|
||||||
|
.\comprehensive_test.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Покрытие тестами
|
||||||
|
|
||||||
|
Комплексный тест проверяет:
|
||||||
|
|
||||||
|
✅ **Валидация конфигурации** (5 тестов)
|
||||||
|
- Корректность настроек JPEG/PNG качества
|
||||||
|
- Граничные значения (10-50%)
|
||||||
|
- Обнаружение невалидных параметров
|
||||||
|
|
||||||
|
✅ **Сжатие JPEG** (4 теста)
|
||||||
|
- Различные уровни качества (10%, 30%, 50%)
|
||||||
|
- Защита от увеличения размера файла
|
||||||
|
|
||||||
|
✅ **Сжатие PNG** (4 теста)
|
||||||
|
- Различные уровни качества
|
||||||
|
- Защита от увеличения размера файла
|
||||||
|
|
||||||
|
✅ **Сжатие PDF** (1 тест)
|
||||||
|
- Алгоритм PDFCPU
|
||||||
|
|
||||||
|
✅ **Структура папок** (1 тест)
|
||||||
|
- Сохранение иерархии при обработке
|
||||||
|
|
||||||
|
✅ **Вычисления** (3 теста)
|
||||||
|
- Коэффициент сжатия
|
||||||
|
- Экономия места
|
||||||
|
|
||||||
|
**Результаты:** Автоматический отчёт с детальной статистикой, время выполнения каждого теста, коды возврата для CI/CD.
|
||||||
|
|
||||||
|
Подробности: [tests/README.md](tests/README.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
## 14. План развития (Roadmap)
|
||||||
|
- [ ] Поддержка WebP / AVIF
|
||||||
|
- [ ] Асинхронное журналирование с буферизацией
|
||||||
|
- [ ] REST API слой / gRPC
|
||||||
|
- [ ] Пакетный режим без TUI (`--headless`)
|
||||||
|
- [ ] Экспорт отчётов (JSON / HTML)
|
||||||
|
- [ ] Более точные эвристики выбора стратегии сжатия
|
||||||
|
- [ ] Плагинная система компрессоров
|
||||||
|
- [x] Комплексное тестирование всех алгоритмов
|
||||||
|
|
||||||
|
---
|
||||||
|
## 15. Лицензия
|
||||||
|
(Добавьте информацию о лицензии проекта при необходимости.)
|
||||||
|
|
||||||
|
---
|
||||||
|
## Приложение A. Пример расширенного запуска
|
||||||
|
```bash
|
||||||
|
PDF_CONFIG=./config.yaml ./pdf-compressor
|
||||||
|
```
|
||||||
|
|
||||||
|
## Приложение B. Метрики качества (пример)
|
||||||
|
| Показатель | PDFCPU (сред.) | UniPDF (сред.) |
|
||||||
|
|------------|----------------|----------------|
|
||||||
|
| Снижение размера | 30–45% | 50–70% |
|
||||||
|
| Время (100 файлов) | 1× | 2–3× |
|
||||||
|
| Ошибки повторной попытки | Низко | Низко |
|
||||||
109
cmd/main.go
Normal file
109
cmd/main.go
Normal 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
74
cmd/processor.go
Normal 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
23
config.yaml
Normal 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
29
config.yaml.example
Normal 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
143
docker-compose.yml
Normal 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
39
go.mod
Normal 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
107
go.sum
Normal 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=
|
||||||
269
internal/domain/entities/app_config.go
Normal file
269
internal/domain/entities/app_config.go
Normal 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()
|
||||||
|
}
|
||||||
88
internal/domain/entities/config.go
Normal file
88
internal/domain/entities/config.go
Normal 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
|
||||||
|
}
|
||||||
127
internal/domain/entities/config_test.go
Normal file
127
internal/domain/entities/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
16
internal/domain/entities/errors.go
Normal file
16
internal/domain/entities/errors.go
Normal 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 файлы не найдены")
|
||||||
|
)
|
||||||
37
internal/domain/entities/pdf.go
Normal file
37
internal/domain/entities/pdf.go
Normal 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
|
||||||
|
}
|
||||||
112
internal/domain/entities/pdf_test.go
Normal file
112
internal/domain/entities/pdf_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
9
internal/domain/repositories/app_config_repository.go
Normal file
9
internal/domain/repositories/app_config_repository.go
Normal 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
|
||||||
|
}
|
||||||
24
internal/domain/repositories/interfaces.go
Normal file
24
internal/domain/repositories/interfaces.go
Normal 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
|
||||||
|
}
|
||||||
11
internal/domain/repositories/logger.go
Normal file
11
internal/domain/repositories/logger.go
Normal 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
|
||||||
|
}
|
||||||
259
internal/infrastructure/compressors/image_compressor.go
Normal file
259
internal/infrastructure/compressors/image_compressor.go
Normal 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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
69
internal/infrastructure/compressors/pdfcpu_compressor.go
Normal file
69
internal/infrastructure/compressors/pdfcpu_compressor.go
Normal 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
|
||||||
|
}
|
||||||
145
internal/infrastructure/compressors/unipdf_compressor.go
Normal file
145
internal/infrastructure/compressors/unipdf_compressor.go
Normal 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
|
||||||
|
}
|
||||||
74
internal/infrastructure/config/repository.go
Normal file
74
internal/infrastructure/config/repository.go
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
110
internal/infrastructure/logging/file_logger.go
Normal file
110
internal/infrastructure/logging/file_logger.go
Normal 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
|
||||||
|
}
|
||||||
24
internal/infrastructure/repositories/config_repository.go
Normal file
24
internal/infrastructure/repositories/config_repository.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
158
internal/interface/controllers/cli_controller.go
Normal file
158
internal/interface/controllers/cli_controller.go
Normal 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)
|
||||||
|
}
|
||||||
83
internal/presentation/tui/logger_adapter.go
Normal file
83
internal/presentation/tui/logger_adapter.go
Normal 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
|
||||||
|
}
|
||||||
786
internal/presentation/tui/manager.go
Normal file
786
internal/presentation/tui/manager.go
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
109
internal/usecase/compress_directory.go
Normal file
109
internal/usecase/compress_directory.go
Normal 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
|
||||||
|
}
|
||||||
175
internal/usecase/compress_images.go
Normal file
175
internal/usecase/compress_images.go
Normal 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
|
||||||
|
}
|
||||||
73
internal/usecase/compress_pdf.go
Normal file
73
internal/usecase/compress_pdf.go
Normal 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
|
||||||
|
}
|
||||||
137
internal/usecase/process_all_files.go
Normal file
137
internal/usecase/process_all_files.go
Normal 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
|
||||||
|
}
|
||||||
401
internal/usecase/process_pdfs.go
Normal file
401
internal/usecase/process_pdfs.go
Normal 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
73
scripts/release-body.md
Normal 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
473
scripts/release-gitea.ps1
Normal 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
369
scripts/release-gitea.sh
Normal 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
90
scripts/release.sh
Normal 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/"
|
||||||
Reference in New Issue
Block a user