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

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

View File

@@ -0,0 +1,269 @@
package entities
import "time"
// Config представляет конфигурацию приложения
type Config struct {
Scanner ScannerConfig `yaml:"scanner"`
Compression AppCompressionConfig `yaml:"compression"`
Processing ProcessingConfig `yaml:"processing"`
Output OutputConfig `yaml:"output"`
}
// ScannerConfig настройки сканирования директорий
type ScannerConfig struct {
SourceDirectory string `yaml:"source_directory"`
TargetDirectory string `yaml:"target_directory"`
ReplaceOriginal bool `yaml:"replace_original"`
}
// AppCompressionConfig настройки сжатия приложения
type AppCompressionConfig struct {
Level int `yaml:"level"`
Algorithm string `yaml:"algorithm"`
AutoStart bool `yaml:"auto_start"`
UniPDFLicenseKey string `yaml:"unipdf_license_key"`
// Настройки сжатия изображений
EnableJPEG bool `yaml:"enable_jpeg"`
EnablePNG bool `yaml:"enable_png"`
JPEGQuality int `yaml:"jpeg_quality"` // Качество JPEG в процентах (10-50)
PNGQuality int `yaml:"png_quality"` // Качество PNG в процентах (10-50)
}
// ProcessingConfig настройки обработки
type ProcessingConfig struct {
ParallelWorkers int `yaml:"parallel_workers"`
TimeoutSeconds int `yaml:"timeout_seconds"`
RetryAttempts int `yaml:"retry_attempts"`
}
// OutputConfig настройки вывода
type OutputConfig struct {
LogLevel string `yaml:"log_level"`
ProgressBar bool `yaml:"progress_bar"`
LogToFile bool `yaml:"log_to_file"`
LogFileName string `yaml:"log_file_name"`
LogMaxSizeMB int `yaml:"log_max_size_mb"`
}
// ProcessingStatus статус обработки
type ProcessingStatus struct {
// Текущая фаза обработки
Phase ProcessingPhase
// Информация о текущем файле
CurrentFile string
CurrentFileSize int64
// Общая статистика
TotalFiles int
ProcessedFiles int
SuccessfulFiles int
FailedFiles int
SkippedFiles int
// Прогресс
Progress float64
// Статистика сжатия
TotalOriginalSize int64
TotalCompressedSize int64
TotalSavedSpace int64
AverageCompression float64
// Текущий результат
LastResult *CompressionResult
// Время выполнения
StartTime time.Time
ElapsedTime time.Duration
EstimatedTime time.Duration
// Состояние
IsComplete bool
Error error
// Сообщение для UI
Message string
}
// ProcessingPhase фаза обработки
type ProcessingPhase int
const (
PhaseInitializing ProcessingPhase = iota
PhaseScanning
PhaseCompressing
PhaseReplacing
PhaseCompleted
PhaseFailed
)
// UIScreen типы экранов UI
type UIScreen int
const (
UIScreenMenu UIScreen = iota
UIScreenConfig
UIScreenProcessing
// UIScreenResults
)
// Validate проверяет корректность конфигурации приложения
func (c *AppCompressionConfig) Validate() error {
// Проверка уровня сжатия
if c.Level < 10 || c.Level > 90 {
return ErrInvalidCompressionLevel
}
// Проверка качества JPEG
if c.EnableJPEG {
if c.JPEGQuality < 10 || c.JPEGQuality > 50 || c.JPEGQuality%5 != 0 {
return ErrInvalidJPEGQuality
}
}
// Проверка качества PNG
if c.EnablePNG {
if c.PNGQuality < 10 || c.PNGQuality > 50 || c.PNGQuality%5 != 0 {
return ErrInvalidPNGQuality
}
}
return nil
}
// GetSupportedImageFormats возвращает список поддерживаемых форматов изображений
func (c *AppCompressionConfig) GetSupportedImageFormats() []string {
var formats []string
if c.EnableJPEG {
formats = append(formats, "JPEG")
}
if c.EnablePNG {
formats = append(formats, "PNG")
}
return formats
}
// NewProcessingStatus создает новый статус обработки
func NewProcessingStatus(totalFiles int) *ProcessingStatus {
return &ProcessingStatus{
Phase: PhaseInitializing,
TotalFiles: totalFiles,
StartTime: time.Now(),
}
}
// UpdateProgress обновляет прогресс обработки
func (ps *ProcessingStatus) UpdateProgress() {
if ps.TotalFiles > 0 {
ps.Progress = float64(ps.ProcessedFiles) / float64(ps.TotalFiles) * 100
}
ps.ElapsedTime = time.Since(ps.StartTime)
// Оценка оставшегося времени
if ps.ProcessedFiles > 0 && ps.ProcessedFiles < ps.TotalFiles {
avgTimePerFile := ps.ElapsedTime / time.Duration(ps.ProcessedFiles)
remainingFiles := ps.TotalFiles - ps.ProcessedFiles
ps.EstimatedTime = avgTimePerFile * time.Duration(remainingFiles)
}
}
// AddResult добавляет результат обработки файла
func (ps *ProcessingStatus) AddResult(result *CompressionResult) {
ps.ProcessedFiles++
ps.LastResult = result
if result.Success && result.Error == nil {
ps.SuccessfulFiles++
ps.TotalOriginalSize += result.OriginalSize
ps.TotalCompressedSize += result.CompressedSize
ps.TotalSavedSpace += result.SavedSpace
// Пересчитываем среднее сжатие
if ps.TotalOriginalSize > 0 {
ps.AverageCompression = ((float64(ps.TotalOriginalSize) - float64(ps.TotalCompressedSize)) / float64(ps.TotalOriginalSize)) * 100
}
} else {
ps.FailedFiles++
}
ps.UpdateProgress()
}
// SetPhase устанавливает фазу обработки
func (ps *ProcessingStatus) SetPhase(phase ProcessingPhase, message string) {
ps.Phase = phase
ps.Message = message
}
// SetCurrentFile устанавлиет текущий обрабатываемый файл
func (ps *ProcessingStatus) SetCurrentFile(filePath string, size int64) {
ps.CurrentFile = filePath
ps.CurrentFileSize = size
}
// Complete завершает обработку
func (ps *ProcessingStatus) Complete() {
ps.IsComplete = true
ps.Phase = PhaseCompleted
ps.Progress = 100
ps.ElapsedTime = time.Since(ps.StartTime)
ps.EstimatedTime = 0
}
// Fail отмечает обработку как неудачную
func (ps *ProcessingStatus) Fail(err error) {
ps.IsComplete = true
ps.Phase = PhaseFailed
ps.Error = err
ps.ElapsedTime = time.Since(ps.StartTime)
}
// GetPhaseName возвращает название фазы
func (phase ProcessingPhase) String() string {
switch phase {
case PhaseInitializing:
return "Инициализация"
case PhaseScanning:
return "Сканирование файлов"
case PhaseCompressing:
return "Сжатие файлов"
case PhaseReplacing:
return "Замена оригиналов"
case PhaseCompleted:
return "Завершено"
case PhaseFailed:
return "Ошибка"
default:
return "Неизвестно"
}
}
// FormatElapsedTime форматирует время выполнения
func (ps *ProcessingStatus) FormatElapsedTime() string {
duration := ps.ElapsedTime
if duration < time.Second {
return "< 1 сек"
}
if duration < time.Minute {
return duration.Round(time.Second).String()
}
return duration.Round(time.Second).String()
}
// FormatEstimatedTime форматирует оставшееся время
func (ps *ProcessingStatus) FormatEstimatedTime() string {
if ps.EstimatedTime == 0 {
return "N/A"
}
duration := ps.EstimatedTime
if duration < time.Second {
return "< 1 сек"
}
if duration < time.Minute {
return duration.Round(time.Second).String()
}
return duration.Round(time.Second).String()
}

View File

@@ -0,0 +1,88 @@
package entities
// CompressionConfig представляет конфигурацию сжатия
type CompressionConfig struct {
Level int // Уровень сжатия (10-90)
ImageQuality int // Качество изображений (10-100)
ImageCompression bool // Сжимать изображения
RemoveDuplicates bool // Удалять дубликаты объектов
CompressStreams bool // Сжимать потоки данных
RemoveMetadata bool // Удалять метаданные
RemoveAnnotations bool // Удалять аннотации
RemoveAttachments bool // Удалять вложения
OptimizeForWeb bool // Оптимизировать для веб
UniPDFLicenseKey string // Лицензионный ключ для UniPDF
}
// NewCompressionConfig создает конфигурацию сжатия на основе уровня
func NewCompressionConfig(level int) *CompressionConfig {
return NewCompressionConfigWithLicense(level, "")
}
// NewCompressionConfigWithLicense создает конфигурацию сжатия с лицензионным ключом
func NewCompressionConfigWithLicense(level int, licenseKey string) *CompressionConfig {
if level < 10 {
level = 10
}
if level > 90 {
level = 90
}
config := &CompressionConfig{
Level: level,
RemoveDuplicates: true,
CompressStreams: true,
OptimizeForWeb: true,
UniPDFLicenseKey: licenseKey,
}
switch {
case level <= 20: // Слабое сжатие
config.ImageQuality = 90
config.ImageCompression = true
config.RemoveMetadata = false
config.RemoveAnnotations = false
config.RemoveAttachments = false
case level <= 40: // Умеренное сжатие
config.ImageQuality = 75
config.ImageCompression = true
config.RemoveMetadata = true
config.RemoveAnnotations = false
config.RemoveAttachments = false
case level <= 60: // Среднее сжатие
config.ImageQuality = 60
config.ImageCompression = true
config.RemoveMetadata = true
config.RemoveAnnotations = true
config.RemoveAttachments = false
case level <= 80: // Высокое сжатие
config.ImageQuality = 40
config.ImageCompression = true
config.RemoveMetadata = true
config.RemoveAnnotations = true
config.RemoveAttachments = true
default: // Максимальное сжатие (81-90%)
config.ImageQuality = 25
config.ImageCompression = true
config.RemoveMetadata = true
config.RemoveAnnotations = true
config.RemoveAttachments = true
}
return config
}
// Validate проверяет корректность конфигурации
func (c *CompressionConfig) Validate() error {
if c.Level < 10 || c.Level > 90 {
return ErrInvalidCompressionLevel
}
if c.ImageQuality < 10 || c.ImageQuality > 100 {
return ErrInvalidImageQuality
}
return nil
}

View File

