1
0
Fork 0

feat(ui): implement 1-click presets, real-time search & filter bar, batch queue actions, and file inspector

This commit is contained in:
Yun Chan 2026-09-03 17:12:14 +09:00
parent 41c8a8a94b
commit b67b3f1b61
17 changed files with 1240 additions and 38 deletions

16
.env.example Normal file
View 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

View file

@ -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
View 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

View file

@ -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) },

View 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;
}
}
}
}

View file

@ -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>
<!-- 실시간 검색 & 카테고리 필터 툴바 -->
<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.Column="0">
<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"
Style="{StaticResource FsSecondaryButtonStyle}"
Command="{Binding PreviewOpenFolderCommand, RelativeSource={RelativeSource AncestorType=Window}}"
HorizontalAlignment="Stretch"/>
<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}}"
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"

View file

@ -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,

View 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));
}
}

View file

@ -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]}";
}
}

View 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";
}

View 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));
}
}

View file

@ -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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View file

@ -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 호출 중 경고: $_"
}