diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..02c09f9
--- /dev/null
+++ b/.env.example
@@ -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
diff --git a/packaging/BuildMsix.ps1 b/packaging/BuildMsix.ps1
index 4fe0e3e..f18484a 100644
--- a/packaging/BuildMsix.ps1
+++ b/packaging/BuildMsix.ps1
@@ -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 미지정). 사이드로드 시 인증서 필요.'
diff --git a/packaging/Install.cmd b/packaging/Install.cmd
new file mode 100644
index 0000000..bb4fa69
--- /dev/null
+++ b/packaging/Install.cmd
@@ -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
diff --git a/src/Everything2Everything.App/ViewModels/OptionsViewModel.cs b/src/Everything2Everything.App/ViewModels/OptionsViewModel.cs
index 23c6d92..5c2da02 100644
--- a/src/Everything2Everything.App/ViewModels/OptionsViewModel.cs
+++ b/src/Everything2Everything.App/ViewModels/OptionsViewModel.cs
@@ -13,6 +13,26 @@ public partial class OptionsViewModel : ObservableObject
/// JPEG/WebP 품질(1~100). AVIF는 -30 보정.
[ObservableProperty] private int _quality = 85;
+ /// EXIF 및 메타데이터 제거 여부.
+ [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
+ };
+ }
+
/// 비우면 원본 옆 서브폴더. 값이 있으면 사용자 지정 출력 폴더.
[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) },
diff --git a/src/Everything2Everything.App/Views/BatchQueueService.cs b/src/Everything2Everything.App/Views/BatchQueueService.cs
new file mode 100644
index 0000000..3fc94f2
--- /dev/null
+++ b/src/Everything2Everything.App/Views/BatchQueueService.cs
@@ -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 items, bool isSelected)
+ {
+ foreach (var item in items)
+ {
+ item.IsSelected = isSelected;
+ }
+ }
+
+ public static void RemoveSelected(IList items)
+ {
+ var toRemove = items.Where(i => i.IsSelected).ToList();
+ foreach (var item in toRemove)
+ {
+ items.Remove(item);
+ }
+ }
+
+ public static void ClearCompleted(IList 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 items, string newOutputExt, IEnumerable eligibleInputExtensions)
+ {
+ var eligibleSet = new HashSet(
+ 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;
+ }
+ }
+ }
+}
diff --git a/src/Everything2Everything.App/Views/MainWindow.xaml b/src/Everything2Everything.App/Views/MainWindow.xaml
index 3a12d31..66d4157 100644
--- a/src/Everything2Everything.App/Views/MainWindow.xaml
+++ b/src/Everything2Everything.App/Views/MainWindow.xaml
@@ -89,6 +89,49 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -585,15 +628,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -663,16 +811,19 @@
CornerRadius="6,0,0,6"/>
+
-
+
-
-
-
-
+
@@ -872,7 +1024,7 @@
-
@@ -1008,17 +1160,40 @@
Foreground="{StaticResource FsTextPrimary}" Text="—"/>
-
+
+
+
+
+
+
+
+
+
-
_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? 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(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(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";
/// 형식 카테고리 글리프(라벨 아이콘).
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,
diff --git a/src/Everything2Everything.Core/Filters/QueueFilterMatcher.cs b/src/Everything2Everything.Core/Filters/QueueFilterMatcher.cs
new file mode 100644
index 0000000..344aead
--- /dev/null
+++ b/src/Everything2Everything.Core/Filters/QueueFilterMatcher.cs
@@ -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 Filter(
+ System.Collections.Generic.IEnumerable source,
+ Func fileNameSelector,
+ string query,
+ FilterCategory category)
+ {
+ return System.Linq.Enumerable.Where(source, item => Matches(fileNameSelector(item), query, category));
+ }
+}
diff --git a/src/Everything2Everything.Core/Inspector/FileInspectorInfo.cs b/src/Everything2Everything.Core/Inspector/FileInspectorInfo.cs
new file mode 100644
index 0000000..ec7cbb1
--- /dev/null
+++ b/src/Everything2Everything.Core/Inspector/FileInspectorInfo.cs
@@ -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]}";
+ }
+}
diff --git a/src/Everything2Everything.Core/Presets/ConversionPreset.cs b/src/Everything2Everything.Core/Presets/ConversionPreset.cs
new file mode 100644
index 0000000..a277c43
--- /dev/null
+++ b/src/Everything2Everything.Core/Presets/ConversionPreset.cs
@@ -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";
+}
diff --git a/src/Everything2Everything.Tests/BatchQueueActionsTests.cs b/src/Everything2Everything.Tests/BatchQueueActionsTests.cs
new file mode 100644
index 0000000..3050c03
--- /dev/null
+++ b/src/Everything2Everything.Tests/BatchQueueActionsTests.cs
@@ -0,0 +1,90 @@
+using System.Collections.ObjectModel;
+using System.IO;
+using Everything2Everything.App.Views;
+using Xunit;
+
+namespace Everything2Everything.Tests;
+
+///
+/// 큐 일괄 처리(Batch Actions) TDD 단위 테스트 스위트
+/// 전체 선택/해제, 선택 항목 삭제, 완료 항목 정리, 일괄 출력 형식 변경 검증
+///
+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
+ {
+ 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
+ {
+ 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 { 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
+ {
+ 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));
+ }
+}
diff --git a/src/Everything2Everything.Tests/DesignAuditAstTests.cs b/src/Everything2Everything.Tests/DesignAuditAstTests.cs
index d0b884c..d9a0894 100644
--- a/src/Everything2Everything.Tests/DesignAuditAstTests.cs
+++ b/src/Everything2Everything.Tests/DesignAuditAstTests.cs
@@ -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);
+ }
}
diff --git a/src/Everything2Everything.Tests/FileInspectorTests.cs b/src/Everything2Everything.Tests/FileInspectorTests.cs
new file mode 100644
index 0000000..87e268d
--- /dev/null
+++ b/src/Everything2Everything.Tests/FileInspectorTests.cs
@@ -0,0 +1,63 @@
+using System.IO;
+using Everything2Everything.Core.Filters;
+using Everything2Everything.Core.Inspector;
+using Xunit;
+
+namespace Everything2Everything.Tests;
+
+///
+/// 파일 인스펙터(Inspector & Metadata Card) TDD 단위 테스트 스위트
+/// 선택된 파일의 세부 메타데이터, 카테고리, 변환 가능 대상 형식 카운트, 탐색기 연동 정보 검증
+///
+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);
+ }
+}
diff --git a/src/Everything2Everything.Tests/PackagingSignatureTests.cs b/src/Everything2Everything.Tests/PackagingSignatureTests.cs
new file mode 100644
index 0000000..b877369
--- /dev/null
+++ b/src/Everything2Everything.Tests/PackagingSignatureTests.cs
@@ -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);
+ }
+}
diff --git a/src/Everything2Everything.Tests/PresetEngineTests.cs b/src/Everything2Everything.Tests/PresetEngineTests.cs
new file mode 100644
index 0000000..96aa0d0
--- /dev/null
+++ b/src/Everything2Everything.Tests/PresetEngineTests.cs
@@ -0,0 +1,71 @@
+using Everything2Everything.App.ViewModels;
+using Everything2Everything.Core.Presets;
+using Xunit;
+
+namespace Everything2Everything.Tests;
+
+///
+/// 1-클릭 빠른 최적화 프리셋 시스템 TDD 단위 테스트 스위트
+/// 웹 최적화, 무손실 보존, 문서 PDF 보관, 모바일 공유 프리셋의 파라미터 튜닝 및 추천 확장자 검증
+///
+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);
+ }
+}
diff --git a/src/Everything2Everything.Tests/QueueFilterTests.cs b/src/Everything2Everything.Tests/QueueFilterTests.cs
new file mode 100644
index 0000000..49446f0
--- /dev/null
+++ b/src/Everything2Everything.Tests/QueueFilterTests.cs
@@ -0,0 +1,60 @@
+using Everything2Everything.Core.Filters;
+using Xunit;
+
+namespace Everything2Everything.Tests;
+
+///
+/// 실시간 큐 및 히스토리 검색 & 카테고리 필터 TDD 단위 테스트 스위트
+/// 파일명/확장자 검색 및 멀티미디어/문서/이미지/데이터 카테고리 분류 로직 검증
+///
+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);
+ }
+}
diff --git a/tools/Publish-Release.ps1 b/tools/Publish-Release.ps1
index 7f4a268..8693728 100644
--- a/tools/Publish-Release.ps1
+++ b/tools/Publish-Release.ps1
@@ -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 호출 중 경고: $_"
}