@@ -0,0 +1,127 @@
package entities_test
import (
"fmt"
"testing"
"compressor/internal/domain/entities"
)
func TestNewCompressionConfig(t *testing.T) {
tests := []struct {
name string
level int
expectedLevel int
}{
{"Normal level", 50, 50},
{"Too low level", 5, 10},
{"Too high level", 95, 90},
{"Minimum level", 10, 10},
{"Maximum level", 90, 90},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := entities.NewCompressionConfig(tt.level)
if config.Level != tt.expectedLevel {
t.Errorf("Expected level %d, got %d", tt.expectedLevel, config.Level)
}
})
}
}
func TestCompressionConfig_Validate(t *testing.T) {
tests := []struct {
name string
config *entities.CompressionConfig
wantErr bool
}{
{
name: "Valid config",
config: &entities.CompressionConfig{
Level: 50,
ImageQuality: 75,
},
wantErr: false,
},
{
name: "Invalid compression level - too low",
config: &entities.CompressionConfig{
Level: 5,
ImageQuality: 75,
},
wantErr: true,
},
{
name: "Invalid compression level - too high",
config: &entities.CompressionConfig{
Level: 95,
ImageQuality: 75,
},
wantErr: true,
},
{
name: "Invalid image quality - too low",
config: &entities.CompressionConfig{
Level: 50,
ImageQuality: 5,
},
wantErr: true,
},
{
name: "Invalid image quality - too high",
config: &entities.CompressionConfig{
Level: 50,
ImageQuality: 105,
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestCompressionConfigLevels(t *testing.T) {
tests := []struct {
level int
expectedImageQuality int
expectedMetadata bool
expectedAnnotations bool
expectedAttachments bool
}{
{15, 90, false, false, false}, // Слабое сжатие
{30, 75, true, false, false}, // Умеренное сжатие
{50, 60, true, true, false}, // Среднее сжатие
{70, 40, true, true, true}, // Высокое сжатие
{85, 25, true, true, true}, // Максимальное сжатие
}
for _, tt := range tests {
t.Run(fmt.Sprintf("Level %d", tt.level), func(t *testing.T) {
config := entities.NewCompressionConfig(tt.level)
if config.ImageQuality != tt.expectedImageQuality {
t.Errorf("Expected ImageQuality %d, got %d", tt.expectedImageQuality, config.ImageQuality)
}
if config.RemoveMetadata != tt.expectedMetadata {
t.Errorf("Expected RemoveMetadata %v, got %v", tt.expectedMetadata, config.RemoveMetadata)
}
if config.RemoveAnnotations != tt.expectedAnnotations {
t.Errorf("Expected RemoveAnnotations %v, got %v", tt.expectedAnnotations, config.RemoveAnnotations)
}
if config.RemoveAttachments != tt.expectedAttachments {
t.Errorf("Expected RemoveAttachments %v, got %v", tt.expectedAttachments, config.RemoveAttachments)
}
})
}
}

View File

@@ -0,0 +1,16 @@
package entities
import "errors"
// Доменные ошибки
var (
ErrInvalidCompressionLevel = errors.New("уровень сжатия должен быть от 10 до 90")
ErrInvalidImageQuality = errors.New("качество изображения должно быть от 10 до 100")
ErrInvalidJPEGQuality = errors.New("качество JPEG должно быть от 10 до 50 с шагом 5")
ErrInvalidPNGQuality = errors.New("качество PNG должно быть от 10 до 50 с шагом 5")
ErrFileNotFound = errors.New("файл не найден")
ErrInvalidFileFormat = errors.New("неверный формат файла")
ErrCompressionFailed = errors.New("ошибка сжатия файла")
ErrDirectoryNotFound = errors.New("директория не найдена")
ErrNoFilesFound = errors.New("PDF файлы не найдены")
)

View File

@@ -0,0 +1,37 @@
package entities
import (
"time"
)
// PDFDocument представляет PDF документ
type PDFDocument struct {
Path string
Size int64
ModifiedTime time.Time
Pages int
}
// CompressionResult представляет результат сжатия
type CompressionResult struct {
CurrentFile string
OriginalSize int64
CompressedSize int64
CompressionRatio float64
SavedSpace int64
Success bool
Error error
}
// CalculateCompressionRatio вычисляет коэффициент сжатия
func (cr *CompressionResult) CalculateCompressionRatio() {
if cr.OriginalSize > 0 {
cr.CompressionRatio = ((float64(cr.OriginalSize) - float64(cr.CompressedSize)) / float64(cr.OriginalSize)) * 100
cr.SavedSpace = cr.OriginalSize - cr.CompressedSize
}
}
// IsEffective проверяет, было ли сжатие эффективным
func (cr *CompressionResult) IsEffective() bool {
return cr.Success && cr.CompressionRatio > 0
}

View File

@@ -0,0 +1,112 @@
package entities_test
import (
"testing"
"compressor/internal/domain/entities"
)
func TestCompressionResult_CalculateCompressionRatio(t *testing.T) {
tests := []struct {
name string
originalSize int64
compressedSize int64
expectedRatio float64
expectedSavedSpace int64
}{
{
name: "50% compression",
originalSize: 1000,
compressedSize: 500,
expectedRatio: 50.0,
expectedSavedSpace: 500,
},
{
name: "25% compression",
originalSize: 1000,
compressedSize: 750,
expectedRatio: 25.0,
expectedSavedSpace: 250,
},
{
name: "No compression",
originalSize: 1000,
compressedSize: 1000,
expectedRatio: 0.0,
expectedSavedSpace: 0,
},
{
name: "File got bigger",
originalSize: 1000,
compressedSize: 1100,
expectedRatio: -10.0,
expectedSavedSpace: -100,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &entities.CompressionResult{
OriginalSize: tt.originalSize,
CompressedSize: tt.compressedSize,
}
result.CalculateCompressionRatio()
if result.CompressionRatio != tt.expectedRatio {
t.Errorf("Expected compression ratio %f, got %f", tt.expectedRatio, result.CompressionRatio)
}
if result.SavedSpace != tt.expectedSavedSpace {
t.Errorf("Expected saved space %d, got %d", tt.expectedSavedSpace, result.SavedSpace)
}
})
}
}
func TestCompressionResult_IsEffective(t *testing.T) {
tests := []struct {
name string
result *entities.CompressionResult
expectedEffective bool
}{
{
name: "Effective compression",
result: &entities.CompressionResult{
OriginalSize: 1000,
CompressedSize: 500,
CompressionRatio: 50.0,
Success: true,
},
expectedEffective: true,
},
{
name: "No compression but successful",
result: &entities.CompressionResult{
OriginalSize: 1000,
CompressedSize: 1000,
CompressionRatio: 0.0,
Success: true,
},
expectedEffective: false,
},
{
name: "Good compression but failed",
result: &entities.CompressionResult{
OriginalSize: 1000,
CompressedSize: 500,
CompressionRatio: 50.0,
Success: false,
},
expectedEffective: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.result.IsEffective(); got != tt.expectedEffective {
t.Errorf("IsEffective() = %v, want %v", got, tt.expectedEffective)
}
})
}
}

View File

@@ -0,0 +1,9 @@
package repositories
import "compressor/internal/domain/entities"
// ConfigRepository интерфейс для работы с конфигурацией приложения
type AppConfigRepository interface {
Load(configPath string) (*entities.Config, error)
Save(configPath string, config *entities.Config) error
}

View File

@@ -0,0 +1,24 @@
package repositories
import (
"compressor/internal/domain/entities"
)
// PDFCompressor интерфейс для сжатия PDF файлов
type PDFCompressor interface {
Compress(inputPath, outputPath string, config *entities.CompressionConfig) (*entities.CompressionResult, error)
}
// FileRepository интерфейс для работы с файловой системой
type FileRepository interface {
GetFileInfo(path string) (*entities.PDFDocument, error)
FileExists(path string) bool
CreateDirectory(path string) error
ListPDFFiles(directory string) ([]string, error)
}
// ConfigRepository интерфейс для работы с конфигурацией
type ConfigRepository interface {
GetCompressionConfig(level int) (*entities.CompressionConfig, error)
ValidateConfig(config *entities.CompressionConfig) error
}

View File

@@ -0,0 +1,11 @@
package repositories
// Logger интерфейс для логирования
type Logger interface {
Debug(format string, args ...interface{})
Info(format string, args ...interface{})
Warning(format string, args ...interface{})
Error(format string, args ...interface{})
Success(format string, args ...interface{})
Close() error
}

View File

@@ -0,0 +1,259 @@
package compressors
import (
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
"os"
"path/filepath"
"strings"
"github.com/nfnt/resize"
)
// ImageCompressor интерфейс для сжатия изображений
type ImageCompressor interface {
CompressJPEG(inputPath, outputPath string, quality int) error
CompressPNG(inputPath, outputPath string, quality int) error
}
// DefaultImageCompressor реализация компрессора изображений
type DefaultImageCompressor struct{}
// NewImageCompressor создает новый компрессор изображений
func NewImageCompressor() ImageCompressor {
return &DefaultImageCompressor{}
}
// CompressJPEG сжимает JPEG файл с указанным качеством
func (c *DefaultImageCompressor) CompressJPEG(inputPath, outputPath string, quality int) error {
// Открываем исходный файл
inputFile, err := os.Open(inputPath)
if err != nil {
return fmt.Errorf("не удалось открыть файл %s: %w", inputPath, err)
}
defer inputFile.Close()
// Декодируем изображение
img, err := jpeg.Decode(inputFile)
if err != nil {
return fmt.Errorf("не удалось декодировать JPEG файл %s: %w", inputPath, err)
}
// Получаем размер исходного файла для сравнения
inputFileInfo, err := inputFile.Stat()
if err != nil {
return fmt.Errorf("не удалось получить информацию о файле %s: %w", inputPath, err)
}
originalSize := inputFileInfo.Size()
// Вычисляем новый размер на основе качества
bounds := img.Bounds()
width := bounds.Dx()
height := bounds.Dy()
// Более агрессивное уменьшение размера для достижения реального сжатия
// quality 10 -> 0.5 (50%), quality 50 -> 0.9 (90%)
scaleFactor := 0.5 + float64(quality-10)/40.0*0.4
if scaleFactor > 1.0 {
scaleFactor = 1.0
}
newWidth := uint(float64(width) * scaleFactor)
newHeight := uint(float64(height) * scaleFactor)
// Изменяем размер изображения только если есть реальная польза
var finalImg image.Image
if newWidth < uint(width) && newHeight < uint(height) {
finalImg = resize.Resize(newWidth, newHeight, img, resize.Lanczos3)
} else {
finalImg = img
}
// Маппинг качества: 10->30, 30->55, 50->75 (более консервативно)
jpegQuality := 20 + int(float64(quality-10)/40.0*55.0)
if jpegQuality < 20 {
jpegQuality = 20
}
if jpegQuality > 75 {
jpegQuality = 75
}
// Создаем временный файл для проверки результата
tmpPath := outputPath + ".tmp"
tmpFile, err := os.Create(tmpPath)
if err != nil {
return fmt.Errorf("не удалось создать временный файл: %w", err)
}
// Кодируем во временный файл
options := &jpeg.Options{Quality: jpegQuality}
err = jpeg.Encode(tmpFile, finalImg, options)
tmpFile.Close()
if err != nil {
os.Remove(tmpPath)
return fmt.Errorf("не удалось закодировать JPEG: %w", err)
}
// Проверяем размер результата
tmpInfo, err := os.Stat(tmpPath)
if err != nil {
os.Remove(tmpPath)
return fmt.Errorf("не удалось получить информацию о временном файле: %w", err)
}
// Если сжатие неэффективно (файл больше или почти такой же), копируем оригинал
if tmpInfo.Size() >= originalSize*95/100 {
os.Remove(tmpPath)
// Копируем оригинал
inputFile.Seek(0, 0)
outputFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("не удалось создать выходной файл: %w", err)
}
defer outputFile.Close()
_, err = io.Copy(outputFile, inputFile)
if err != nil {
return fmt.Errorf("не удалось скопировать файл: %w", err)
}
return nil
}
// Переименовываем временный файл в выходной
err = os.Rename(tmpPath, outputPath)
if err != nil {
os.Remove(tmpPath)
return fmt.Errorf("не удалось переименовать временный файл: %w", err)
}
return nil
}
// CompressPNG сжимает PNG файл с указанным качеством
func (c *DefaultImageCompressor) CompressPNG(inputPath, outputPath string, quality int) error {
// Открываем исходный файл
inputFile, err := os.Open(inputPath)
if err != nil {
return fmt.Errorf("не удалось открыть файл %s: %w", inputPath, err)
}
defer inputFile.Close()
// Получаем размер исходного файла для сравнения
inputFileInfo, err := inputFile.Stat()
if err != nil {
return fmt.Errorf("не удалось получить информацию о файле %s: %w", inputPath, err)
}
originalSize := inputFileInfo.Size()
// Декодируем изображение
img, err := png.Decode(inputFile)
if err != nil {
return fmt.Errorf("не удалось декодировать PNG файл %s: %w", inputPath, err)
}
// Вычисляем новый размер на основе качества
bounds := img.Bounds()
width := bounds.Dx()
height := bounds.Dy()
// Более консервативное масштабирование для PNG
// quality 10 -> 0.6 (60%), quality 50 -> 0.9 (90%)
scaleFactor := 0.6 + float64(quality-10)/40.0*0.3
if scaleFactor > 1.0 {
scaleFactor = 1.0
}
newWidth := uint(float64(width) * scaleFactor)
newHeight := uint(float64(height) * scaleFactor)
// Не изменяем размер для маленьких изображений
if width < 400 && height < 400 {
newWidth = uint(width)
newHeight = uint(height)
}
// Изменяем размер изображения только если это даст выигрыш
var finalImg image.Image
if newWidth < uint(width) && newHeight < uint(height) {
finalImg = resize.Resize(newWidth, newHeight, img, resize.Lanczos3)
} else {
finalImg = img
}
// Создаем временный файл для проверки результата
tmpPath := outputPath + ".tmp"
tmpFile, err := os.Create(tmpPath)
if err != nil {
return fmt.Errorf("не удалось создать временный файл: %w", err)
}
// Для PNG используем максимальное сжатие
encoder := &png.Encoder{
CompressionLevel: png.BestCompression,
}
err = encoder.Encode(tmpFile, finalImg)
tmpFile.Close()
if err != nil {
os.Remove(tmpPath)
return fmt.Errorf("не удалось закодировать PNG: %w", err)
}
// Проверяем размер результата
tmpInfo, err := os.Stat(tmpPath)
if err != nil {
os.Remove(tmpPath)
return fmt.Errorf("не удалось получить информацию о временном файле: %w", err)
}
// Если сжатие неэффективно (файл больше или почти такой же), копируем оригинал
if tmpInfo.Size() >= originalSize*95/100 {
os.Remove(tmpPath)
// Копируем оригинал
inputFile.Seek(0, 0)
outputFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("не удалось создать выходной файл: %w", err)
}
defer outputFile.Close()
_, err = io.Copy(outputFile, inputFile)
if err != nil {
return fmt.Errorf("не удалось скопировать файл: %w", err)
}
return nil
}
// Переименовываем временный файл в выходной
err = os.Rename(tmpPath, outputPath)
if err != nil {
os.Remove(tmpPath)
return fmt.Errorf("не удалось переименовать временный файл: %w", err)
}
return nil
}
// IsImageFile проверяет, является ли файл изображением поддерживаемого формата
func IsImageFile(filename string) bool {
ext := strings.ToLower(filepath.Ext(filename))
return ext == ".jpg" || ext == ".jpeg" || ext == ".png"
}
// GetImageFormat возвращает формат изображения по расширению файла
func GetImageFormat(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".jpg", ".jpeg":
return "jpeg"
case ".png":
return "png"
default:
return ""
}
}

View File

@@ -0,0 +1,69 @@
package compressors
import (
"fmt"
"os"
"github.com/pdfcpu/pdfcpu/pkg/api"
"compressor/internal/domain/entities"
)
// PDFCPUCompressor реализация компрессора с использованием PDFCPU
type PDFCPUCompressor struct{}
// NewPDFCPUCompressor создает новый PDFCPU компрессор
func NewPDFCPUCompressor() *PDFCPUCompressor {
return &PDFCPUCompressor{}
}
// Compress сжимает PDF файл используя PDFCPU библиотеку
func (p *PDFCPUCompressor) Compress(inputPath, outputPath string, config *entities.CompressionConfig) (*entities.CompressionResult, error) {
fmt.Printf("🔄 Сжатие PDF с уровнем %d%% (PDFCPU)...\n", config.Level)
// Получаем исходный размер файла
originalInfo, err := os.Stat(inputPath)
if err != nil {
return nil, fmt.Errorf("ошибка получения информации об исходном файле: %w", err)
}
// Применяем настройки в зависимости от уровня сжатия
if config.ImageCompression {
fmt.Printf("📸 Включено сжатие изображений (качество: %d%%)\n", config.ImageQuality)
}
if config.RemoveDuplicates {
fmt.Println("🔄 Удаление дубликатов объектов")
}
// Выполняем оптимизацию с базовыми настройками
err = api.OptimizeFile(inputPath, outputPath, nil)
if err != nil {
return &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
Success: false,
Error: err,
}, fmt.Errorf("ошибка оптимизации PDFCPU: %w", err)
}
// Получаем размер сжатого файла
compressedInfo, err := os.Stat(outputPath)
if err != nil {
return &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
Success: false,
Error: err,
}, fmt.Errorf("ошибка получения информации о сжатом файле: %w", err)
}
result := &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
CompressedSize: compressedInfo.Size(),
Success: true,
}
result.CalculateCompressionRatio()
fmt.Printf("✅ Сжатие завершено: %s\n", outputPath)
return result, nil
}

View File

@@ -0,0 +1,145 @@
package compressors
import (
"fmt"
"os"
"github.com/unidoc/unipdf/v3/common"
"github.com/unidoc/unipdf/v3/model"
"github.com/unidoc/unipdf/v3/model/optimize"
"compressor/internal/domain/entities"
)
// UniPDFCompressor реализация компрессора с использованием UniPDF
type UniPDFCompressor struct{}
// NewUniPDFCompressor создает новый UniPDF компрессор
func NewUniPDFCompressor() *UniPDFCompressor {
return &UniPDFCompressor{}
}
// Compress сжимает PDF файл используя UniPDF библиотеку
func (u *UniPDFCompressor) Compress(inputPath, outputPath string, config *entities.CompressionConfig) (*entities.CompressionResult, error) {
fmt.Printf("🔄 Сжатие PDF с уровнем %d%% (UniPDF)...\n", config.Level)
// Инициализируем логгер
common.SetLogger(common.NewConsoleLogger(common.LogLevelInfo))
// Проверяем лицензионный ключ из конфигурации или переменной окружения
licenseKey := config.UniPDFLicenseKey
if licenseKey == "" {
licenseKey = os.Getenv("UNIDOC_LICENSE_API_KEY")
}
if licenseKey == "" {
return &entities.CompressionResult{
OriginalSize: 0,
Success: false,
Error: fmt.Errorf("UniPDF требует лицензионный ключ. Установите его в конфигурации или в переменной UNIDOC_LICENSE_API_KEY. Используйте алгоритм 'pdfcpu' для бесплатной обработки или получите ключ на https://cloud.unidoc.io"),
}, fmt.Errorf("UniPDF лицензия не настроена. Установите лицензионный ключ в конфигурации или используйте алгоритм 'pdfcpu'")
}
// Устанавливаем лицензионный ключ
fmt.Printf("🔑 Устанавливаем лицензионный ключ UniPDF...\n")
os.Setenv("UNIDOC_LICENSE_API_KEY", licenseKey) // Получаем исходный размер файла
originalInfo, err := os.Stat(inputPath)
if err != nil {
return nil, fmt.Errorf("ошибка получения информации об исходном файле: %w", err)
}
// Открываем исходный PDF файл
pdfReader, file, err := model.NewPdfReaderFromFile(inputPath, nil)
if err != nil {
return &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
Success: false,
Error: err,
}, fmt.Errorf("ошибка открытия файла: %w", err)
}
defer file.Close()
// Создаем writer с оптимизацией
pdfWriter := model.NewPdfWriter()
// Настраиваем оптимизацию в зависимости от уровня сжатия
optimizer := optimize.New(optimize.Options{
CombineDuplicateDirectObjects: true,
CombineIdenticalIndirectObjects: true,
ImageUpperPPI: float64(150 - config.Level), // чем выше уровень, тем ниже PPI
ImageQuality: 100 - config.Level, // чем выше уровень, тем ниже качество
})
pdfWriter.SetOptimizer(optimizer)
// Копируем страницы
numPages, err := pdfReader.GetNumPages()
if err != nil {
return &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
Success: false,
Error: err,
}, fmt.Errorf("ошибка получения количества страниц: %w", err)
}
for i := 1; i <= numPages; i++ {
page, err := pdfReader.GetPage(i)
if err != nil {
return &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
Success: false,
Error: err,
}, fmt.Errorf("ошибка получения страницы %d: %w", i, err)
}
err = pdfWriter.AddPage(page)
if err != nil {
return &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
Success: false,
Error: err,
}, fmt.Errorf("ошибка добавления страницы %d: %w", i, err)
}
}
// Сохраняем оптимизированный файл
outputFile, err := os.Create(outputPath)
if err != nil {
return &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
Success: false,
Error: err,
}, fmt.Errorf("ошибка создания выходного файла: %w", err)
}
defer outputFile.Close()
err = pdfWriter.Write(outputFile)
if err != nil {
return &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
Success: false,
Error: err,
}, fmt.Errorf("ошибка записи файла: %w", err)
}
// Получаем размер сжатого файла
compressedInfo, err := os.Stat(outputPath)
if err != nil {
return &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
Success: false,
Error: err,
}, fmt.Errorf("ошибка получения информации о сжатом файле: %w", err)
}
result := &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
CompressedSize: compressedInfo.Size(),
Success: true,
}
result.CalculateCompressionRatio()
fmt.Printf("✅ Сжатие завершено: %s\n", outputPath)
return result, nil
}

View File

@@ -0,0 +1,74 @@
package config
import (
"compressor/internal/domain/entities"
"os"
"gopkg.in/yaml.v3"
)
// Repository реализация репозитория конфигурации
type Repository struct{}
// NewRepository создает новый репозиторий конфигурации
func NewRepository() *Repository {
return &Repository{}
}
// Load загружает конфигурацию из файла
func (r *Repository) Load(configPath string) (*entities.Config, error) {
// Если файл не существует, создаем конфигурацию по умолчанию
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return r.createDefaultConfig(), nil
}
data, err := os.ReadFile(configPath)
if err != nil {
return nil, err
}
var config entities.Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, err
}
return &config, nil
}
// Save сохраняет конфигурацию в файл
func (r *Repository) Save(configPath string, config *entities.Config) error {
data, err := yaml.Marshal(config)
if err != nil {
return err
}
return os.WriteFile(configPath, data, 0644)
}
// createDefaultConfig создает конфигурацию по умолчанию
func (r *Repository) createDefaultConfig() *entities.Config {
return &entities.Config{
Scanner: entities.ScannerConfig{
SourceDirectory: "./pdfs",
TargetDirectory: "./compressed",
ReplaceOriginal: false,
},
Compression: entities.AppCompressionConfig{
Level: 50,
Algorithm: "pdfcpu",
AutoStart: false,
},
Processing: entities.ProcessingConfig{
ParallelWorkers: 2,
TimeoutSeconds: 30,
RetryAttempts: 3,
},
Output: entities.OutputConfig{
LogLevel: "info",
ProgressBar: true,
LogToFile: true,
LogFileName: "compressor.log",
LogMaxSizeMB: 10,
},
}
}

View File

@@ -0,0 +1,110 @@
package logging
import (
"fmt"
"log"
"os"
"strings"
)
// FileLogger реализация логгера в файл
type FileLogger struct {
file *os.File
logger *log.Logger
logLevel string
}
// NewFileLogger создает новый файловый логгер
func NewFileLogger(filename, logLevel string, maxSizeMB int, logToFile bool) (*FileLogger, error) {
if !logToFile {
return nil, nil
}
file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return nil, err
}
logger := log.New(file, "", log.LstdFlags)
return &FileLogger{
file: file,
logger: logger,
logLevel: strings.ToLower(logLevel),
}, nil
}
// Debug логирует отладочное сообщение
func (l *FileLogger) Debug(format string, args ...interface{}) {
if l.shouldLog("debug") {
l.writeLog("DEBUG", format, args...)
}
}
// Info логирует информационное сообщение
func (l *FileLogger) Info(format string, args ...interface{}) {
if l.shouldLog("info") {
l.writeLog("INFO", format, args...)
}
}
// Warning логирует предупреждение
func (l *FileLogger) Warning(format string, args ...interface{}) {
if l.shouldLog("warning") {
l.writeLog("WARNING", format, args...)
}
}
// Error логирует ошибку
func (l *FileLogger) Error(format string, args ...interface{}) {
if l.shouldLog("error") {
l.writeLog("ERROR", format, args...)
}
}
// Success логирует успешное выполнение
func (l *FileLogger) Success(format string, args ...interface{}) {
if l.shouldLog("info") {
l.writeLog("SUCCESS", format, args...)
}
}
// Close закрывает логгер
func (l *FileLogger) Close() error {
if l.file != nil {
return l.file.Close()
}
return nil
}
// writeLog записывает лог
func (l *FileLogger) writeLog(level, format string, args ...interface{}) {
if l.logger == nil {
return
}
message := fmt.Sprintf(format, args...)
l.logger.Printf("[%s] %s", level, message)
}
// shouldLog проверяет, нужно ли логировать на данном уровне
func (l *FileLogger) shouldLog(level string) bool {
levels := map[string]int{
"debug": 0,
"info": 1,
"warning": 2,
"error": 3,
}
currentLevel, ok := levels[l.logLevel]
if !ok {
currentLevel = 1 // default to info
}
messageLevel, ok := levels[level]
if !ok {
return false
}
return messageLevel >= currentLevel
}

View File

@@ -0,0 +1,24 @@
package repositories
import (
"compressor/internal/domain/entities"
)
// ConfigRepository реализация репозитория конфигурации
type ConfigRepository struct{}
// NewConfigRepository создает новый репозиторий конфигурации
func NewConfigRepository() *ConfigRepository {
return &ConfigRepository{}
}
// GetCompressionConfig получает конфигурацию сжатия по уровню
func (r *ConfigRepository) GetCompressionConfig(level int) (*entities.CompressionConfig, error) {
config := entities.NewCompressionConfig(level)
return config, nil
}
// ValidateConfig валидирует конфигурацию
func (r *ConfigRepository) ValidateConfig(config *entities.CompressionConfig) error {
return config.Validate()
}

View File

