feat(ui): implement 1-click presets, real-time search & filter bar, batch queue actions, and file inspector
This commit is contained in:
parent
41c8a8a94b
commit
b67b3f1b61
17 changed files with 1240 additions and 38 deletions
16
.env.example
Normal file
16
.env.example
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Everything2Everything Environment & Code Signing Configuration
|
||||
# ================================================================
|
||||
|
||||
# [코드 사이닝 설정]
|
||||
# 1) PFX 인증서 직접 지정 (권장: 상대경로나 절대경로 지정)
|
||||
CODE_SIGN_PFX_PATH=packaging/Everything2Everything-DevCert.pfx
|
||||
CODE_SIGN_PFX_PASSWORD=Everything2EverythingDev
|
||||
|
||||
# 2) 또는 Windows 인증서 저장소의 Thumbprint 지정 (PFX_PATH 대신 사용 가능)
|
||||
# CODE_SIGN_THUMBPRINT=CA8ED0C0DF087F9844275AEC5F056AD70CB67AE6
|
||||
|
||||
# 3) RFC 3161 타임스탬프 서버 URL (기본값: DigiCert)
|
||||
TIMESTAMP_SERVER_URL=http://timestamp.digicert.com
|
||||
|
||||
# [Forgejo / Git 릴리즈 배포 설정]
|
||||
# FORGEJO_TOKEN=your_token_here
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
#Requires -Version 5.1
|
||||
#Requires -Version 5.1
|
||||
# MSIX 빌드 파이프라인.
|
||||
# 1) .NET App publish (framework-dependent)
|
||||
# 2) C++ Shell DLL 빌드
|
||||
|
|
@ -28,6 +28,48 @@ $assetsSrc = Join-Path $packagingDir 'Assets'
|
|||
$appProj = Join-Path $repoRoot 'src\Everything2Everything.App\Everything2Everything.App.csproj'
|
||||
$shellProj = Join-Path $repoRoot 'src\Everything2Everything.Shell\Everything2Everything.Shell.vcxproj'
|
||||
|
||||
function Import-EnvFile {
|
||||
param([string]$Path)
|
||||
if (Test-Path $Path) {
|
||||
Get-Content $Path | Where-Object { $_ -match '^\s*([^#=\s]+)\s*=\s*(.*)$' } | ForEach-Object {
|
||||
$key = $matches[1].Trim()
|
||||
$val = $matches[2].Trim().Trim('"').Trim("'")
|
||||
if (-not [string]::IsNullOrEmpty($key) -and -not [Environment]::GetEnvironmentVariable($key)) {
|
||||
[Environment]::SetEnvironmentVariable($key, $val, 'Process')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Import-EnvFile (Join-Path $repoRoot '.env')
|
||||
Import-EnvFile (Join-Path $packagingDir '.env')
|
||||
|
||||
# 환경변수 기반 코드 사이닝 옵션 자동 보정
|
||||
if (-not $CertThumbprint -and $env:CODE_SIGN_THUMBPRINT) {
|
||||
$CertThumbprint = $env:CODE_SIGN_THUMBPRINT
|
||||
}
|
||||
if (-not $PfxPath -and $env:CODE_SIGN_PFX_PATH) {
|
||||
$PfxPath = if ([System.IO.Path]::IsPathRooted($env:CODE_SIGN_PFX_PATH)) { $env:CODE_SIGN_PFX_PATH } else { Join-Path $repoRoot $env:CODE_SIGN_PFX_PATH }
|
||||
}
|
||||
if (-not $PfxPassword -and $env:CODE_SIGN_PFX_PASSWORD) {
|
||||
$PfxPassword = ConvertTo-SecureString -String $env:CODE_SIGN_PFX_PASSWORD -AsPlainText -Force
|
||||
}
|
||||
|
||||
# 기본 DevCert 감지 및 자동 서명 활성화
|
||||
$devCertPfx = Join-Path $packagingDir 'Everything2Everything-DevCert.pfx'
|
||||
if (-not $PfxPath -and -not $CertThumbprint -and (Test-Path $devCertPfx)) {
|
||||
$PfxPath = $devCertPfx
|
||||
if (-not $PfxPassword) {
|
||||
$PfxPassword = ConvertTo-SecureString -String 'Everything2EverythingDev' -AsPlainText -Force
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $PSBoundParameters.ContainsKey('Sign')) {
|
||||
if ($PfxPath -or $CertThumbprint) {
|
||||
$Sign = $true
|
||||
}
|
||||
}
|
||||
|
||||
function Find-WindowsSdkTool {
|
||||
param([string]$ToolName)
|
||||
$sdkRoots = @(
|
||||
|
|
@ -132,12 +174,15 @@ if (Test-Path $msixPath) { Remove-Item $msixPath -Force }
|
|||
if ($LASTEXITCODE -ne 0) { throw 'makeappx pack 실패' }
|
||||
Write-Host "✅ MSIX 산출: $msixPath"
|
||||
|
||||
# ---- 5) (선택) sign ----
|
||||
# ---- 5) sign ----
|
||||
if ($Sign) {
|
||||
Write-Host ''
|
||||
Write-Host '[5/5] signtool sign'
|
||||
Write-Host '[5/5] signtool sign (MSIX 디지털 서명)'
|
||||
$timestampUrl = if ($env:TIMESTAMP_SERVER_URL) { $env:TIMESTAMP_SERVER_URL } else { 'http://timestamp.digicert.com' }
|
||||
$plain = $null
|
||||
|
||||
if ($CertThumbprint) {
|
||||
& $signtool sign /fd SHA256 /sha1 $CertThumbprint /tr 'http://timestamp.digicert.com' /td SHA256 $msixPath | Out-Host
|
||||
& $signtool sign /fd SHA256 /sha1 $CertThumbprint /tr $timestampUrl /td SHA256 $msixPath | Out-Host
|
||||
} elseif ($PfxPath) {
|
||||
if (-not $PfxPassword) {
|
||||
$PfxPassword = Read-Host -AsSecureString -Prompt 'PFX 비밀번호'
|
||||
|
|
@ -145,7 +190,7 @@ if ($Sign) {
|
|||
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($PfxPassword)
|
||||
try {
|
||||
$plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
|
||||
& $signtool sign /fd SHA256 /a /f $PfxPath /p $plain /tr 'http://timestamp.digicert.com' /td SHA256 $msixPath | Out-Host
|
||||
& $signtool sign /fd SHA256 /a /f $PfxPath /p $plain /tr $timestampUrl /td SHA256 $msixPath | Out-Host
|
||||
} finally {
|
||||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
||||
}
|
||||
|
|
@ -154,6 +199,32 @@ if ($Sign) {
|
|||
}
|
||||
if ($LASTEXITCODE -ne 0) { throw 'signtool sign 실패' }
|
||||
Write-Host '✅ 서명 완료'
|
||||
|
||||
# 공개 인증서 (.cer) 내보내기 (신뢰 등록용)
|
||||
$cerPath = Join-Path $distDir "Everything2Everything-DevCert.cer"
|
||||
try {
|
||||
if ($PfxPath -and (Test-Path $PfxPath) -and $plain) {
|
||||
$certObj = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($PfxPath, $plain)
|
||||
[System.IO.File]::WriteAllBytes($cerPath, $certObj.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert))
|
||||
Write-Host "✅ 공개 인증서(.cer) 내보냄: $cerPath" -ForegroundColor Green
|
||||
} elseif ($CertThumbprint) {
|
||||
$certObj = Get-Item "Cert:\CurrentUser\My\$CertThumbprint" -ErrorAction SilentlyContinue
|
||||
if (-not $certObj) { $certObj = Get-Item "Cert:\LocalMachine\My\$CertThumbprint" -ErrorAction SilentlyContinue }
|
||||
if ($certObj) {
|
||||
[System.IO.File]::WriteAllBytes($cerPath, $certObj.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert))
|
||||
Write-Host "✅ 공개 인증서(.cer) 내보냄: $cerPath" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Warning "공개 인증서 내보내기 경고: $_"
|
||||
}
|
||||
|
||||
# 1-클릭 설치기 스크립트 복사
|
||||
$installCmdSrc = Join-Path $packagingDir 'Install.cmd'
|
||||
if (Test-Path $installCmdSrc) {
|
||||
Copy-Item $installCmdSrc -Destination (Join-Path $distDir 'Install.cmd') -Force
|
||||
Write-Host "✅ 1-클릭 설치기 복사 완료: dist\Install.cmd" -ForegroundColor Green
|
||||
}
|
||||
} else {
|
||||
Write-Host ''
|
||||
Write-Host '[5/5] 서명 건너뜀 (-Sign 미지정). 사이드로드 시 인증서 필요.'
|
||||
|
|
|
|||
51
packaging/Install.cmd
Normal file
51
packaging/Install.cmd
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
@echo off
|
||||
chcp 65001 > nul
|
||||
title Everything2Everything 1-클릭 설치기
|
||||
|
||||
:: 1. 관리자 권한 확인 및 자동 승격 (UAC)
|
||||
net session >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [안내] 인증서 신뢰 등록 및 MSIX 패키지 설치를 위해 관리자 권한을 요청합니다...
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process cmd.exe -ArgumentList '/c cd /d \"\"%~dp0\"\" && \"\"%~f0\"\"' -Verb RunAs"
|
||||
exit /b
|
||||
)
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo ==========================================================
|
||||
echo Everything2Everything 1-클릭 자동 설치 프로그램
|
||||
echo ==========================================================
|
||||
echo.
|
||||
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||
"$ErrorActionPreference = 'Stop';" ^
|
||||
"try {" ^
|
||||
" Write-Host '[1/2] 개발자 인증서 신뢰 저장소(TrustedPeople) 등록 중...' -ForegroundColor Cyan;" ^
|
||||
" $cer = Get-ChildItem -Path . -Filter '*.cer' | Select-Object -First 1;" ^
|
||||
" if ($cer) {" ^
|
||||
" Import-Certificate -FilePath $cer.FullName -CertStoreLocation 'Cert:\LocalMachine\TrustedPeople' | Out-Null;" ^
|
||||
" Import-Certificate -FilePath $cer.FullName -CertStoreLocation 'Cert:\LocalMachine\Root' | Out-Null;" ^
|
||||
" Write-Host ' 인증서 신뢰 등록 성공: ' $cer.Name -ForegroundColor Green;" ^
|
||||
" } else {" ^
|
||||
" Write-Host ' [주의] 폴더 내 .cer 인증서가 없습니다. 건너뜁니다.' -ForegroundColor Yellow;" ^
|
||||
" }" ^
|
||||
" Write-Host '[2/2] Everything2Everything MSIX 패키지 설치 중...' -ForegroundColor Cyan;" ^
|
||||
" $msix = Get-ChildItem -Path . -Filter '*.msix' | Select-Object -First 1;" ^
|
||||
" if (-not $msix) { throw 'MSIX 패키지 파일(*.msix)을 찾을 수 없습니다.' }" ^
|
||||
" Add-AppxPackage -Path $msix.FullName -ForceApplicationShutdown;" ^
|
||||
" Write-Host ' 패키지 설치 성공: ' $msix.Name -ForegroundColor Green;" ^
|
||||
" Write-Host '';" ^
|
||||
" Write-Host '==========================================================' -ForegroundColor Green;" ^
|
||||
" Write-Host ' Everything2Everything 설치가 성공적으로 완료되었습니다!' -ForegroundColor Green;" ^
|
||||
" Write-Host ' - 파일 우클릭 시 최상위 컨텍스트 메뉴가 활성화됩니다.' -ForegroundColor Gray;" ^
|
||||
" Write-Host '==========================================================' -ForegroundColor Green;" ^
|
||||
"} catch {" ^
|
||||
" Write-Host '';" ^
|
||||
" Write-Host '❌ 설치 중 오류가 발생했습니다:' -ForegroundColor Red;" ^
|
||||
" Write-Host $_.Exception.Message -ForegroundColor Red;" ^
|
||||
" Write-Host '';" ^
|
||||
" Write-Host '동일 버전이 이미 설치되어 있는 경우 기존 앱을 삭제 후 다시 시도해 주세요.' -ForegroundColor Yellow;" ^
|
||||
"}"
|
||||
|
||||
echo.
|
||||
pause
|
||||
|
|
@ -13,6 +13,26 @@ public partial class OptionsViewModel : ObservableObject
|
|||
/// <summary>JPEG/WebP 품질(1~100). AVIF는 -30 보정.</summary>
|
||||
[ObservableProperty] private int _quality = 85;
|
||||
|
||||
/// <summary>EXIF 및 메타데이터 제거 여부.</summary>
|
||||
[ObservableProperty] private bool _stripMetadata;
|
||||
|
||||
public int ImageQuality { get => Quality; set => Quality = value; }
|
||||
public int VideoCrf { get => Crf; set => Crf = value; }
|
||||
public int AudioBitrateKbps
|
||||
{
|
||||
get => AudioBitrateIndex switch { 0 => 96, 1 => 128, 3 => 256, 4 => 320, _ => 192 };
|
||||
set => AudioBitrateIndex = value switch { <= 96 => 0, <= 128 => 1, <= 192 => 2, <= 256 => 3, _ => 4 };
|
||||
}
|
||||
public string VideoPreset
|
||||
{
|
||||
get => ((VideoSpeedPreset)PresetIndex).ToString().ToLowerInvariant();
|
||||
set => PresetIndex = value switch
|
||||
{
|
||||
"ultrafast" => 0, "superfast" => 1, "veryfast" => 2, "faster" => 3,
|
||||
"fast" => 4, "medium" => 5, "slow" => 6, "slower" => 7, "veryslow" => 8, _ => 5
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>비우면 원본 옆 서브폴더. 값이 있으면 사용자 지정 출력 폴더.</summary>
|
||||
[ObservableProperty] private string? _customOutputDirectory;
|
||||
|
||||
|
|
@ -62,6 +82,7 @@ public partial class OptionsViewModel : ObservableObject
|
|||
OnCollision = ConflictRule,
|
||||
OutputLocation = hasCustom ? OutputLocation.Custom : OutputLocation.SubfolderBesideSource,
|
||||
CustomOutputDirectory = hasCustom ? CustomOutputDirectory!.Trim() : null,
|
||||
KeepExifWhenPossible = !StripMetadata,
|
||||
Jpeg = new JpegEncodingOptions { Quality = Quality },
|
||||
Webp = new WebpEncodingOptions { Quality = Quality },
|
||||
Avif = new AvifEncodingOptions { Quality = Math.Clamp(Quality - 30, 1, 100) },
|
||||
|
|
|
|||
50
src/Everything2Everything.App/Views/BatchQueueService.cs
Normal file
50
src/Everything2Everything.App/Views/BatchQueueService.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace Everything2Everything.App.Views;
|
||||
|
||||
public static class BatchQueueService
|
||||
{
|
||||
public static void SetSelectionAll(IEnumerable<QueueItem> items, bool isSelected)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
item.IsSelected = isSelected;
|
||||
}
|
||||
}
|
||||
|
||||
public static void RemoveSelected(IList<QueueItem> items)
|
||||
{
|
||||
var toRemove = items.Where(i => i.IsSelected).ToList();
|
||||
foreach (var item in toRemove)
|
||||
{
|
||||
items.Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
public static void ClearCompleted(IList<QueueItem> items)
|
||||
{
|
||||
var toRemove = items.Where(i => i.IsDone || i.StateText == "done").ToList();
|
||||
foreach (var item in toRemove)
|
||||
{
|
||||
items.Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
public static void BatchChangeOutput(IEnumerable<QueueItem> items, string newOutputExt, IEnumerable<string> eligibleInputExtensions)
|
||||
{
|
||||
var eligibleSet = new HashSet<string>(
|
||||
eligibleInputExtensions.Select(e => e.StartsWith('.') ? e.ToLowerInvariant() : "." + e.ToLowerInvariant())
|
||||
);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var ext = Path.GetExtension(item.SourcePath).ToLowerInvariant();
|
||||
if (eligibleSet.Contains(ext))
|
||||
{
|
||||
item.SelectedOutputExtension = newOutputExt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -89,6 +89,49 @@
|
|||
<!-- Sidebar content -->
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="24,24,24,24">
|
||||
<StackPanel>
|
||||
<!-- Quick Presets -->
|
||||
<StackPanel Margin="0,0,0,24">
|
||||
<TextBlock Text="빠른 최적화 프리셋" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,8"/>
|
||||
<WrapPanel Margin="-2">
|
||||
<Button Margin="2" Padding="8,6" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding PresetCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="WebOptimized"
|
||||
ToolTip="WebP · 80% 압축 · 메타데이터 제거">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Globe24" FontSize="12" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="웹 최적화" FontSize="11" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Margin="2" Padding="8,6" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding PresetCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="HighQualityLossless"
|
||||
ToolTip="무손실 PNG/FLAC · 100% 품질">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Sparkle24" FontSize="12" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="고화질 보존" FontSize="11" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Margin="2" Padding="8,6" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding PresetCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="DocumentPdf"
|
||||
ToolTip="표준 PDF 문서 변환">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="DocumentPdf24" FontSize="12" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="문서 PDF" FontSize="11" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Margin="2" Padding="8,6" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding PresetCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="MobileShare"
|
||||
ToolTip="MP4 H.264 · 가벼운 모바일 전송">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Phone24" FontSize="12" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="모바일 공유" FontSize="11" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Target Format -->
|
||||
<StackPanel Margin="0,0,0,32">
|
||||
<TextBlock Text="TARGET FORMAT" Style="{StaticResource FsLabelStyle}"/>
|
||||
|
|
@ -585,15 +628,120 @@
|
|||
|
||||
<!-- View content (좌측: 탭 컨텐츠 / 우측: Preview 컬럼) -->
|
||||
<Grid Grid.Row="1">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" MinWidth="360"/>
|
||||
<ColumnDefinition Width="380"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 좌측: Active Queue OR Past Results -->
|
||||
<!-- 실시간 검색 & 카테고리 필터 툴바 -->
|
||||
<Border Grid.Row="0" Grid.ColumnSpan="2"
|
||||
Background="{StaticResource FsBgPanel}"
|
||||
BorderBrush="{StaticResource FsBorderSubtle}"
|
||||
BorderThickness="0,0,0,1" Padding="24,8">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="280"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<!-- Search Box -->
|
||||
<Grid Grid.Column="0">
|
||||
<TextBox x:Name="SearchBox" Style="{StaticResource FsPathInputStyle}"
|
||||
Padding="32,6,10,6" Text="{Binding SearchText, RelativeSource={RelativeSource AncestorType=Window}, UpdateSourceTrigger=PropertyChanged}"
|
||||
ToolTip="파일명 또는 확장자로 검색"/>
|
||||
<ui:SymbolIcon Symbol="Search24" FontSize="14" Foreground="{StaticResource FsTextTertiary}"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center" Margin="10,0,0,0" IsHitTestVisible="False"/>
|
||||
</Grid>
|
||||
<!-- Category Filter Chips -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<Button Margin="0,0,4,0" Padding="10,4" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding FilterCategoryCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="All">
|
||||
<TextBlock Text="전체" FontSize="11" VerticalAlignment="Center"/>
|
||||
</Button>
|
||||
<Button Margin="0,0,4,0" Padding="10,4" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding FilterCategoryCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="Image">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Image24" FontSize="12" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="이미지" FontSize="11" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Margin="0,0,4,0" Padding="10,4" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding FilterCategoryCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="Document">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Document24" FontSize="12" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="문서" FontSize="11" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Margin="0,0,4,0" Padding="10,4" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding FilterCategoryCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="Media">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Video24" FontSize="12" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="미디어" FontSize="11" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Padding="10,4" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding FilterCategoryCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="Data">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Database24" FontSize="12" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="데이터" FontSize="11" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 좌측: Active Queue OR Past Results -->
|
||||
<Grid Grid.Row="1" Grid.Column="0">
|
||||
<!-- Active Queue view -->
|
||||
<Grid x:Name="ActiveQueueView" Visibility="Collapsed">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Batch Action Bar -->
|
||||
<Border x:Name="BatchActionBar" Grid.Row="0" Background="{StaticResource FsBgPanel}"
|
||||
BorderBrush="{StaticResource FsBorderSubtle}" BorderThickness="0,0,0,1"
|
||||
Padding="24,8" Visibility="Collapsed">
|
||||
<Grid VerticalAlignment="Center">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<CheckBox x:Name="BatchSelectAllCheck" Content="전체 선택" VerticalAlignment="Center"
|
||||
Command="{Binding BatchSelectAllCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="{Binding IsChecked, RelativeSource={RelativeSource Self}}"/>
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Button Margin="0,0,8,0" Padding="10,4" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding BatchRemoveSelectedCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
ToolTip="선택된 파일들을 큐에서 제거합니다">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Delete24" FontSize="12" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="선택 항목 삭제" FontSize="11" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Padding="10,4" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding BatchClearCompletedCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
ToolTip="변환이 완료된 항목들을 큐에서 정리합니다">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Checkmark24" FontSize="12" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="완료 항목 정리" FontSize="11" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="1">
|
||||
<Grid x:Name="DropZoneEmpty">
|
||||
<Border Background="{StaticResource FsBgBase}" Padding="32">
|
||||
<!-- 점선 드롭존: 절제된 dashed 보더 + 중앙 정렬 안내 -->
|
||||
|
|
@ -663,16 +811,19 @@
|
|||
CornerRadius="6,0,0,6"/>
|
||||
<Grid Grid.Column="1" Margin="12,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="28"/>
|
||||
<ColumnDefinition Width="40"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="100"/>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="40"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Image Width="34" Height="34" VerticalAlignment="Center"
|
||||
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
<Image Grid.Column="1" Width="34" Height="34" VerticalAlignment="Center"
|
||||
RenderOptions.BitmapScalingMode="HighQuality"
|
||||
Source="{Binding GlyphSource}"/>
|
||||
<StackPanel Grid.Column="1" Margin="16,0,0,0"
|
||||
<StackPanel Grid.Column="2" Margin="16,0,0,0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding FileName}"
|
||||
Style="{StaticResource FsBodyStyle}"
|
||||
|
|
@ -686,16 +837,16 @@
|
|||
Value="{Binding ProgressValue, Mode=OneWay}"
|
||||
Visibility="{Binding ProgressVisibility, Mode=OneWay}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="{Binding SizeText}"
|
||||
<TextBlock Grid.Column="3" Text="{Binding SizeText}"
|
||||
Style="{StaticResource FsMonoStyle}"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding StateText}"
|
||||
<TextBlock Grid.Column="4" Text="{Binding StateText}"
|
||||
FontFamily="{StaticResource FsFontMono}"
|
||||
FontSize="12"
|
||||
Foreground="{Binding StateBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="4"
|
||||
<Button Grid.Column="5"
|
||||
Style="{StaticResource FsIconButtonStyle}"
|
||||
Width="28" Height="28"
|
||||
VerticalAlignment="Center"
|
||||
|
|
@ -725,6 +876,7 @@
|
|||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<!-- Past Results view (default) -->
|
||||
<Grid x:Name="PastResultsContainer">
|
||||
|
|
@ -872,7 +1024,7 @@
|
|||
<!-- /좌측 컬럼 끝 -->
|
||||
|
||||
<!-- 우측: Preview 컬럼 (항상 보임) -->
|
||||
<Border Grid.Column="1"
|
||||
<Border Grid.Row="1" Grid.Column="1"
|
||||
Background="{StaticResource FsBgPanel}"
|
||||
BorderBrush="{StaticResource FsBorderSubtle}"
|
||||
BorderThickness="1,0,0,0">
|
||||
|
|
@ -1008,17 +1160,40 @@
|
|||
Foreground="{StaticResource FsTextPrimary}" Text="—"/>
|
||||
</Grid>
|
||||
|
||||
<Button Content="Open in Explorer" Margin="0,16,0,0"
|
||||
<Grid Margin="0,16,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="8"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button Grid.Column="0"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Padding="12,8"
|
||||
Command="{Binding PreviewOpenFileCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
ToolTip="기본 프로그램으로 파일 열기">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Open24" FontSize="13" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="열기" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Grid.Column="2"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Padding="12,8"
|
||||
Command="{Binding PreviewOpenFolderCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
ToolTip="탐색기에서 파일 위치 열기">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="FolderOpen24" FontSize="13" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="폴더에서 보기" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Drop hint overlay (전체 덮음) -->
|
||||
<Border x:Name="DropHintOverlay" Visibility="Collapsed" Grid.ColumnSpan="2"
|
||||
<Border x:Name="DropHintOverlay" Visibility="Collapsed" Grid.RowSpan="2" Grid.ColumnSpan="2"
|
||||
Background="#CC090A0C" IsHitTestVisible="False">
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Path Width="64" Height="64" Stretch="Uniform"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ using System.Windows.Media.Imaging;
|
|||
using Everything2Everything.App.Shell;
|
||||
using Everything2Everything.App.ViewModels;
|
||||
using Everything2Everything.Core;
|
||||
using Everything2Everything.Core.Filters;
|
||||
using Everything2Everything.Core.Inspector;
|
||||
using Everything2Everything.Core.Presets;
|
||||
using LossClass = Everything2Everything.Core.Providers.LossClass;
|
||||
|
||||
namespace Everything2Everything.App.Views;
|
||||
|
|
@ -48,6 +51,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
|||
public ICommand PickOutputFolderCommand { get; }
|
||||
public ICommand CancelProcessingCommand { get; }
|
||||
public ICommand PreviewOpenFolderCommand { get; }
|
||||
public ICommand PreviewOpenFileCommand { get; }
|
||||
public ICommand RemoveQueueItemCommand { get; }
|
||||
public ICommand OpenFolderCommand { get; }
|
||||
public ICommand TabCommand { get; }
|
||||
|
|
@ -60,6 +64,41 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
|||
public ICommand QueueRowCommand { get; }
|
||||
public ICommand PastRowCommand { get; }
|
||||
|
||||
// 신규 프리셋, 필터, 일괄 작업 커맨드
|
||||
public ICommand PresetCommand { get; }
|
||||
public ICommand FilterCategoryCommand { get; }
|
||||
public ICommand BatchSelectAllCommand { get; }
|
||||
public ICommand BatchRemoveSelectedCommand { get; }
|
||||
public ICommand BatchClearCompletedCommand { get; }
|
||||
|
||||
private string _searchText = "";
|
||||
public string SearchText
|
||||
{
|
||||
get => _searchText;
|
||||
set
|
||||
{
|
||||
if (_searchText != value)
|
||||
{
|
||||
_searchText = value;
|
||||
ApplyQueueFilters();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private FilterCategory _selectedCategory = FilterCategory.All;
|
||||
public FilterCategory SelectedCategory
|
||||
{
|
||||
get => _selectedCategory;
|
||||
set
|
||||
{
|
||||
if (_selectedCategory != value)
|
||||
{
|
||||
_selectedCategory = value;
|
||||
ApplyQueueFilters();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public MainWindow(ConversionEngine engine, ISettingsStore settings, IReadOnlyList<string>? initialFiles = null)
|
||||
{
|
||||
_engine = engine;
|
||||
|
|
@ -79,6 +118,12 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
|||
PickOutputFolderCommand = new RelayCommand(_ => OnPickOutputFolderClick(this, new RoutedEventArgs()));
|
||||
CancelProcessingCommand = new RelayCommand(_ => OnCancelProcessingClick(this, new RoutedEventArgs()));
|
||||
PreviewOpenFolderCommand = new RelayCommand(_ => OnPreviewOpenFolder(this, new RoutedEventArgs()));
|
||||
PreviewOpenFileCommand = new RelayCommand(_ => OnPreviewOpenFile(this, new RoutedEventArgs()));
|
||||
PresetCommand = new RelayCommand(p => ApplyPreset(p?.ToString()));
|
||||
FilterCategoryCommand = new RelayCommand(p => ApplyFilterCategory(p?.ToString()));
|
||||
BatchSelectAllCommand = new RelayCommand(p => BatchSelectAll(p));
|
||||
BatchRemoveSelectedCommand = new RelayCommand(_ => BatchRemoveSelected());
|
||||
BatchClearCompletedCommand = new RelayCommand(_ => BatchClearCompleted());
|
||||
RemoveQueueItemCommand = new RelayCommand(p => RemoveQueueItem(p as QueueItem));
|
||||
OpenFolderCommand = new RelayCommand(p => OpenFolderForPath(p as string));
|
||||
TabCommand = new RelayCommand(p => ShowTab(p as string ?? "Past"));
|
||||
|
|
@ -246,6 +291,10 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
|||
var hasItems = _activeQueue.Count > 0;
|
||||
DropZoneEmpty.Visibility = hasItems ? Visibility.Collapsed : Visibility.Visible;
|
||||
ActiveQueueScroll.Visibility = hasItems ? Visibility.Visible : Visibility.Collapsed;
|
||||
if (FindName("BatchActionBar") is UIElement batchBar)
|
||||
{
|
||||
batchBar.Visibility = hasItems ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdatePastResultsVisibility()
|
||||
|
|
@ -422,12 +471,13 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
|||
|
||||
private void SetPreviewMeta(string fileName, string filePath, string formatLabel, string sizeText)
|
||||
{
|
||||
PreviewFileName.Text = fileName;
|
||||
PreviewFilePath.Text = filePath;
|
||||
PreviewFormatText.Text = formatLabel;
|
||||
PreviewSizeText.Text = sizeText;
|
||||
PreviewDimText.Text = "—";
|
||||
PreviewPageText.Text = "—";
|
||||
var info = FileInspectorBuilder.Build(filePath);
|
||||
PreviewFileName.Text = string.IsNullOrEmpty(fileName) ? info.FileName : fileName;
|
||||
PreviewFilePath.Text = string.IsNullOrEmpty(filePath) ? info.FullPath : filePath;
|
||||
PreviewFormatText.Text = string.IsNullOrEmpty(formatLabel) || formatLabel == "—" ? info.Extension.TrimStart('.').ToUpperInvariant() : formatLabel;
|
||||
PreviewSizeText.Text = string.IsNullOrEmpty(sizeText) || sizeText == "—" ? info.FormattedSize : sizeText;
|
||||
PreviewDimText.Text = info.DimensionsOrMeta;
|
||||
PreviewPageText.Text = info.Category.ToString();
|
||||
}
|
||||
|
||||
private void ShowPreviewLoading()
|
||||
|
|
@ -478,6 +528,97 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
|||
catch { }
|
||||
}
|
||||
|
||||
private void OnPreviewOpenFile(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var path = _selectedPreviewPath ?? _selectedPreviewItem?.SourcePath;
|
||||
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return;
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path)
|
||||
{
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private void ApplyPreset(string? presetName)
|
||||
{
|
||||
if (Enum.TryParse<PresetType>(presetName, true, out var type))
|
||||
{
|
||||
var firstItem = _activeQueue.FirstOrDefault()?.SourcePath;
|
||||
var ext = !string.IsNullOrEmpty(firstItem) ? Path.GetExtension(firstItem) : ".png";
|
||||
var recommended = ConversionPreset.Apply(type, _options, ext);
|
||||
|
||||
for (int i = 0; i < OutputFormatCombo.Items.Count; i++)
|
||||
{
|
||||
if (OutputFormatCombo.Items[i] is OutputFormatInfo info &&
|
||||
info.Extension.Equals(recommended, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
OutputFormatCombo.SelectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var targets = _activeQueue.Where(q => q.IsSelected).ToList();
|
||||
if (targets.Count == 0) targets = _activeQueue.ToList();
|
||||
foreach (var item in targets)
|
||||
{
|
||||
item.SelectedOutputExtension = recommended;
|
||||
}
|
||||
|
||||
QualitySlider.Value = _options.Quality;
|
||||
QualityValueText.Text = _options.Quality.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFilterCategory(string? categoryName)
|
||||
{
|
||||
if (Enum.TryParse<FilterCategory>(categoryName, true, out var cat))
|
||||
{
|
||||
SelectedCategory = cat;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyQueueFilters()
|
||||
{
|
||||
var view = System.Windows.Data.CollectionViewSource.GetDefaultView(_activeQueue);
|
||||
if (view != null)
|
||||
{
|
||||
view.Filter = item =>
|
||||
{
|
||||
if (item is QueueItem q)
|
||||
{
|
||||
return QueueFilterMatcher.Matches(q.FileName, _searchText, _selectedCategory);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
view.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private void BatchSelectAll(object? parameter)
|
||||
{
|
||||
bool select = parameter is true;
|
||||
BatchQueueService.SetSelectionAll(_activeQueue, select);
|
||||
}
|
||||
|
||||
private void BatchRemoveSelected()
|
||||
{
|
||||
BatchQueueService.RemoveSelected(_activeQueue);
|
||||
UpdateBadges();
|
||||
UpdateProcessQueueButton();
|
||||
UpdateActiveQueueVisibility();
|
||||
}
|
||||
|
||||
private void BatchClearCompleted()
|
||||
{
|
||||
BatchQueueService.ClearCompleted(_activeQueue);
|
||||
UpdateBadges();
|
||||
UpdateProcessQueueButton();
|
||||
UpdateActiveQueueVisibility();
|
||||
}
|
||||
|
||||
// ============== Export Log ==============
|
||||
|
||||
private void OnExportLogClick(object sender, RoutedEventArgs e)
|
||||
|
|
@ -1132,14 +1273,30 @@ public sealed class QueueItem : INotifyPropertyChanged
|
|||
private string _state = "queued";
|
||||
private double _progressValue;
|
||||
private Visibility _progressVisibility = Visibility.Collapsed;
|
||||
private bool _isSelected;
|
||||
private string? _selectedOutputExtension;
|
||||
|
||||
public required string SourcePath { get; init; }
|
||||
public required string FileName { get; init; }
|
||||
public required string FormatLabel { get; init; }
|
||||
public required Brush FormatBrush { get; init; }
|
||||
public required string SizeText { get; init; }
|
||||
public required string MetaLine { get; init; }
|
||||
public required long SourceSizeBytes { get; init; }
|
||||
public string SourcePath { get; init; } = "";
|
||||
public string FileName { get; init; } = "";
|
||||
public string FormatLabel { get; init; } = "";
|
||||
public Brush FormatBrush { get; init; } = Brushes.Gray;
|
||||
public string SizeText { get; init; } = "";
|
||||
public string MetaLine { get; init; } = "";
|
||||
public long SourceSizeBytes { get; init; }
|
||||
|
||||
public bool IsSelected
|
||||
{
|
||||
get => _isSelected;
|
||||
set { _isSelected = value; Raise(nameof(IsSelected)); }
|
||||
}
|
||||
|
||||
public string? SelectedOutputExtension
|
||||
{
|
||||
get => _selectedOutputExtension;
|
||||
set { _selectedOutputExtension = value; Raise(nameof(SelectedOutputExtension)); }
|
||||
}
|
||||
|
||||
public bool IsDone => _state == "done";
|
||||
|
||||
/// <summary>형식 카테고리 글리프(라벨 아이콘).</summary>
|
||||
public System.Windows.Media.ImageSource GlyphSource => CategoryGlyphs.ForExtension(Path.GetExtension(SourcePath));
|
||||
|
|
@ -1147,14 +1304,14 @@ public sealed class QueueItem : INotifyPropertyChanged
|
|||
public string StateText
|
||||
{
|
||||
get => _state;
|
||||
set { _state = value; Raise(nameof(StateText)); }
|
||||
set { _state = value; Raise(nameof(StateText)); Raise(nameof(IsDone)); }
|
||||
}
|
||||
|
||||
public Brush StateBrush => _state switch
|
||||
{
|
||||
"queued" => (Brush)Application.Current.FindResource("FsTextTertiary"),
|
||||
"done" => (Brush)Application.Current.FindResource("FsAccentGreen"),
|
||||
_ => (Brush)Application.Current.FindResource("FsAccentBlue"),
|
||||
"queued" => (Application.Current?.TryFindResource("FsTextTertiary") as Brush) ?? Brushes.Gray,
|
||||
"done" => (Application.Current?.TryFindResource("FsAccentGreen") as Brush) ?? Brushes.LightGreen,
|
||||
_ => (Application.Current?.TryFindResource("FsAccentBlue") as Brush) ?? Brushes.DodgerBlue,
|
||||
};
|
||||
|
||||
public double ProgressValue
|
||||
|
|
@ -1198,12 +1355,15 @@ public sealed class QueueItem : INotifyPropertyChanged
|
|||
long size = 0;
|
||||
try { size = new FileInfo(path).Length; } catch { }
|
||||
|
||||
var brush = (Application.Current?.TryFindResource(brushKey) as Brush)
|
||||
?? new SolidColorBrush(Color.FromRgb(0x10, 0xB9, 0x81));
|
||||
|
||||
return new QueueItem
|
||||
{
|
||||
SourcePath = path,
|
||||
FileName = Path.GetFileName(path),
|
||||
FormatLabel = label,
|
||||
FormatBrush = (Brush)Application.Current.FindResource(brushKey),
|
||||
FormatBrush = brush,
|
||||
SizeText = MainWindow.HumanizeBytes(size),
|
||||
MetaLine = $"{ext.ToUpperInvariant()} • {MainWindow.HumanizeBytes(size)}",
|
||||
SourceSizeBytes = size,
|
||||
|
|
|
|||
71
src/Everything2Everything.Core/Filters/QueueFilterMatcher.cs
Normal file
71
src/Everything2Everything.Core/Filters/QueueFilterMatcher.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace Everything2Everything.Core.Filters;
|
||||
|
||||
public enum FilterCategory
|
||||
{
|
||||
All,
|
||||
Image,
|
||||
Document,
|
||||
Media,
|
||||
Data,
|
||||
}
|
||||
|
||||
public static class QueueFilterMatcher
|
||||
{
|
||||
public static FilterCategory GetCategory(string pathOrExt)
|
||||
{
|
||||
var ext = Path.GetExtension(pathOrExt).Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(ext))
|
||||
{
|
||||
ext = pathOrExt.Trim().ToLowerInvariant();
|
||||
if (!ext.StartsWith('.')) ext = "." + ext;
|
||||
}
|
||||
|
||||
return ext switch
|
||||
{
|
||||
".jpg" or ".jpeg" or ".png" or ".webp" or ".avif" or ".gif" or ".bmp" or ".tif" or ".tiff"
|
||||
or ".svg" or ".heic" or ".heif" or ".raw" or ".dng" or ".cr2" or ".cr3" or ".nef" or ".arw"
|
||||
=> FilterCategory.Image,
|
||||
|
||||
".pdf" or ".docx" or ".doc" or ".hwp" or ".hwpx" or ".txt" or ".md" or ".markdown" or ".html" or ".htm" or ".xlsx" or ".xls"
|
||||
=> FilterCategory.Document,
|
||||
|
||||
".mp4" or ".mkv" or ".webm" or ".mov" or ".avi" or ".mp3" or ".wav" or ".flac" or ".aac" or ".m4a" or ".ogg" or ".opus"
|
||||
=> FilterCategory.Media,
|
||||
|
||||
".csv" or ".json" or ".tsv" or ".xml" or ".yaml" or ".yml"
|
||||
=> FilterCategory.Data,
|
||||
|
||||
_ => FilterCategory.All,
|
||||
};
|
||||
}
|
||||
|
||||
public static bool Matches(string filename, string query, FilterCategory category)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filename)) return false;
|
||||
|
||||
// 1. 카테고리 필터 검사
|
||||
if (category != FilterCategory.All)
|
||||
{
|
||||
var itemCategory = GetCategory(filename);
|
||||
if (itemCategory != category) return false;
|
||||
}
|
||||
|
||||
// 2. 검색어 필터 검사
|
||||
if (string.IsNullOrWhiteSpace(query)) return true;
|
||||
|
||||
var cleanQuery = query.Trim();
|
||||
return filename.Contains(cleanQuery, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static System.Collections.Generic.IEnumerable<T> Filter<T>(
|
||||
System.Collections.Generic.IEnumerable<T> source,
|
||||
Func<T, string> fileNameSelector,
|
||||
string query,
|
||||
FilterCategory category)
|
||||
{
|
||||
return System.Linq.Enumerable.Where(source, item => Matches(fileNameSelector(item), query, category));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using Everything2Everything.Core.Filters;
|
||||
|
||||
namespace Everything2Everything.Core.Inspector;
|
||||
|
||||
public sealed record FileInspectorInfo(
|
||||
string FileName,
|
||||
string FullPath,
|
||||
FilterCategory Category,
|
||||
long FileSizeBytes,
|
||||
string FormattedSize,
|
||||
string Extension,
|
||||
string DimensionsOrMeta,
|
||||
bool CanOpen,
|
||||
bool CanReveal);
|
||||
|
||||
public static class FileInspectorBuilder
|
||||
{
|
||||
public static FileInspectorInfo Build(string filePath)
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
if (string.IsNullOrEmpty(fileName)) fileName = filePath;
|
||||
|
||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
var category = QueueFilterMatcher.GetCategory(filePath);
|
||||
|
||||
long size = 0;
|
||||
bool exists = false;
|
||||
try
|
||||
{
|
||||
exists = File.Exists(filePath);
|
||||
if (exists)
|
||||
{
|
||||
size = new FileInfo(filePath).Length;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
var formattedSize = HumanizeBytes(size);
|
||||
var meta = $"{ext.TrimStart('.').ToUpperInvariant()} · {category}";
|
||||
|
||||
return new FileInspectorInfo(
|
||||
FileName: fileName,
|
||||
FullPath: filePath,
|
||||
Category: category,
|
||||
FileSizeBytes: size,
|
||||
FormattedSize: formattedSize,
|
||||
Extension: ext,
|
||||
DimensionsOrMeta: meta,
|
||||
CanOpen: exists,
|
||||
CanReveal: exists);
|
||||
}
|
||||
|
||||
private static string HumanizeBytes(long bytes)
|
||||
{
|
||||
if (bytes <= 0) return "0 B";
|
||||
string[] units = { "B", "KB", "MB", "GB", "TB" };
|
||||
double b = bytes;
|
||||
int u = 0;
|
||||
while (b >= 1024.0 && u < units.Length - 1)
|
||||
{
|
||||
b /= 1024.0;
|
||||
u++;
|
||||
}
|
||||
return u == 0 ? $"{b:0} B" : $"{b:0.1} {units[u]}";
|
||||
}
|
||||
}
|
||||
68
src/Everything2Everything.Core/Presets/ConversionPreset.cs
Normal file
68
src/Everything2Everything.Core/Presets/ConversionPreset.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
using System;
|
||||
|
||||
namespace Everything2Everything.Core.Presets;
|
||||
|
||||
public enum PresetType
|
||||
{
|
||||
WebOptimized,
|
||||
HighQualityLossless,
|
||||
DocumentPdf,
|
||||
MobileShare,
|
||||
}
|
||||
|
||||
public sealed record PresetInfo(PresetType Type, string Title, string Description, string IconSymbol);
|
||||
|
||||
public static class ConversionPreset
|
||||
{
|
||||
public static PresetInfo GetInfo(PresetType type) => type switch
|
||||
{
|
||||
PresetType.WebOptimized => new PresetInfo(type, "웹 최적화", "WebP · 80% 압축 · 메타데이터 제거", "Globe24"),
|
||||
PresetType.HighQualityLossless => new PresetInfo(type, "초고화질 보존", "무손실 PNG/FLAC · 100% 품질", "Sparkle24"),
|
||||
PresetType.DocumentPdf => new PresetInfo(type, "문서 PDF 보관", "표준 PDF/A 문서 변환", "DocumentPdf24"),
|
||||
PresetType.MobileShare => new PresetInfo(type, "모바일 공유", "MP4 H.264 · 가벼운 용량 전송", "Phone24"),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type)),
|
||||
};
|
||||
|
||||
public static string Apply(PresetType type, dynamic options, string inputExtension)
|
||||
{
|
||||
var ext = inputExtension.Trim().ToLowerInvariant();
|
||||
if (!ext.StartsWith('.')) ext = "." + ext;
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case PresetType.WebOptimized:
|
||||
options.ImageQuality = 80;
|
||||
options.StripMetadata = true;
|
||||
if (IsVideo(ext)) return ".mp4";
|
||||
if (IsAudio(ext)) return ".mp3";
|
||||
if (IsDoc(ext)) return ".pdf";
|
||||
return ".webp";
|
||||
|
||||
case PresetType.HighQualityLossless:
|
||||
options.ImageQuality = 100;
|
||||
options.StripMetadata = false;
|
||||
options.AudioBitrateKbps = 320;
|
||||
if (IsAudio(ext)) return ".flac";
|
||||
if (IsVideo(ext)) return ".mkv";
|
||||
return ".png";
|
||||
|
||||
case PresetType.DocumentPdf:
|
||||
return ".pdf";
|
||||
|
||||
case PresetType.MobileShare:
|
||||
options.VideoCrf = 26;
|
||||
options.AudioBitrateKbps = 128;
|
||||
options.VideoPreset = "veryfast";
|
||||
if (IsVideo(ext)) return ".mp4";
|
||||
if (IsAudio(ext)) return ".mp3";
|
||||
return ".jpg";
|
||||
|
||||
default:
|
||||
return ext;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsVideo(string ext) => ext is ".mp4" or ".mkv" or ".webm" or ".mov" or ".avi" or ".m4v";
|
||||
private static bool IsAudio(string ext) => ext is ".mp3" or ".wav" or ".flac" or ".aac" or ".m4a" or ".ogg" or ".opus";
|
||||
private static bool IsDoc(string ext) => ext is ".docx" or ".doc" or ".hwp" or ".hwpx" or ".txt" or ".md" or ".markdown" or ".html";
|
||||
}
|
||||
90
src/Everything2Everything.Tests/BatchQueueActionsTests.cs
Normal file
90
src/Everything2Everything.Tests/BatchQueueActionsTests.cs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using Everything2Everything.App.Views;
|
||||
using Xunit;
|
||||
|
||||
namespace Everything2Everything.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 큐 일괄 처리(Batch Actions) TDD 단위 테스트 스위트
|
||||
/// 전체 선택/해제, 선택 항목 삭제, 완료 항목 정리, 일괄 출력 형식 변경 검증
|
||||
/// </summary>
|
||||
public class BatchQueueActionsTests
|
||||
{
|
||||
private static QueueItem CreateItem(string path, string outExt = ".jpg") =>
|
||||
new()
|
||||
{
|
||||
SourcePath = path,
|
||||
FileName = Path.GetFileName(path),
|
||||
SelectedOutputExtension = outExt,
|
||||
StateText = "queued",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void BatchChangeOutput_UpdatesEligibleItems()
|
||||
{
|
||||
var items = new ObservableCollection<QueueItem>
|
||||
{
|
||||
CreateItem("a.png", ".jpg"),
|
||||
CreateItem("b.png", ".jpg"),
|
||||
CreateItem("c.mp4", ".mp4"),
|
||||
};
|
||||
|
||||
// .webp 지원 여부에 따라 이미지(a.png, b.png)만 .webp로 일괄 변경
|
||||
BatchQueueService.BatchChangeOutput(items, ".webp", new[] { ".png", ".jpg" });
|
||||
|
||||
Assert.Equal(".webp", items[0].SelectedOutputExtension);
|
||||
Assert.Equal(".webp", items[1].SelectedOutputExtension);
|
||||
Assert.Equal(".mp4", items[2].SelectedOutputExtension); // mp4는 제외
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveSelected_RemovesOnlySelectedItems()
|
||||
{
|
||||
var items = new ObservableCollection<QueueItem>
|
||||
{
|
||||
CreateItem("1.png"),
|
||||
CreateItem("2.png"),
|
||||
CreateItem("3.png"),
|
||||
};
|
||||
items[0].IsSelected = true;
|
||||
items[2].IsSelected = true;
|
||||
|
||||
BatchQueueService.RemoveSelected(items);
|
||||
|
||||
Assert.Single(items);
|
||||
Assert.Equal("2.png", items[0].FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearCompleted_RemovesOnlyDoneItems()
|
||||
{
|
||||
var i1 = CreateItem("1.png"); i1.SetState("done");
|
||||
var i2 = CreateItem("2.png"); i2.SetState("45%");
|
||||
var i3 = CreateItem("3.png"); i3.SetState("queued");
|
||||
var i4 = CreateItem("4.png"); i4.SetState("done");
|
||||
|
||||
var items = new ObservableCollection<QueueItem> { i1, i2, i3, i4 };
|
||||
|
||||
BatchQueueService.ClearCompleted(items);
|
||||
|
||||
Assert.Equal(2, items.Count);
|
||||
Assert.All(items, item => Assert.False(item.IsDone));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectAll_TogglesAllItems()
|
||||
{
|
||||
var items = new ObservableCollection<QueueItem>
|
||||
{
|
||||
CreateItem("1.png"),
|
||||
CreateItem("2.png"),
|
||||
};
|
||||
|
||||
BatchQueueService.SetSelectionAll(items, true);
|
||||
Assert.All(items, item => Assert.True(item.IsSelected));
|
||||
|
||||
BatchQueueService.SetSelectionAll(items, false);
|
||||
Assert.All(items, item => Assert.False(item.IsSelected));
|
||||
}
|
||||
}
|
||||
|
|
@ -227,4 +227,36 @@ public class DesignAuditAstTests
|
|||
Assert.True(paddings.Count == 1,
|
||||
$"SettingsWindow 푸터 버튼들의 패딩이 서로 다릅니다 (동일 위계 버튼은 패딩 통일 필수): {string.Join(", ", paddings)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MainWindow_MustHave_Presets_Search_Batch_And_Inspector_Elements()
|
||||
{
|
||||
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||
|
||||
// 1. Quick Presets (PresetCommand 바인딩 버튼 존재)
|
||||
var presetButtons = doc.Descendants()
|
||||
.Where(e => (e.Name.LocalName == "Button" || e.Name.LocalName == "ToggleButton") &&
|
||||
e.Attribute("Command")?.Value.Contains("PresetCommand") == true)
|
||||
.ToList();
|
||||
Assert.True(presetButtons.Count >= 4, "빠른 최적화 프리셋 버튼 4개(웹/고화질/문서/모바일)가 MainWindow.xaml에 선언되어야 합니다.");
|
||||
|
||||
// 2. SearchBox (검색창 존재)
|
||||
var searchBox = doc.Descendants()
|
||||
.FirstOrDefault(e => e.Name.LocalName == "TextBox" &&
|
||||
e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "SearchBox");
|
||||
Assert.NotNull(searchBox);
|
||||
|
||||
// 3. BatchActionBar (일괄 작업 툴바 존재)
|
||||
var batchBar = doc.Descendants()
|
||||
.FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "BatchActionBar");
|
||||
Assert.NotNull(batchBar);
|
||||
|
||||
// 4. Right Inspector Open Button (PreviewOpenFileCommand)
|
||||
var openButtons = doc.Descendants()
|
||||
.Where(e => e.Name.LocalName == "Button" &&
|
||||
e.Attribute("Command")?.Value.Contains("PreviewOpenFileCommand") == true)
|
||||
.ToList();
|
||||
Assert.NotEmpty(openButtons);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
63
src/Everything2Everything.Tests/FileInspectorTests.cs
Normal file
63
src/Everything2Everything.Tests/FileInspectorTests.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using System.IO;
|
||||
using Everything2Everything.Core.Filters;
|
||||
using Everything2Everything.Core.Inspector;
|
||||
using Xunit;
|
||||
|
||||
namespace Everything2Everything.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 파일 인스펙터(Inspector & Metadata Card) TDD 단위 테스트 스위트
|
||||
/// 선택된 파일의 세부 메타데이터, 카테고리, 변환 가능 대상 형식 카운트, 탐색기 연동 정보 검증
|
||||
/// </summary>
|
||||
public class FileInspectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_ValidTextFile_ExtractsMetadataAndTargetFormats()
|
||||
{
|
||||
var tempFile = Path.GetTempFileName() + ".txt";
|
||||
File.WriteAllText(tempFile, "Hello World Everything2Everything Test");
|
||||
|
||||
try
|
||||
{
|
||||
var info = FileInspectorBuilder.Build(tempFile);
|
||||
|
||||
Assert.Equal(Path.GetFileName(tempFile), info.FileName);
|
||||
Assert.Equal(tempFile, info.FullPath);
|
||||
Assert.Equal(FilterCategory.Document, info.Category);
|
||||
Assert.True(info.FileSizeBytes > 0);
|
||||
Assert.NotEmpty(info.FormattedSize);
|
||||
Assert.True(info.CanOpen);
|
||||
Assert.True(info.CanReveal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempFile)) File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_NonExistentFile_ReturnsSafeDefaults()
|
||||
{
|
||||
var ghostFile = "C:\\path\\does_not_exist\\nonexistent.png";
|
||||
var info = FileInspectorBuilder.Build(ghostFile);
|
||||
|
||||
Assert.Equal("nonexistent.png", info.FileName);
|
||||
Assert.Equal(FilterCategory.Image, info.Category);
|
||||
Assert.Equal(0, info.FileSizeBytes);
|
||||
Assert.Equal("0 B", info.FormattedSize);
|
||||
Assert.False(info.CanOpen);
|
||||
Assert.False(info.CanReveal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(".png", FilterCategory.Image)]
|
||||
[InlineData(".mp4", FilterCategory.Media)]
|
||||
[InlineData(".docx", FilterCategory.Document)]
|
||||
[InlineData(".json", FilterCategory.Data)]
|
||||
public void Build_IdentifiesCategoryAccurately(string ext, FilterCategory expectedCategory)
|
||||
{
|
||||
var dummyPath = "C:\\test\\sample" + ext;
|
||||
var info = FileInspectorBuilder.Build(dummyPath);
|
||||
Assert.Equal(expectedCategory, info.Category);
|
||||
}
|
||||
}
|
||||
81
src/Everything2Everything.Tests/PackagingSignatureTests.cs
Normal file
81
src/Everything2Everything.Tests/PackagingSignatureTests.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace Everything2Everything.Tests;
|
||||
|
||||
public class PackagingSignatureTests
|
||||
{
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var current = AppDomain.CurrentDomain.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(current))
|
||||
{
|
||||
if (File.Exists(Path.Combine(current, "Everything2Everything.slnx")) ||
|
||||
File.Exists(Path.Combine(current, "AGENTS.md")))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
var parent = Directory.GetParent(current);
|
||||
if (parent == null) break;
|
||||
current = parent.FullName;
|
||||
}
|
||||
return Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", ".."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppxManifest_Publisher_Matches_DevCert_Subject()
|
||||
{
|
||||
var repoRoot = FindRepoRoot();
|
||||
var manifestPath = Path.Combine(repoRoot, "packaging", "Package.appxmanifest");
|
||||
Assert.True(File.Exists(manifestPath), $"Package.appxmanifest must exist at {manifestPath}");
|
||||
|
||||
var doc = XDocument.Load(manifestPath);
|
||||
var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None;
|
||||
var identity = doc.Root?.Element(ns + "Identity");
|
||||
Assert.NotNull(identity);
|
||||
|
||||
var publisher = identity.Attribute("Publisher")?.Value;
|
||||
Assert.Equal("CN=Everything2EverythingDev", publisher);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvExample_Contains_CodeSigning_Variables()
|
||||
{
|
||||
var repoRoot = FindRepoRoot();
|
||||
var envExamplePath = Path.Combine(repoRoot, ".env.example");
|
||||
Assert.True(File.Exists(envExamplePath), ".env.example template file must exist");
|
||||
|
||||
var content = File.ReadAllText(envExamplePath);
|
||||
Assert.Contains("CODE_SIGN_PFX_PATH", content);
|
||||
Assert.Contains("CODE_SIGN_PFX_PASSWORD", content);
|
||||
Assert.Contains("CODE_SIGN_THUMBPRINT", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishReleaseScript_Enforces_Signing_Flag()
|
||||
{
|
||||
var repoRoot = FindRepoRoot();
|
||||
var publishScript = Path.Combine(repoRoot, "tools", "Publish-Release.ps1");
|
||||
Assert.True(File.Exists(publishScript), "Publish-Release.ps1 must exist");
|
||||
|
||||
var content = File.ReadAllText(publishScript);
|
||||
// BuildMsix must be invoked with -Sign flag
|
||||
Assert.Contains("BuildMsix.ps1", content);
|
||||
Assert.Matches(@"(?i)-Sign\b", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallCmd_Exists_For_OneClick_Elevation()
|
||||
{
|
||||
var repoRoot = FindRepoRoot();
|
||||
var installCmdPath = Path.Combine(repoRoot, "packaging", "Install.cmd");
|
||||
Assert.True(File.Exists(installCmdPath), "packaging/Install.cmd must exist for 1-click end-user installation");
|
||||
|
||||
var content = File.ReadAllText(installCmdPath);
|
||||
Assert.Contains("powershell", content, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("TrustedPeople", content, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
71
src/Everything2Everything.Tests/PresetEngineTests.cs
Normal file
71
src/Everything2Everything.Tests/PresetEngineTests.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using Everything2Everything.App.ViewModels;
|
||||
using Everything2Everything.Core.Presets;
|
||||
using Xunit;
|
||||
|
||||
namespace Everything2Everything.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 1-클릭 빠른 최적화 프리셋 시스템 TDD 단위 테스트 스위트
|
||||
/// 웹 최적화, 무손실 보존, 문서 PDF 보관, 모바일 공유 프리셋의 파라미터 튜닝 및 추천 확장자 검증
|
||||
/// </summary>
|
||||
public class PresetEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public void Apply_WebOptimized_SetsWebPAndOptimalCompression()
|
||||
{
|
||||
var options = new OptionsViewModel();
|
||||
var targetExt = ConversionPreset.Apply(PresetType.WebOptimized, options, ".png");
|
||||
|
||||
Assert.Equal(".webp", targetExt);
|
||||
Assert.Equal(80, options.ImageQuality);
|
||||
Assert.True(options.StripMetadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_HighQualityLossless_SetsMaxQualityAndFlacOrPng()
|
||||
{
|
||||
var options = new OptionsViewModel();
|
||||
var audioTarget = ConversionPreset.Apply(PresetType.HighQualityLossless, options, ".wav");
|
||||
Assert.Equal(".flac", audioTarget);
|
||||
Assert.Equal(320, options.AudioBitrateKbps);
|
||||
|
||||
var imgTarget = ConversionPreset.Apply(PresetType.HighQualityLossless, options, ".jpg");
|
||||
Assert.Equal(".png", imgTarget);
|
||||
Assert.Equal(100, options.ImageQuality);
|
||||
Assert.False(options.StripMetadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_DocumentPdf_SetsPdfTargetForDocuments()
|
||||
{
|
||||
var options = new OptionsViewModel();
|
||||
var docxTarget = ConversionPreset.Apply(PresetType.DocumentPdf, options, ".docx");
|
||||
Assert.Equal(".pdf", docxTarget);
|
||||
|
||||
var hwpTarget = ConversionPreset.Apply(PresetType.DocumentPdf, options, ".hwp");
|
||||
Assert.Equal(".pdf", hwpTarget);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_MobileShare_SetsLightweightVideoAndAudio()
|
||||
{
|
||||
var options = new OptionsViewModel();
|
||||
var videoTarget = ConversionPreset.Apply(PresetType.MobileShare, options, ".mkv");
|
||||
Assert.Equal(".mp4", videoTarget);
|
||||
Assert.Equal(26, options.VideoCrf);
|
||||
Assert.Equal(128, options.AudioBitrateKbps);
|
||||
Assert.Equal("veryfast", options.VideoPreset);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PresetType.WebOptimized, "웹 최적화", "WebP · 80% 압축 · 메타데이터 제거")]
|
||||
[InlineData(PresetType.HighQualityLossless, "초고화질 보존", "무손실 PNG/FLAC · 100% 품질")]
|
||||
[InlineData(PresetType.DocumentPdf, "문서 PDF 보관", "표준 PDF/A 문서 변환")]
|
||||
[InlineData(PresetType.MobileShare, "모바일 공유", "MP4 H.264 · 가벼운 용량 전송")]
|
||||
public void Presets_HaveUserFacingTitleAndDescription(PresetType type, string expectedTitle, string expectedDesc)
|
||||
{
|
||||
var info = ConversionPreset.GetInfo(type);
|
||||
Assert.Equal(expectedTitle, info.Title);
|
||||
Assert.Equal(expectedDesc, info.Description);
|
||||
}
|
||||
}
|
||||
60
src/Everything2Everything.Tests/QueueFilterTests.cs
Normal file
60
src/Everything2Everything.Tests/QueueFilterTests.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
using Everything2Everything.Core.Filters;
|
||||
using Xunit;
|
||||
|
||||
namespace Everything2Everything.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 실시간 큐 및 히스토리 검색 & 카테고리 필터 TDD 단위 테스트 스위트
|
||||
/// 파일명/확장자 검색 및 멀티미디어/문서/이미지/데이터 카테고리 분류 로직 검증
|
||||
/// </summary>
|
||||
public class QueueFilterTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("photo.jpg", FilterCategory.Image)]
|
||||
[InlineData("vector.svg", FilterCategory.Image)]
|
||||
[InlineData("report.docx", FilterCategory.Document)]
|
||||
[InlineData("hwp_doc.hwp", FilterCategory.Document)]
|
||||
[InlineData("document.pdf", FilterCategory.Document)]
|
||||
[InlineData("movie.mp4", FilterCategory.Media)]
|
||||
[InlineData("podcast.mp3", FilterCategory.Media)]
|
||||
[InlineData("audio.wav", FilterCategory.Media)]
|
||||
[InlineData("dataset.csv", FilterCategory.Data)]
|
||||
[InlineData("config.json", FilterCategory.Data)]
|
||||
public void GetCategory_ClassifiesCorrectCategory(string filename, FilterCategory expected)
|
||||
{
|
||||
var category = QueueFilterMatcher.GetCategory(filename);
|
||||
Assert.Equal(expected, category);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("photo.jpg", "", FilterCategory.All, true)]
|
||||
[InlineData("photo.jpg", "photo", FilterCategory.All, true)]
|
||||
[InlineData("photo.jpg", "PHOTO", FilterCategory.All, true)]
|
||||
[InlineData("photo.jpg", ".jpg", FilterCategory.All, true)]
|
||||
[InlineData("photo.jpg", "video", FilterCategory.All, false)]
|
||||
[InlineData("photo.jpg", "", FilterCategory.Image, true)]
|
||||
[InlineData("photo.jpg", "", FilterCategory.Document, false)]
|
||||
[InlineData("report.docx", "rep", FilterCategory.Document, true)]
|
||||
[InlineData("report.docx", "rep", FilterCategory.Image, false)]
|
||||
[InlineData("video.mp4", "vid", FilterCategory.Media, true)]
|
||||
public void Matches_FiltersBySearchTextAndCategory(string filename, string query, FilterCategory category, bool expected)
|
||||
{
|
||||
var matches = QueueFilterMatcher.Matches(filename, query, category);
|
||||
Assert.Equal(expected, matches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterCollection_ReturnsMatchingItemsOnly()
|
||||
{
|
||||
var items = new[]
|
||||
{
|
||||
new Everything2Everything.App.Views.QueueItem { SourcePath = "C:\\a.png", FileName = "a.png" },
|
||||
new Everything2Everything.App.Views.QueueItem { SourcePath = "C:\\b.docx", FileName = "b.docx" },
|
||||
new Everything2Everything.App.Views.QueueItem { SourcePath = "C:\\c.mp4", FileName = "c.mp4" },
|
||||
};
|
||||
|
||||
var filtered = QueueFilterMatcher.Filter(items, item => item.FileName, "", FilterCategory.Image).ToList();
|
||||
Assert.Single(filtered);
|
||||
Assert.Equal("a.png", filtered[0].FileName);
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,20 @@ $RootDir = Split-Path -Parent $ScriptDir
|
|||
$Tag = "v$Version"
|
||||
$FourPartVersion = "$Version.0"
|
||||
|
||||
function Import-EnvFile {
|
||||
param([string]$Path)
|
||||
if (Test-Path $Path) {
|
||||
Get-Content $Path | Where-Object { $_ -match '^\s*([^#=\s]+)\s*=\s*(.*)$' } | ForEach-Object {
|
||||
$key = $matches[1].Trim()
|
||||
$val = $matches[2].Trim().Trim('"').Trim("'")
|
||||
if (-not [string]::IsNullOrEmpty($key) -and -not [Environment]::GetEnvironmentVariable($key)) {
|
||||
[Environment]::SetEnvironmentVariable($key, $val, 'Process')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Import-EnvFile (Join-Path $RootDir '.env')
|
||||
|
||||
Write-Host "==========================================================" -ForegroundColor Cyan
|
||||
Write-Host " Everything2Everything 릴리즈 파이프라인" -ForegroundColor Cyan
|
||||
Write-Host " 버전: $Version (Tag: $Tag, Manifest: $FourPartVersion)" -ForegroundColor Cyan
|
||||
|
|
@ -107,12 +121,40 @@ if (-not $DryRun) {
|
|||
Compress-Archive -Path "$publishDir\*" -DestinationPath $portableZip
|
||||
Write-Host " - Portable ZIP 생성: $portableZip" -ForegroundColor Gray
|
||||
|
||||
# 3. MSIX 패키징
|
||||
# 3. MSIX 패키징 및 디지털 서명
|
||||
$buildMsixScript = Join-Path $RootDir "packaging/BuildMsix.ps1"
|
||||
if (Test-Path $buildMsixScript) {
|
||||
Write-Host " - MSIX 패키징 실행..." -ForegroundColor Gray
|
||||
& pwsh -File $buildMsixScript -Configuration Release -Platform x64
|
||||
Write-Host " - MSIX 패키징 및 자동 서명 실행..." -ForegroundColor Gray
|
||||
& pwsh -File $buildMsixScript -Configuration Release -Platform x64 -Sign
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "MSIX 패키징 및 서명 실패!"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# 서명 무결성 검증 (0x800B010A 방지 게이트)
|
||||
$msixFile = Join-Path $distDir "Everything2Everything-x64.msix"
|
||||
if (Test-Path $msixFile) {
|
||||
$sig = Get-AuthenticodeSignature $msixFile
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
Write-Error "MSIX 서명 검증 실패 (Status: $($sig.Status))! 미서명 패키지는 배포할 수 없습니다."
|
||||
exit 1
|
||||
}
|
||||
Write-Host " - MSIX 디지털 서명 검증 통과 (Status: Valid, Signer: $($sig.SignerCertificate.Subject))" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# 4. 1-클릭 설치 번들 ZIP 생성 (MSIX + .cer + Install.cmd)
|
||||
$setupZip = Join-Path $distDir "Everything2Everything-$Version-Setup.zip"
|
||||
$cerFile = Join-Path $distDir "Everything2Everything-DevCert.cer"
|
||||
$installCmd = Join-Path $distDir "Install.cmd"
|
||||
|
||||
$bundleFiles = @($msixFile)
|
||||
if (Test-Path $cerFile) { $bundleFiles += $cerFile }
|
||||
if (Test-Path $installCmd) { $bundleFiles += $installCmd }
|
||||
|
||||
if (Test-Path $setupZip) { Remove-Item $setupZip -Force }
|
||||
Compress-Archive -Path $bundleFiles -DestinationPath $setupZip
|
||||
Write-Host " - 1-클릭 설치 번들 ZIP 생성: $setupZip" -ForegroundColor Gray
|
||||
} else {
|
||||
Write-Host " (DryRun: 빌드 단계 건너뜀)" -ForegroundColor DarkGray
|
||||
}
|
||||
|
|
@ -199,6 +241,18 @@ $ReleaseNotes
|
|||
curl.exe -s -u "yunchan:ONVI2v4J#y" -X POST "$uploadUrl`?name=Everything2Everything-x64.msix" -F "attachment=@$msixFile" | Out-Null
|
||||
Write-Host " -> MSIX 업로드 완료!" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
if (Test-Path $setupZip) {
|
||||
Write-Host " - 1-클릭 설치 번들 ZIP 업로드 중..." -ForegroundColor Gray
|
||||
curl.exe -s -u "yunchan:ONVI2v4J#y" -X POST "$uploadUrl`?name=Everything2Everything-$Version-Setup.zip" -F "attachment=@$setupZip" | Out-Null
|
||||
Write-Host " -> 1-클릭 설치 번들 ZIP 업로드 완료!" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
if (Test-Path $cerFile) {
|
||||
Write-Host " - 공개 개발자 인증서(.cer) 업로드 중..." -ForegroundColor Gray
|
||||
curl.exe -s -u "yunchan:ONVI2v4J#y" -X POST "$uploadUrl`?name=Everything2Everything-DevCert.cer" -F "attachment=@$cerFile" | Out-Null
|
||||
Write-Host " -> 공개 개발자 인증서 업로드 완료!" -ForegroundColor Gray
|
||||
}
|
||||
} catch {
|
||||
Write-Warning "Forgejo 릴리즈 API 호출 중 경고: $_"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue