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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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