@@ -0,0 +1,69 @@
package repositories
import (
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"compressor/internal/domain/entities"
)
// FileSystemRepository реализация репозитория для работы с файловой системой
type FileSystemRepository struct{}
// NewFileSystemRepository создает новый репозиторий файловой системы
func NewFileSystemRepository() *FileSystemRepository {
return &FileSystemRepository{}
}
// GetFileInfo получает информацию о PDF файле
func (r *FileSystemRepository) GetFileInfo(path string) (*entities.PDFDocument, error) {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
return &entities.PDFDocument{
Path: path,
Size: info.Size(),
ModifiedTime: info.ModTime(),
Pages: 0, // TODO: Можно добавить определение количества страниц
}, nil
}
// FileExists проверяет существование файла
func (r *FileSystemRepository) FileExists(path string) bool {
_, err := os.Stat(path)
return !os.IsNotExist(err)
}
// CreateDirectory создает директорию
func (r *FileSystemRepository) CreateDirectory(path string) error {
return os.MkdirAll(path, 0755)
}
// ListPDFFiles возвращает список PDF файлов в директории и всех подпапках
func (r *FileSystemRepository) ListPDFFiles(directory string) ([]string, error) {
var pdfFiles []string
err := filepath.WalkDir(directory, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
return nil
}
if strings.EqualFold(filepath.Ext(d.Name()), ".pdf") {
pdfFiles = append(pdfFiles, path)
}
return nil
})
if err != nil {
return nil, err
}
sort.Strings(pdfFiles)
return pdfFiles, nil
}

View File

@@ -0,0 +1,158 @@
package controllers
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
"compressor/internal/domain/entities"
usecases "compressor/internal/usecase"
)
// CLIController контроллер для командной строки
//
// ⚠️ DEPRECATED / LEGACY CODE ⚠️
//
// Данный контроллер НЕ используется в текущей версии приложения.
// Приложение использует только TUI интерфейс (internal/presentation/tui/manager.go).
// Сохранен для возможной будущей поддержки CLI режима или миграции на cobra/viper.
//
// Рекомендация: при необходимости CLI использовать флаги в main.go вместо этого контроллера.
type CLIController struct {
compressPDFUseCase *usecases.CompressPDFUseCase
compressDirectoryUseCase *usecases.CompressDirectoryUseCase
}
// NewCLIController создает новый CLI контроллер
func NewCLIController(
compressPDFUseCase *usecases.CompressPDFUseCase,
compressDirectoryUseCase *usecases.CompressDirectoryUseCase,
) *CLIController {
return &CLIController{
compressPDFUseCase: compressPDFUseCase,
compressDirectoryUseCase: compressDirectoryUseCase,
}
}
// HandleSingleFile обрабатывает сжатие одного файла
func (c *CLIController) HandleSingleFile(inputPath, outputPath string) error {
fmt.Println("🔥 PDF Compressor - Сжатие PDF файлов")
fmt.Println("====================================")
// Запрашиваем уровень сжатия
compressionLevel := c.askForCompressionLevel()
fmt.Printf("\n🚀 Начинаем сжатие файла: %s\n", inputPath)
// Выполняем сжатие
result, err := c.compressPDFUseCase.Execute(inputPath, outputPath, compressionLevel)
if err != nil {
return fmt.Errorf("ошибка сжатия: %w", err)
}
// Показываем результаты
c.showCompressionResult(result, outputPath)
return nil
}
// HandleDirectory обрабатывает сжатие директории
func (c *CLIController) HandleDirectory(inputDir, outputDir string) error {
fmt.Println("🔥 PDF Compressor - Сжатие директории PDF файлов")
fmt.Println("================================================")
// Запрашиваем уровень сжатия
compressionLevel := c.askForCompressionLevel()
fmt.Printf("\n🚀 Начинаем сжатие директории: %s\n", inputDir)
// Выполняем сжатие
result, err := c.compressDirectoryUseCase.Execute(inputDir, outputDir, compressionLevel)
if err != nil {
return fmt.Errorf("ошибка сжатия директории: %w", err)
}
// Показываем результаты
c.showDirectoryResult(result)
return nil
}
// askForCompressionLevel запрашивает уровень сжатия у пользователя
func (c *CLIController) askForCompressionLevel() int {
reader := bufio.NewReader(os.Stdin)
fmt.Println("\n🎯 Выберите уровень сжатия:")
fmt.Println("10-20%: Слабое сжатие (высокое качество)")
fmt.Println("21-40%: Умеренное сжатие (хорошее качество)")
fmt.Println("41-60%: Среднее сжатие (среднее качество)")
fmt.Println("61-80%: Высокое сжатие (низкое качество)")
fmt.Println("81-90%: Максимальное сжатие (очень низкое качество)")
for {
fmt.Print("\nВведите процент сжатия (10-90): ")
input, err := reader.ReadString('\n')
if err != nil {
fmt.Println("❌ Ошибка ввода")
continue
}
input = strings.TrimSpace(input)
level, err := strconv.Atoi(input)
if err != nil {
fmt.Println("❌ Введите число")
continue
}
if level < 10 || level > 90 {
fmt.Println("❌ Уровень сжатия должен быть от 10 до 90")
continue
}
return level
}
}
// showCompressionResult показывает результат сжатия файла
func (c *CLIController) showCompressionResult(result *entities.CompressionResult, outputPath string) {
fmt.Println("\n📊 Результаты сжатия:")
fmt.Printf("Исходный размер: %.2f MB\n", float64(result.OriginalSize)/1024/1024)
fmt.Printf("Сжатый размер: %.2f MB\n", float64(result.CompressedSize)/1024/1024)
fmt.Printf("Сжатие: %.1f%%\n", result.CompressionRatio)
fmt.Printf("Сэкономлено: %.2f MB\n", float64(result.SavedSpace)/1024/1024)
if result.IsEffective() {
fmt.Println("✅ Сжатие выполнено успешно!")
} else {
fmt.Println("⚠️ Файл не был сжат (возможно, уже оптимизирован)")
}
fmt.Printf("\n🎉 Готово! Сжатый файл сохранен как: %s\n", outputPath)
}
// showDirectoryResult показывает результат сжатия директории
func (c *CLIController) showDirectoryResult(result *usecases.DirectoryCompressionResult) {
fmt.Printf("\n📊 Результаты сжатия директории:\n")
fmt.Printf("Всего файлов: %d\n", result.TotalFiles)
fmt.Printf("Успешно сжато: %d\n", result.SuccessCount)
fmt.Printf("Ошибок: %d\n", result.FailedCount)
// Показываем статистику по каждому файлу
for i, fileResult := range result.Results {
fmt.Printf("\n[%d] Сжатие: %.1f%%, Сэкономлено: %.2f MB\n",
i+1, fileResult.CompressionRatio, float64(fileResult.SavedSpace)/1024/1024)
}
// Показываем ошибки, если есть
if len(result.Errors) > 0 {
fmt.Println("\n❌ Ошибки:")
for i, err := range result.Errors {
fmt.Printf("[%d] %v\n", i+1, err)
}
}
fmt.Printf("\n🎉 Обработка завершена! Успешно сжато: %d/%d файлов\n",
result.SuccessCount, result.TotalFiles)
}

View File

@@ -0,0 +1,83 @@
package tui
import (
"compressor/internal/domain/repositories"
"fmt"
)
// UILogger адаптер логгера для отображения в UI
type UILogger struct {
fileLogger repositories.Logger
tuiManager *Manager
}
// NewUILogger создает новый UI логгер
func NewUILogger(fileLogger repositories.Logger, tuiManager *Manager) *UILogger {
return &UILogger{
fileLogger: fileLogger,
tuiManager: tuiManager,
}
}
// Debug логирует отладочное сообщение
func (l *UILogger) Debug(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
if l.fileLogger != nil {
l.fileLogger.Debug(format, args...)
}
if l.tuiManager != nil {
l.tuiManager.AddLog("DEBUG", message)
}
}
// Info логирует информационное сообщение
func (l *UILogger) Info(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
if l.fileLogger != nil {
l.fileLogger.Info(format, args...)
}
if l.tuiManager != nil {
l.tuiManager.AddLog("INFO", message)
}
}
// Warning логирует предупреждение
func (l *UILogger) Warning(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
if l.fileLogger != nil {
l.fileLogger.Warning(format, args...)
}
if l.tuiManager != nil {
l.tuiManager.AddLog("WARNING", message)
}
}
// Error логирует ошибку
func (l *UILogger) Error(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
if l.fileLogger != nil {
l.fileLogger.Error(format, args...)
}
if l.tuiManager != nil {
l.tuiManager.AddLog("ERROR", message)
}
}
// Success логирует успешное выполнение
func (l *UILogger) Success(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
if l.fileLogger != nil {
l.fileLogger.Success(format, args...)
}
if l.tuiManager != nil {
l.tuiManager.AddLog("SUCCESS", message)
}
}
// Close закрывает логгер
func (l *UILogger) Close() error {
if l.fileLogger != nil {
return l.fileLogger.Close()
}
return nil
}

View File

