Достижение: Добавлены скрипты и документация для релиза PDF Compressor.
- Добавлен release-body.md для подробных заметок о релизе на русском языке. - Реализован release-gitea.ps1 для автоматизированного релиза Gitea с помощью PowerShell. - Создан release-gitea.sh для автоматизированного релиза Gitea с помощью Bash. - Добавлен release.sh для сборки и маркировки релизов с поддержкой нескольких платформ. - Улучшен пользовательский интерфейс благодаря информативному логированию и обработке ошибок. - Добавлена поддержка переменных окружения и управления конфигурацией. - Добавлена функция создания архивов и загрузки ресурсов в Gitea.
This commit is contained in:
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...)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user