@@ -0,0 +1,786 @@
package tui
import (
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"compressor/internal/domain/entities"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"gopkg.in/yaml.v3"
)
// ConfigData структура для отображения конфигурации в UI
type ConfigData struct {
Scanner struct {
SourceDirectory string `yaml:"source_directory"`
TargetDirectory string `yaml:"target_directory"`
ReplaceOriginal bool `yaml:"replace_original"`
} `yaml:"scanner"`
Compression struct {
Level int `yaml:"level"`
Algorithm string `yaml:"algorithm"`
AutoStart bool `yaml:"auto_start"`
UniPDFLicenseKey string `yaml:"unipdf_license_key"`
EnableJPEG bool `yaml:"enable_jpeg"`
EnablePNG bool `yaml:"enable_png"`
JPEGQuality int `yaml:"jpeg_quality"`
PNGQuality int `yaml:"png_quality"`
} `yaml:"compression"`
Processing struct {
ParallelWorkers int `yaml:"parallel_workers"`
TimeoutSeconds int `yaml:"timeout_seconds"`
RetryAttempts int `yaml:"retry_attempts"`
} `yaml:"processing"`
Output struct {
LogLevel string `yaml:"log_level"`
ProgressBar bool `yaml:"progress_bar"`
LogToFile bool `yaml:"log_to_file"`
LogFileName string `yaml:"log_file_name"`
LogMaxSizeMB int `yaml:"log_max_size_mb"`
} `yaml:"output"`
}
// UI Configuration constants
const (
MaxLogBufferSize = 1000
LogFlushInterval = 50 * time.Millisecond
ProgressBarWidth = 40
MaxFileNameLength = 60
MaxFileNameDisplay = 57
ProgressViewHeight = 9
FormItemLicenseIndex = 5
)
// Manager управляет TUI интерфейсом
type Manager struct {
app *tview.Application
pages *tview.Pages
currentScreen entities.UIScreen
// UI компоненты
mainMenu *tview.List
configForm *tview.Form
progressView *tview.TextView
logView *tview.TextView
statusBar *tview.TextView
// Callbacks
onStartProcessing func()
// Состояние
configData ConfigData
logBuffer []string
statusMutex sync.RWMutex
isProcessing bool
// Оптимизированный батчинг логов через канал
logChan chan string
logDone chan struct{}
logMutex sync.Mutex
}
// NewManager создает новый менеджер TUI
func NewManager() *Manager {
m := &Manager{
app: tview.NewApplication(),
pages: tview.NewPages(),
logBuffer: make([]string, 0, MaxLogBufferSize),
logChan: make(chan string, 100), // Buffered channel для батчинга
logDone: make(chan struct{}),
}
// Запускаем горутину обработки логов
go m.logProcessor()
return m
}
// Initialize инициализирует TUI
func (m *Manager) Initialize() {
m.loadConfig()
m.createUI()
m.setupKeyBindings()
}
// Run запускает TUI
func (m *Manager) Run() error {
return m.app.SetRoot(m.pages, true).EnableMouse(true).Run()
}
// SetOnStartProcessing устанавливает callback для начала обработки
func (m *Manager) SetOnStartProcessing(callback func()) {
m.onStartProcessing = callback
}
// SendStatusUpdate отправляет обновление статуса
func (m *Manager) SendStatusUpdate(status entities.ProcessingStatus) {
m.updateProgress(status)
}
// loadConfig загружает конфигурацию
func (m *Manager) loadConfig() {
configPath := "config.yaml"
if _, err := os.Stat(configPath); os.IsNotExist(err) {
// Создаем конфигурацию по умолчанию
m.configData = ConfigData{
Scanner: struct {
SourceDirectory string `yaml:"source_directory"`
TargetDirectory string `yaml:"target_directory"`
ReplaceOriginal bool `yaml:"replace_original"`
}{
SourceDirectory: "./pdfs",
TargetDirectory: "./compressed",
ReplaceOriginal: false,
},
Compression: struct {
Level int `yaml:"level"`
Algorithm string `yaml:"algorithm"`
AutoStart bool `yaml:"auto_start"`
UniPDFLicenseKey string `yaml:"unipdf_license_key"`
EnableJPEG bool `yaml:"enable_jpeg"`
EnablePNG bool `yaml:"enable_png"`
JPEGQuality int `yaml:"jpeg_quality"`
PNGQuality int `yaml:"png_quality"`
}{
Level: 50,
Algorithm: "pdfcpu",
AutoStart: false,
UniPDFLicenseKey: "",
EnableJPEG: false,
EnablePNG: false,
JPEGQuality: 30,
PNGQuality: 25,
},
Processing: struct {
ParallelWorkers int `yaml:"parallel_workers"`
TimeoutSeconds int `yaml:"timeout_seconds"`
RetryAttempts int `yaml:"retry_attempts"`
}{
ParallelWorkers: 2,
TimeoutSeconds: 30,
RetryAttempts: 3,
},
Output: struct {
LogLevel string `yaml:"log_level"`
ProgressBar bool `yaml:"progress_bar"`
LogToFile bool `yaml:"log_to_file"`
LogFileName string `yaml:"log_file_name"`
LogMaxSizeMB int `yaml:"log_max_size_mb"`
}{
LogLevel: "info",
ProgressBar: true,
LogToFile: true,
LogFileName: "compressor.log",
LogMaxSizeMB: 10,
},
}
m.saveConfig()
return
}
data, err := os.ReadFile(configPath)
if err != nil {
return
}
yaml.Unmarshal(data, &m.configData)
}
// saveConfig сохраняет конфигурацию
func (m *Manager) saveConfig() {
data, err := yaml.Marshal(&m.configData)
if err != nil {
return
}
os.WriteFile("config.yaml", data, 0644)
}
// createUI создает пользовательский интерфейс
func (m *Manager) createUI() {
m.createMainMenu()
m.createConfigScreen()
m.createProcessingScreen()
// m.createResultsScreen()
m.pages.AddPage("menu", m.mainMenu, true, true)
m.pages.AddPage("config", m.configForm, true, false)
m.pages.AddPage("processing", m.createProcessingLayout(), true, false)
m.currentScreen = entities.UIScreenMenu
}
// createMainMenu создает главное меню
func (m *Manager) createMainMenu() {
m.mainMenu = tview.NewList().
AddItem("🚀 Запуск алгоритма сжатия", "Начать автоматическое сжатие PDF файлов", '1', func() {
m.startProcessing()
}).
AddItem("⚙️ Конфигурация", "Настроить параметры сжатия и обработки", '2', func() {
m.switchToScreen(entities.UIScreenConfig)
}).
AddItem("❌ Выход", "Закрыть приложение", 'q', func() {
m.Cleanup()
m.app.Stop()
})
m.mainMenu.SetBorder(true).
SetTitle("🔥 Universal File Compressor - Главное меню").
SetTitleAlign(tview.AlignCenter)
// Настраиваем стиль
m.mainMenu.SetSelectedBackgroundColor(tcell.ColorDarkBlue).
SetSelectedTextColor(tcell.ColorWhite).
SetMainTextColor(tcell.ColorWhite).
SetSecondaryTextColor(tcell.ColorGray)
}
// createConfigScreen создает экран конфигурации
func (m *Manager) createConfigScreen() {
m.configForm = tview.NewForm().
AddInputField("Исходная директория", m.configData.Scanner.SourceDirectory, 60, nil, func(text string) {
m.configData.Scanner.SourceDirectory = text
}).
AddInputField("Целевая директория", m.configData.Scanner.TargetDirectory, 60, nil, func(text string) {
m.configData.Scanner.TargetDirectory = text
}).
AddCheckbox("Заменить оригинал", m.configData.Scanner.ReplaceOriginal, func(checked bool) {
m.configData.Scanner.ReplaceOriginal = checked
}).
AddInputField("Уровень сжатия (10-90)", strconv.Itoa(m.configData.Compression.Level), 10, nil, func(text string) {
if level, err := strconv.Atoi(text); err == nil && level >= 10 && level <= 90 {
m.configData.Compression.Level = level
}
}).
AddDropDown("Алгоритм", []string{"pdfcpu", "unipdf"}, func() int {
if m.configData.Compression.Algorithm == "unipdf" {
return 1
}
return 0
}(), func(option string, optionIndex int) {
m.configData.Compression.Algorithm = option
m.updateLicenseFieldVisibility()
}).
AddInputField("Лицензия UniPDF (UNIDOC_LICENSE_API_KEY)", m.configData.Compression.UniPDFLicenseKey, 60, nil, func(text string) {
m.configData.Compression.UniPDFLicenseKey = text
}).
AddCheckbox("Автостарт", m.configData.Compression.AutoStart, func(checked bool) {
m.configData.Compression.AutoStart = checked
}).
AddCheckbox("Сжимать JPEG", m.configData.Compression.EnableJPEG, func(checked bool) {
m.configData.Compression.EnableJPEG = checked
}).
AddDropDown("Качество JPEG (%)", []string{"10", "15", "20", "25", "30", "35", "40", "45", "50"}, func() int {
return (m.configData.Compression.JPEGQuality - 10) / 5
}(), func(option string, optionIndex int) {
if quality, err := strconv.Atoi(option); err == nil {
m.configData.Compression.JPEGQuality = quality
}
}).
AddCheckbox("Сжимать PNG", m.configData.Compression.EnablePNG, func(checked bool) {
m.configData.Compression.EnablePNG = checked
}).
AddDropDown("Качество PNG (%)", []string{"10", "15", "20", "25", "30", "35", "40", "45", "50"}, func() int {
return (m.configData.Compression.PNGQuality - 10) / 5
}(), func(option string, optionIndex int) {
if quality, err := strconv.Atoi(option); err == nil {
m.configData.Compression.PNGQuality = quality
}
}).
AddButton("Сохранить", func() {
m.saveConfig()
m.switchToScreen(entities.UIScreenMenu)
// Позиционируемся на пункте "Конфигурация" (индекс 1)
m.mainMenu.SetCurrentItem(1)
})
m.updateLicenseFieldVisibility()
m.configForm.SetBorder(true).
SetTitle("🔥 Universal File Compressor - Конфигурация (ESC - выйти без сохранения)").
SetTitleAlign(tview.AlignCenter)
// Обработка ESC для выхода без сохранения
m.configForm.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
// Перезагружаем конфигурацию из файла (отменяем изменения)
m.loadConfig()
m.switchToScreen(entities.UIScreenMenu)
return nil
}
return event
})
}
// createProcessingScreen создает экран обработки
func (m *Manager) createProcessingScreen() {
m.progressView = tview.NewTextView().
SetDynamicColors(true).
SetRegions(true).
SetScrollable(true)
m.progressView.SetBorder(true).
SetTitle("📊 Прогресс обработки").
SetTitleAlign(tview.AlignCenter)
m.logView = tview.NewTextView().
SetDynamicColors(true).
SetScrollable(true).
SetMaxLines(MaxLogBufferSize)
m.logView.SetBorder(true).
SetTitle("📋 Журнал событий").
SetTitleAlign(tview.AlignCenter)
}
// createProcessingLayout создает layout для экрана обработки
func (m *Manager) createProcessingLayout() *tview.Flex {
return tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(m.logView, 0, 1, false).
AddItem(m.progressView, ProgressViewHeight, 0, false)
}
// setupKeyBindings настраивает горячие клавиши
func (m *Manager) setupKeyBindings() {
m.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyF1:
m.switchToScreen(entities.UIScreenMenu)
return nil
case tcell.KeyF2:
m.switchToScreen(entities.UIScreenConfig)
return nil
case tcell.KeyF3:
if m.isProcessing {
m.switchToScreen(entities.UIScreenProcessing)
}
return nil
case tcell.KeyEscape:
// ESC работает по-разному в зависимости от экрана
if m.currentScreen == entities.UIScreenConfig {
// В конфигурации ESC обрабатывается локально формой
return event
} else if m.currentScreen != entities.UIScreenMenu {
m.switchToScreen(entities.UIScreenMenu)
return nil
}
}
// Обработка числовых клавиш для меню
if m.currentScreen == entities.UIScreenMenu {
switch event.Rune() {
case '1':
m.startProcessing()
return nil
case '2':
m.switchToScreen(entities.UIScreenConfig)
return nil
case 'q', 'Q':
m.Cleanup()
m.app.Stop()
return nil
}
}
return event
})
}
// switchToScreen переключает на указанный экран
func (m *Manager) switchToScreen(screen entities.UIScreen) {
m.statusMutex.Lock()
defer m.statusMutex.Unlock()
m.currentScreen = screen
switch screen {
case entities.UIScreenMenu:
m.pages.SwitchToPage("menu")
case entities.UIScreenConfig:
// При входе в конфигурацию обновляем данные из файла и синхронизируем форму
m.loadConfig()
m.refreshConfigForm()
m.pages.SwitchToPage("config")
case entities.UIScreenProcessing:
m.pages.SwitchToPage("processing")
}
}
// startProcessing начинает обработку
func (m *Manager) startProcessing() {
m.saveConfig()
m.isProcessing = true
m.switchToScreen(entities.UIScreenProcessing)
if m.onStartProcessing != nil {
go m.onStartProcessing()
}
}
// updateProgress обновляет прогресс
func (m *Manager) updateProgress(status entities.ProcessingStatus) {
if m.progressView == nil {
return
}
// Обновляем прогресс-бар
progressBar := m.createProgressBar(status.Progress, ProgressBarWidth)
// Корректное усечение имени файла с учетом UTF-8
displayFile := m.truncateFileName(status.CurrentFile, MaxFileNameLength, MaxFileNameDisplay)
// Формируем текст статуса
var progressText string
// Фаза обработки
phaseText := status.Phase.String()
if status.Message != "" {
phaseText = status.Message
}
progressText = fmt.Sprintf(
"[yellow]⚙️ Фаза:[white] %s\n\n"+
"[yellow]📁 Текущий файл:[white] %s\n",
phaseText,
filepath.Base(displayFile),
)
// Размер текущего файла
if status.CurrentFileSize > 0 {
progressText += fmt.Sprintf("[dim] Размер: %.2f MB[white]\n", float64(status.CurrentFileSize)/1024/1024)
}
// Прогресс-бар
progressText += fmt.Sprintf(
"\n[cyan]📊 Прогресс:[white] %s [cyan]%.1f%%[white]\n\n",
progressBar,
status.Progress,
)
// Статистика файлов
progressText += fmt.Sprintf(
"[green]📈 Статистика файлов:[white]\n"+
" • Всего: [cyan]%d[white]\n"+
" • Обработано: [cyan]%d[white]\n"+
" • Успешно: [green]%d[white]",
status.TotalFiles,
status.ProcessedFiles,
status.SuccessfulFiles,
)
if status.FailedFiles > 0 {
progressText += fmt.Sprintf("\n • Ошибок: [red]%d[white]", status.FailedFiles)
}
if status.SkippedFiles > 0 {
progressText += fmt.Sprintf("\n • Пропущено: [yellow]%d[white]", status.SkippedFiles)
}
// Статистика сжатия
if status.TotalOriginalSize > 0 {
progressText += fmt.Sprintf(
"\n\n[green]💾 Статистика сжатия:[white]\n"+
" • Исходный размер: [cyan]%.2f MB[white]\n"+
" • Сжатый размер: [cyan]%.2f MB[white]\n"+
" • Среднее сжатие: [green]%.1f%%[white]\n"+
" • Сэкономлено: [green]%.2f MB[white]",
float64(status.TotalOriginalSize)/1024/1024,
float64(status.TotalCompressedSize)/1024/1024,
status.AverageCompression,
float64(status.TotalSavedSpace)/1024/1024,
)
}
// Время выполнения
progressText += fmt.Sprintf(
"\n\n[yellow]⏱️ Время:[white]\n"+
" • Прошло: [cyan]%s[white]",
status.FormatElapsedTime(),
)
if !status.IsComplete && status.EstimatedTime > 0 {
progressText += fmt.Sprintf("\n • Осталось: [cyan]~%s[white]", status.FormatEstimatedTime())
}
progressText += "\n\n"
if status.IsComplete {
if status.Error != nil {
progressText += "[red]❌ Обработка завершена с ошибкой![white]\n"
progressText += fmt.Sprintf("[red]Ошибка: %v[white]\n", status.Error)
} else {
progressText += "[green]✅ Обработка успешно завершена![white]\n"
}
progressText += "\n[yellow]F1[white] - Главное меню\n"
progressText += "[yellow]ESC[white] - Главное меню\n"
m.isProcessing = false
} else {
progressText += "[yellow]F1[white] - Главное меню\n"
progressText += "[yellow]ESC[white] - Главное меню\n"
}
if status.Error != nil {
progressText += fmt.Sprintf("\n[red]❌ Ошибка: %v[white]\n", status.Error)
}
// Обновляем UI потокобезопасно через QueueUpdateDraw
m.app.QueueUpdateDraw(func() {
m.progressView.SetText(progressText)
})
}
// truncateFileName корректно усекает имя файла с учетом UTF-8
func (m *Manager) truncateFileName(fileName string, maxLength, truncateAt int) string {
runes := []rune(fileName)
if len(runes) <= maxLength {
return fileName
}
return string(runes[:truncateAt]) + "..."
}
// createProgressBar создает красивый цветной прогресс-бар
func (m *Manager) createProgressBar(progress float64, width int) string {
// Нормализуем значения
if progress < 0 {
progress = 0
} else if progress > 100 {
progress = 100
}
filled := int(math.Round(progress * float64(width) / 100))
if filled > width {
filled = width
}
if filled < 0 {
filled = 0
}
// Разные символы для заполненной и пустой части
const filledChar = "█"
const emptyChar = "░"
// Цвет зависит от прогресса
var color string
switch {
case progress < 25:
color = "red"
case progress < 50:
color = "yellow"
case progress < 75:
color = "blue"
default:
color = "green"
}
filledPart := strings.Repeat(filledChar, filled)
emptyPart := strings.Repeat(emptyChar, width-filled)
return fmt.Sprintf("[%s]%s[gray]%s", color, filledPart, emptyPart)
}
// AddLog добавляет запись в лог через канал (неблокирующе)
func (m *Manager) AddLog(level, message string) {
var color string
switch strings.ToLower(level) {
case "error":
color = "red"
case "warning":
color = "yellow"
case "success":
color = "green"
case "debug":
color = "gray"
default:
color = "white"
}
logLine := fmt.Sprintf("[%s]%s:[white] %s", color, strings.ToUpper(level), message)
// Неблокирующая отправка в канал
select {
case m.logChan <- logLine:
default:
// Если канал переполнен, пропускаем лог (лучше чем блокировка)
}
}
// logProcessor обрабатывает логи в отдельной горутине с батчингом
func (m *Manager) logProcessor() {
ticker := time.NewTicker(LogFlushInterval)
defer ticker.Stop()
batch := make([]string, 0, 50)
for {
select {
case logLine := <-m.logChan:
batch = append(batch, logLine)
// Если накопился достаточный батч, сбрасываем
if len(batch) >= 20 {
m.flushLogBatch(batch)
batch = make([]string, 0, 50)
}
case <-ticker.C:
// Периодический сброс
if len(batch) > 0 {
m.flushLogBatch(batch)
batch = make([]string, 0, 50)
}
case <-m.logDone:
// Финальный сброс при завершении
if len(batch) > 0 {
m.flushLogBatch(batch)
}
return
}
}
}
// flushLogBatch сбрасывает батч логов в UI
func (m *Manager) flushLogBatch(batch []string) {
m.statusMutex.Lock()
m.logBuffer = append(m.logBuffer, batch...)
// Ограничиваем размер буфера
if len(m.logBuffer) > MaxLogBufferSize {
m.logBuffer = m.logBuffer[len(m.logBuffer)-MaxLogBufferSize:]
}
// Создаем копию буфера для UI
logText := strings.Join(m.logBuffer, "\n")
m.statusMutex.Unlock()
// Обновляем UI потокобезопасно
if m.logView != nil {
m.app.QueueUpdateDraw(func() {
if m.logView != nil { // Двойная проверка
m.logView.SetText(logText)
m.logView.ScrollToEnd()
}
})
}
}
// Cleanup освобождает ресурсы менеджера (идемпотентный)
func (m *Manager) Cleanup() {
m.logMutex.Lock()
defer m.logMutex.Unlock()
// Проверяем, что канал еще открыт
select {
case <-m.logDone:
// Канал уже закрыт
return
default:
// Закрываем канал
close(m.logDone)
}
} // updateLicenseFieldVisibility обновляет видимость поля лицензии в зависимости от выбранного алгоритма
func (m *Manager) updateLicenseFieldVisibility() {
if m.configForm == nil {
return
}
// Получаем количество элементов формы
formItemCount := m.configForm.GetFormItemCount()
if formItemCount > FormItemLicenseIndex {
// Получаем поле лицензии
licenseField := m.configForm.GetFormItem(FormItemLicenseIndex)
if m.configData.Compression.Algorithm == "unipdf" {
// Показываем поле лицензии для UniPDF
licenseField.(*tview.InputField).SetTitle("🔑 Лицензия UniPDF (UNIDOC_LICENSE_API_KEY) - ОБЯЗАТЕЛЬНО")
licenseField.(*tview.InputField).SetFieldBackgroundColor(tcell.ColorDarkBlue)
} else {
// Скрываем поле лицензии для PDFCPU
licenseField.(*tview.InputField).SetTitle("Лицензия UniPDF (не требуется для PDFCPU)")
licenseField.(*tview.InputField).SetFieldBackgroundColor(tcell.ColorDarkGray)
}
}
}
// refreshConfigForm синхронизирует значения формы с текущими данными конфигурации
func (m *Manager) refreshConfigForm() {
if m.configForm == nil {
return
}
// 0: Исходная директория (Input)
if item := m.configForm.GetFormItem(0); item != nil {
item.(*tview.InputField).SetText(m.configData.Scanner.SourceDirectory)
}
// 1: Целевая директория (Input)
if item := m.configForm.GetFormItem(1); item != nil {
item.(*tview.InputField).SetText(m.configData.Scanner.TargetDirectory)
}
// 2: Заменить оригинал (Checkbox)
if item := m.configForm.GetFormItem(2); item != nil {
item.(*tview.Checkbox).SetChecked(m.configData.Scanner.ReplaceOriginal)
}
// 3: Уровень сжатия (Input)
if item := m.configForm.GetFormItem(3); item != nil {
item.(*tview.InputField).SetText(strconv.Itoa(m.configData.Compression.Level))
}
// 4: Алгоритм (DropDown)
if item := m.configForm.GetFormItem(4); item != nil {
dd := item.(*tview.DropDown)
if m.configData.Compression.Algorithm == "unipdf" {
dd.SetCurrentOption(1)
} else {
dd.SetCurrentOption(0)
}
}
// 5: Лицензия UniPDF (Input)
if item := m.configForm.GetFormItem(5); item != nil {
item.(*tview.InputField).SetText(m.configData.Compression.UniPDFLicenseKey)
}
// 6: Автостарт (Checkbox)
if item := m.configForm.GetFormItem(6); item != nil {
item.(*tview.Checkbox).SetChecked(m.configData.Compression.AutoStart)
}
m.updateLicenseFieldVisibility()
}
// GetConfig возвращает текущую конфигурацию в формате entities.Config
func (m *Manager) GetConfig() *entities.Config {
return &entities.Config{
Scanner: entities.ScannerConfig{
SourceDirectory: m.configData.Scanner.SourceDirectory,
TargetDirectory: m.configData.Scanner.TargetDirectory,
ReplaceOriginal: m.configData.Scanner.ReplaceOriginal,
},
Compression: entities.AppCompressionConfig{
Level: m.configData.Compression.Level,
Algorithm: m.configData.Compression.Algorithm,
AutoStart: m.configData.Compression.AutoStart,
UniPDFLicenseKey: m.configData.Compression.UniPDFLicenseKey,
EnableJPEG: m.configData.Compression.EnableJPEG,
EnablePNG: m.configData.Compression.EnablePNG,
JPEGQuality: m.configData.Compression.JPEGQuality,
PNGQuality: m.configData.Compression.PNGQuality,
},
Processing: entities.ProcessingConfig{
ParallelWorkers: m.configData.Processing.ParallelWorkers,
TimeoutSeconds: m.configData.Processing.TimeoutSeconds,
RetryAttempts: m.configData.Processing.RetryAttempts,
},
Output: entities.OutputConfig{
LogLevel: m.configData.Output.LogLevel,
ProgressBar: m.configData.Output.ProgressBar,
LogToFile: m.configData.Output.LogToFile,
LogFileName: m.configData.Output.LogFileName,
LogMaxSizeMB: m.configData.Output.LogMaxSizeMB,
},
}
}

View File

@@ -0,0 +1,109 @@
package usecases
import (
"fmt"
"path/filepath"
"compressor/internal/domain/entities"
"compressor/internal/domain/repositories"
)
// CompressDirectoryUseCase сценарий сжатия всех PDF файлов в директории
type CompressDirectoryUseCase struct {
compressor repositories.PDFCompressor
fileRepo repositories.FileRepository
configRepo repositories.ConfigRepository
}
// NewCompressDirectoryUseCase создает новый сценарий сжатия директории
func NewCompressDirectoryUseCase(
compressor repositories.PDFCompressor,
fileRepo repositories.FileRepository,
configRepo repositories.ConfigRepository,
) *CompressDirectoryUseCase {
return &CompressDirectoryUseCase{
compressor: compressor,
fileRepo: fileRepo,
configRepo: configRepo,
}
}
// DirectoryCompressionResult результат сжатия директории
type DirectoryCompressionResult struct {
TotalFiles int
SuccessCount int
FailedCount int
Results []*entities.CompressionResult
Errors []error
}
// Execute выполняет сжатие всех PDF файлов в директории
func (uc *CompressDirectoryUseCase) Execute(inputDir, outputDir string, compressionLevel int) (*DirectoryCompressionResult, error) {
// Проверяем существование входной директории
if !uc.fileRepo.FileExists(inputDir) {
return nil, entities.ErrDirectoryNotFound
}
// Создаем выходную директорию
if err := uc.fileRepo.CreateDirectory(outputDir); err != nil {
return nil, fmt.Errorf("ошибка создания выходной директории: %w", err)
}
// Получаем список PDF файлов
files, err := uc.fileRepo.ListPDFFiles(inputDir)
if err != nil {
return nil, fmt.Errorf("ошибка получения списка файлов: %w", err)
}
if len(files) == 0 {
return nil, entities.ErrNoFilesFound
}
// Создаем конфигурацию сжатия
config, err := uc.configRepo.GetCompressionConfig(compressionLevel)
if err != nil {
return nil, fmt.Errorf("ошибка создания конфигурации: %w", err)
}
// Валидируем конфигурацию
if err := uc.configRepo.ValidateConfig(config); err != nil {
return nil, fmt.Errorf("ошибка валидации конфигурации: %w", err)
}
result := &DirectoryCompressionResult{
TotalFiles: len(files),
Results: make([]*entities.CompressionResult, 0, len(files)),
Errors: make([]error, 0),
}
// Обрабатываем каждый файл
for _, inputFile := range files {
fileName := filepath.Base(inputFile)
outputFile := filepath.Join(outputDir, fmt.Sprintf("compressed_%s", fileName))
// Получаем информацию о файле
fileInfo, err := uc.fileRepo.GetFileInfo(inputFile)
if err != nil {
result.Errors = append(result.Errors, fmt.Errorf("ошибка получения информации о файле %s: %w", fileName, err))
result.FailedCount++
continue
}
// Выполняем сжатие
compressionResult, err := uc.compressor.Compress(inputFile, outputFile, config)
if err != nil {
result.Errors = append(result.Errors, fmt.Errorf("ошибка сжатия файла %s: %w", fileName, err))
result.FailedCount++
continue
}
// Устанавливаем исходный размер и вычисляем коэффициент сжатия
compressionResult.OriginalSize = fileInfo.Size
compressionResult.CalculateCompressionRatio()
result.Results = append(result.Results, compressionResult)
result.SuccessCount++
}
return result, nil
}

View File

@@ -0,0 +1,175 @@
package usecases
import (
"fmt"
"os"
"path/filepath"
"compressor/internal/domain/entities"
"compressor/internal/domain/repositories"
"compressor/internal/infrastructure/compressors"
)
// CompressImageUseCase обрабатывает сжатие изображений
type CompressImageUseCase struct {
logger repositories.Logger
compressor compressors.ImageCompressor
}
// NewCompressImageUseCase создает новый UseCase для сжатия изображений
func NewCompressImageUseCase(logger repositories.Logger, compressor compressors.ImageCompressor) *CompressImageUseCase {
return &CompressImageUseCase{
logger: logger,
compressor: compressor,
}
}
// CompressImage сжимает одно изображение
func (uc *CompressImageUseCase) CompressImage(inputPath, outputPath string, config *entities.AppCompressionConfig) error {
format := compressors.GetImageFormat(inputPath)
if format == "" {
return fmt.Errorf("неподдерживаемый формат изображения: %s", inputPath)
}
// Проверяем, включено ли сжатие для данного формата
switch format {
case "jpeg":
if !config.EnableJPEG {
uc.logger.Info(fmt.Sprintf("Пропуск JPEG файла (сжатие отключено): %s", inputPath))
return nil
}
return uc.compressor.CompressJPEG(inputPath, outputPath, config.JPEGQuality)
case "png":
if !config.EnablePNG {
uc.logger.Info(fmt.Sprintf("Пропуск PNG файла (сжатие отключено): %s", inputPath))
return nil
}
return uc.compressor.CompressPNG(inputPath, outputPath, config.PNGQuality)
default:
return fmt.Errorf("неподдерживаемый формат изображения: %s", format)
}
}
// ProcessImagesInDirectory обрабатывает все изображения в директории
func (uc *CompressImageUseCase) ProcessImagesInDirectory(sourceDir, targetDir string, config *entities.AppCompressionConfig, replaceOriginal bool) (*ProcessingResult, error) {
result := &ProcessingResult{
ProcessedFiles: make([]string, 0),
FailedFiles: make([]ProcessingError, 0),
SuccessfulFiles: 0,
TotalFiles: 0,
}
// Если включены изображения, проверяем настройки
if !config.EnableJPEG && !config.EnablePNG {
uc.logger.Info("Сжатие изображений отключено в конфигурации")
return result, nil
}
// Рекурсивно обходим директорию
err := filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
uc.logger.Error(fmt.Sprintf("Ошибка доступа к файлу %s: %v", path, err))
return nil // Продолжаем обработку других файлов
}
// Пропускаем директории
if info.IsDir() {
return nil
}
// Проверяем, является ли файл изображением
if !compressors.IsImageFile(path) {
return nil // Не изображение, пропускаем
}
result.TotalFiles++
// Определяем путь выходного файла
var outputPath string
if replaceOriginal {
outputPath = path
} else {
relPath, err := filepath.Rel(sourceDir, path)
if err != nil {
uc.logger.Error(fmt.Sprintf("Не удалось получить относительный путь для %s: %v", path, err))
result.FailedFiles = append(result.FailedFiles, ProcessingError{
FilePath: path,
Error: err,
})
return nil
}
outputPath = filepath.Join(targetDir, relPath)
// Создаем директорию для выходного файла
outputDir := filepath.Dir(outputPath)
if err := os.MkdirAll(outputDir, 0755); err != nil {
uc.logger.Error(fmt.Sprintf("Не удалось создать директорию %s: %v", outputDir, err))
result.FailedFiles = append(result.FailedFiles, ProcessingError{
FilePath: path,
Error: err,
})
return nil
}
}
// Сжимаем изображение
uc.logger.Info(fmt.Sprintf("Сжатие изображения: %s", path))
err = uc.CompressImage(path, outputPath, config)
if err != nil {
uc.logger.Error(fmt.Sprintf("Ошибка сжатия изображения %s: %v", path, err))
result.FailedFiles = append(result.FailedFiles, ProcessingError{
FilePath: path,
Error: err,
})
} else {
result.ProcessedFiles = append(result.ProcessedFiles, path)
result.SuccessfulFiles++
uc.logger.Info(fmt.Sprintf("Изображение успешно сжато: %s", path))
}
return nil
})
if err != nil {
return result, fmt.Errorf("ошибка обхода директории %s: %w", sourceDir, err)
}
return result, nil
}
// ProcessingResult результат обработки изображений
type ProcessingResult struct {
ProcessedFiles []string
FailedFiles []ProcessingError
SuccessfulFiles int
TotalFiles int
}
// ProcessingError ошибка обработки файла
type ProcessingError struct {
FilePath string
Error error
}
// GetSupportedImageExtensions возвращает список поддерживаемых расширений изображений
func GetSupportedImageExtensions() []string {
return []string{".jpg", ".jpeg", ".png"}
}
// CountImageFiles подсчитывает количество изображений в директории
func CountImageFiles(dir string) (int, error) {
count := 0
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Игнорируем ошибки доступа к файлам
}
if !info.IsDir() && compressors.IsImageFile(path) {
count++
}
return nil
})
return count, err
}

View File

@@ -0,0 +1,73 @@
package usecases
import (
"fmt"
"path/filepath"
"compressor/internal/domain/entities"
"compressor/internal/domain/repositories"
)
// CompressPDFUseCase сценарий сжатия одного PDF файла
type CompressPDFUseCase struct {
compressor repositories.PDFCompressor
fileRepo repositories.FileRepository
configRepo repositories.ConfigRepository
}
// NewCompressPDFUseCase создает новый сценарий сжатия PDF
func NewCompressPDFUseCase(
compressor repositories.PDFCompressor,
fileRepo repositories.FileRepository,
configRepo repositories.ConfigRepository,
) *CompressPDFUseCase {
return &CompressPDFUseCase{
compressor: compressor,
fileRepo: fileRepo,
configRepo: configRepo,
}
}
// Execute выполняет сжатие PDF файла
func (uc *CompressPDFUseCase) Execute(inputPath string, outputPath string, compressionLevel int) (*entities.CompressionResult, error) {
// Проверяем существование входного файла
if !uc.fileRepo.FileExists(inputPath) {
return nil, entities.ErrFileNotFound
}
// Получаем информацию о файле
fileInfo, err := uc.fileRepo.GetFileInfo(inputPath)
if err != nil {
return nil, fmt.Errorf("ошибка получения информации о файле: %w", err)
}
// Создаем конфигурацию сжатия
config, err := uc.configRepo.GetCompressionConfig(compressionLevel)
if err != nil {
return nil, fmt.Errorf("ошибка создания конфигурации: %w", err)
}
// Валидируем конфигурацию
if err := uc.configRepo.ValidateConfig(config); err != nil {
return nil, fmt.Errorf("ошибка валидации конфигурации: %w", err)
}
// Генерируем имя выходного файла, если не указано
if outputPath == "" {
ext := filepath.Ext(inputPath)
base := inputPath[:len(inputPath)-len(ext)]
outputPath = base + "_compressed" + ext
}
// Выполняем сжатие
result, err := uc.compressor.Compress(inputPath, outputPath, config)
if err != nil {
return nil, fmt.Errorf("ошибка сжатия файла: %w", err)
}
// Устанавливаем исходный размер
result.OriginalSize = fileInfo.Size
result.CalculateCompressionRatio()
return result, nil
}

View File

@@ -0,0 +1,137 @@
package usecases
import (
"fmt"
"path/filepath"
"strings"
"compressor/internal/domain/entities"
"compressor/internal/domain/repositories"
"compressor/internal/infrastructure/compressors"
)
// ProcessAllFilesUseCase сценарий для обработки всех поддерживаемых типов файлов
type ProcessAllFilesUseCase struct {
pdfProcessor *ProcessPDFsUseCase
imageProcessor *CompressImageUseCase
logger repositories.Logger
}
// NewProcessAllFilesUseCase создает новый сценарий обработки всех файлов
func NewProcessAllFilesUseCase(
pdfProcessor *ProcessPDFsUseCase,
imageProcessor *CompressImageUseCase,
logger repositories.Logger,
) *ProcessAllFilesUseCase {
return &ProcessAllFilesUseCase{
pdfProcessor: pdfProcessor,
imageProcessor: imageProcessor,
logger: logger,
}
}
// Execute выполняет обработку всех поддерживаемых файлов
func (uc *ProcessAllFilesUseCase) Execute(config *entities.Config) error {
uc.logger.Info("Начинаем обработку файлов")
uc.logger.Info("Исходная директория: %s", config.Scanner.SourceDirectory)
var processedPDFs, processedImages bool
// Обрабатываем PDF файлы
if uc.shouldProcessPDFs(config) {
uc.logger.Info("Обработка PDF файлов...")
err := uc.pdfProcessor.Execute(config)
if err != nil {
uc.logger.Error("Ошибка обработки PDF файлов: %v", err)
return fmt.Errorf("ошибка обработки PDF файлов: %w", err)
}
processedPDFs = true
uc.logger.Info("Обработка PDF файлов завершена")
}
// Обрабатываем изображения
if uc.shouldProcessImages(config) {
uc.logger.Info("Обработка изображений...")
result, err := uc.imageProcessor.ProcessImagesInDirectory(
config.Scanner.SourceDirectory,
config.Scanner.TargetDirectory,
&config.Compression,
config.Scanner.ReplaceOriginal,
)
if err != nil {
uc.logger.Error("Ошибка обработки изображений: %v", err)
return fmt.Errorf("ошибка обработки изображений: %w", err)
}
// Логируем результаты обработки изображений
uc.logger.Info("Обработка изображений завершена. Всего файлов: %d, Успешно: %d, Ошибок: %d",
result.TotalFiles, result.SuccessfulFiles, len(result.FailedFiles))
for _, failed := range result.FailedFiles {
uc.logger.Error("Не удалось обработать изображение %s: %v", failed.FilePath, failed.Error)
}
processedImages = true
}
if !processedPDFs && !processedImages {
uc.logger.Warning("Не выбрано ни одного типа файлов для обработки")
return fmt.Errorf("не выбрано ни одного типа файлов для обработки")
}
uc.logger.Info("Обработка всех файлов завершена успешно")
return nil
}
// shouldProcessPDFs проверяет, нужно ли обрабатывать PDF файлы
func (uc *ProcessAllFilesUseCase) shouldProcessPDFs(config *entities.Config) bool {
// PDF файлы обрабатываются всегда, если есть алгоритм сжатия
return config.Compression.Algorithm != ""
}
// shouldProcessImages проверяет, нужно ли обрабатывать изображения
func (uc *ProcessAllFilesUseCase) shouldProcessImages(config *entities.Config) bool {
return config.Compression.EnableJPEG || config.Compression.EnablePNG
}
// GetSupportedFileTypes возвращает список поддерживаемых типов файлов
func (uc *ProcessAllFilesUseCase) GetSupportedFileTypes(config *entities.Config) []string {
var types []string
if uc.shouldProcessPDFs(config) {
types = append(types, "PDF")
}
if config.Compression.EnableJPEG {
types = append(types, "JPEG")
}
if config.Compression.EnablePNG {
types = append(types, "PNG")
}
return types
}
// IsFileSupported проверяет, поддерживается ли данный файл для обработки
func (uc *ProcessAllFilesUseCase) IsFileSupported(filename string, config *entities.Config) bool {
ext := strings.ToLower(filepath.Ext(filename))
// Проверяем PDF
if ext == ".pdf" && uc.shouldProcessPDFs(config) {
return true
}
// Проверяем изображения
if compressors.IsImageFile(filename) {
format := compressors.GetImageFormat(filename)
switch format {
case "jpeg":
return config.Compression.EnableJPEG
case "png":
return config.Compression.EnablePNG
}
}
return false
}

View File

@@ -0,0 +1,401 @@
package usecases
import (
"fmt"
"os"
"path/filepath"
"sync"
"time"
"compressor/internal/domain/entities"
"compressor/internal/domain/repositories"
)
// ProcessPDFsUseCase сценарий автоматической обработки PDF файлов
type ProcessPDFsUseCase struct {
compressor repositories.PDFCompressor
fileRepo repositories.FileRepository
configRepo repositories.ConfigRepository
logger repositories.Logger
progressReporter func(entities.ProcessingStatus)
}
// NewProcessPDFsUseCase создает новый сценарий обработки PDF
func NewProcessPDFsUseCase(
compressor repositories.PDFCompressor,
fileRepo repositories.FileRepository,
configRepo repositories.ConfigRepository,
logger repositories.Logger,
) *ProcessPDFsUseCase {
return &ProcessPDFsUseCase{
compressor: compressor,
fileRepo: fileRepo,
configRepo: configRepo,
logger: logger,
}
}
// SetProgressReporter устанавливает функцию для отчета о прогрессе
func (uc *ProcessPDFsUseCase) SetProgressReporter(reporter func(entities.ProcessingStatus)) {
uc.progressReporter = reporter
}
// reportProgress отправляет обновление прогресса
func (uc *ProcessPDFsUseCase) reportProgress(status *entities.ProcessingStatus) {
if uc.progressReporter != nil {
uc.progressReporter(*status)
}
}
// Execute выполняет автоматическую обработку PDF файлов согласно конфигурации
func (uc *ProcessPDFsUseCase) Execute(config *entities.Config) error {
// Фаза 1: Инициализация
status := entities.NewProcessingStatus(0)
status.SetPhase(entities.PhaseInitializing, "Инициализация обработки...")
uc.reportProgress(status)
uc.logInfo("╔════════════════════════════════════════════════════════════")
uc.logInfo("║ Начало обработки PDF файлов")
uc.logInfo("╠════════════════════════════════════════════════════════════")
uc.logInfo("║ Исходная директория: %s", config.Scanner.SourceDirectory)
if config.Scanner.ReplaceOriginal {
uc.logInfo("║ Режим: Замена оригинальных файлов")
} else {
uc.logInfo("║ Целевая директория: %s", config.Scanner.TargetDirectory)
}
uc.logInfo("║ Алгоритм: %s", config.Compression.Algorithm)
uc.logInfo("║ Уровень сжатия: %d%%", config.Compression.Level)
uc.logInfo("║ Параллельных воркеров: %d", config.Processing.ParallelWorkers)
uc.logInfo("╚════════════════════════════════════════════════════════════")
// Проверяем существование исходной директории
if !uc.fileRepo.FileExists(config.Scanner.SourceDirectory) {
err := fmt.Errorf("исходная директория не существует: %s", config.Scanner.SourceDirectory)
status.Fail(err)
uc.reportProgress(status)
return err
}
// Создаем целевую директорию, если нужно
if !config.Scanner.ReplaceOriginal {
if err := uc.fileRepo.CreateDirectory(config.Scanner.TargetDirectory); err != nil {
err = fmt.Errorf("ошибка создания целевой директории: %w", err)
status.Fail(err)
uc.reportProgress(status)
return err
}
}
// Фаза 2: Сканирование файлов
status.SetPhase(entities.PhaseScanning, "Сканирование PDF файлов...")
uc.reportProgress(status)
uc.logInfo("🔍 Сканирование директории...")
files, err := uc.fileRepo.ListPDFFiles(config.Scanner.SourceDirectory)
if err != nil {
err = fmt.Errorf("ошибка получения списка файлов: %w", err)
status.Fail(err)
uc.reportProgress(status)
return err
}
if len(files) == 0 {
uc.logWarning("⚠️ PDF файлы не найдены в директории: %s", config.Scanner.SourceDirectory)
status.Complete()
uc.reportProgress(status)
return nil
}
status.TotalFiles = len(files)
uc.logSuccess("✓ Найдено файлов для обработки: %d", len(files))
// Создаем конфигурацию сжатия
compressionConfig := entities.NewCompressionConfigWithLicense(config.Compression.Level, config.Compression.UniPDFLicenseKey)
if err := uc.configRepo.ValidateConfig(compressionConfig); err != nil {
err = fmt.Errorf("ошибка валидации конфигурации сжатия: %w", err)
status.Fail(err)
uc.reportProgress(status)
return err
}
// Фаза 3: Сжатие файлов
status.SetPhase(entities.PhaseCompressing, "Сжатие PDF файлов...")
uc.reportProgress(status)
uc.logInfo("")
uc.logInfo("🔄 Начало сжатия файлов...")
uc.logInfo("─────────────────────────────────────────────────────────────")
// Создаем воркеры для параллельной обработки
workers := config.Processing.ParallelWorkers
if workers <= 0 {
workers = 1
}
// Каналы для координации работы
jobs := make(chan string, len(files))
results := make(chan *entities.CompressionResult, len(files))
var wg sync.WaitGroup
// Запускаем воркеров
for w := 0; w < workers; w++ {
wg.Add(1)
go uc.worker(w, jobs, results, &wg, config, compressionConfig, status)
}
// Отправляем задачи воркерам
for _, file := range files {
jobs <- file
}
close(jobs)
// Горутина для сбора результатов
go func() {
wg.Wait()
close(results)
}()
// Обрабатываем результаты
fileCounter := 0
for result := range results {
fileCounter++
status.AddResult(result)
// Обновляем текущий файл
status.SetCurrentFile(result.CurrentFile, result.OriginalSize)
// Отправляем обновление прогресса
uc.reportProgress(status)
// Логируем результат обработки файла
fileName := filepath.Base(result.CurrentFile)
if result.Success && result.Error == nil {
uc.logSuccess("[%d/%d] ✓ %s", fileCounter, status.TotalFiles, fileName)
uc.logInfo(" └─ Размер: %.2f MB → %.2f MB",
float64(result.OriginalSize)/1024/1024,
float64(result.CompressedSize)/1024/1024)
uc.logInfo(" └─ Сжатие: %.1f%% | Сэкономлено: %.2f MB",
result.CompressionRatio,
float64(result.SavedSpace)/1024/1024)
} else {
uc.logError("[%d/%d] ✗ %s", fileCounter, status.TotalFiles, fileName)
uc.logError(" └─ Ошибка: %v", result.Error)
}
}
// Финальная фаза
status.Complete()
uc.reportProgress(status)
// Логируем итоговую статистику
uc.logInfo("")
uc.logInfo("╔════════════════════════════════════════════════════════════")
uc.logInfo("║ Обработка завершена")
uc.logInfo("╠════════════════════════════════════════════════════════════")
uc.logInfo("║ Время выполнения: %s", status.FormatElapsedTime())
uc.logInfo("╠════════════════════════════════════════════════════════════")
uc.logInfo("║ Статистика файлов:")
uc.logInfo("║ • Всего: %d", status.TotalFiles)
uc.logSuccess("║ • Успешно: %d", status.SuccessfulFiles)
if status.FailedFiles > 0 {
uc.logError("║ • Ошибок: %d", status.FailedFiles)
}
if status.SkippedFiles > 0 {
uc.logWarning("║ • Пропущено: %d", status.SkippedFiles)
}
if status.TotalOriginalSize > 0 {
uc.logInfo("╠════════════════════════════════════════════════════════════")
uc.logInfo("║ Статистика сжатия:")
uc.logInfo("║ • Исходный размер: %.2f MB", float64(status.TotalOriginalSize)/1024/1024)
uc.logInfo("║ • Сжатый размер: %.2f MB", float64(status.TotalCompressedSize)/1024/1024)
uc.logSuccess("║ • Среднее сжатие: %.1f%%", status.AverageCompression)
uc.logSuccess("║ • Сэкономлено: %.2f MB", float64(status.TotalSavedSpace)/1024/1024)
}
uc.logInfo("╚════════════════════════════════════════════════════════════")
return nil
}
// worker обрабатывает файлы в отдельной горутине
func (uc *ProcessPDFsUseCase) worker(
id int,
jobs <-chan string,
results chan<- *entities.CompressionResult,
wg *sync.WaitGroup,
config *entities.Config,
compressionConfig *entities.CompressionConfig,
status *entities.ProcessingStatus,
) {
defer wg.Done()
for inputFile := range jobs {
fileName := filepath.Base(inputFile)
// Определяем путь выходного файла
var outputFile string
if config.Scanner.ReplaceOriginal {
outputFile = inputFile + ".tmp"
} else {
// Получаем относительный путь от исходной директории
relPath, err := filepath.Rel(config.Scanner.SourceDirectory, inputFile)
if err != nil {
// Если не удалось получить относительный путь, используем просто имя файла
outputFile = filepath.Join(config.Scanner.TargetDirectory, fileName)
} else {
// Сохраняем структуру директорий
outputFile = filepath.Join(config.Scanner.TargetDirectory, relPath)
// Создаем директорию для выходного файла
outputDir := filepath.Dir(outputFile)
if err := os.MkdirAll(outputDir, 0755); err != nil {
results <- &entities.CompressionResult{
CurrentFile: inputFile,
Success: false,
Error: fmt.Errorf("не удалось создать директорию %s: %w", outputDir, err),
}
continue
}
}
}
// Получаем информацию о файле
fileInfo, err := uc.fileRepo.GetFileInfo(inputFile)
if err != nil {
results <- &entities.CompressionResult{
CurrentFile: inputFile,
Success: false,
Error: fmt.Errorf("ошибка получения информации о файле: %w", err),
}
continue
}
// Выполняем сжатие с повторными попытками
var result *entities.CompressionResult
for attempt := 0; attempt < config.Processing.RetryAttempts; attempt++ {
result, err = uc.compressor.Compress(inputFile, outputFile, compressionConfig)
if err == nil {
break
}
if attempt < config.Processing.RetryAttempts-1 {
if uc.logger != nil {
uc.logger.Warning("Попытка %d/%d для файла %s не удалась: %v",
attempt+1, config.Processing.RetryAttempts, fileName, err)
}
time.Sleep(time.Second * 2) // Пауза перед повторной попыткой
}
}
if err != nil {
results <- &entities.CompressionResult{
CurrentFile: inputFile,
OriginalSize: fileInfo.Size,
Success: false,
Error: err,
}
continue
}
// Устанавливаем исходный размер и пересчитываем статистику
result.CurrentFile = inputFile
result.OriginalSize = fileInfo.Size
result.CalculateCompressionRatio()
// Если заменяем оригинал, переименовываем временный файл
if config.Scanner.ReplaceOriginal {
if err := uc.replaceOriginalFile(inputFile, outputFile); err != nil {
result.Success = false
result.Error = fmt.Errorf("ошибка замены оригинального файла: %w", err)
// Удаляем временный файл при ошибке
_ = os.Remove(outputFile)
if uc.logger != nil {
uc.logger.Error("Не удалось заменить оригинальный файл %s: %v", inputFile, err)
}
} else {
// Успешно заменили - обновляем путь к файлу в результате
result.CurrentFile = inputFile
if uc.logger != nil {
uc.logger.Info("Файл %s успешно заменен сжатой версией", inputFile)
}
}
}
results <- result
}
}
// replaceOriginalFile заменяет оригинальный файл сжатым
func (uc *ProcessPDFsUseCase) replaceOriginalFile(originalFile, tempFile string) error {
// Проверяем существование временного файла
if _, err := os.Stat(tempFile); os.IsNotExist(err) {
return fmt.Errorf("временный файл не существует: %s", tempFile)
}
if uc.logger != nil {
uc.logger.Info("Замена оригинального файла: %s", originalFile)
}
backupFile := originalFile + ".backup"
// Создаем резервную копию оригинала
if err := os.Rename(originalFile, backupFile); err != nil {
if uc.logger != nil {
uc.logger.Error("Ошибка создания резервной копии %s: %v", originalFile, err)
}
return fmt.Errorf("ошибка создания резервной копии: %w", err)
}
// Переименовываем временный файл в оригинальный
if err := os.Rename(tempFile, originalFile); err != nil {
if uc.logger != nil {
uc.logger.Error("Ошибка замены файла %s: %v", originalFile, err)
}
// Восстанавливаем оригинальный файл из резервной копии
_ = os.Rename(backupFile, originalFile)
return fmt.Errorf("ошибка замены файла: %w", err)
}
// Удаляем резервную копию
if err := os.Remove(backupFile); err != nil {
if uc.logger != nil {
uc.logger.Warning("Не удалось удалить резервную копию %s: %v", backupFile, err)
}
}
if uc.logger != nil {
uc.logger.Info("Оригинальный файл успешно заменен: %s", originalFile)
}
return nil
}
// Методы для логирования
func (uc *ProcessPDFsUseCase) logInfo(format string, args ...interface{}) {
if uc.logger != nil {
uc.logger.Info(format, args...)
}
}
func (uc *ProcessPDFsUseCase) logSuccess(format string, args ...interface{}) {
if uc.logger != nil {
uc.logger.Success(format, args...)
}
}
func (uc *ProcessPDFsUseCase) logWarning(format string, args ...interface{}) {
if uc.logger != nil {
uc.logger.Warning(format, args...)
}
}
func (uc *ProcessPDFsUseCase) logError(format string, args ...interface{}) {
if uc.logger != nil {
uc.logger.Error(format, args...)
}
}