diff --git a/.github/actions/spell-check/allow/code.txt b/.github/actions/spell-check/allow/code.txt index 756c450534..41a53d33ed 100644 --- a/.github/actions/spell-check/allow/code.txt +++ b/.github/actions/spell-check/allow/code.txt @@ -321,3 +321,10 @@ REGSTR # Misc Win32 APIs and PInvokes INVOKEIDLIST + +# PowerRename metadata pattern abbreviations (used in tests and regex patterns) +DDDD +FFF +HHH +riday +YYY diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 31dabc785a..63469ee8ee 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -171,9 +171,12 @@ BYPOSITION CALCRECT CALG callbackptr +cabstr calpwstr +caub Cangjie CANRENAME +Carlseibert Canvascustomlayout CAPTUREBLT CAPTURECHANGED @@ -279,6 +282,7 @@ cpptools cppvsdbg cppwinrt createdump +creativecommons CREATEPROCESS CREATESCHEDULEDTASK CREATESTRUCT @@ -341,6 +345,7 @@ Deact debugbreak decryptor Dedup +dfx Deduplicator Deeplink DEFAULTBOOTSTRAPPERINSTALLFOLDER @@ -514,6 +519,7 @@ EXTRINSICPROPERTIES eyetracker FANCYZONESDRAWLAYOUTTEST FANCYZONESEDITOR +FNumber FARPROC fdx fesf @@ -697,6 +703,7 @@ HTCLIENT hthumbnail HTOUCHINPUT HTTRANSPARENT +hutchinsoniana HVal HValue Hvci @@ -718,7 +725,9 @@ IDCANCEL IDD idk idl +IIM idlist +ifd IDOK IDOn IDR @@ -735,6 +744,7 @@ Ijwhost ILD IMAGEHLP IMAGERESIZERCONTEXTMENU +IPTC IMAGERESIZEREXT imageresizerinput imageresizersettings @@ -874,6 +884,7 @@ LOCKTYPE LOGFONT LOGFONTW logon +lon LOGMSG LOGPIXELSX LOGPIXELSY @@ -965,6 +976,7 @@ MENUITEMINFOW MERGECOPY MERGEPAINT Metadatas +metadatamatters metafile mfc Mgmt @@ -1124,6 +1136,7 @@ NONCLIENTMETRICSW NONELEVATED nonspace nonstd +nullrefs NOOWNERZORDER NOPARENTNOTIFY NOPREFIX @@ -1280,6 +1293,7 @@ pnid PNMLINK Poc Podcasts +Photoshop POINTERID POINTERUPDATE Pokedex @@ -1482,6 +1496,7 @@ sacl safeprojectname SAMEKEYPREVIOUSLYMAPPED SAMESHORTCUTPREVIOUSLYMAPPED +samsung sancov SAVEFAILED scanled @@ -1844,6 +1859,7 @@ USEINSTALLERFORTEST USESHOWWINDOW USESTDHANDLES USRDLL +utm UType uuidv uwp @@ -1933,6 +1949,7 @@ wgpocpl WHEREID wic wifi +wikimedia wikipedia WIL winapi @@ -2027,7 +2044,9 @@ XAxis XButton xclip xcopy +xap XDeployment +XDimension xdf XDocument XElement @@ -2045,6 +2064,7 @@ xsi XSpeed XStr xstyler +xmp XTimer XUP XVIRTUALSCREEN @@ -2052,6 +2072,7 @@ xxxxxx YAxis ycombinator YIncrement +YDimension yinle yinyue YPels diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu.win10/NewPlus.ShellExtension.win10.vcxproj b/src/modules/NewPlus/NewShellExtensionContextMenu.win10/NewPlus.ShellExtension.win10.vcxproj index 16c6b4efbc..74d87c5623 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu.win10/NewPlus.ShellExtension.win10.vcxproj +++ b/src/modules/NewPlus/NewShellExtensionContextMenu.win10/NewPlus.ShellExtension.win10.vcxproj @@ -92,6 +92,7 @@ + @@ -99,7 +100,7 @@ - + diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu.win10/pch.h b/src/modules/NewPlus/NewShellExtensionContextMenu.win10/pch.h index 5018653070..711063d0cb 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu.win10/pch.h +++ b/src/modules/NewPlus/NewShellExtensionContextMenu.win10/pch.h @@ -3,6 +3,7 @@ #pragma once #define WIN32_LEAN_AND_MEAN +#define NOMINMAX #define NOMCX #define NOHELP #define NOCOMM diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/Helpers.cpp b/src/modules/NewPlus/NewShellExtensionContextMenu/Helpers.cpp new file mode 100644 index 0000000000..945a516ca4 --- /dev/null +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/Helpers.cpp @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#include "pch.h" +#include "Helpers.h" +#include + +// Minimal subset of PowerRename Helpers used by NewPlus +// This is a copy from PowerRename main branch to avoid cross-module dependencies + +HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SYSTEMTIME fileTime) +{ + std::locale::global(std::locale("")); + HRESULT hr = E_INVALIDARG; + if (source && wcslen(source) > 0) + { + std::wstring res(source); + wchar_t replaceTerm[MAX_PATH] = { 0 }; + wchar_t formattedDate[MAX_PATH] = { 0 }; + + wchar_t localeName[LOCALE_NAME_MAX_LENGTH]; + if (GetUserDefaultLocaleName(localeName, LOCALE_NAME_MAX_LENGTH) == 0) + { + StringCchCopy(localeName, LOCALE_NAME_MAX_LENGTH, L"en_US"); + } + + int hour12 = (fileTime.wHour % 12); + if (hour12 == 0) + { + hour12 = 12; + } + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%04d"), L"$01", fileTime.wYear); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$YYYY"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", (fileTime.wYear % 100)); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$YY"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", (fileTime.wYear % 10)); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$Y"), replaceTerm); + + GetDateFormatEx(localeName, NULL, &fileTime, L"MMMM", formattedDate, MAX_PATH, NULL); + formattedDate[0] = towupper(formattedDate[0]); + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", formattedDate); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$MMMM"), replaceTerm); + + GetDateFormatEx(localeName, NULL, &fileTime, L"MMM", formattedDate, MAX_PATH, NULL); + formattedDate[0] = towupper(formattedDate[0]); + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", formattedDate); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$MMM"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wMonth); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$MM"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wMonth); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$M"), replaceTerm); + + GetDateFormatEx(localeName, NULL, &fileTime, L"dddd", formattedDate, MAX_PATH, NULL); + formattedDate[0] = towupper(formattedDate[0]); + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", formattedDate); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$DDDD"), replaceTerm); + + GetDateFormatEx(localeName, NULL, &fileTime, L"ddd", formattedDate, MAX_PATH, NULL); + formattedDate[0] = towupper(formattedDate[0]); + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", formattedDate); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$DDD"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wDay); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$DD"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wDay); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$D"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", hour12); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$HH"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", hour12); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$H"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", (fileTime.wHour < 12) ? L"AM" : L"PM"); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$TT"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", (fileTime.wHour < 12) ? L"am" : L"pm"); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$tt"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wHour); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$hh"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wHour); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$h"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wMinute); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$mm"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wMinute); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$m"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wSecond); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$ss"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wSecond); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$s"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%03d"), L"$01", fileTime.wMilliseconds); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$fff"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wMilliseconds / 10); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$ff"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wMilliseconds / 100); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$f"), replaceTerm); + + hr = StringCchCopy(result, cchMax, res.c_str()); + } + + return hr; +} diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/Helpers.h b/src/modules/NewPlus/NewShellExtensionContextMenu/Helpers.h new file mode 100644 index 0000000000..540478856e --- /dev/null +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/Helpers.h @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma once + +// Minimal subset of PowerRename Helpers used by NewPlus +// This is a copy from PowerRename's main branch to avoid cross-module dependencies +HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SYSTEMTIME fileTime); diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj index 6685afafc2..c650439b65 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj @@ -110,6 +110,7 @@ MakeAppx.exe pack /d . /p $(OutDir)NewPlusPackage.msix /nv + @@ -127,7 +128,7 @@ MakeAppx.exe pack /d . /p $(OutDir)NewPlusPackage.msix /nv - + diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/helpers_variables.h b/src/modules/NewPlus/NewShellExtensionContextMenu/helpers_variables.h index 63f23c3e86..1d511f2afe 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/helpers_variables.h +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/helpers_variables.h @@ -1,7 +1,7 @@ #pragma once #include -#include "..\..\powerrename\lib\Helpers.h" +#include "Helpers.h" #include "helpers_filesystem.h" #pragma comment(lib, "Pathcch.lib") diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/new_utilities.h b/src/modules/NewPlus/NewShellExtensionContextMenu/new_utilities.h index 50f92562d2..02874ab8f3 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/new_utilities.h +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/new_utilities.h @@ -302,9 +302,9 @@ namespace newplus::utilities POINT mouse_position; GetCursorPos(&mouse_position); mouse_position.x -= GetSystemMetrics(SM_CXMENUSIZE); - mouse_position.x = max(mouse_position.x, 20); + mouse_position.x = (std::max)(mouse_position.x, 20L); mouse_position.y -= GetSystemMetrics(SM_CXMENUSIZE)/2; - mouse_position.y = max(mouse_position.y, 20); + mouse_position.y = (std::max)(mouse_position.y, 20L); POINT position[] = { mouse_position }; folder_view->SelectAndPositionItems(1, shell_item_to_select_and_position, position, common_select_flags | SVSI_POSITIONITEM); } diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/pch.h b/src/modules/NewPlus/NewShellExtensionContextMenu/pch.h index b766a837d5..13093e1d08 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/pch.h +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/pch.h @@ -3,6 +3,7 @@ #pragma once #define WIN32_LEAN_AND_MEAN +#define NOMINMAX #define NOMCX #define NOHELP #define NOCOMM @@ -13,6 +14,7 @@ #include #include #include +#include #include #include #include diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/template_item.cpp b/src/modules/NewPlus/NewShellExtensionContextMenu/template_item.cpp index a7ddfe835f..a178e00195 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/template_item.cpp +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/template_item.cpp @@ -60,8 +60,8 @@ std::wstring template_item::get_target_filename(const bool include_starting_digi std::wstring template_item::remove_starting_digits_from_filename(std::wstring filename) const { - filename.erase(0, min(filename.find_first_not_of(L"0123456789"), filename.size())); - filename.erase(0, min(filename.find_first_not_of(L" ."), filename.size())); + filename.erase(0, std::min(filename.find_first_not_of(L"0123456789"), filename.size())); + filename.erase(0, std::min(filename.find_first_not_of(L" ."), filename.size())); return filename; } diff --git a/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj b/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj index 1a7ca91972..3afb41f546 100644 --- a/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj +++ b/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj @@ -52,7 +52,7 @@ true true true - legacy_stdio_definitions.lib;$(VCToolsInstallDir)lib\$(Platform)\libsancov.lib;$(CoreLibraryDependencies);%(AdditionalDependencies) + legacy_stdio_definitions.lib;windowscodecs.lib;$(VCToolsInstallDir)lib\$(Platform)\libsancov.lib;$(CoreLibraryDependencies);%(AdditionalDependencies) xcopy /y "$(VCToolsInstallDir)bin\Hostx64\x64\clang_rt.asan_dynamic-x86_64.dll" "$(OutDir)" diff --git a/src/modules/powerrename/PowerRenameContextMenu/PowerRenameContextMenu.vcxproj b/src/modules/powerrename/PowerRenameContextMenu/PowerRenameContextMenu.vcxproj index af3c71ad8e..a101c28ac9 100644 --- a/src/modules/powerrename/PowerRenameContextMenu/PowerRenameContextMenu.vcxproj +++ b/src/modules/powerrename/PowerRenameContextMenu/PowerRenameContextMenu.vcxproj @@ -51,7 +51,7 @@ Windows true false - runtimeobject.lib;%(AdditionalDependencies) + runtimeobject.lib;windowscodecs.lib;%(AdditionalDependencies) Source.def @@ -75,7 +75,7 @@ MakeAppx.exe pack /d . /p $(OutDir)PowerRenameContextMenuPackage.msix /nvtrue true false - runtimeobject.lib;%(AdditionalDependencies) + runtimeobject.lib;windowscodecs.lib;%(AdditionalDependencies) Source.def diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj b/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj index df105e5815..eb21a94049 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj @@ -79,7 +79,7 @@ _DEBUG;%(PreprocessorDefinitions) - kernel32.lib;user32.lib;dwmapi.lib;Shcore.lib;%(AdditionalDependencies) + kernel32.lib;user32.lib;dwmapi.lib;Shcore.lib;windowscodecs.lib;propsys.lib;ole32.lib;%(AdditionalDependencies) @@ -89,7 +89,7 @@ true true - kernel32.lib;user32.lib;dwmapi.lib;Shcore.lib;%(AdditionalDependencies) + kernel32.lib;user32.lib;dwmapi.lib;Shcore.lib;windowscodecs.lib;propsys.lib;ole32.lib;%(AdditionalDependencies) diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/App.xaml.cpp b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/App.xaml.cpp index 67f1834499..b46c5e548d 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/App.xaml.cpp +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/App.xaml.cpp @@ -1,4 +1,4 @@ -#include "pch.h" +#include "pch.h" #include "App.xaml.h" #include "MainWindow.xaml.h" @@ -117,6 +117,9 @@ App::App() /// Details about the launch request and process. void App::OnLaunched(LaunchActivatedEventArgs const&) { + // WinUI3 framework automatically initializes COM as STA on the main thread + // No manual initialization needed for WIC operations + LoggerHelpers::init_logger(moduleName, L"", LogSettings::powerRenameLoggerName); if (powertoys_gpo::getConfiguredPowerRenameEnabledValue() == powertoys_gpo::gpo_rule_configured_disabled) @@ -237,7 +240,6 @@ void App::OnLaunched(LaunchActivatedEventArgs const&) } #else #define BUFSIZE 4096 * 4 - BOOL bSuccess; WCHAR chBuf[BUFSIZE]; DWORD dwRead; @@ -269,4 +271,4 @@ void App::OnLaunched(LaunchActivatedEventArgs const&) window = make(); window.Activate(); -} \ No newline at end of file +} diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.idl b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.idl index 041e3d1921..bb02ec2e14 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.idl +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.idl @@ -16,6 +16,7 @@ namespace PowerRenameUI Windows.Foundation.Collections.IObservableVector DateTimeShortcuts { get; }; Windows.Foundation.Collections.IObservableVector CounterShortcuts { get; }; Windows.Foundation.Collections.IObservableVector RandomizerShortcuts { get; }; + Windows.Foundation.Collections.IObservableVector MetadataShortcuts { get; }; String OriginalCount; String RenamedCount; diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml index 7126a63604..1c67d9a73b 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml @@ -330,6 +330,8 @@ + + + + + + + + + + + + + + + + + + + + @@ -560,31 +604,61 @@ FontFamily="{ThemeResource SymbolThemeFontFamily}" /> - + + + + + + + + + + + + + - - + + + + + + + + + diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.cpp b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.cpp index 7cc3bf9543..01c7c517c2 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.cpp +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.cpp @@ -1,4 +1,4 @@ -#include "pch.h" +#include "pch.h" #include "MainWindow.xaml.h" #if __has_include("MainWindow.g.cpp") #include "MainWindow.g.cpp" @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -225,6 +226,11 @@ namespace winrt::PowerRenameUI::implementation m_RandomizerShortcuts.Append(winrt::make(L"${rstringdigit=36}", manager.MainResourceMap().GetValue(L"Resources/RandomizerCheatSheet_Digit").ValueAsString())); m_RandomizerShortcuts.Append(winrt::make(L"${ruuidv4}", manager.MainResourceMap().GetValue(L"Resources/RandomizerCheatSheet_Uuid").ValueAsString())); + // Initialize metadata shortcuts - will be populated based on selected metadata type + m_metadataShortcuts = winrt::single_threaded_observable_vector(); + // Initialize with EXIF patterns (default) + UpdateMetadataShortcuts(PowerRenameLib::MetadataType::EXIF); + InitializeComponent(); m_etwTrace.UpdateState(true); @@ -356,7 +362,10 @@ namespace winrt::PowerRenameUI::implementation hstring MainWindow::OriginalCount() { UINT count = 0; - m_prManager->GetItemCount(&count); + if (m_prManager) + { + m_prManager->GetItemCount(&count); + } return hstring{ std::to_wstring(count) }; } @@ -394,13 +403,16 @@ namespace winrt::PowerRenameUI::implementation button_showAll().IsChecked(true); button_showRenamed().IsChecked(false); - DWORD filter = 0; - m_prManager->GetFilter(&filter); - if (filter != PowerRenameFilters::None) + if (m_prManager) { - m_prManager->SwitchFilter(0); - get_self(m_explorerItems)->SetIsFiltered(false); - InvalidateItemListViewState(); + DWORD filter = 0; + m_prManager->GetFilter(&filter); + if (filter != PowerRenameFilters::None) + { + m_prManager->SwitchFilter(0); + get_self(m_explorerItems)->SetIsFiltered(false); + InvalidateItemListViewState(); + } } } @@ -409,14 +421,17 @@ namespace winrt::PowerRenameUI::implementation button_showRenamed().IsChecked(true); button_showAll().IsChecked(false); - DWORD filter = 0; - m_prManager->GetFilter(&filter); - if (filter != PowerRenameFilters::ShouldRename) + if (m_prManager) { - m_prManager->SwitchFilter(0); - UpdateCounts(); - get_self(m_explorerItems)->SetIsFiltered(true); - InvalidateItemListViewState(); + DWORD filter = 0; + m_prManager->GetFilter(&filter); + if (filter != PowerRenameFilters::ShouldRename) + { + m_prManager->SwitchFilter(0); + UpdateCounts(); + get_self(m_explorerItems)->SetIsFiltered(true); + InvalidateItemListViewState(); + } } } @@ -434,6 +449,27 @@ namespace winrt::PowerRenameUI::implementation textBox_replace().Text(textBox_replace().Text() + s->Code()); } + void MainWindow::MetadataItemClick(winrt::Windows::Foundation::IInspectable const&, winrt::Microsoft::UI::Xaml::Controls::ItemClickEventArgs const& e) + { + auto s = e.ClickedItem().try_as(); + DateTimeFlyout().Hide(); + textBox_replace().Text(textBox_replace().Text() + s->Code()); + } + + void MainWindow::MetadataSourceComboBox_SelectionChanged(winrt::Windows::Foundation::IInspectable const&, winrt::Microsoft::UI::Xaml::Controls::SelectionChangedEventArgs const&) + { + int selectedIndex = comboBox_metadataSource().SelectedIndex(); + + // Get the selected metadata type based on ComboBox selection + PowerRenameLib::MetadataType metadataType = static_cast(selectedIndex); + + // Update the metadata shortcuts list + UpdateMetadataShortcuts(metadataType); + + // Update the metadata source flags + UpdateMetadataSourceFlags(selectedIndex); + } + void MainWindow::button_rename_Click(winrt::Microsoft::UI::Xaml::Controls::SplitButton const&, winrt::Microsoft::UI::Xaml::Controls::SplitButtonClickEventArgs const&) { Rename(false); @@ -621,6 +657,12 @@ namespace winrt::PowerRenameUI::implementation { _TRACER_; + if (!m_prManager) + { + // Manager not initialized yet, ignore flag updates + return; + } + DWORD flags{}; m_prManager->GetFlags(&flags); @@ -818,6 +860,7 @@ namespace winrt::PowerRenameUI::implementation UpdateFlag(ModificationTime, UpdateFlagCommand::Reset); } }); + } void MainWindow::ToggleItem(int32_t id, bool checked) @@ -1049,6 +1092,15 @@ namespace winrt::PowerRenameUI::implementation { toggleButton_capitalize().IsChecked(true); } + + int metadataIndex = (flags & MetadataSourceXMP) ? 1 : 0; + if (comboBox_metadataSource().SelectedIndex() != metadataIndex) + { + comboBox_metadataSource().SelectedIndex(metadataIndex); + } + + auto metadataType = metadataIndex == 1 ? PowerRenameLib::MetadataType::XMP : PowerRenameLib::MetadataType::EXIF; + UpdateMetadataShortcuts(metadataType); } void MainWindow::UpdateCounts() @@ -1081,6 +1133,220 @@ namespace winrt::PowerRenameUI::implementation RenamedCount(hstring{ std::to_wstring(m_renamingCount) }); } + void MainWindow::UpdateMetadataShortcuts(PowerRenameLib::MetadataType metadataType) + { + // Clear existing list + m_metadataShortcuts.Clear(); + + // Get supported patterns for the selected metadata type + auto supportedPatterns = PowerRenameLib::MetadataPatternExtractor::GetSupportedPatterns(metadataType); + + auto factory = winrt::get_activation_factory(); + ResourceManager manager = factory.CreateInstance(L"PowerToys.PowerRename.pri"); + + // Add each supported pattern to the list + for (const auto& pattern : supportedPatterns) + { + std::wstring resourceKey = L"Resources/MetadataCheatSheet_" + ConvertPatternToResourceKey(pattern); + winrt::hstring patternWithDollar = winrt::hstring(L"$" + pattern); + + try { + auto description = manager.MainResourceMap().GetValue(resourceKey).ValueAsString(); + m_metadataShortcuts.Append(winrt::make(patternWithDollar, description)); + } + catch (...) { + // If resource doesn't exist, use the pattern name as description + m_metadataShortcuts.Append(winrt::make(patternWithDollar, winrt::hstring(pattern))); + } + } + } + + std::wstring MainWindow::ConvertPatternToResourceKey(const std::wstring& pattern) + { + // Special cases for patterns that don't follow the standard naming convention + if (pattern == L"TITLE") + { + return L"DocTitle"; + } + else if (pattern == L"DATE_TAKEN_YYYY") + { + return L"DateTakenYear4"; + } + else if (pattern == L"DATE_TAKEN_YY") + { + return L"DateTakenYear2"; + } + else if (pattern == L"DATE_TAKEN_MM") + { + return L"DateTakenMonth"; + } + else if (pattern == L"DATE_TAKEN_DD") + { + return L"DateTakenDay"; + } + else if (pattern == L"DATE_TAKEN_HH") + { + return L"DateTakenHour"; + } + else if (pattern == L"DATE_TAKEN_mm") + { + return L"DateTakenMinute"; + } + else if (pattern == L"DATE_TAKEN_SS") + { + return L"DateTakenSecond"; + } + else if (pattern == L"CREATE_DATE_YYYY") + { + return L"CreateDateYear4"; + } + else if (pattern == L"CREATE_DATE_YY") + { + return L"CreateDateYear2"; + } + else if (pattern == L"CREATE_DATE_MM") + { + return L"CreateDateMonth"; + } + else if (pattern == L"CREATE_DATE_DD") + { + return L"CreateDateDay"; + } + else if (pattern == L"CREATE_DATE_HH") + { + return L"CreateDateHour"; + } + else if (pattern == L"CREATE_DATE_mm") + { + return L"CreateDateMinute"; + } + else if (pattern == L"CREATE_DATE_SS") + { + return L"CreateDateSecond"; + } + else if (pattern == L"MODIFY_DATE_YYYY") + { + return L"ModifyDateYear4"; + } + else if (pattern == L"MODIFY_DATE_YY") + { + return L"ModifyDateYear2"; + } + else if (pattern == L"MODIFY_DATE_MM") + { + return L"ModifyDateMonth"; + } + else if (pattern == L"MODIFY_DATE_DD") + { + return L"ModifyDateDay"; + } + else if (pattern == L"MODIFY_DATE_HH") + { + return L"ModifyDateHour"; + } + else if (pattern == L"MODIFY_DATE_mm") + { + return L"ModifyDateMinute"; + } + else if (pattern == L"MODIFY_DATE_SS") + { + return L"ModifyDateSecond"; + } + else if (pattern == L"METADATA_DATE_YYYY") + { + return L"MetadataDateYear4"; + } + else if (pattern == L"METADATA_DATE_YY") + { + return L"MetadataDateYear2"; + } + else if (pattern == L"METADATA_DATE_MM") + { + return L"MetadataDateMonth"; + } + else if (pattern == L"METADATA_DATE_DD") + { + return L"MetadataDateDay"; + } + else if (pattern == L"METADATA_DATE_HH") + { + return L"MetadataDateHour"; + } + else if (pattern == L"METADATA_DATE_mm") + { + return L"MetadataDateMinute"; + } + else if (pattern == L"METADATA_DATE_SS") + { + return L"MetadataDateSecond"; + } + else if (pattern == L"ISO") + { + return L"ISO"; + } + else if (pattern == L"TITLE") + { + return L"DocTitle"; + } + else if (pattern == L"DESCRIPTION") + { + return L"DocDescription"; + } + else if (pattern == L"CREATOR") + { + return L"DocCreator"; + } + else if (pattern == L"SUBJECT") + { + return L"DocSubject"; + } + else if (pattern == L"RIGHTS") + { + return L"Rights"; + } + + // Convert pattern name to resource key format + // e.g., "CAMERA_MAKE" -> "CameraMake" + std::wstring result; + bool capitalizeNext = true; + + for (wchar_t ch : pattern) + { + if (ch == L'_') + { + capitalizeNext = true; + } + else + { + if (capitalizeNext) + { + result += static_cast(std::toupper(ch)); + capitalizeNext = false; + } + else + { + result += static_cast(std::tolower(ch)); + } + } + } + + return result; + } + + void MainWindow::UpdateMetadataSourceFlags(int selectedIndex) + { + // Clear all metadata source flags first + UpdateFlag(MetadataSourceEXIF, UpdateFlagCommand::Reset); + UpdateFlag(MetadataSourceXMP, UpdateFlagCommand::Reset); + + // Set the appropriate metadata source flag based on selection + switch(selectedIndex) { + case 0: UpdateFlag(MetadataSourceEXIF, UpdateFlagCommand::Set); break; + case 1: UpdateFlag(MetadataSourceXMP, UpdateFlagCommand::Set); break; + default: UpdateFlag(MetadataSourceEXIF, UpdateFlagCommand::Set); break; // Default to EXIF + } + } + HRESULT MainWindow::OnRename(_In_ IPowerRenameItem* /*renameItem*/) { UpdateCounts(); @@ -1122,3 +1388,6 @@ namespace winrt::PowerRenameUI::implementation return S_OK; } } + + + diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.h b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.h index 8c70194f1b..cff802f582 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.h +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.h @@ -1,4 +1,4 @@ -#pragma once +#pragma once #include "winrt/Windows.UI.Xaml.h" #include "winrt/Windows.UI.Xaml.Markup.h" @@ -20,6 +20,8 @@ #include #include #include +#include +#include namespace winrt::PowerRenameUI::implementation { @@ -88,6 +90,7 @@ namespace winrt::PowerRenameUI::implementation winrt::Windows::Foundation::Collections::IObservableVector DateTimeShortcuts() { return m_dateTimeShortcuts; } winrt::Windows::Foundation::Collections::IObservableVector CounterShortcuts() { return m_CounterShortcuts; } winrt::Windows::Foundation::Collections::IObservableVector RandomizerShortcuts() { return m_RandomizerShortcuts; } + winrt::Windows::Foundation::Collections::IObservableVector MetadataShortcuts() { return m_metadataShortcuts; } hstring OriginalCount(); void OriginalCount(hstring value); @@ -111,6 +114,7 @@ namespace winrt::PowerRenameUI::implementation winrt::Windows::Foundation::Collections::IObservableVector m_dateTimeShortcuts; winrt::Windows::Foundation::Collections::IObservableVector m_CounterShortcuts; winrt::Windows::Foundation::Collections::IObservableVector m_RandomizerShortcuts; + winrt::Windows::Foundation::Collections::IObservableVector m_metadataShortcuts; // Used by PowerRenameManagerEvents HRESULT OnRename(_In_ IPowerRenameItem* renameItem); @@ -144,6 +148,9 @@ namespace winrt::PowerRenameUI::implementation HRESULT OpenSettingsApp(); void SetCheckboxesFromFlags(DWORD flags); void UpdateCounts(); + void UpdateMetadataShortcuts(PowerRenameLib::MetadataType metadataType); + std::wstring ConvertPatternToResourceKey(const std::wstring& pattern); + void UpdateMetadataSourceFlags(int selectedIndex); Shared::Trace::ETWTrace m_etwTrace{}; @@ -167,6 +174,8 @@ namespace winrt::PowerRenameUI::implementation public: void RegExItemClick(winrt::Windows::Foundation::IInspectable const& sender, winrt::Microsoft::UI::Xaml::Controls::ItemClickEventArgs const& e); void DateTimeItemClick(winrt::Windows::Foundation::IInspectable const& sender, winrt::Microsoft::UI::Xaml::Controls::ItemClickEventArgs const& e); + void MetadataItemClick(winrt::Windows::Foundation::IInspectable const& sender, winrt::Microsoft::UI::Xaml::Controls::ItemClickEventArgs const& e); + void MetadataSourceComboBox_SelectionChanged(winrt::Windows::Foundation::IInspectable const& sender, winrt::Microsoft::UI::Xaml::Controls::SelectionChangedEventArgs const& e); void button_rename_Click(winrt::Microsoft::UI::Xaml::Controls::SplitButton const& sender, winrt::Microsoft::UI::Xaml::Controls::SplitButtonClickEventArgs const& args); void MenuFlyoutItem_Click(winrt::Windows::Foundation::IInspectable const& sender, winrt::Microsoft::UI::Xaml::RoutedEventArgs const& e); void OpenDocs(winrt::Windows::Foundation::IInspectable const& sender, winrt::Microsoft::UI::Xaml::RoutedEventArgs const& e); @@ -179,3 +188,4 @@ namespace winrt::PowerRenameUI::factory_implementation { }; } + diff --git a/src/modules/powerrename/PowerRenameUILib/Strings/en-us/Resources.resw b/src/modules/powerrename/PowerRenameUILib/Strings/en-us/Resources.resw index 9af9e2365b..178106908d 100644 --- a/src/modules/powerrename/PowerRenameUILib/Strings/en-us/Resources.resw +++ b/src/modules/powerrename/PowerRenameUILib/Strings/en-us/Resources.resw @@ -414,6 +414,9 @@ Time used for replacement + + Metadata source for replacement + Creation Time @@ -423,4 +426,149 @@ Access Time + + + EXIF Metadata + + + XMP Metadata + + + + Replace with media metadata + + + Camera manufacturer name + + + Camera model name + + + Lens model name + + + ISO sensitivity value + + + F-number aperture value + + + Shutter speed value + + + Focal length in millimeters + + + Flash status (On/Off) + + + Image width in pixels + + + Image height in pixels + + + Image author/artist + + + Copyright information + + + GPS latitude coordinate + + + GPS longitude coordinate + + + GPS altitude in meters + + + Exposure compensation value + + + Image orientation + + + Color space information + + + Year photo was taken (4 digits) + + + Year photo was taken (2 digits) + + + Month photo was taken (01-12) + + + Day photo was taken (01-31) + + + Hour photo was taken (00-23) + + + Minute photo was taken (00-59) + + + Second photo was taken (00-59) + + + Year from XMP create date (4 digits) + + + Year from XMP create date (2 digits) + + + Month from XMP create date (01-12) + + + Day from XMP create date (01-31) + + + Hour from XMP create date (00-23) + + + Minute from XMP create date (00-59) + + + Second from XMP create date (00-59) + + + + + Software used to create/edit + + + + + Document title + + + Document description + + + Document creator/author + + + Keywords/tags + + + + + Copyright/rights information + + + + + Document unique identifier + + + Instance unique identifier + + + Original document identifier + + + Version identifier + \ No newline at end of file diff --git a/src/modules/powerrename/dll/PowerRenameExt.vcxproj b/src/modules/powerrename/dll/PowerRenameExt.vcxproj index 9c612a08ff..2364012861 100644 --- a/src/modules/powerrename/dll/PowerRenameExt.vcxproj +++ b/src/modules/powerrename/dll/PowerRenameExt.vcxproj @@ -24,7 +24,7 @@ ..\lib\;..\PowerRenameUILib\;..\;..\..\..\;..\..\..\common\telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories) - Pathcch.lib;comctl32.lib;shcore.lib;%(AdditionalDependencies) + Pathcch.lib;comctl32.lib;shcore.lib;windowscodecs.lib;%(AdditionalDependencies) PowerRenameExt.def gdi32.dll;shell32.dll;ole32.dll;shlwapi.dll;oleaut32.dll;%(DelayLoadDLLs) diff --git a/src/modules/powerrename/lib/Helpers.cpp b/src/modules/powerrename/lib/Helpers.cpp index 2515a8a7ae..c3902c7b93 100644 --- a/src/modules/powerrename/lib/Helpers.cpp +++ b/src/modules/powerrename/lib/Helpers.cpp @@ -1,9 +1,13 @@ #include "pch.h" #include "Helpers.h" +#include "MetadataTypes.h" #include #include #include #include +#include +#include +#include namespace fs = std::filesystem; @@ -12,6 +16,50 @@ namespace const int MAX_INPUT_STRING_LEN = 1024; const wchar_t c_rootRegPath[] = L"Software\\Microsoft\\PowerRename"; + + // Helper function: Find the longest matching pattern starting at the given position + // Returns the matched pattern name, or empty string if no match found + std::wstring FindLongestPattern( + const std::wstring& input, + size_t startPos, + size_t maxPatternLength, + const std::unordered_set& validPatterns) + { + const size_t remaining = input.length() - startPos; + const size_t searchLength = std::min(maxPatternLength, remaining); + + // Try to match from longest to shortest to ensure greedy matching + // e.g., DATE_TAKEN_YYYY should be matched before DATE_TAKEN_YY + for (size_t len = searchLength; len > 0; --len) + { + std::wstring candidate = input.substr(startPos, len); + if (validPatterns.find(candidate) != validPatterns.end()) + { + return candidate; + } + } + + return L""; + } + + // Helper function: Get the replacement value for a pattern + // Returns the actual metadata value if available; if not, returns the pattern name with $ prefix + std::wstring GetPatternValue( + const std::wstring& patternName, + const PowerRenameLib::MetadataPatternMap& patterns) + { + auto it = patterns.find(patternName); + + // Return actual value if found and valid (non-empty) + if (it != patterns.end() && !it->second.empty()) + { + return it->second; + } + + // Return pattern name with $ prefix if value is unavailable + // This provides visual feedback that the field exists but has no data + return L"$" + patternName; + } } HRESULT GetTrimmedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source) @@ -271,6 +319,72 @@ bool isFileTimeUsed(_In_ PCWSTR source) return used; } +bool isMetadataUsed(_In_ PCWSTR source, PowerRenameLib::MetadataType metadataType, _In_opt_ PCWSTR filePath, bool isFolder) +{ + if (!source) return false; + + // Early exit: If file path is provided, check file type first (fastest checks) + // This avoids expensive pattern matching for files that don't support metadata + if (filePath != nullptr) + { + // Folders don't support metadata extraction + if (isFolder) + { + return false; + } + + // Check if file path is valid + if (wcslen(filePath) == 0) + { + return false; + } + + // Get file extension + std::wstring extension = fs::path(filePath).extension().wstring(); + + // Convert to lowercase for case-insensitive comparison + std::transform(extension.begin(), extension.end(), extension.begin(), ::towlower); + + // According to the metadata support table, only these formats support metadata extraction: + // - JPEG (IFD, Exif, XMP, GPS, IPTC) - supports fast metadata encoding + // - TIFF (IFD, Exif, XMP, GPS, IPTC) - supports fast metadata encoding + // - PNG (text chunks) + static const std::unordered_set supportedExtensions = { + L".jpg", + L".jpeg", + L".png", + L".tif", + L".tiff" + }; + + // If file type doesn't support metadata, no need to check patterns + if (supportedExtensions.find(extension) == supportedExtensions.end()) + { + return false; + } + } + + // Now check if any metadata pattern exists in the source string + // This is the most expensive check, so we do it last + std::wstring str(source); + + // Get supported patterns for the specified metadata type + auto supportedPatterns = PowerRenameLib::MetadataPatternExtractor::GetSupportedPatterns(metadataType); + + // Check if any metadata pattern exists in the source string + for (const auto& pattern : supportedPatterns) + { + std::wstring searchPattern = L"$" + pattern; + if (str.find(searchPattern) != std::wstring::npos) + { + return true; + } + } + + // No metadata pattern found + return false; +} + HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SYSTEMTIME fileTime) { std::locale::global(std::locale("")); @@ -297,10 +411,10 @@ HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SY res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$YYYY"), replaceTerm); StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", (fileTime.wYear % 100)); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$YY"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$YY(?![A-Z])"), replaceTerm); // Negative lookahead prevents matching $YYY, $YYYY, or metadata patterns StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", (fileTime.wYear % 10)); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$Y"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$Y(?![A-Z])"), replaceTerm); // Negative lookahead prevents matching $YY, $YYYY, or metadata patterns GetDateFormatEx(localeName, NULL, &fileTime, L"MMMM", formattedDate, MAX_PATH, NULL); formattedDate[0] = towupper(formattedDate[0]); @@ -310,13 +424,13 @@ HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SY GetDateFormatEx(localeName, NULL, &fileTime, L"MMM", formattedDate, MAX_PATH, NULL); formattedDate[0] = towupper(formattedDate[0]); StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", formattedDate); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$MMM"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$MMM(?!M)"), replaceTerm); // Negative lookahead prevents matching $MMMM StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wMonth); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$MM"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$MM(?![A-Z])"), replaceTerm); // Negative lookahead prevents matching $MMM, $MMMM, or metadata patterns StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wMonth); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$M"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$M(?![A-Z])"), replaceTerm); // Negative lookahead prevents matching $MM, $MMM, $MMMM, or metadata patterns GetDateFormatEx(localeName, NULL, &fileTime, L"dddd", formattedDate, MAX_PATH, NULL); formattedDate[0] = towupper(formattedDate[0]); @@ -326,19 +440,19 @@ HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SY GetDateFormatEx(localeName, NULL, &fileTime, L"ddd", formattedDate, MAX_PATH, NULL); formattedDate[0] = towupper(formattedDate[0]); StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", formattedDate); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$DDD"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$DDD(?![A-Z])"), replaceTerm); // Negative lookahead prevents matching $DDDD or metadata patterns StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wDay); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$DD"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$DD(?![A-Z])"), replaceTerm); // Negative lookahead prevents matching $DDD, $DDDD, or metadata patterns StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wDay); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$D"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$D(?![A-Z])"), replaceTerm); // Negative lookahead prevents matching $DD, $DDD, $DDDD, or metadata patterns like $DATE_TAKEN_YYYY StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", hour12); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$HH"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$HH(?![A-Z])"), replaceTerm); // Negative lookahead prevents matching $HHH or metadata patterns StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", hour12); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$H"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$H(?![A-Z])"), replaceTerm); // Negative lookahead prevents matching $HH or metadata patterns StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", (fileTime.wHour < 12) ? L"AM" : L"PM"); res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$TT"), replaceTerm); @@ -347,31 +461,31 @@ HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SY res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$tt"), replaceTerm); StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wHour); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$hh"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$hh(?!h)"), replaceTerm); // Negative lookahead prevents matching $hhh StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wHour); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$h"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$h(?!h)"), replaceTerm); // Negative lookahead prevents matching $hh StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wMinute); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$mm"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$mm(?!m)"), replaceTerm); // Negative lookahead prevents matching $mmm StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wMinute); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$m"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$m(?!m)"), replaceTerm); // Negative lookahead prevents matching $mm StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wSecond); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$ss"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$ss(?!s)"), replaceTerm); // Negative lookahead prevents matching $sss StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wSecond); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$s"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$s(?!s)"), replaceTerm); // Negative lookahead prevents matching $ss StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%03d"), L"$01", fileTime.wMilliseconds); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$fff"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$fff(?!f)"), replaceTerm); // Negative lookahead prevents matching $ffff StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wMilliseconds / 10); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$ff"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$ff(?!f)"), replaceTerm); // Negative lookahead prevents matching $fff StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wMilliseconds / 100); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$f"), replaceTerm); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$f(?!f)"), replaceTerm); // Negative lookahead prevents matching $ff or $fff hr = StringCchCopy(result, cchMax, res.c_str()); } @@ -379,6 +493,91 @@ HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SY return hr; } +HRESULT GetMetadataFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, const PowerRenameLib::MetadataPatternMap& patterns) +{ + if (!source || wcslen(source) == 0) + { + return E_INVALIDARG; + } + + std::wstring input(source); + std::wstring output; + output.reserve(input.length() * 2); // Reserve space to avoid frequent reallocations + + // Build pattern lookup table for fast validation + // Using all possible patterns to recognize valid pattern names even when metadata is unavailable + auto allPatterns = PowerRenameLib::MetadataPatternExtractor::GetAllPossiblePatterns(); + std::unordered_set validPatterns; + validPatterns.reserve(allPatterns.size()); + size_t maxPatternLength = 0; + for (const auto& pattern : allPatterns) + { + validPatterns.insert(pattern); + maxPatternLength = std::max(maxPatternLength, pattern.length()); + } + + size_t pos = 0; + while (pos < input.length()) + { + // Handle regular characters + if (input[pos] != L'$') + { + output += input[pos]; + pos++; + continue; + } + + // Count consecutive dollar signs + size_t dollarCount = 0; + while (pos < input.length() && input[pos] == L'$') + { + dollarCount++; + pos++; + } + + // Even number of dollars: all are escaped (e.g., $$ -> $, $$$$ -> $$) + if (dollarCount % 2 == 0) + { + output.append(dollarCount / 2, L'$'); + continue; + } + + // Odd number of dollars: pairs are escaped, last one might be a pattern prefix + // e.g., $ -> might be pattern, $$$ -> $ + might be pattern + size_t escapedDollars = dollarCount / 2; + + // If no more characters, output all dollar signs + if (pos >= input.length()) + { + output.append(dollarCount, L'$'); + continue; + } + + // Try to match a pattern (greedy matching for longest pattern) + std::wstring matchedPattern = FindLongestPattern(input, pos, maxPatternLength, validPatterns); + + if (matchedPattern.empty()) + { + // No pattern matched, output all dollar signs + output.append(dollarCount, L'$'); + } + else + { + // Pattern matched + output.append(escapedDollars, L'$'); // Output escaped dollars first + + // Replace pattern with its value or keep pattern name if value unavailable + std::wstring replacementValue = GetPatternValue(matchedPattern, patterns); + output += replacementValue; + + pos += matchedPattern.length(); + } + } + + return StringCchCopy(result, cchMax, output.c_str()); +} + + HRESULT GetShellItemArrayFromDataObject(_In_ IUnknown* dataSource, _COM_Outptr_ IShellItemArray** items) { *items = nullptr; @@ -707,4 +906,4 @@ std::wstring CreateGuidStringWithoutBrackets() } return L""; -} \ No newline at end of file +} diff --git a/src/modules/powerrename/lib/Helpers.h b/src/modules/powerrename/lib/Helpers.h index 05b8eab19d..83659637c9 100644 --- a/src/modules/powerrename/lib/Helpers.h +++ b/src/modules/powerrename/lib/Helpers.h @@ -1,13 +1,17 @@ #pragma once #include "PowerRenameInterfaces.h" +#include "MetadataTypes.h" +#include "MetadataPatternExtractor.h" #include HRESULT GetTrimmedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source); HRESULT GetTransformedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, DWORD flags, bool isFolder); HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SYSTEMTIME fileTime); +HRESULT GetMetadataFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, const PowerRenameLib::MetadataPatternMap& patterns); bool isFileTimeUsed(_In_ PCWSTR source); +bool isMetadataUsed(_In_ PCWSTR source, PowerRenameLib::MetadataType metadataType, _In_opt_ PCWSTR filePath = nullptr, bool isFolder = false); bool ShellItemArrayContainsRenamableItem(_In_ IShellItemArray* shellItemArray); bool DataObjectContainsRenamableItem(_In_ IUnknown* dataSource); HRESULT GetShellItemArrayFromDataObject(_In_ IUnknown* dataSource, _COM_Outptr_ IShellItemArray** items); diff --git a/src/modules/powerrename/lib/MetadataFormatHelper.cpp b/src/modules/powerrename/lib/MetadataFormatHelper.cpp new file mode 100644 index 0000000000..e6b88e913a --- /dev/null +++ b/src/modules/powerrename/lib/MetadataFormatHelper.cpp @@ -0,0 +1,237 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#include "pch.h" +#include "MetadataFormatHelper.h" +#include +#include +#include + +using namespace PowerRenameLib; + +// Formatting functions + +std::wstring MetadataFormatHelper::FormatAperture(double aperture) +{ + return std::format(L"f/{:.1f}", aperture); +} + +std::wstring MetadataFormatHelper::FormatShutterSpeed(double speed) +{ + if (speed <= 0.0) + { + return L"0"; + } + + if (speed >= 1.0) + { + return std::format(L"{:.1f}s", speed); + } + + const double reciprocal = std::round(1.0 / speed); + if (reciprocal <= 1.0) + { + return std::format(L"{:.3f}s", speed); + } + + return std::format(L"1/{:.0f}s", reciprocal); +} + +std::wstring MetadataFormatHelper::FormatISO(int64_t iso) +{ + if (iso <= 0) + { + return L"ISO"; + } + + return std::format(L"ISO {}", iso); +} + +std::wstring MetadataFormatHelper::FormatFlash(int64_t flashValue) +{ + switch (flashValue & 0x1) + { + case 0: + return L"Flash Off"; + case 1: + return L"Flash On"; + default: + break; + } + + return std::format(L"Flash 0x{:X}", static_cast(flashValue)); +} + +std::wstring MetadataFormatHelper::FormatCoordinate(double coord, bool isLatitude) +{ + wchar_t direction = isLatitude ? (coord >= 0.0 ? L'N' : L'S') : (coord >= 0.0 ? L'E' : L'W'); + double absolute = std::abs(coord); + int degrees = static_cast(absolute); + double minutes = (absolute - static_cast(degrees)) * 60.0; + + return std::format(L"{:d}°{:.2f}'{}", degrees, minutes, direction); +} + +std::wstring MetadataFormatHelper::FormatSystemTime(const SYSTEMTIME& st) +{ + return std::format(L"{:04d}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}", + st.wYear, + st.wMonth, + st.wDay, + st.wHour, + st.wMinute, + st.wSecond); +} + +// Parsing functions + +double MetadataFormatHelper::ParseGPSRational(const PROPVARIANT& pv) +{ + if ((pv.vt & VT_VECTOR) && pv.caub.cElems >= 8) + { + return ParseSingleRational(pv.caub.pElems, 0); + } + return 0.0; +} + +double MetadataFormatHelper::ParseSingleRational(const uint8_t* bytes, size_t offset) +{ + // Parse a single rational number (8 bytes: numerator + denominator) + if (!bytes) + return 0.0; + + // Note: Callers are responsible for ensuring the buffer is large enough. + // This function assumes offset points to at least 8 bytes of valid data. + // All current callers perform cElems >= required_size checks before calling. + const uint8_t* rationalBytes = bytes + offset; + + // Parse as little-endian uint32_t values + uint32_t numerator = static_cast(rationalBytes[0]) | + (static_cast(rationalBytes[1]) << 8) | + (static_cast(rationalBytes[2]) << 16) | + (static_cast(rationalBytes[3]) << 24); + + uint32_t denominator = static_cast(rationalBytes[4]) | + (static_cast(rationalBytes[5]) << 8) | + (static_cast(rationalBytes[6]) << 16) | + (static_cast(rationalBytes[7]) << 24); + + if (denominator != 0) + { + return static_cast(numerator) / static_cast(denominator); + } + + return 0.0; +} + +double MetadataFormatHelper::ParseSingleSRational(const uint8_t* bytes, size_t offset) +{ + // Parse a single signed rational number (8 bytes: signed numerator + signed denominator) + if (!bytes) + return 0.0; + + // Note: Callers are responsible for ensuring the buffer is large enough. + // This function assumes offset points to at least 8 bytes of valid data. + // All current callers perform cElems >= required_size checks before calling. + const uint8_t* rationalBytes = bytes + offset; + + // Parse as little-endian int32_t values (signed) + // First construct as unsigned, then reinterpret as signed + uint32_t numerator_uint = static_cast(rationalBytes[0]) | + (static_cast(rationalBytes[1]) << 8) | + (static_cast(rationalBytes[2]) << 16) | + (static_cast(rationalBytes[3]) << 24); + + uint32_t denominator_uint = static_cast(rationalBytes[4]) | + (static_cast(rationalBytes[5]) << 8) | + (static_cast(rationalBytes[6]) << 16) | + (static_cast(rationalBytes[7]) << 24); + + // Reinterpret as signed + int32_t numerator = static_cast(numerator_uint); + int32_t denominator = static_cast(denominator_uint); + + if (denominator != 0) + { + return static_cast(numerator) / static_cast(denominator); + } + + return 0.0; +} + +std::pair MetadataFormatHelper::ParseGPSCoordinates( + const PROPVARIANT& latitude, + const PROPVARIANT& longitude, + const PROPVARIANT& latRef, + const PROPVARIANT& lonRef) +{ + double lat = 0.0, lon = 0.0; + + // Parse latitude - typically stored as 3 rationals (degrees, minutes, seconds) + if ((latitude.vt & VT_VECTOR) && latitude.caub.cElems >= 24) // 3 rationals * 8 bytes each + { + const uint8_t* bytes = latitude.caub.pElems; + + // degrees, minutes, seconds (each rational is 8 bytes) + double degrees = ParseSingleRational(bytes, 0); + double minutes = ParseSingleRational(bytes, 8); + double seconds = ParseSingleRational(bytes, 16); + + lat = degrees + minutes / 60.0 + seconds / 3600.0; + } + + // Parse longitude + if ((longitude.vt & VT_VECTOR) && longitude.caub.cElems >= 24) + { + const uint8_t* bytes = longitude.caub.pElems; + + double degrees = ParseSingleRational(bytes, 0); + double minutes = ParseSingleRational(bytes, 8); + double seconds = ParseSingleRational(bytes, 16); + + lon = degrees + minutes / 60.0 + seconds / 3600.0; + } + + // Apply direction references (N/S for latitude, E/W for longitude) + if (latRef.vt == VT_LPSTR && latRef.pszVal) + { + if (strcmp(latRef.pszVal, "S") == 0) + lat = -lat; + } + + if (lonRef.vt == VT_LPSTR && lonRef.pszVal) + { + if (strcmp(lonRef.pszVal, "W") == 0) + lon = -lon; + } + + return { lat, lon }; +} + +std::wstring MetadataFormatHelper::SanitizeForFileName(const std::wstring& str) +{ + // Windows illegal filename characters: < > : " / \ | ? * + // Also control characters (0-31) and some others + std::wstring sanitized = str; + + // Replace illegal characters with underscore + for (auto& ch : sanitized) + { + // Check for illegal characters + if (ch == L'<' || ch == L'>' || ch == L':' || ch == L'"' || + ch == L'/' || ch == L'\\' || ch == L'|' || ch == L'?' || ch == L'*' || + ch < 32) // Control characters + { + ch = L'_'; + } + } + + // Also remove trailing dots and spaces (Windows doesn't like them at end of filename) + while (!sanitized.empty() && (sanitized.back() == L'.' || sanitized.back() == L' ')) + { + sanitized.pop_back(); + } + + return sanitized; +} diff --git a/src/modules/powerrename/lib/MetadataFormatHelper.h b/src/modules/powerrename/lib/MetadataFormatHelper.h new file mode 100644 index 0000000000..86208225cf --- /dev/null +++ b/src/modules/powerrename/lib/MetadataFormatHelper.h @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma once +#include +#include +#include +#include + +namespace PowerRenameLib +{ + /// + /// Helper class for formatting and parsing metadata values + /// Provides static utility functions for converting metadata to human-readable strings + /// and parsing raw metadata values + /// + class MetadataFormatHelper + { + public: + // Formatting functions - Convert metadata values to display strings + + /// + /// Format aperture value (f-number) + /// + /// Aperture value (e.g., 2.8) + /// Formatted string (e.g., "f/2.8") + static std::wstring FormatAperture(double aperture); + + /// + /// Format shutter speed + /// + /// Shutter speed in seconds + /// Formatted string (e.g., "1/100s" or "2.5s") + static std::wstring FormatShutterSpeed(double speed); + + /// + /// Format ISO value + /// + /// ISO speed value + /// Formatted string (e.g., "ISO 400") + static std::wstring FormatISO(int64_t iso); + + /// + /// Format flash status + /// + /// Flash value from EXIF + /// Formatted string (e.g., "Flash On" or "Flash Off") + static std::wstring FormatFlash(int64_t flashValue); + + /// + /// Format GPS coordinate + /// + /// Coordinate value in decimal degrees + /// true for latitude, false for longitude + /// Formatted string (e.g., "40°26.76'N") + static std::wstring FormatCoordinate(double coord, bool isLatitude); + + /// + /// Format SYSTEMTIME to string + /// + /// SYSTEMTIME structure + /// Formatted string (e.g., "2024-03-15 14:30:45") + static std::wstring FormatSystemTime(const SYSTEMTIME& st); + + // Parsing functions - Convert raw metadata to usable values + + /// + /// Parse GPS rational value from PROPVARIANT + /// + /// PROPVARIANT containing GPS rational data + /// Parsed double value + static double ParseGPSRational(const PROPVARIANT& pv); + + /// + /// Parse single rational value from byte array + /// + /// Byte array containing rational data + /// Offset in the byte array + /// Parsed double value (numerator / denominator) + static double ParseSingleRational(const uint8_t* bytes, size_t offset); + + /// + /// Parse single signed rational value from byte array + /// + /// Byte array containing signed rational data + /// Offset in the byte array + /// Parsed double value (signed numerator / signed denominator) + static double ParseSingleSRational(const uint8_t* bytes, size_t offset); + + /// + /// Parse GPS coordinates from PROPVARIANT values + /// + /// PROPVARIANT containing latitude + /// PROPVARIANT containing longitude + /// PROPVARIANT containing latitude reference (N/S) + /// PROPVARIANT containing longitude reference (E/W) + /// Pair of (latitude, longitude) in decimal degrees + static std::pair ParseGPSCoordinates( + const PROPVARIANT& latitude, + const PROPVARIANT& longitude, + const PROPVARIANT& latRef, + const PROPVARIANT& lonRef); + + /// + /// Sanitize a string to make it safe for use in filenames + /// Replaces illegal filename characters (< > : " / \ | ? * and control chars) with underscore + /// Also removes trailing dots and spaces which Windows doesn't allow at end of filename + /// + /// IMPORTANT: This should ONLY be called in ExtractPatterns to avoid performance waste. + /// Do NOT call this function when reading raw metadata values. + /// + /// String to sanitize + /// Sanitized string safe for use in filename + static std::wstring SanitizeForFileName(const std::wstring& str); + }; +} diff --git a/src/modules/powerrename/lib/MetadataPatternExtractor.cpp b/src/modules/powerrename/lib/MetadataPatternExtractor.cpp new file mode 100644 index 0000000000..cfbc40837d --- /dev/null +++ b/src/modules/powerrename/lib/MetadataPatternExtractor.cpp @@ -0,0 +1,353 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#include "pch.h" +#include "MetadataPatternExtractor.h" +#include "MetadataFormatHelper.h" +#include "WICMetadataExtractor.h" +#include +#include +#include +#include +#include +#include + +using namespace PowerRenameLib; + +MetadataPatternExtractor::MetadataPatternExtractor() + : extractor(std::make_unique()) +{ +} + +MetadataPatternExtractor::~MetadataPatternExtractor() = default; + +MetadataPatternMap MetadataPatternExtractor::ExtractPatterns( + const std::wstring& filePath, + MetadataType type) +{ + MetadataPatternMap patterns; + + switch (type) + { + case MetadataType::EXIF: + patterns = ExtractEXIFPatterns(filePath); + break; + case MetadataType::XMP: + patterns = ExtractXMPPatterns(filePath); + break; + default: + return MetadataPatternMap(); + } + + // Sanitize all pattern values for filename safety before returning + // This ensures all metadata values are safe to use in filenames (removes illegal chars like <>:"/\|?*) + // IMPORTANT: Only call SanitizeForFileName here to avoid performance waste + for (auto& [key, value] : patterns) + { + value = MetadataFormatHelper::SanitizeForFileName(value); + } + + return patterns; +} + +void MetadataPatternExtractor::ClearCache() +{ + if (extractor) + { + extractor->ClearCache(); + } +} + +MetadataPatternMap MetadataPatternExtractor::ExtractEXIFPatterns(const std::wstring& filePath) +{ + MetadataPatternMap patterns; + + EXIFMetadata exif; + if (!extractor->ExtractEXIFMetadata(filePath, exif)) + { + return patterns; + } + + if (exif.cameraMake.has_value()) + { + patterns[MetadataPatterns::CAMERA_MAKE] = exif.cameraMake.value(); + } + + if (exif.cameraModel.has_value()) + { + patterns[MetadataPatterns::CAMERA_MODEL] = exif.cameraModel.value(); + } + + if (exif.lensModel.has_value()) + { + patterns[MetadataPatterns::LENS] = exif.lensModel.value(); + } + + if (exif.iso.has_value()) + { + patterns[MetadataPatterns::ISO] = MetadataFormatHelper::FormatISO(exif.iso.value()); + } + + if (exif.aperture.has_value()) + { + patterns[MetadataPatterns::APERTURE] = MetadataFormatHelper::FormatAperture(exif.aperture.value()); + } + + if (exif.shutterSpeed.has_value()) + { + patterns[MetadataPatterns::SHUTTER] = MetadataFormatHelper::FormatShutterSpeed(exif.shutterSpeed.value()); + } + + if (exif.focalLength.has_value()) + { + patterns[MetadataPatterns::FOCAL] = std::to_wstring(static_cast(exif.focalLength.value())) + L"mm"; + } + + if (exif.flash.has_value()) + { + patterns[MetadataPatterns::FLASH] = MetadataFormatHelper::FormatFlash(exif.flash.value()); + } + + if (exif.width.has_value()) + { + patterns[MetadataPatterns::WIDTH] = std::to_wstring(exif.width.value()); + } + + if (exif.height.has_value()) + { + patterns[MetadataPatterns::HEIGHT] = std::to_wstring(exif.height.value()); + } + + if (exif.author.has_value()) + { + patterns[MetadataPatterns::AUTHOR] = exif.author.value(); + } + + if (exif.copyright.has_value()) + { + patterns[MetadataPatterns::COPYRIGHT] = exif.copyright.value(); + } + + if (exif.latitude.has_value()) + { + patterns[MetadataPatterns::LATITUDE] = MetadataFormatHelper::FormatCoordinate(exif.latitude.value(), true); + } + + if (exif.longitude.has_value()) + { + patterns[MetadataPatterns::LONGITUDE] = MetadataFormatHelper::FormatCoordinate(exif.longitude.value(), false); + } + + // Only extract DATE_TAKEN patterns (most commonly used) + if (exif.dateTaken.has_value()) + { + const SYSTEMTIME& date = exif.dateTaken.value(); + patterns[MetadataPatterns::DATE_TAKEN_YYYY] = std::format(L"{:04d}", date.wYear); + patterns[MetadataPatterns::DATE_TAKEN_YY] = std::format(L"{:02d}", date.wYear % 100); + patterns[MetadataPatterns::DATE_TAKEN_MM] = std::format(L"{:02d}", date.wMonth); + patterns[MetadataPatterns::DATE_TAKEN_DD] = std::format(L"{:02d}", date.wDay); + patterns[MetadataPatterns::DATE_TAKEN_HH] = std::format(L"{:02d}", date.wHour); + patterns[MetadataPatterns::DATE_TAKEN_mm] = std::format(L"{:02d}", date.wMinute); + patterns[MetadataPatterns::DATE_TAKEN_SS] = std::format(L"{:02d}", date.wSecond); + } + // Note: dateDigitized and dateModified are still extracted but not exposed as patterns + + if (exif.exposureBias.has_value()) + { + patterns[MetadataPatterns::EXPOSURE_BIAS] = std::format(L"{:.2f}", exif.exposureBias.value()); + } + + if (exif.orientation.has_value()) + { + patterns[MetadataPatterns::ORIENTATION] = std::to_wstring(exif.orientation.value()); + } + + if (exif.colorSpace.has_value()) + { + patterns[MetadataPatterns::COLOR_SPACE] = std::to_wstring(exif.colorSpace.value()); + } + + if (exif.altitude.has_value()) + { + patterns[MetadataPatterns::ALTITUDE] = std::format(L"{:.2f} m", exif.altitude.value()); + } + + return patterns; +} + +MetadataPatternMap MetadataPatternExtractor::ExtractXMPPatterns(const std::wstring& filePath) +{ + MetadataPatternMap patterns; + + XMPMetadata xmp; + if (!extractor->ExtractXMPMetadata(filePath, xmp)) + { + return patterns; + } + + if (xmp.creator.has_value()) + { + const auto& creator = xmp.creator.value(); + patterns[MetadataPatterns::AUTHOR] = creator; + patterns[MetadataPatterns::CREATOR] = creator; + } + + if (xmp.rights.has_value()) + { + const auto& rights = xmp.rights.value(); + patterns[MetadataPatterns::RIGHTS] = rights; + patterns[MetadataPatterns::COPYRIGHT] = rights; + } + + if (xmp.title.has_value()) + { + patterns[MetadataPatterns::TITLE] = xmp.title.value(); + } + + if (xmp.description.has_value()) + { + patterns[MetadataPatterns::DESCRIPTION] = xmp.description.value(); + } + + if (xmp.subject.has_value()) + { + std::wstring joined; + for (const auto& entry : xmp.subject.value()) + { + if (!joined.empty()) + { + joined.append(L"; "); + } + joined.append(entry); + } + if (!joined.empty()) + { + patterns[MetadataPatterns::SUBJECT] = joined; + } + } + + if (xmp.creatorTool.has_value()) + { + patterns[MetadataPatterns::CREATOR_TOOL] = xmp.creatorTool.value(); + } + + if (xmp.documentID.has_value()) + { + patterns[MetadataPatterns::DOCUMENT_ID] = xmp.documentID.value(); + } + + if (xmp.instanceID.has_value()) + { + patterns[MetadataPatterns::INSTANCE_ID] = xmp.instanceID.value(); + } + + if (xmp.originalDocumentID.has_value()) + { + patterns[MetadataPatterns::ORIGINAL_DOCUMENT_ID] = xmp.originalDocumentID.value(); + } + + if (xmp.versionID.has_value()) + { + patterns[MetadataPatterns::VERSION_ID] = xmp.versionID.value(); + } + + // Only extract CREATE_DATE patterns (primary creation time) + if (xmp.createDate.has_value()) + { + const SYSTEMTIME& date = xmp.createDate.value(); + patterns[MetadataPatterns::CREATE_DATE_YYYY] = std::format(L"{:04d}", date.wYear); + patterns[MetadataPatterns::CREATE_DATE_YY] = std::format(L"{:02d}", date.wYear % 100); + patterns[MetadataPatterns::CREATE_DATE_MM] = std::format(L"{:02d}", date.wMonth); + patterns[MetadataPatterns::CREATE_DATE_DD] = std::format(L"{:02d}", date.wDay); + patterns[MetadataPatterns::CREATE_DATE_HH] = std::format(L"{:02d}", date.wHour); + patterns[MetadataPatterns::CREATE_DATE_mm] = std::format(L"{:02d}", date.wMinute); + patterns[MetadataPatterns::CREATE_DATE_SS] = std::format(L"{:02d}", date.wSecond); + } + // Note: modifyDate and metadataDate are still extracted but not exposed as patterns + + return patterns; +} + +// AddDatePatterns function has been removed as dynamic patterns are no longer supported. +// Date patterns are now directly added inline for DATE_TAKEN and CREATE_DATE only. +// Formatting functions have been moved to MetadataFormatHelper for better testability. + +std::vector MetadataPatternExtractor::GetSupportedPatterns(MetadataType type) +{ + switch (type) + { + case MetadataType::EXIF: + return { + MetadataPatterns::CAMERA_MAKE, + MetadataPatterns::CAMERA_MODEL, + MetadataPatterns::LENS, + MetadataPatterns::ISO, + MetadataPatterns::APERTURE, + MetadataPatterns::SHUTTER, + MetadataPatterns::FOCAL, + MetadataPatterns::FLASH, + MetadataPatterns::WIDTH, + MetadataPatterns::HEIGHT, + MetadataPatterns::AUTHOR, + MetadataPatterns::COPYRIGHT, + MetadataPatterns::LATITUDE, + MetadataPatterns::LONGITUDE, + MetadataPatterns::DATE_TAKEN_YYYY, + MetadataPatterns::DATE_TAKEN_YY, + MetadataPatterns::DATE_TAKEN_MM, + MetadataPatterns::DATE_TAKEN_DD, + MetadataPatterns::DATE_TAKEN_HH, + MetadataPatterns::DATE_TAKEN_mm, + MetadataPatterns::DATE_TAKEN_SS, + MetadataPatterns::EXPOSURE_BIAS, + MetadataPatterns::ORIENTATION, + MetadataPatterns::COLOR_SPACE, + MetadataPatterns::ALTITUDE + }; + + case MetadataType::XMP: + return { + MetadataPatterns::AUTHOR, + MetadataPatterns::COPYRIGHT, + MetadataPatterns::RIGHTS, + MetadataPatterns::TITLE, + MetadataPatterns::DESCRIPTION, + MetadataPatterns::SUBJECT, + MetadataPatterns::CREATOR, + MetadataPatterns::CREATOR_TOOL, + MetadataPatterns::DOCUMENT_ID, + MetadataPatterns::INSTANCE_ID, + MetadataPatterns::ORIGINAL_DOCUMENT_ID, + MetadataPatterns::VERSION_ID, + MetadataPatterns::CREATE_DATE_YYYY, + MetadataPatterns::CREATE_DATE_YY, + MetadataPatterns::CREATE_DATE_MM, + MetadataPatterns::CREATE_DATE_DD, + MetadataPatterns::CREATE_DATE_HH, + MetadataPatterns::CREATE_DATE_mm, + MetadataPatterns::CREATE_DATE_SS + }; + + default: + return {}; + } +} + +std::vector MetadataPatternExtractor::GetAllPossiblePatterns() +{ + auto exifPatterns = GetSupportedPatterns(MetadataType::EXIF); + auto xmpPatterns = GetSupportedPatterns(MetadataType::XMP); + + std::vector allPatterns; + allPatterns.reserve(exifPatterns.size() + xmpPatterns.size()); + + allPatterns.insert(allPatterns.end(), exifPatterns.begin(), exifPatterns.end()); + allPatterns.insert(allPatterns.end(), xmpPatterns.begin(), xmpPatterns.end()); + + std::sort(allPatterns.begin(), allPatterns.end()); + allPatterns.erase(std::unique(allPatterns.begin(), allPatterns.end()), allPatterns.end()); + + return allPatterns; +} + diff --git a/src/modules/powerrename/lib/MetadataPatternExtractor.h b/src/modules/powerrename/lib/MetadataPatternExtractor.h new file mode 100644 index 0000000000..787e5c437d --- /dev/null +++ b/src/modules/powerrename/lib/MetadataPatternExtractor.h @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma once +#include +#include +#include +#include +#include "MetadataTypes.h" + +namespace PowerRenameLib +{ + // Pattern-Value mapping for metadata replacement + using MetadataPatternMap = std::unordered_map; + + /// + /// Metadata pattern extractor that converts metadata into replaceable patterns + /// + class MetadataPatternExtractor + { + public: + MetadataPatternExtractor(); + ~MetadataPatternExtractor(); + + MetadataPatternMap ExtractPatterns(const std::wstring& filePath, MetadataType type); + + void ClearCache(); + + static std::vector GetSupportedPatterns(MetadataType type); + static std::vector GetAllPossiblePatterns(); + + private: + std::unique_ptr extractor; + + MetadataPatternMap ExtractEXIFPatterns(const std::wstring& filePath); + MetadataPatternMap ExtractXMPPatterns(const std::wstring& filePath); + }; +} diff --git a/src/modules/powerrename/lib/MetadataResultCache.cpp b/src/modules/powerrename/lib/MetadataResultCache.cpp new file mode 100644 index 0000000000..5f30b24abe --- /dev/null +++ b/src/modules/powerrename/lib/MetadataResultCache.cpp @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#include "pch.h" +#include "MetadataResultCache.h" + +using namespace PowerRenameLib; + +namespace +{ + template + bool GetOrLoadInternal(const std::wstring& filePath, + Metadata& outMetadata, + Cache& cache, + Mutex& mutex, + const Loader& loader) + { + { + std::shared_lock sharedLock(mutex); + auto it = cache.find(filePath); + if (it != cache.end()) + { + // Return cached result (success or failure) + outMetadata = it->second.data; + return it->second.wasSuccessful; + } + } + + if (!loader) + { + // No loader provided + return false; + } + + Metadata loaded{}; + const bool result = loader(loaded); + + // Cache the result (success or failure) + { + std::unique_lock uniqueLock(mutex); + // Check if another thread cached it while we were loading + auto it = cache.find(filePath); + if (it == cache.end()) + { + // Not cached yet, insert our result + cache.emplace(filePath, CacheEntry{ result, loaded }); + } + else + { + // Another thread cached it, use their result + outMetadata = it->second.data; + return it->second.wasSuccessful; + } + } + + outMetadata = loaded; + return result; + } +} + +bool MetadataResultCache::GetOrLoadEXIF(const std::wstring& filePath, + EXIFMetadata& outMetadata, + const EXIFLoader& loader) +{ + return GetOrLoadInternal>(filePath, outMetadata, exifCache, exifMutex, loader); +} + +bool MetadataResultCache::GetOrLoadXMP(const std::wstring& filePath, + XMPMetadata& outMetadata, + const XMPLoader& loader) +{ + return GetOrLoadInternal>(filePath, outMetadata, xmpCache, xmpMutex, loader); +} + +void MetadataResultCache::ClearAll() +{ + { + std::unique_lock lock(exifMutex); + exifCache.clear(); + } + + { + std::unique_lock lock(xmpMutex); + xmpCache.clear(); + } +} diff --git a/src/modules/powerrename/lib/MetadataResultCache.h b/src/modules/powerrename/lib/MetadataResultCache.h new file mode 100644 index 0000000000..ad3b9782c4 --- /dev/null +++ b/src/modules/powerrename/lib/MetadataResultCache.h @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma once +#include "MetadataTypes.h" +#include +#include +#include +#include + +namespace PowerRenameLib +{ + class MetadataResultCache + { + public: + using EXIFLoader = std::function; + using XMPLoader = std::function; + + bool GetOrLoadEXIF(const std::wstring& filePath, EXIFMetadata& outMetadata, const EXIFLoader& loader); + bool GetOrLoadXMP(const std::wstring& filePath, XMPMetadata& outMetadata, const XMPLoader& loader); + + void ClearAll(); + + private: + // Wrapper to cache both success and failure states + template + struct CacheEntry + { + bool wasSuccessful; + T data; + }; + + mutable std::shared_mutex exifMutex; + mutable std::shared_mutex xmpMutex; + std::unordered_map> exifCache; + std::unordered_map> xmpCache; + }; +} diff --git a/src/modules/powerrename/lib/MetadataTypes.h b/src/modules/powerrename/lib/MetadataTypes.h new file mode 100644 index 0000000000..aa6a721e4c --- /dev/null +++ b/src/modules/powerrename/lib/MetadataTypes.h @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma once +#include +#include +#include +#include + +namespace PowerRenameLib +{ + /// + /// Supported metadata format types + /// + enum class MetadataType + { + EXIF, // EXIF metadata (camera settings, date taken, etc.) + XMP // XMP metadata (Dublin Core, Photoshop, etc.) + }; + + /// + /// Complete EXIF metadata structure + /// Contains all commonly used EXIF fields with optional values + /// + struct EXIFMetadata + { + // Date and time information + std::optional dateTaken; // DateTimeOriginal + std::optional dateDigitized; // DateTimeDigitized + std::optional dateModified; // DateTime + + // Camera information + std::optional cameraMake; // Make + std::optional cameraModel; // Model + std::optional lensModel; // LensModel + + // Shooting parameters + std::optional iso; // ISO speed + std::optional aperture; // F-number + std::optional shutterSpeed; // Exposure time + std::optional focalLength; // Focal length in mm + std::optional exposureBias; // Exposure bias value + std::optional flash; // Flash status + + // Image properties + std::optional width; // Image width in pixels + std::optional height; // Image height in pixels + std::optional orientation; // Image orientation + std::optional colorSpace; // Color space + + // Author and copyright + std::optional author; // Artist + std::optional copyright; // Copyright notice + + // GPS information + std::optional latitude; // GPS latitude in decimal degrees + std::optional longitude; // GPS longitude in decimal degrees + std::optional altitude; // GPS altitude in meters + }; + + /// + /// XMP (Extensible Metadata Platform) metadata structure + /// Contains XMP Basic, Dublin Core, Rights and Media Management schema fields + /// + struct XMPMetadata + { + // XMP Basic schema - https://ns.adobe.com/xap/1.0/ + std::optional createDate; // xmp:CreateDate + std::optional modifyDate; // xmp:ModifyDate + std::optional metadataDate; // xmp:MetadataDate + std::optional creatorTool; // xmp:CreatorTool + + // Dublin Core schema - http://purl.org/dc/elements/1.1/ + std::optional title; // dc:title + std::optional description; // dc:description + std::optional creator; // dc:creator (author) + std::optional> subject; // dc:subject (keywords) + + // XMP Rights Management schema - http://ns.adobe.com/xap/1.0/rights/ + std::optional rights; // xmpRights:WebStatement (copyright) + + // XMP Media Management schema - http://ns.adobe.com/xap/1.0/mm/ + std::optional documentID; // xmpMM:DocumentID + std::optional instanceID; // xmpMM:InstanceID + std::optional originalDocumentID; // xmpMM:OriginalDocumentID + std::optional versionID; // xmpMM:VersionID + }; + + + + + /// + /// Constants for metadata pattern names + /// + namespace MetadataPatterns + { + // EXIF patterns + constexpr wchar_t CAMERA_MAKE[] = L"CAMERA_MAKE"; + constexpr wchar_t CAMERA_MODEL[] = L"CAMERA_MODEL"; + constexpr wchar_t LENS[] = L"LENS"; + constexpr wchar_t ISO[] = L"ISO"; + constexpr wchar_t APERTURE[] = L"APERTURE"; + constexpr wchar_t SHUTTER[] = L"SHUTTER"; + constexpr wchar_t FOCAL[] = L"FOCAL"; + constexpr wchar_t FLASH[] = L"FLASH"; + constexpr wchar_t WIDTH[] = L"WIDTH"; + constexpr wchar_t HEIGHT[] = L"HEIGHT"; + constexpr wchar_t AUTHOR[] = L"AUTHOR"; + constexpr wchar_t COPYRIGHT[] = L"COPYRIGHT"; + constexpr wchar_t LATITUDE[] = L"LATITUDE"; + constexpr wchar_t LONGITUDE[] = L"LONGITUDE"; + + // Date components from EXIF DateTimeOriginal (when photo was taken) + constexpr wchar_t DATE_TAKEN_YYYY[] = L"DATE_TAKEN_YYYY"; + constexpr wchar_t DATE_TAKEN_YY[] = L"DATE_TAKEN_YY"; + constexpr wchar_t DATE_TAKEN_MM[] = L"DATE_TAKEN_MM"; + constexpr wchar_t DATE_TAKEN_DD[] = L"DATE_TAKEN_DD"; + constexpr wchar_t DATE_TAKEN_HH[] = L"DATE_TAKEN_HH"; + constexpr wchar_t DATE_TAKEN_mm[] = L"DATE_TAKEN_mm"; + constexpr wchar_t DATE_TAKEN_SS[] = L"DATE_TAKEN_SS"; + + // Additional EXIF patterns + constexpr wchar_t EXPOSURE_BIAS[] = L"EXPOSURE_BIAS"; + constexpr wchar_t ORIENTATION[] = L"ORIENTATION"; + constexpr wchar_t COLOR_SPACE[] = L"COLOR_SPACE"; + constexpr wchar_t ALTITUDE[] = L"ALTITUDE"; + + // XMP patterns + constexpr wchar_t CREATOR_TOOL[] = L"CREATOR_TOOL"; + + // Date components from XMP CreateDate + constexpr wchar_t CREATE_DATE_YYYY[] = L"CREATE_DATE_YYYY"; + constexpr wchar_t CREATE_DATE_YY[] = L"CREATE_DATE_YY"; + constexpr wchar_t CREATE_DATE_MM[] = L"CREATE_DATE_MM"; + constexpr wchar_t CREATE_DATE_DD[] = L"CREATE_DATE_DD"; + constexpr wchar_t CREATE_DATE_HH[] = L"CREATE_DATE_HH"; + constexpr wchar_t CREATE_DATE_mm[] = L"CREATE_DATE_mm"; + constexpr wchar_t CREATE_DATE_SS[] = L"CREATE_DATE_SS"; + + // Dublin Core patterns + constexpr wchar_t TITLE[] = L"TITLE"; + constexpr wchar_t DESCRIPTION[] = L"DESCRIPTION"; + constexpr wchar_t CREATOR[] = L"CREATOR"; + constexpr wchar_t SUBJECT[] = L"SUBJECT"; // Keywords + + // XMP Rights pattern + constexpr wchar_t RIGHTS[] = L"RIGHTS"; // Copyright + + // XMP Media Management patterns + constexpr wchar_t DOCUMENT_ID[] = L"DOCUMENT_ID"; + constexpr wchar_t INSTANCE_ID[] = L"INSTANCE_ID"; + constexpr wchar_t ORIGINAL_DOCUMENT_ID[] = L"ORIGINAL_DOCUMENT_ID"; + constexpr wchar_t VERSION_ID[] = L"VERSION_ID"; + } +} \ No newline at end of file diff --git a/src/modules/powerrename/lib/PowerRenameInterfaces.h b/src/modules/powerrename/lib/PowerRenameInterfaces.h index ea761d156a..7e3402433b 100644 --- a/src/modules/powerrename/lib/PowerRenameInterfaces.h +++ b/src/modules/powerrename/lib/PowerRenameInterfaces.h @@ -1,7 +1,10 @@ #pragma once #include "pch.h" +#include "MetadataTypes.h" +#include "MetadataPatternExtractor.h" #include #include +#include enum PowerRenameFlags { @@ -22,6 +25,9 @@ enum PowerRenameFlags CreationTime = 0x4000, ModificationTime = 0x8000, AccessTime = 0x10000, + // Metadata source flags + MetadataSourceEXIF = 0x20000, // Default + MetadataSourceXMP = 0x40000, }; enum PowerRenameFilters @@ -47,6 +53,7 @@ public: IFACEMETHOD(OnReplaceTermChanged)(_In_ PCWSTR replaceTerm) = 0; IFACEMETHOD(OnFlagsChanged)(_In_ DWORD flags) = 0; IFACEMETHOD(OnFileTimeChanged)(_In_ SYSTEMTIME fileTime) = 0; + IFACEMETHOD(OnMetadataChanged)() = 0; }; interface __declspec(uuid("E3ED45B5-9CE0-47E2-A595-67EB950B9B72")) IPowerRenameRegEx : public IUnknown @@ -62,6 +69,9 @@ public: IFACEMETHOD(PutFlags)(_In_ DWORD flags) = 0; IFACEMETHOD(PutFileTime)(_In_ SYSTEMTIME fileTime) = 0; IFACEMETHOD(ResetFileTime)() = 0; + IFACEMETHOD(PutMetadataPatterns)(_In_ const PowerRenameLib::MetadataPatternMap& patterns) = 0; + IFACEMETHOD(ResetMetadata)() = 0; + IFACEMETHOD(GetMetadataType)(_Out_ PowerRenameLib::MetadataType* metadataType) = 0; IFACEMETHOD(Replace)(_In_ PCWSTR source, _Outptr_ PWSTR* result, unsigned long& enumIndex) = 0; }; diff --git a/src/modules/powerrename/lib/PowerRenameItem.cpp b/src/modules/powerrename/lib/PowerRenameItem.cpp index b33fccfb89..61e07a93fc 100644 --- a/src/modules/powerrename/lib/PowerRenameItem.cpp +++ b/src/modules/powerrename/lib/PowerRenameItem.cpp @@ -74,7 +74,7 @@ IFACEMETHODIMP CPowerRenameItem::GetTime(_In_ DWORD flags, _Outptr_ SYSTEMTIME* else { // Default to modification time if no specific flag is set - parsedTimeType = PowerRenameFlags::CreationTime; + parsedTimeType = PowerRenameFlags::CreationTime; } if (m_isTimeParsed && parsedTimeType == m_parsedTimeType) @@ -86,6 +86,13 @@ IFACEMETHODIMP CPowerRenameItem::GetTime(_In_ DWORD flags, _Outptr_ SYSTEMTIME* HANDLE hFile = CreateFileW(m_path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); if (hFile != INVALID_HANDLE_VALUE) { + // Use RAII-style scope guard to ensure handle is always closed + struct FileHandleCloser + { + HANDLE handle; + ~FileHandleCloser() { if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); } + } scopedHandle{ hFile }; + FILETIME FileTime; bool success = false; @@ -122,8 +129,6 @@ IFACEMETHODIMP CPowerRenameItem::GetTime(_In_ DWORD flags, _Outptr_ SYSTEMTIME* } } } - - CloseHandle(hFile); } *time = m_time; return hr; diff --git a/src/modules/powerrename/lib/PowerRenameLib.vcxproj b/src/modules/powerrename/lib/PowerRenameLib.vcxproj index 103eab8e8e..bd5740dee7 100644 --- a/src/modules/powerrename/lib/PowerRenameLib.vcxproj +++ b/src/modules/powerrename/lib/PowerRenameLib.vcxproj @@ -16,19 +16,24 @@ - - - ..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\ + $(ProjectDir)..\..\..\..\deps + + + Level3 WIN32;_LIB;%(PreprocessorDefinitions) $(ProjectDir)..\;$(ProjectDir)..\ui;$(ProjectDir)..\dll;$(ProjectDir)..\lib;$(ProjectDir)..\..\..\;$(ProjectDir)..\..\..\common\Telemetry;%(AdditionalIncludeDirectories);$(GeneratedFilesDir) + /FS %(AdditionalOptions) + + windowscodecs.lib;propsys.lib;ole32.lib;%(AdditionalDependencies) + @@ -47,6 +52,12 @@ + + + + + + @@ -64,6 +75,10 @@ Create + + + + diff --git a/src/modules/powerrename/lib/PowerRenameManager.cpp b/src/modules/powerrename/lib/PowerRenameManager.cpp index b6641374ba..160d064e09 100644 --- a/src/modules/powerrename/lib/PowerRenameManager.cpp +++ b/src/modules/powerrename/lib/PowerRenameManager.cpp @@ -462,6 +462,12 @@ IFACEMETHODIMP CPowerRenameManager::OnFileTimeChanged(_In_ SYSTEMTIME /*fileTime return S_OK; } +IFACEMETHODIMP CPowerRenameManager::OnMetadataChanged() +{ + _PerformRegExRename(); + return S_OK; +} + HRESULT CPowerRenameManager::s_CreateInstance(_Outptr_ IPowerRenameManager** ppsrm) { *ppsrm = nullptr; diff --git a/src/modules/powerrename/lib/PowerRenameManager.h b/src/modules/powerrename/lib/PowerRenameManager.h index f339f5c9d4..a9fa44d144 100644 --- a/src/modules/powerrename/lib/PowerRenameManager.h +++ b/src/modules/powerrename/lib/PowerRenameManager.h @@ -50,6 +50,7 @@ public: IFACEMETHODIMP OnReplaceTermChanged(_In_ PCWSTR replaceTerm); IFACEMETHODIMP OnFlagsChanged(_In_ DWORD flags); IFACEMETHODIMP OnFileTimeChanged(_In_ SYSTEMTIME fileTime); + IFACEMETHODIMP OnMetadataChanged(); static HRESULT s_CreateInstance(_Outptr_ IPowerRenameManager** ppsrm); diff --git a/src/modules/powerrename/lib/PowerRenameRegEx.cpp b/src/modules/powerrename/lib/PowerRenameRegEx.cpp index 34d3cc5c0c..567df48606 100644 --- a/src/modules/powerrename/lib/PowerRenameRegEx.cpp +++ b/src/modules/powerrename/lib/PowerRenameRegEx.cpp @@ -328,6 +328,22 @@ IFACEMETHODIMP CPowerRenameRegEx::ResetFileTime() return S_OK; } +IFACEMETHODIMP CPowerRenameRegEx::PutMetadataPatterns(_In_ const PowerRenameLib::MetadataPatternMap& patterns) +{ + m_metadataPatterns = patterns; + m_useMetadata = true; + _OnMetadataChanged(); + return S_OK; +} + +IFACEMETHODIMP CPowerRenameRegEx::ResetMetadata() +{ + m_metadataPatterns.clear(); + m_useMetadata = false; + _OnMetadataChanged(); + return S_OK; +} + HRESULT CPowerRenameRegEx::s_CreateInstance(_Outptr_ IPowerRenameRegEx** renameRegEx) { *renameRegEx = nullptr; @@ -387,10 +403,39 @@ HRESULT CPowerRenameRegEx::Replace(_In_ PCWSTR source, _Outptr_ PWSTR* result, u // TODO: creating the regex could be costly. May want to cache this. wchar_t newReplaceTerm[MAX_PATH] = { 0 }; bool fileTimeErrorOccurred = false; + bool metadataErrorOccurred = false; + bool appliedTemplateTransform = false; + + std::wstring replaceTemplate; + if (m_replaceTerm) + { + replaceTemplate = m_replaceTerm; + } + if (m_useFileTime) { - if (FAILED(GetDatedFileName(newReplaceTerm, ARRAYSIZE(newReplaceTerm), m_replaceTerm, m_fileTime))) + if (FAILED(GetDatedFileName(newReplaceTerm, ARRAYSIZE(newReplaceTerm), replaceTemplate.c_str(), m_fileTime))) + { fileTimeErrorOccurred = true; + } + else + { + replaceTemplate.assign(newReplaceTerm); + appliedTemplateTransform = true; + } + } + + if (m_useMetadata) + { + if (FAILED(GetMetadataFileName(newReplaceTerm, ARRAYSIZE(newReplaceTerm), replaceTemplate.c_str(), m_metadataPatterns))) + { + metadataErrorOccurred = true; + } + else + { + replaceTemplate.assign(newReplaceTerm); + appliedTemplateTransform = true; + } } std::wstring sourceToUse; @@ -399,9 +444,9 @@ HRESULT CPowerRenameRegEx::Replace(_In_ PCWSTR source, _Outptr_ PWSTR* result, u std::wstring searchTerm(m_searchTerm); std::wstring replaceTerm; - if (m_useFileTime && !fileTimeErrorOccurred) + if (appliedTemplateTransform) { - replaceTerm = newReplaceTerm; + replaceTerm = replaceTemplate; } else if (m_replaceTerm) { @@ -606,3 +651,43 @@ void CPowerRenameRegEx::_OnFileTimeChanged() } } } + +void CPowerRenameRegEx::_OnMetadataChanged() +{ + CSRWSharedAutoLock lock(&m_lockEvents); + + for (auto it : m_renameRegExEvents) + { + if (it.pEvents) + { + it.pEvents->OnMetadataChanged(); + } + } +} + +PowerRenameLib::MetadataType CPowerRenameRegEx::_GetMetadataTypeFromFlags() const +{ + if (m_flags & MetadataSourceXMP) + return PowerRenameLib::MetadataType::XMP; + + // Default to EXIF + return PowerRenameLib::MetadataType::EXIF; +} + +// Interface method implementation +IFACEMETHODIMP CPowerRenameRegEx::GetMetadataType(_Out_ PowerRenameLib::MetadataType* metadataType) +{ + if (metadataType == nullptr) + return E_POINTER; + + *metadataType = _GetMetadataTypeFromFlags(); + return S_OK; +} + +// Convenience method for internal use +PowerRenameLib::MetadataType CPowerRenameRegEx::GetMetadataType() const +{ + return _GetMetadataTypeFromFlags(); +} + + diff --git a/src/modules/powerrename/lib/PowerRenameRegEx.h b/src/modules/powerrename/lib/PowerRenameRegEx.h index 55c6c14c17..9e43107efa 100644 --- a/src/modules/powerrename/lib/PowerRenameRegEx.h +++ b/src/modules/powerrename/lib/PowerRenameRegEx.h @@ -5,6 +5,8 @@ #include "Enumerating.h" #include "Randomizer.h" +#include "MetadataTypes.h" +#include "MetadataPatternExtractor.h" #include "PowerRenameInterfaces.h" @@ -29,7 +31,13 @@ public: IFACEMETHODIMP PutFlags(_In_ DWORD flags); IFACEMETHODIMP PutFileTime(_In_ SYSTEMTIME fileTime); IFACEMETHODIMP ResetFileTime(); + IFACEMETHODIMP PutMetadataPatterns(_In_ const PowerRenameLib::MetadataPatternMap& patterns); + IFACEMETHODIMP ResetMetadata(); + IFACEMETHODIMP GetMetadataType(_Out_ PowerRenameLib::MetadataType* metadataType); IFACEMETHODIMP Replace(_In_ PCWSTR source, _Outptr_ PWSTR* result, unsigned long& enumIndex); + + // Get current metadata type based on flags + PowerRenameLib::MetadataType GetMetadataType() const; static HRESULT s_CreateInstance(_Outptr_ IPowerRenameRegEx** renameRegEx); @@ -41,7 +49,9 @@ protected: void _OnReplaceTermChanged(); void _OnFlagsChanged(); void _OnFileTimeChanged(); + void _OnMetadataChanged(); HRESULT _OnEnumerateOrRandomizeItemsChanged(); + PowerRenameLib::MetadataType _GetMetadataTypeFromFlags() const; size_t _Find(std::wstring data, std::wstring toSearch, bool caseInsensitive, size_t pos); @@ -54,6 +64,9 @@ protected: SYSTEMTIME m_fileTime = { 0 }; bool m_useFileTime = false; + PowerRenameLib::MetadataPatternMap m_metadataPatterns; + bool m_useMetadata = false; + CSRWLock m_lock; CSRWLock m_lockEvents; diff --git a/src/modules/powerrename/lib/PropVariantValue.h b/src/modules/powerrename/lib/PropVariantValue.h new file mode 100644 index 0000000000..23e5973d25 --- /dev/null +++ b/src/modules/powerrename/lib/PropVariantValue.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +namespace PowerRenameLib +{ + /// + /// RAII wrapper around PROPVARIANT to ensure proper initialization and cleanup. + /// Move-only semantics keep ownership simple while still allowing use in optionals. + /// + struct PropVariantValue + { + PropVariantValue() noexcept + { + PropVariantInit(&value); + } + + ~PropVariantValue() + { + PropVariantClear(&value); + } + + PropVariantValue(const PropVariantValue&) = delete; + PropVariantValue& operator=(const PropVariantValue&) = delete; + + PropVariantValue(PropVariantValue&& other) noexcept + { + value = other.value; + PropVariantInit(&other.value); // Properly clear the moved-from object + } + + PropVariantValue& operator=(PropVariantValue&& other) noexcept + { + if (this != &other) + { + PropVariantClear(&value); + value = other.value; + PropVariantInit(&other.value); // Properly clear the moved-from object + } + return *this; + } + + PROPVARIANT* GetAddressOf() noexcept + { + return &value; + } + + PROPVARIANT& Get() noexcept + { + return value; + } + + const PROPVARIANT& Get() const noexcept + { + return value; + } + + private: + PROPVARIANT value; + }; +} diff --git a/src/modules/powerrename/lib/Renaming.cpp b/src/modules/powerrename/lib/Renaming.cpp index aa21666783..028621eef4 100644 --- a/src/modules/powerrename/lib/Renaming.cpp +++ b/src/modules/powerrename/lib/Renaming.cpp @@ -1,9 +1,13 @@ #include "pch.h" #include +#include +#include +#include #include "Renaming.h" #include - +#include "MetadataPatternExtractor.h" +#include "PowerRenameRegEx.h" namespace fs = std::filesystem; bool DoRename(CComPtr& spRenameRegEx, unsigned long& itemEnumIndex, CComPtr& spItem) @@ -14,6 +18,7 @@ bool DoRename(CComPtr& spRenameRegEx, unsigned long& itemEnum PWSTR replaceTerm = nullptr; bool useFileTime = false; + bool useMetadata = false; winrt::check_hresult(spRenameRegEx->GetReplaceTerm(&replaceTerm)); @@ -21,7 +26,6 @@ bool DoRename(CComPtr& spRenameRegEx, unsigned long& itemEnum { useFileTime = true; } - CoTaskMemFree(replaceTerm); int id = -1; winrt::check_hresult(spItem->GetId(&id)); @@ -30,6 +34,29 @@ bool DoRename(CComPtr& spRenameRegEx, unsigned long& itemEnum bool isSubFolderContent = false; winrt::check_hresult(spItem->GetIsFolder(&isFolder)); winrt::check_hresult(spItem->GetIsSubFolderContent(&isSubFolderContent)); + + // Get metadata type to check if metadata patterns are used + PowerRenameLib::MetadataType metadataType; + HRESULT hr = spRenameRegEx->GetMetadataType(&metadataType); + if (FAILED(hr)) + { + // Fallback to default metadata type if call fails + metadataType = PowerRenameLib::MetadataType::EXIF; + } + + // Check if metadata is used AND if this file type supports metadata + // Get file path early for metadata type checking and reuse later + PWSTR filePath = nullptr; + winrt::check_hresult(spItem->GetPath(&filePath)); + std::wstring filePathStr(filePath); // Copy once for reuse + CoTaskMemFree(filePath); // Free immediately after copying + + if (isMetadataUsed(replaceTerm, metadataType, filePathStr.c_str(), isFolder)) + { + useMetadata = true; + } + + CoTaskMemFree(replaceTerm); if ((isFolder && (flags & PowerRenameFlags::ExcludeFolders)) || (!isFolder && (flags & PowerRenameFlags::ExcludeFiles)) || (isSubFolderContent && (flags & PowerRenameFlags::ExcludeSubfolders)) || @@ -82,6 +109,53 @@ bool DoRename(CComPtr& spRenameRegEx, unsigned long& itemEnum winrt::check_hresult(spRenameRegEx->PutFileTime(fileTime)); } + if (useMetadata) + { + // Extract metadata patterns from the file + // Note: filePathStr was already obtained and saved earlier for reuse + + // Get metadata type using the interface method + PowerRenameLib::MetadataType metadataType; + HRESULT hr = spRenameRegEx->GetMetadataType(&metadataType); + if (FAILED(hr)) + { + // Fallback to default metadata type if call fails + metadataType = PowerRenameLib::MetadataType::EXIF; + } + // Extract all patterns for the selected metadata type + // At this point we know the file is a supported image format (jpg/jpeg/png/tif/tiff) + static std::mutex s_metadataMutex; // Mutex to protect static variables + static std::once_flag s_metadataExtractorInitFlag; + static std::shared_ptr s_metadataExtractor; + static std::optional s_activeMetadataType; + + // Initialize the extractor only once + std::call_once(s_metadataExtractorInitFlag, []() { + s_metadataExtractor = std::make_shared(); + }); + + // Protect access to shared state + { + std::lock_guard lock(s_metadataMutex); + + // Clear cache if metadata type has changed + if (s_activeMetadataType.has_value() && s_activeMetadataType.value() != metadataType) + { + s_metadataExtractor->ClearCache(); + } + + // Update the active metadata type + s_activeMetadataType = metadataType; + } + + // Extract patterns (this can be done outside the lock if ExtractPatterns is thread-safe) + PowerRenameLib::MetadataPatternMap patterns = s_metadataExtractor->ExtractPatterns(filePathStr, metadataType); + + // Always call PutMetadataPatterns to ensure all patterns get replaced + // Even if empty, this keeps metadata placeholders consistent when no values are extracted + winrt::check_hresult(spRenameRegEx->PutMetadataPatterns(patterns)); + } + PWSTR newName = nullptr; // Failure here means we didn't match anything or had nothing to match @@ -93,6 +167,10 @@ bool DoRename(CComPtr& spRenameRegEx, unsigned long& itemEnum winrt::check_hresult(spRenameRegEx->ResetFileTime()); } + if (useMetadata) + { + winrt::check_hresult(spRenameRegEx->ResetMetadata()); + } wchar_t resultName[MAX_PATH] = { 0 }; PWSTR newNameToUse = nullptr; @@ -206,4 +284,4 @@ bool DoRename(CComPtr& spRenameRegEx, unsigned long& itemEnum CoTaskMemFree(originalName); return wouldRename; -} \ No newline at end of file +} diff --git a/src/modules/powerrename/lib/WICMetadataExtractor.cpp b/src/modules/powerrename/lib/WICMetadataExtractor.cpp new file mode 100644 index 0000000000..bd2f9c08dc --- /dev/null +++ b/src/modules/powerrename/lib/WICMetadataExtractor.cpp @@ -0,0 +1,1021 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#include "pch.h" +#include "WICMetadataExtractor.h" +#include "MetadataFormatHelper.h" +#include +#include +#include +#include +#include +#include + +using namespace PowerRenameLib; + +namespace +{ + // Documentation: https://learn.microsoft.com/en-us/windows/win32/wic/-wic-native-image-format-metadata-queries + + // WIC metadata property paths + const std::wstring EXIF_DATE_TAKEN = L"/app1/ifd/exif/{ushort=36867}"; // DateTimeOriginal + const std::wstring EXIF_DATE_DIGITIZED = L"/app1/ifd/exif/{ushort=36868}"; // DateTimeDigitized + const std::wstring EXIF_DATE_MODIFIED = L"/app1/ifd/{ushort=306}"; // DateTime + const std::wstring EXIF_CAMERA_MAKE = L"/app1/ifd/{ushort=271}"; // Make + const std::wstring EXIF_CAMERA_MODEL = L"/app1/ifd/{ushort=272}"; // Model + const std::wstring EXIF_LENS_MODEL = L"/app1/ifd/exif/{ushort=42036}"; // LensModel + const std::wstring EXIF_ISO = L"/app1/ifd/exif/{ushort=34855}"; // ISOSpeedRatings + const std::wstring EXIF_APERTURE = L"/app1/ifd/exif/{ushort=33437}"; // FNumber + const std::wstring EXIF_SHUTTER_SPEED = L"/app1/ifd/exif/{ushort=33434}"; // ExposureTime + const std::wstring EXIF_FOCAL_LENGTH = L"/app1/ifd/exif/{ushort=37386}"; // FocalLength + const std::wstring EXIF_EXPOSURE_BIAS = L"/app1/ifd/exif/{ushort=37380}"; // ExposureBiasValue + const std::wstring EXIF_FLASH = L"/app1/ifd/exif/{ushort=37385}"; // Flash + const std::wstring EXIF_ORIENTATION = L"/app1/ifd/{ushort=274}"; // Orientation + const std::wstring EXIF_COLOR_SPACE = L"/app1/ifd/exif/{ushort=40961}"; // ColorSpace + const std::wstring EXIF_WIDTH = L"/app1/ifd/exif/{ushort=40962}"; // PixelXDimension - actual image width + const std::wstring EXIF_HEIGHT = L"/app1/ifd/exif/{ushort=40963}"; // PixelYDimension - actual image height + const std::wstring EXIF_ARTIST = L"/app1/ifd/{ushort=315}"; // Artist + const std::wstring EXIF_COPYRIGHT = L"/app1/ifd/{ushort=33432}"; // Copyright + + // GPS paths + const std::wstring GPS_LATITUDE = L"/app1/ifd/gps/{ushort=2}"; // GPSLatitude + const std::wstring GPS_LATITUDE_REF = L"/app1/ifd/gps/{ushort=1}"; // GPSLatitudeRef + const std::wstring GPS_LONGITUDE = L"/app1/ifd/gps/{ushort=4}"; // GPSLongitude + const std::wstring GPS_LONGITUDE_REF = L"/app1/ifd/gps/{ushort=3}"; // GPSLongitudeRef + const std::wstring GPS_ALTITUDE = L"/app1/ifd/gps/{ushort=6}"; // GPSAltitude + const std::wstring GPS_ALTITUDE_REF = L"/app1/ifd/gps/{ushort=5}"; // GPSAltitudeRef + + + // Documentation: https://developer.adobe.com/xmp/docs/XMPNamespaces/xmp/ + // Based on actual WIC path format discovered through enumeration + // XMP Basic schema - xmp: namespace + const std::wstring XMP_CREATE_DATE = L"/xmp/xmp:CreateDate"; // XMP Create Date + const std::wstring XMP_MODIFY_DATE = L"/xmp/xmp:ModifyDate"; // XMP Modify Date + const std::wstring XMP_METADATA_DATE = L"/xmp/xmp:MetadataDate"; // XMP Metadata Date + const std::wstring XMP_CREATOR_TOOL = L"/xmp/xmp:CreatorTool"; // XMP Creator Tool + + // Dublin Core schema - dc: namespace + // Note: For language alternatives like title/description, we need to append /x-default + const std::wstring XMP_DC_TITLE = L"/xmp/dc:title/x-default"; // Title (default language) + const std::wstring XMP_DC_DESCRIPTION = L"/xmp/dc:description/x-default"; // Description (default language) + const std::wstring XMP_DC_CREATOR = L"/xmp/dc:creator"; // Creator/Author + const std::wstring XMP_DC_SUBJECT = L"/xmp/dc:subject"; // Subject/Keywords (array) + + // XMP Rights Management schema - xmpRights: namespace + const std::wstring XMP_RIGHTS = L"/xmp/xmpRights:WebStatement"; // Copyright/Rights + + // XMP Media Management schema - xmpMM: namespace + const std::wstring XMP_MM_DOCUMENT_ID = L"/xmp/xmpMM:DocumentID"; // Document ID + const std::wstring XMP_MM_INSTANCE_ID = L"/xmp/xmpMM:InstanceID"; // Instance ID + const std::wstring XMP_MM_ORIGINAL_DOCUMENT_ID = L"/xmp/xmpMM:OriginalDocumentID"; // Original Document ID + const std::wstring XMP_MM_VERSION_ID = L"/xmp/xmpMM:VersionID"; // Version ID + + + std::wstring TrimWhitespace(const std::wstring& value) + { + const auto first = value.find_first_not_of(L" \t\r\n"); + if (first == std::wstring::npos) + { + return {}; + } + + const auto last = value.find_last_not_of(L" \t\r\n"); + return value.substr(first, last - first + 1); + } + + bool TryParseFixedWidthInt(const std::wstring& source, size_t start, size_t length, int& value) + { + if (start + length > source.size()) + { + return false; + } + + int result = 0; + for (size_t i = 0; i < length; ++i) + { + const wchar_t ch = source[start + i]; + if (ch < L'0' || ch > L'9') + { + return false; + } + + result = result * 10 + static_cast(ch - L'0'); + } + + value = result; + return true; + } + + bool ValidateAndBuildSystemTime(int year, int month, int day, int hour, int minute, int second, int milliseconds, SYSTEMTIME& outTime) + { + if (year < 1601 || year > 9999 || + month < 1 || month > 12 || + day < 1 || day > 31 || + hour < 0 || hour > 23 || + minute < 0 || minute > 59 || + second < 0 || second > 59 || + milliseconds < 0 || milliseconds > 999) + { + return false; + } + + SYSTEMTIME candidate{}; + candidate.wYear = static_cast(year); + candidate.wMonth = static_cast(month); + candidate.wDay = static_cast(day); + candidate.wHour = static_cast(hour); + candidate.wMinute = static_cast(minute); + candidate.wSecond = static_cast(second); + candidate.wMilliseconds = static_cast(milliseconds); + + FILETIME fileTime{}; + if (!SystemTimeToFileTime(&candidate, &fileTime)) + { + return false; + } + + outTime = candidate; + return true; + } + + std::optional ParseExifDateTime(const std::wstring& date) + { + if (date.size() < 19) + { + return std::nullopt; + } + + if (date[4] != L':' || date[7] != L':' || + (date[10] != L' ' && date[10] != L'T') || + date[13] != L':' || date[16] != L':') + { + return std::nullopt; + } + + int year = 0; + int month = 0; + int day = 0; + int hour = 0; + int minute = 0; + int second = 0; + + if (!TryParseFixedWidthInt(date, 0, 4, year) || + !TryParseFixedWidthInt(date, 5, 2, month) || + !TryParseFixedWidthInt(date, 8, 2, day) || + !TryParseFixedWidthInt(date, 11, 2, hour) || + !TryParseFixedWidthInt(date, 14, 2, minute) || + !TryParseFixedWidthInt(date, 17, 2, second)) + { + return std::nullopt; + } + + int milliseconds = 0; + size_t pos = 19; + if (pos < date.size() && (date[pos] == L'.' || date[pos] == L',')) + { + ++pos; + int digits = 0; + while (pos < date.size() && std::iswdigit(date[pos]) && digits < 3) + { + milliseconds = milliseconds * 10 + static_cast(date[pos] - L'0'); + ++pos; + ++digits; + } + + while (digits > 0 && digits < 3) + { + milliseconds *= 10; + ++digits; + } + } + + SYSTEMTIME result{}; + if (!ValidateAndBuildSystemTime(year, month, day, hour, minute, second, milliseconds, result)) + { + return std::nullopt; + } + + return result; + } + + std::optional ParseIso8601DateTime(const std::wstring& date) + { + if (date.size() < 19) + { + return std::nullopt; + } + + size_t separator = date.find(L'T'); + if (separator == std::wstring::npos) + { + separator = date.find(L' '); + } + + if (separator == std::wstring::npos) + { + return std::nullopt; + } + + int year = 0; + int month = 0; + int day = 0; + if (!TryParseFixedWidthInt(date, 0, 4, year) || + date[4] != L'-' || + !TryParseFixedWidthInt(date, 5, 2, month) || + date[7] != L'-' || + !TryParseFixedWidthInt(date, 8, 2, day)) + { + return std::nullopt; + } + + size_t timePos = separator + 1; + if (timePos + 7 >= date.size()) + { + return std::nullopt; + } + + int hour = 0; + int minute = 0; + int second = 0; + if (!TryParseFixedWidthInt(date, timePos, 2, hour) || + date[timePos + 2] != L':' || + !TryParseFixedWidthInt(date, timePos + 3, 2, minute) || + date[timePos + 5] != L':' || + !TryParseFixedWidthInt(date, timePos + 6, 2, second)) + { + return std::nullopt; + } + + size_t pos = timePos + 8; + int milliseconds = 0; + if (pos < date.size() && (date[pos] == L'.' || date[pos] == L',')) + { + ++pos; + int digits = 0; + while (pos < date.size() && std::iswdigit(date[pos]) && digits < 3) + { + milliseconds = milliseconds * 10 + static_cast(date[pos] - L'0'); + ++pos; + ++digits; + } + + while (pos < date.size() && std::iswdigit(date[pos])) + { + ++pos; + } + + while (digits > 0 && digits < 3) + { + milliseconds *= 10; + ++digits; + } + } + + bool hasOffset = false; + int offsetMinutes = 0; + if (pos < date.size()) + { + const wchar_t tzIndicator = date[pos]; + if (tzIndicator == L'Z' || tzIndicator == L'z') + { + hasOffset = true; + offsetMinutes = 0; + ++pos; + } + else if (tzIndicator == L'+' || tzIndicator == L'-') + { + hasOffset = true; + const int sign = (tzIndicator == L'-') ? -1 : 1; + ++pos; + + int offsetHours = 0; + int offsetMins = 0; + if (!TryParseFixedWidthInt(date, pos, 2, offsetHours)) + { + return std::nullopt; + } + pos += 2; + + if (pos < date.size() && date[pos] == L':') + { + ++pos; + } + + if (pos + 1 < date.size() && std::iswdigit(date[pos]) && std::iswdigit(date[pos + 1])) + { + if (!TryParseFixedWidthInt(date, pos, 2, offsetMins)) + { + return std::nullopt; + } + pos += 2; + } + + if (offsetHours < 0 || offsetHours > 23 || offsetMins < 0 || offsetMins > 59) + { + return std::nullopt; + } + + offsetMinutes = sign * (offsetHours * 60 + offsetMins); + } + + while (pos < date.size() && std::iswspace(date[pos])) + { + ++pos; + } + + if (pos != date.size()) + { + return std::nullopt; + } + } + + SYSTEMTIME baseTime{}; + if (!ValidateAndBuildSystemTime(year, month, day, hour, minute, second, milliseconds, baseTime)) + { + return std::nullopt; + } + + if (!hasOffset) + { + return baseTime; + } + + FILETIME utcFileTime{}; + if (!SystemTimeToFileTime(&baseTime, &utcFileTime)) + { + return std::nullopt; + } + + ULARGE_INTEGER timeValue{}; + timeValue.LowPart = utcFileTime.dwLowDateTime; + timeValue.HighPart = utcFileTime.dwHighDateTime; + + constexpr long long TicksPerMinute = 60LL * 10000000LL; + timeValue.QuadPart -= static_cast(offsetMinutes) * TicksPerMinute; + + FILETIME adjustedUtc{}; + adjustedUtc.dwLowDateTime = timeValue.LowPart; + adjustedUtc.dwHighDateTime = timeValue.HighPart; + + FILETIME localFileTime{}; + if (!FileTimeToLocalFileTime(&adjustedUtc, &localFileTime)) + { + return std::nullopt; + } + + SYSTEMTIME localTime{}; + if (!FileTimeToSystemTime(&localFileTime, &localTime)) + { + return std::nullopt; + } + + return localTime; + } +// Global WIC factory management with thread-safe access + CComPtr g_wicFactory; + std::once_flag g_wicInitFlag; + std::mutex g_wicFactoryMutex; // Protect access to g_wicFactory +} + +WICMetadataExtractor::WICMetadataExtractor() +{ + InitializeWIC(); +} + +WICMetadataExtractor::~WICMetadataExtractor() +{ + // WIC cleanup handled statically +} + +void WICMetadataExtractor::InitializeWIC() +{ + std::call_once(g_wicInitFlag, []() { + // Don't initialize COM in library code - assume caller has done it + // Just create the WIC factory + HRESULT hr = CoCreateInstance( + CLSID_WICImagingFactory, + nullptr, + CLSCTX_INPROC_SERVER, + IID_IWICImagingFactory, + reinterpret_cast(&g_wicFactory) + ); + + if (FAILED(hr)) + { + g_wicFactory = nullptr; + } + }); +} + +CComPtr WICMetadataExtractor::GetWICFactory() +{ + std::lock_guard lock(g_wicFactoryMutex); + return g_wicFactory; +} + +bool WICMetadataExtractor::ExtractEXIFMetadata( + const std::wstring& filePath, + EXIFMetadata& outMetadata) +{ + return cache.GetOrLoadEXIF(filePath, outMetadata, [this, &filePath](EXIFMetadata& metadata) { + return LoadEXIFMetadata(filePath, metadata); + }); +} + +bool WICMetadataExtractor::LoadEXIFMetadata( + const std::wstring& filePath, + EXIFMetadata& outMetadata) +{ + CComPtr reader; + + if (!PathFileExistsW(filePath.c_str())) + { +#ifdef _DEBUG + std::wstring msg = L"[PowerRename] EXIF metadata extraction failed: File not found - " + filePath + L"\n"; + OutputDebugStringW(msg.c_str()); +#endif + return false; + } + + auto decoder = CreateDecoder(filePath); + if (!decoder) + { +#ifdef _DEBUG + std::wstring msg = L"[PowerRename] EXIF metadata extraction: Unsupported format or unable to create decoder - " + filePath + L"\n"; + OutputDebugStringW(msg.c_str()); +#endif + return false; + } + + CComPtr frame; + if (FAILED(decoder->GetFrame(0, &frame))) + { +#ifdef _DEBUG + std::wstring msg = L"[PowerRename] EXIF metadata extraction failed: WIC decoder error - " + filePath + L"\n"; + OutputDebugStringW(msg.c_str()); +#endif + return false; + } + + reader = GetMetadataReader(decoder); + if (!reader) + { + // No metadata is not necessarily an error - just means the file has no EXIF data + return false; + } + + ExtractAllEXIFFields(reader, outMetadata); + ExtractGPSData(reader, outMetadata); + + return true; +} + +void WICMetadataExtractor::ClearCache() +{ + cache.ClearAll(); +} + +CComPtr WICMetadataExtractor::CreateDecoder(const std::wstring& filePath) +{ + auto factory = GetWICFactory(); + if (!factory) + { + return nullptr; + } + + CComPtr decoder; + HRESULT hr = factory->CreateDecoderFromFilename( + filePath.c_str(), + nullptr, + GENERIC_READ, + WICDecodeMetadataCacheOnLoad, + &decoder + ); + + if (FAILED(hr)) + { + return nullptr; + } + + return decoder; +} + +CComPtr WICMetadataExtractor::GetMetadataReader(IWICBitmapDecoder* decoder) +{ + if (!decoder) + { + return nullptr; + } + + CComPtr frame; + if (FAILED(decoder->GetFrame(0, &frame))) + { + return nullptr; + } + + CComPtr reader; + frame->GetMetadataQueryReader(&reader); + + return reader; +} + +void WICMetadataExtractor::ExtractAllEXIFFields(IWICMetadataQueryReader* reader, EXIFMetadata& metadata) +{ + if (!reader) + return; + + // Extract date/time fields + metadata.dateTaken = ReadDateTime(reader, EXIF_DATE_TAKEN); + metadata.dateDigitized = ReadDateTime(reader, EXIF_DATE_DIGITIZED); + metadata.dateModified = ReadDateTime(reader, EXIF_DATE_MODIFIED); + + // Extract camera information + metadata.cameraMake = ReadString(reader, EXIF_CAMERA_MAKE); + metadata.cameraModel = ReadString(reader, EXIF_CAMERA_MODEL); + metadata.lensModel = ReadString(reader, EXIF_LENS_MODEL); + + // Extract shooting parameters + metadata.iso = ReadInteger(reader, EXIF_ISO); + metadata.aperture = ReadDouble(reader, EXIF_APERTURE); + metadata.shutterSpeed = ReadDouble(reader, EXIF_SHUTTER_SPEED); + metadata.focalLength = ReadDouble(reader, EXIF_FOCAL_LENGTH); + metadata.exposureBias = ReadDouble(reader, EXIF_EXPOSURE_BIAS); + metadata.flash = ReadInteger(reader, EXIF_FLASH); + + // Extract image properties + metadata.width = ReadInteger(reader, EXIF_WIDTH); + metadata.height = ReadInteger(reader, EXIF_HEIGHT); + metadata.orientation = ReadInteger(reader, EXIF_ORIENTATION); + metadata.colorSpace = ReadInteger(reader, EXIF_COLOR_SPACE); + + // Extract author information + metadata.author = ReadString(reader, EXIF_ARTIST); + metadata.copyright = ReadString(reader, EXIF_COPYRIGHT); +} + +void WICMetadataExtractor::ExtractGPSData(IWICMetadataQueryReader* reader, EXIFMetadata& metadata) +{ + if (!reader) + { + return; + } + + auto lat = ReadMetadata(reader, GPS_LATITUDE); + auto lon = ReadMetadata(reader, GPS_LONGITUDE); + auto latRef = ReadMetadata(reader, GPS_LATITUDE_REF); + auto lonRef = ReadMetadata(reader, GPS_LONGITUDE_REF); + + if (lat && lon) + { + PropVariantValue emptyLatRef; + PropVariantValue emptyLonRef; + + const PROPVARIANT& latRefVar = latRef ? latRef->Get() : emptyLatRef.Get(); + const PROPVARIANT& lonRefVar = lonRef ? lonRef->Get() : emptyLonRef.Get(); + + auto coords = MetadataFormatHelper::ParseGPSCoordinates( + lat->Get(), + lon->Get(), + latRefVar, + lonRefVar); + + metadata.latitude = coords.first; + metadata.longitude = coords.second; + } + + auto alt = ReadMetadata(reader, GPS_ALTITUDE); + if (alt) + { + metadata.altitude = MetadataFormatHelper::ParseGPSRational(alt->Get()); + } +} + + +std::optional WICMetadataExtractor::ReadDateTime(IWICMetadataQueryReader* reader, const std::wstring& path) +{ + auto propVar = ReadMetadata(reader, path); + if (!propVar) + { + return std::nullopt; + } + + std::wstring rawValue; + const PROPVARIANT& variant = propVar->Get(); + + switch (variant.vt) + { + case VT_LPWSTR: + if (variant.pwszVal) + { + rawValue = variant.pwszVal; + } + break; + case VT_BSTR: + if (variant.bstrVal) + { + rawValue = variant.bstrVal; + } + break; + case VT_LPSTR: + if (variant.pszVal) + { + const int size = MultiByteToWideChar(CP_UTF8, 0, variant.pszVal, -1, nullptr, 0); + if (size > 1) + { + rawValue.resize(static_cast(size) - 1); + MultiByteToWideChar(CP_UTF8, 0, variant.pszVal, -1, &rawValue[0], size); + } + } + break; + default: + break; + } + + if (rawValue.empty()) + { + return std::nullopt; + } + + const std::wstring normalized = TrimWhitespace(rawValue); + if (normalized.empty()) + { + return std::nullopt; + } + + if (auto exifDate = ParseExifDateTime(normalized)) + { + return exifDate; + } + + if (auto isoDate = ParseIso8601DateTime(normalized)) + { + return isoDate; + } + + return std::nullopt; +} + +std::optional WICMetadataExtractor::ReadString(IWICMetadataQueryReader* reader, const std::wstring& path) +{ + auto propVar = ReadMetadata(reader, path); + if (!propVar.has_value()) + return std::nullopt; + + std::wstring result; + switch (propVar->Get().vt) + { + case VT_LPWSTR: + if (propVar->Get().pwszVal) + result = propVar->Get().pwszVal; + break; + case VT_BSTR: + if (propVar->Get().bstrVal) + result = propVar->Get().bstrVal; + break; + case VT_LPSTR: + if (propVar->Get().pszVal) + { + int size = MultiByteToWideChar(CP_UTF8, 0, propVar->Get().pszVal, -1, nullptr, 0); + if (size > 1) + { + result.resize(static_cast(size) - 1); + MultiByteToWideChar(CP_UTF8, 0, propVar->Get().pszVal, -1, &result[0], size); + } + } + break; + } + + + // Trim whitespace from both ends + if (!result.empty()) + { + size_t start = result.find_first_not_of(L" \t\r\n"); + size_t end = result.find_last_not_of(L" \t\r\n"); + if (start != std::wstring::npos && end != std::wstring::npos) + { + result = result.substr(start, end - start + 1); + } + else if (start == std::wstring::npos) + { + result.clear(); + } + } + + return result.empty() ? std::nullopt : std::make_optional(result); +} + +std::optional WICMetadataExtractor::ReadInteger(IWICMetadataQueryReader* reader, const std::wstring& path) +{ + auto propVar = ReadMetadata(reader, path); + if (!propVar.has_value()) + return std::nullopt; + + int64_t result = 0; + switch (propVar->Get().vt) + { + case VT_I1: result = propVar->Get().cVal; break; + case VT_I2: result = propVar->Get().iVal; break; + case VT_I4: result = propVar->Get().lVal; break; + case VT_I8: result = propVar->Get().hVal.QuadPart; break; + case VT_UI1: result = propVar->Get().bVal; break; + case VT_UI2: result = propVar->Get().uiVal; break; + case VT_UI4: result = propVar->Get().ulVal; break; + case VT_UI8: result = static_cast(propVar->Get().uhVal.QuadPart); break; + default: + return std::nullopt; + } + + return result; +} + +std::optional WICMetadataExtractor::ReadDouble(IWICMetadataQueryReader* reader, const std::wstring& path) +{ + auto propVar = ReadMetadata(reader, path); + if (!propVar.has_value()) + return std::nullopt; + + double result = 0.0; + switch (propVar->Get().vt) + { + case VT_R4: + result = static_cast(propVar->Get().fltVal); + break; + case VT_R8: + result = propVar->Get().dblVal; + break; + case VT_UI1 | VT_VECTOR: + case VT_UI4 | VT_VECTOR: + // Handle rational number (common for EXIF values) + // Rational data is stored as 8 bytes: 4-byte numerator + 4-byte denominator + if (propVar->Get().caub.cElems >= 8) + { + // ExposureBias (EXIF tag 37380) uses SRATIONAL type (signed rational) + // which can represent negative values like -0.33 EV for exposure compensation. + // Most other EXIF fields use RATIONAL type (unsigned) for values like aperture, shutter speed. + if (path == EXIF_EXPOSURE_BIAS) + { + // Parse as signed rational: int32_t / int32_t + result = MetadataFormatHelper::ParseSingleSRational(propVar->Get().caub.pElems, 0); + break; + } + else + { + // Parse as unsigned rational: uint32_t / uint32_t + // First check if denominator is valid (non-zero) to avoid division by zero + const uint8_t* bytes = propVar->Get().caub.pElems; + uint32_t denominator = static_cast(bytes[4]) | + (static_cast(bytes[5]) << 8) | + (static_cast(bytes[6]) << 16) | + (static_cast(bytes[7]) << 24); + + if (denominator != 0) + { + result = MetadataFormatHelper::ParseSingleRational(propVar->Get().caub.pElems, 0); + break; + } + } + } + return std::nullopt; + default: + // Try integer conversion + switch (propVar->Get().vt) + { + case VT_I1: result = static_cast(propVar->Get().cVal); break; + case VT_I2: result = static_cast(propVar->Get().iVal); break; + case VT_I4: result = static_cast(propVar->Get().lVal); break; + case VT_I8: + { + // ExposureBias (EXIF tag 37380) may be stored as VT_I8 in some WIC implementations + // It represents a signed rational (SRATIONAL) packed into a 64-bit integer + if (path == EXIF_EXPOSURE_BIAS) + { + // Parse signed rational from int64: low 32 bits = numerator, high 32 bits = denominator + // Some implementations may reverse the order, so we try both + int32_t numerator = static_cast(propVar->Get().hVal.QuadPart & 0xFFFFFFFF); + int32_t denominator = static_cast(propVar->Get().hVal.QuadPart >> 32); + if (denominator != 0) + { + result = static_cast(numerator) / static_cast(denominator); + } + else + { + // Try reversed order: high 32 bits = numerator, low 32 bits = denominator + numerator = static_cast(propVar->Get().hVal.QuadPart >> 32); + denominator = static_cast(propVar->Get().hVal.QuadPart & 0xFFFFFFFF); + if (denominator != 0) + { + result = static_cast(numerator) / static_cast(denominator); + } + else + { + result = 0.0; // Default to 0 if both attempts fail + } + } + } + else + { + // For other fields, treat VT_I8 as a simple 64-bit integer + result = static_cast(propVar->Get().hVal.QuadPart); + } + } + break; + case VT_UI1: result = static_cast(propVar->Get().bVal); break; + case VT_UI2: result = static_cast(propVar->Get().uiVal); break; + case VT_UI4: result = static_cast(propVar->Get().ulVal); break; + case VT_UI8: + { + // ExposureBias (EXIF tag 37380) may be stored as VT_UI8 in some WIC implementations + // Even though it's unsigned, we need to reinterpret it as signed for SRATIONAL + if (path == EXIF_EXPOSURE_BIAS) + { + // Parse signed rational from uint64 (reinterpret as signed) + // Low 32 bits = numerator, high 32 bits = denominator + int32_t numerator = static_cast(propVar->Get().uhVal.QuadPart & 0xFFFFFFFF); + int32_t denominator = static_cast(propVar->Get().uhVal.QuadPart >> 32); + if (denominator != 0) + { + result = static_cast(numerator) / static_cast(denominator); + } + else + { + // Try reversed order: high 32 bits = numerator, low 32 bits = denominator + numerator = static_cast(propVar->Get().uhVal.QuadPart >> 32); + denominator = static_cast(propVar->Get().uhVal.QuadPart & 0xFFFFFFFF); + if (denominator != 0) + { + result = static_cast(numerator) / static_cast(denominator); + } + else + { + result = 0.0; // Default to 0 if both attempts fail + } + } + } + else + { + // For other EXIF rational fields (unsigned), try both byte orders to handle different encodings + // First try: low 32 bits = numerator, high 32 bits = denominator + uint32_t numerator = static_cast(propVar->Get().uhVal.QuadPart & 0xFFFFFFFF); + uint32_t denominator = static_cast(propVar->Get().uhVal.QuadPart >> 32); + + if (denominator != 0) + { + result = static_cast(numerator) / static_cast(denominator); + } + else + { + // Second try: high 32 bits = numerator, low 32 bits = denominator + numerator = static_cast(propVar->Get().uhVal.QuadPart >> 32); + denominator = static_cast(propVar->Get().uhVal.QuadPart & 0xFFFFFFFF); + if (denominator != 0) + { + result = static_cast(numerator) / static_cast(denominator); + } + else + { + // Fall back to treating as regular integer if denominator is 0 + result = static_cast(propVar->Get().uhVal.QuadPart); + } + } + } + } + break; + default: + return std::nullopt; + } + } + + return result; +} + +std::optional WICMetadataExtractor::ReadMetadata(IWICMetadataQueryReader* reader, const std::wstring& path) +{ + if (!reader) + return std::nullopt; + + PropVariantValue value; + + HRESULT hr = reader->GetMetadataByName(path.c_str(), value.GetAddressOf()); + if (SUCCEEDED(hr)) + { + return std::optional(std::move(value)); + } + + return std::nullopt; +} + +// GPS parsing functions have been moved to MetadataFormatHelper for better testability + +bool WICMetadataExtractor::ExtractXMPMetadata( + const std::wstring& filePath, + XMPMetadata& outMetadata) +{ + return cache.GetOrLoadXMP(filePath, outMetadata, [this, &filePath](XMPMetadata& metadata) { + return LoadXMPMetadata(filePath, metadata); + }); +} + +bool WICMetadataExtractor::LoadXMPMetadata( + const std::wstring& filePath, + XMPMetadata& outMetadata) +{ + if (!PathFileExistsW(filePath.c_str())) + { +#ifdef _DEBUG + std::wstring msg = L"[PowerRename] XMP metadata extraction failed: File not found - " + filePath + L"\n"; + OutputDebugStringW(msg.c_str()); +#endif + return false; + } + + auto decoder = CreateDecoder(filePath); + if (!decoder) + { +#ifdef _DEBUG + std::wstring msg = L"[PowerRename] XMP metadata extraction: Unsupported format or unable to create decoder - " + filePath + L"\n"; + OutputDebugStringW(msg.c_str()); +#endif + return false; + } + + CComPtr frame; + if (FAILED(decoder->GetFrame(0, &frame))) + { +#ifdef _DEBUG + std::wstring msg = L"[PowerRename] XMP metadata extraction failed: WIC decoder error - " + filePath + L"\n"; + OutputDebugStringW(msg.c_str()); +#endif + return false; + } + + CComPtr rootReader; + if (FAILED(frame->GetMetadataQueryReader(&rootReader))) + { + // No metadata is not necessarily an error - just means the file has no XMP data + return false; + } + + ExtractAllXMPFields(rootReader, outMetadata); + + return true; +} + +// Batch extraction method implementations +void WICMetadataExtractor::ExtractAllXMPFields(IWICMetadataQueryReader* reader, XMPMetadata& metadata) +{ + if (!reader) + return; + + // XMP Basic schema - xmp: namespace + metadata.creatorTool = ReadString(reader, XMP_CREATOR_TOOL); + metadata.createDate = ReadDateTime(reader, XMP_CREATE_DATE); + metadata.modifyDate = ReadDateTime(reader, XMP_MODIFY_DATE); + metadata.metadataDate = ReadDateTime(reader, XMP_METADATA_DATE); + + // Dublin Core schema - dc: namespace + metadata.title = ReadString(reader, XMP_DC_TITLE); + metadata.description = ReadString(reader, XMP_DC_DESCRIPTION); + metadata.creator = ReadString(reader, XMP_DC_CREATOR); + + // For dc:subject, we need to handle the array structure + // Try to read individual elements + // XMP allows for large arrays, but we limit to a reasonable number to avoid performance issues + constexpr int MAX_XMP_SUBJECTS = 50; + std::vector subjects; + for (int i = 0; i < MAX_XMP_SUBJECTS; ++i) + { + std::wstring subjectPath = L"/xmp/dc:subject/{ulong=" + std::to_wstring(i) + L"}"; + auto subject = ReadString(reader, subjectPath); + if (subject.has_value()) + { + subjects.push_back(subject.value()); + } + else + { + break; // No more subjects + } + } + if (!subjects.empty()) + { + metadata.subject = subjects; + } + + // XMP Rights Management schema + metadata.rights = ReadString(reader, XMP_RIGHTS); + + // XMP Media Management schema - xmpMM: namespace + metadata.documentID = ReadString(reader, XMP_MM_DOCUMENT_ID); + metadata.instanceID = ReadString(reader, XMP_MM_INSTANCE_ID); + metadata.originalDocumentID = ReadString(reader, XMP_MM_ORIGINAL_DOCUMENT_ID); + metadata.versionID = ReadString(reader, XMP_MM_VERSION_ID); +} + + + + + + + + + diff --git a/src/modules/powerrename/lib/WICMetadataExtractor.h b/src/modules/powerrename/lib/WICMetadataExtractor.h new file mode 100644 index 0000000000..868d18aa7c --- /dev/null +++ b/src/modules/powerrename/lib/WICMetadataExtractor.h @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma once +#include "MetadataTypes.h" +#include "MetadataResultCache.h" +#include "PropVariantValue.h" +#include +#include + +namespace PowerRenameLib +{ + /// + /// Windows Imaging Component (WIC) implementation for metadata extraction + /// Provides efficient batch extraction of all metadata types with built-in caching + /// + class WICMetadataExtractor + { + public: + WICMetadataExtractor(); + ~WICMetadataExtractor(); + + // Public metadata extraction methods + bool ExtractEXIFMetadata( + const std::wstring& filePath, + EXIFMetadata& outMetadata); + + bool ExtractXMPMetadata( + const std::wstring& filePath, + XMPMetadata& outMetadata); + + void ClearCache(); + + private: + // WIC factory management + static CComPtr GetWICFactory(); + static void InitializeWIC(); + + // WIC operations + CComPtr CreateDecoder(const std::wstring& filePath); + CComPtr GetMetadataReader(IWICBitmapDecoder* decoder); + + bool LoadEXIFMetadata(const std::wstring& filePath, EXIFMetadata& outMetadata); + bool LoadXMPMetadata(const std::wstring& filePath, XMPMetadata& outMetadata); + + // Batch extraction methods + void ExtractAllEXIFFields(IWICMetadataQueryReader* reader, EXIFMetadata& metadata); + void ExtractGPSData(IWICMetadataQueryReader* reader, EXIFMetadata& metadata); + void ExtractAllXMPFields(IWICMetadataQueryReader* reader, XMPMetadata& metadata); + + // Field reading helpers + std::optional ReadDateTime(IWICMetadataQueryReader* reader, const std::wstring& path); + std::optional ReadString(IWICMetadataQueryReader* reader, const std::wstring& path); + std::optional ReadInteger(IWICMetadataQueryReader* reader, const std::wstring& path); + std::optional ReadDouble(IWICMetadataQueryReader* reader, const std::wstring& path); + + // Helper methods + std::optional ReadMetadata(IWICMetadataQueryReader* reader, const std::wstring& path); + + private: + MetadataResultCache cache; + }; +} diff --git a/src/modules/powerrename/lib/pch.h b/src/modules/powerrename/lib/pch.h index c5a4711a03..2ee372ae61 100644 --- a/src/modules/powerrename/lib/pch.h +++ b/src/modules/powerrename/lib/pch.h @@ -28,5 +28,17 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include + +// Windows Imaging Component (WIC) headers +#include +#include +#include +#include diff --git a/src/modules/powerrename/unittests/HelpersTests.cpp b/src/modules/powerrename/unittests/HelpersTests.cpp new file mode 100644 index 0000000000..9a6d1b2028 --- /dev/null +++ b/src/modules/powerrename/unittests/HelpersTests.cpp @@ -0,0 +1,766 @@ +#include "pch.h" +#include "Helpers.h" +#include "MetadataPatternExtractor.h" +#include "MetadataTypes.h" + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace HelpersTests +{ + TEST_CLASS(GetMetadataFileNameTests) + { + public: + TEST_METHOD(BasicPatternReplacement) + { + // Test basic pattern replacement with available metadata + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + patterns[L"ISO"] = L"ISO 400"; + patterns[L"DATE_TAKEN_YYYY"] = L"2024"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE_$ISO", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_Canon_ISO 400", result); + } + + TEST_METHOD(PatternWithoutValueShowsPatternName) + { + // Test that patterns without values show the pattern name with $ prefix + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + // ISO is not in the map + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE_$ISO", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_Canon_$ISO", result); + } + + TEST_METHOD(EmptyPatternShowsPatternName) + { + // Test that patterns with empty value show the pattern name with $ prefix + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + patterns[L"ISO"] = L""; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE_$ISO", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_Canon_$ISO", result); + } + + TEST_METHOD(EscapedDollarSigns) + { + // Test that $$ is converted to single $ + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"ISO"] = L"ISO 400"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$$_$ISO", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_$_ISO 400", result); + } + + TEST_METHOD(MultipleEscapedDollarSigns) + { + // Test that $$$$ is converted to $$ + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"ISO"] = L"ISO 400"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$$$$price", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_$$price", result); + } + + TEST_METHOD(OddDollarSignsWithPattern) + { + // Test that $$$ becomes $ followed by pattern + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"ISO"] = L"ISO 400"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$$$ISO", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_$ISO 400", result); + } + + TEST_METHOD(LongestPatternMatchPriority) + { + // Test that longer patterns are matched first (DATE_TAKEN_YYYY vs DATE_TAKEN_YY) + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"DATE_TAKEN_YYYY"] = L"2024"; + patterns[L"DATE_TAKEN_YY"] = L"24"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$DATE_TAKEN_YYYY", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_2024", result); + } + + TEST_METHOD(MultiplePatterns) + { + // Test multiple patterns in one string + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + patterns[L"CAMERA_MODEL"] = L"EOS R5"; + patterns[L"ISO"] = L"ISO 800"; + patterns[L"DATE_TAKEN_YYYY"] = L"2024"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, + L"$DATE_TAKEN_YYYY-$CAMERA_MAKE-$CAMERA_MODEL-$ISO", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"2024-Canon-EOS R5-ISO 800", result); + } + + TEST_METHOD(UnrecognizedPatternIgnored) + { + // Test that unrecognized patterns are not replaced + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE_$INVALID_PATTERN", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_Canon_$INVALID_PATTERN", result); + } + + TEST_METHOD(NoPatterns) + { + // Test string with no patterns + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_name_without_patterns", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_name_without_patterns", result); + } + + TEST_METHOD(EmptyInput) + { + // Test with empty input string + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"", patterns); + + Assert::IsTrue(FAILED(hr)); + } + + TEST_METHOD(NullInput) + { + // Test with null input + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, nullptr, patterns); + + Assert::IsTrue(FAILED(hr)); + } + + TEST_METHOD(DollarAtEnd) + { + // Test dollar sign at the end of string + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"ISO"] = L"ISO 400"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$ISO$", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_ISO 400$", result); + } + + TEST_METHOD(ThreeDollarsAtEnd) + { + // Test three dollar signs at the end + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo$$$", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo$$$", result); + } + + TEST_METHOD(ComplexMixedScenario) + { + // Test complex scenario with mixed patterns, escapes, and regular text + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + patterns[L"ISO"] = L"ISO 400"; + patterns[L"APERTURE"] = L"f/2.8"; + patterns[L"LENS"] = L""; // Empty value + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, + L"$$price_$CAMERA_MAKE_$$$ISO_$APERTURE_$LENS_$$end", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"$price_Canon_$ISO 400_f/2.8_$LENS_$end", result); + } + + TEST_METHOD(AllEXIFPatterns) + { + // Test with various EXIF patterns + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"WIDTH"] = L"4000"; + patterns[L"HEIGHT"] = L"3000"; + patterns[L"FOCAL"] = L"50mm"; + patterns[L"SHUTTER"] = L"1/100s"; + patterns[L"FLASH"] = L"Flash Off"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, + L"photo_$WIDTH x $HEIGHT_$FOCAL_$SHUTTER_$FLASH", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_4000 x 3000_50mm_1/100s_Flash Off", result); + } + + TEST_METHOD(AllXMPPatterns) + { + // Test with various XMP patterns + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"TITLE"] = L"Sunset"; + patterns[L"CREATOR"] = L"John Doe"; + patterns[L"DESCRIPTION"] = L"Beautiful sunset"; + patterns[L"CREATE_DATE_YYYY"] = L"2024"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, + L"$CREATE_DATE_YYYY-$TITLE-by-$CREATOR", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"2024-Sunset-by-John Doe", result); + } + + TEST_METHOD(DateComponentPatterns) + { + // Test date component patterns + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"DATE_TAKEN_YYYY"] = L"2024"; + patterns[L"DATE_TAKEN_MM"] = L"03"; + patterns[L"DATE_TAKEN_DD"] = L"15"; + patterns[L"DATE_TAKEN_HH"] = L"14"; + patterns[L"DATE_TAKEN_mm"] = L"30"; + patterns[L"DATE_TAKEN_SS"] = L"45"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, + L"photo_$DATE_TAKEN_YYYY-$DATE_TAKEN_MM-$DATE_TAKEN_DD_$DATE_TAKEN_HH-$DATE_TAKEN_mm-$DATE_TAKEN_SS", + patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_2024-03-15_14-30-45", result); + } + + TEST_METHOD(SpecialCharactersInValues) + { + // Test that special characters in metadata values are preserved + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"TITLE"] = L"Photo (with) [brackets] & symbols!"; + patterns[L"DESCRIPTION"] = L"Test: value; with, punctuation."; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, + L"$TITLE - $DESCRIPTION", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"Photo (with) [brackets] & symbols! - Test: value; with, punctuation.", result); + } + + TEST_METHOD(ConsecutivePatternsWithoutSeparator) + { + // Test consecutive patterns without separator + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + patterns[L"CAMERA_MODEL"] = L"R5"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$CAMERA_MAKE$CAMERA_MODEL", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"CanonR5", result); + } + + TEST_METHOD(PatternAtStart) + { + // Test pattern at the beginning of string + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$CAMERA_MAKE_photo", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"Canon_photo", result); + } + + TEST_METHOD(PatternAtEnd) + { + // Test pattern at the end of string + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_Canon", result); + } + + TEST_METHOD(OnlyPattern) + { + // Test string with only a pattern + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$CAMERA_MAKE", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"Canon", result); + } + }; + + TEST_CLASS(PatternMatchingTests) + { + public: + TEST_METHOD(VerifyLongestPatternMatching) + { + // This test verifies the greedy matching behavior + // When we have overlapping pattern names, the longest should be matched first + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"DATE_TAKEN_Y"] = L"4"; + patterns[L"DATE_TAKEN_YY"] = L"24"; + patterns[L"DATE_TAKEN_YYYY"] = L"2024"; + + wchar_t result[MAX_PATH] = { 0 }; + + // Should match YYYY (longest) + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$DATE_TAKEN_YYYY", patterns); + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"2024", result); + + // Should match YY (available pattern) + hr = GetMetadataFileName(result, MAX_PATH, L"$DATE_TAKEN_YY", patterns); + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"24", result); + } + + TEST_METHOD(PartialPatternNames) + { + // Test that partial pattern names don't match longer patterns + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MODEL"] = L"EOS R5"; + + wchar_t result[MAX_PATH] = { 0 }; + // CAMERA is not a valid pattern, should not match + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$CAMERA_MODEL", patterns); + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"EOS R5", result); + } + + TEST_METHOD(CaseSensitivePatterns) + { + // Test that pattern names are case-sensitive + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + wchar_t result[MAX_PATH] = { 0 }; + // lowercase should not match + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$camera_make", patterns); + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"$camera_make", result); // Not replaced + } + + TEST_METHOD(EmptyPatternMap) + { + // Test with empty pattern map + PowerRenameLib::MetadataPatternMap patterns; // Empty + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$ISO_$CAMERA_MAKE", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + // Patterns should show with $ prefix since they're valid but have no values + Assert::AreEqual(L"photo_$ISO_$CAMERA_MAKE", result); + } + }; + + TEST_CLASS(EdgeCaseTests) + { + public: + TEST_METHOD(VeryLongString) + { + // Test with a very long input string + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + std::wstring longInput = L"prefix_"; + for (int i = 0; i < 100; i++) + { + longInput += L"$CAMERA_MAKE_"; + } + + wchar_t result[4096] = { 0 }; + HRESULT hr = GetMetadataFileName(result, 4096, longInput.c_str(), patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + // Verify it starts correctly + Assert::IsTrue(wcsstr(result, L"prefix_Canon_") == result); + } + + TEST_METHOD(ManyConsecutiveDollars) + { + // Test with many consecutive dollar signs + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + // 8 dollars should become 4 dollars + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo$$$$$$$$name", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo$$$$name", result); + } + + TEST_METHOD(OnlyDollars) + { + // Test string with only dollar signs + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$$$$", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"$$", result); + } + + TEST_METHOD(UnicodeCharacters) + { + // Test with unicode characters in pattern values + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"TITLE"] = L"照片_фото_φωτογραφία"; + patterns[L"CREATOR"] = L"张三_Иван_Γιάννης"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$TITLE-$CREATOR", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"照片_фото_φωτογραφία-张三_Иван_Γιάννης", result); + } + + TEST_METHOD(SingleDollar) + { + // Test with single dollar not followed by pattern + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"price$100", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"price$100", result); + } + + TEST_METHOD(DollarFollowedByNumber) + { + // Test dollar followed by numbers (not a pattern) + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"cost_$123.45", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"cost_$123.45", result); + } + }; + + TEST_CLASS(GetDatedFileNameTests) + { + public: + // Helper to get a fixed test time for consistent testing + SYSTEMTIME GetTestTime() + { + SYSTEMTIME testTime = { 0 }; + testTime.wYear = 2024; + testTime.wMonth = 3; // March + testTime.wDay = 15; // 15th + testTime.wHour = 14; // 2 PM (24-hour format) + testTime.wMinute = 30; + testTime.wSecond = 45; + testTime.wMilliseconds = 123; + testTime.wDayOfWeek = 5; // Friday (0=Sunday, 5=Friday) + return testTime; + } + + // Category 1: Tests for invalid patterns with extra characters (verify negative lookahead prevents wrong matching) + + TEST_METHOD(InvalidPattern_YYY_NotMatched) + { + // Test $YYY (3 Y's) is not a valid pattern and should remain unchanged + // Negative lookahead in $YY(?!Y) prevents matching $YYY + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$YYY", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_$YYY", result); // $YYY is invalid, should remain unchanged + } + + TEST_METHOD(InvalidPattern_DDD_NotPartiallyMatched) + { + // Test that $DDD (short weekday) is not confused with $DD (2-digit day) + // This verifies negative lookahead works correctly + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DDD", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Fri", result); // Should be "Fri", not "15D" + } + + TEST_METHOD(InvalidPattern_MMM_NotPartiallyMatched) + { + // Test that $MMM (short month name) is not confused with $MM (2-digit month) + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$MMM", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Mar", result); // Should be "Mar", not "03M" + } + + TEST_METHOD(InvalidPattern_HHH_NotMatched) + { + // Test $HHH (3 H's) is not valid and negative lookahead prevents $HH from matching + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$HHH", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_$HHH", result); // Should remain unchanged + } + + TEST_METHOD(SeparatedPatterns_SingleY) + { + // Test multiple $Y with separators works correctly + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$Y-$Y-$Y", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_4-4-4", result); // Each $Y outputs "4" (from 2024) + } + + TEST_METHOD(SeparatedPatterns_SingleD) + { + // Test multiple $D with separators works correctly + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$D.$D.$D", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_15.15.15", result); // Each $D outputs "15" + } + + // Category 2: Tests for mixed length patterns (verify longer patterns don't get matched incorrectly) + + TEST_METHOD(MixedLengthYear_QuadFollowedBySingle) + { + // Test $YYYY$Y - should be 2024 + 4 + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$YYYY$Y", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_20244", result); + } + + TEST_METHOD(MixedLengthDay_TripleFollowedBySingle) + { + // Test $DDD$D - should be "Fri" + "15" + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DDD$D", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Fri15", result); + } + + TEST_METHOD(MixedLengthDay_QuadFollowedByDouble) + { + // Test $DDDD$DD - should be "Friday" + "15" + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DDDD$DD", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Friday15", result); + } + + TEST_METHOD(MixedLengthMonth_TripleFollowedBySingle) + { + // Test $MMM$M - should be "Mar" + "3" + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$MMM$M", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Mar3", result); + } + + // Category 3: Tests for boundary conditions (patterns at start, end, with special chars) + + TEST_METHOD(PatternAtStart) + { + // Test pattern at the very start of filename + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"$YYYY$M$D", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"2024315", result); + } + + TEST_METHOD(PatternAtEnd) + { + // Test pattern at the very end of filename + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$Y", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_4", result); + } + + TEST_METHOD(PatternWithSpecialChars) + { + // Test patterns surrounded by special characters + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file-$Y.$Y-$M", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file-4.4-3", result); + } + + TEST_METHOD(EmptyFileName) + { + // Test with empty input string - should return E_INVALIDARG + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"", testTime); + + Assert::IsTrue(FAILED(hr)); // Empty string should fail + Assert::AreEqual(E_INVALIDARG, hr); + } + + // Category 4: Tests to explicitly verify negative lookahead is working + + TEST_METHOD(NegativeLookahead_YearNotMatchedInYYYY) + { + // Verify $Y doesn't match when part of $YYYY + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$YYYY", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_2024", result); // Should be "2024", not "202Y" + } + + TEST_METHOD(NegativeLookahead_MonthNotMatchedInMMM) + { + // Verify $M doesn't match when part of $MMM + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$MMM", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Mar", result); // Should be "Mar", not "3ar" + } + + TEST_METHOD(NegativeLookahead_DayNotMatchedInDDDD) + { + // Verify $D doesn't match when part of $DDDD + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DDDD", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Friday", result); // Should be "Friday", not "15riday" + } + + TEST_METHOD(NegativeLookahead_HourNotMatchedInHH) + { + // Verify $H doesn't match when part of $HH + // Note: $HH is 12-hour format, so 14:00 (2 PM) displays as "02" + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$HH", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_02", result); // 14:00 in 12-hour format is "02 PM" + } + + TEST_METHOD(NegativeLookahead_MillisecondNotMatchedInFFF) + { + // Verify $f doesn't match when part of $fff + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$fff", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_123", result); // Should be "123", not "1ff" + } + + // Category 5: Complex mixed scenarios + + TEST_METHOD(ComplexMixedPattern_AllFormats) + { + // Test a complex realistic filename with mixed pattern lengths + // Note: Using $hh for 24-hour format instead of $HH (which is 12-hour) + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"Photo_$YYYY-$MM-$DD_$hh-$mm-$ss_$fff", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"Photo_2024-03-15_14-30-45_123", result); + } + + TEST_METHOD(ComplexMixedPattern_WithSeparators) + { + // Test multiple patterns of different lengths with separators + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"$YYYY_$Y-$Y_$MM_$M", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"2024_4-4_03_3", result); + } + + TEST_METHOD(ComplexMixedPattern_DayFormats) + { + // Test all day format variations in one string + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"$D-$DD-$DDD-$DDDD", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"15-15-Fri-Friday", result); + } + }; +} diff --git a/src/modules/powerrename/unittests/MetadataFormatHelperTests.cpp b/src/modules/powerrename/unittests/MetadataFormatHelperTests.cpp new file mode 100644 index 0000000000..6fd5badca8 --- /dev/null +++ b/src/modules/powerrename/unittests/MetadataFormatHelperTests.cpp @@ -0,0 +1,487 @@ +#include "pch.h" +#include "MetadataFormatHelper.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; +using namespace PowerRenameLib; + +namespace MetadataFormatHelperTests +{ + TEST_CLASS(FormatApertureTests) + { + public: + TEST_METHOD(FormatAperture_ValidValue) + { + // Test formatting a typical aperture value + std::wstring result = MetadataFormatHelper::FormatAperture(2.8); + Assert::AreEqual(L"f/2.8", result.c_str()); + } + + TEST_METHOD(FormatAperture_SmallValue) + { + // Test small aperture (large f-number) + std::wstring result = MetadataFormatHelper::FormatAperture(1.4); + Assert::AreEqual(L"f/1.4", result.c_str()); + } + + TEST_METHOD(FormatAperture_LargeValue) + { + // Test large aperture (small f-number) + std::wstring result = MetadataFormatHelper::FormatAperture(22.0); + Assert::AreEqual(L"f/22.0", result.c_str()); + } + + TEST_METHOD(FormatAperture_RoundedValue) + { + // Test rounding to one decimal place + std::wstring result = MetadataFormatHelper::FormatAperture(5.66666); + Assert::AreEqual(L"f/5.7", result.c_str()); + } + + TEST_METHOD(FormatAperture_Zero) + { + // Test zero value + std::wstring result = MetadataFormatHelper::FormatAperture(0.0); + Assert::AreEqual(L"f/0.0", result.c_str()); + } + }; + + TEST_CLASS(FormatShutterSpeedTests) + { + public: + TEST_METHOD(FormatShutterSpeed_FastSpeed) + { + // Test fast shutter speed (fraction of second) + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(0.002); + Assert::AreEqual(L"1/500s", result.c_str()); + } + + TEST_METHOD(FormatShutterSpeed_VeryFastSpeed) + { + // Test very fast shutter speed + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(0.0001); + Assert::AreEqual(L"1/10000s", result.c_str()); + } + + TEST_METHOD(FormatShutterSpeed_SlowSpeed) + { + // Test slow shutter speed (more than 1 second) + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(2.5); + Assert::AreEqual(L"2.5s", result.c_str()); + } + + TEST_METHOD(FormatShutterSpeed_OneSecond) + { + // Test exactly 1 second + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(1.0); + Assert::AreEqual(L"1.0s", result.c_str()); + } + + TEST_METHOD(FormatShutterSpeed_VerySlowSpeed) + { + // Test very slow shutter speed (< 1 second but close) + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(0.5); + Assert::AreEqual(L"1/2s", result.c_str()); + } + + TEST_METHOD(FormatShutterSpeed_Zero) + { + // Test zero value + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(0.0); + Assert::AreEqual(L"0", result.c_str()); + } + + TEST_METHOD(FormatShutterSpeed_Negative) + { + // Test negative value (invalid but should handle gracefully) + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(-1.0); + Assert::AreEqual(L"0", result.c_str()); + } + }; + + TEST_CLASS(FormatISOTests) + { + public: + TEST_METHOD(FormatISO_TypicalValue) + { + // Test typical ISO value + std::wstring result = MetadataFormatHelper::FormatISO(400); + Assert::AreEqual(L"ISO 400", result.c_str()); + } + + TEST_METHOD(FormatISO_LowValue) + { + // Test low ISO value + std::wstring result = MetadataFormatHelper::FormatISO(100); + Assert::AreEqual(L"ISO 100", result.c_str()); + } + + TEST_METHOD(FormatISO_HighValue) + { + // Test high ISO value + std::wstring result = MetadataFormatHelper::FormatISO(12800); + Assert::AreEqual(L"ISO 12800", result.c_str()); + } + + TEST_METHOD(FormatISO_Zero) + { + // Test zero value + std::wstring result = MetadataFormatHelper::FormatISO(0); + Assert::AreEqual(L"ISO", result.c_str()); + } + + TEST_METHOD(FormatISO_Negative) + { + // Test negative value (invalid but should handle gracefully) + std::wstring result = MetadataFormatHelper::FormatISO(-100); + Assert::AreEqual(L"ISO", result.c_str()); + } + }; + + TEST_CLASS(FormatFlashTests) + { + public: + TEST_METHOD(FormatFlash_Off) + { + // Test flash off (bit 0 = 0) + std::wstring result = MetadataFormatHelper::FormatFlash(0x0); + Assert::AreEqual(L"Flash Off", result.c_str()); + } + + TEST_METHOD(FormatFlash_On) + { + // Test flash on (bit 0 = 1) + std::wstring result = MetadataFormatHelper::FormatFlash(0x1); + Assert::AreEqual(L"Flash On", result.c_str()); + } + + TEST_METHOD(FormatFlash_OnWithAdditionalFlags) + { + // Test flash on with additional flags + std::wstring result = MetadataFormatHelper::FormatFlash(0x5); // 0b0101 = fired, return detected + Assert::AreEqual(L"Flash On", result.c_str()); + } + + TEST_METHOD(FormatFlash_OffWithAdditionalFlags) + { + // Test flash off with additional flags + std::wstring result = MetadataFormatHelper::FormatFlash(0x10); // Bit 0 is 0 + Assert::AreEqual(L"Flash Off", result.c_str()); + } + }; + + TEST_CLASS(FormatCoordinateTests) + { + public: + TEST_METHOD(FormatCoordinate_NorthLatitude) + { + // Test north latitude + std::wstring result = MetadataFormatHelper::FormatCoordinate(40.7128, true); + Assert::AreEqual(L"40°42.77'N", result.c_str()); + } + + TEST_METHOD(FormatCoordinate_SouthLatitude) + { + // Test south latitude + std::wstring result = MetadataFormatHelper::FormatCoordinate(-33.8688, true); + Assert::AreEqual(L"33°52.13'S", result.c_str()); + } + + TEST_METHOD(FormatCoordinate_EastLongitude) + { + // Test east longitude + std::wstring result = MetadataFormatHelper::FormatCoordinate(151.2093, false); + Assert::AreEqual(L"151°12.56'E", result.c_str()); + } + + TEST_METHOD(FormatCoordinate_WestLongitude) + { + // Test west longitude + std::wstring result = MetadataFormatHelper::FormatCoordinate(-74.0060, false); + Assert::AreEqual(L"74°0.36'W", result.c_str()); + } + + TEST_METHOD(FormatCoordinate_ZeroLatitude) + { + // Test equator (0 degrees latitude) + std::wstring result = MetadataFormatHelper::FormatCoordinate(0.0, true); + Assert::AreEqual(L"0°0.00'N", result.c_str()); + } + + TEST_METHOD(FormatCoordinate_ZeroLongitude) + { + // Test prime meridian (0 degrees longitude) + std::wstring result = MetadataFormatHelper::FormatCoordinate(0.0, false); + Assert::AreEqual(L"0°0.00'E", result.c_str()); + } + }; + + TEST_CLASS(FormatSystemTimeTests) + { + public: + TEST_METHOD(FormatSystemTime_ValidDateTime) + { + // Test formatting a valid date and time + SYSTEMTIME st = { 0 }; + st.wYear = 2024; + st.wMonth = 3; + st.wDay = 15; + st.wHour = 14; + st.wMinute = 30; + st.wSecond = 45; + + std::wstring result = MetadataFormatHelper::FormatSystemTime(st); + Assert::AreEqual(L"2024-03-15 14:30:45", result.c_str()); + } + + TEST_METHOD(FormatSystemTime_Midnight) + { + // Test midnight time + SYSTEMTIME st = { 0 }; + st.wYear = 2024; + st.wMonth = 1; + st.wDay = 1; + st.wHour = 0; + st.wMinute = 0; + st.wSecond = 0; + + std::wstring result = MetadataFormatHelper::FormatSystemTime(st); + Assert::AreEqual(L"2024-01-01 00:00:00", result.c_str()); + } + + TEST_METHOD(FormatSystemTime_EndOfDay) + { + // Test end of day time + SYSTEMTIME st = { 0 }; + st.wYear = 2024; + st.wMonth = 12; + st.wDay = 31; + st.wHour = 23; + st.wMinute = 59; + st.wSecond = 59; + + std::wstring result = MetadataFormatHelper::FormatSystemTime(st); + Assert::AreEqual(L"2024-12-31 23:59:59", result.c_str()); + } + }; + + TEST_CLASS(ParseSingleRationalTests) + { + public: + TEST_METHOD(ParseSingleRational_ValidValue) + { + // Test parsing a valid rational: 5/2 = 2.5 + uint8_t bytes[] = { 5, 0, 0, 0, 2, 0, 0, 0 }; + double result = MetadataFormatHelper::ParseSingleRational(bytes, 0); + Assert::AreEqual(2.5, result, 0.001); + } + + TEST_METHOD(ParseSingleRational_IntegerResult) + { + // Test parsing rational that results in integer: 10/5 = 2.0 + uint8_t bytes[] = { 10, 0, 0, 0, 5, 0, 0, 0 }; + double result = MetadataFormatHelper::ParseSingleRational(bytes, 0); + Assert::AreEqual(2.0, result, 0.001); + } + + TEST_METHOD(ParseSingleRational_LargeNumerator) + { + // Test parsing with large numerator: 1000/100 = 10.0 + uint8_t bytes[] = { 0xE8, 0x03, 0, 0, 100, 0, 0, 0 }; // 1000 in little-endian + double result = MetadataFormatHelper::ParseSingleRational(bytes, 0); + Assert::AreEqual(10.0, result, 0.001); + } + + TEST_METHOD(ParseSingleRational_ZeroDenominator) + { + // Test parsing with zero denominator (should return 0.0) + uint8_t bytes[] = { 5, 0, 0, 0, 0, 0, 0, 0 }; + double result = MetadataFormatHelper::ParseSingleRational(bytes, 0); + Assert::AreEqual(0.0, result, 0.001); + } + + TEST_METHOD(ParseSingleRational_ZeroNumerator) + { + // Test parsing with zero numerator: 0/5 = 0.0 + uint8_t bytes[] = { 0, 0, 0, 0, 5, 0, 0, 0 }; + double result = MetadataFormatHelper::ParseSingleRational(bytes, 0); + Assert::AreEqual(0.0, result, 0.001); + } + + TEST_METHOD(ParseSingleRational_WithOffset) + { + // Test parsing with offset + uint8_t bytes[] = { 0xFF, 0xFF, 0xFF, 0xFF, 10, 0, 0, 0, 5, 0, 0, 0 }; // Offset = 4 + double result = MetadataFormatHelper::ParseSingleRational(bytes, 4); + Assert::AreEqual(2.0, result, 0.001); + } + + TEST_METHOD(ParseSingleRational_NullPointer) + { + // Test with null pointer (should return 0.0) + double result = MetadataFormatHelper::ParseSingleRational(nullptr, 0); + Assert::AreEqual(0.0, result, 0.001); + } + }; + + TEST_CLASS(ParseSingleSRationalTests) + { + public: + TEST_METHOD(ParseSingleSRational_PositiveValue) + { + // Test parsing positive signed rational: 5/2 = 2.5 + uint8_t bytes[] = { 5, 0, 0, 0, 2, 0, 0, 0 }; + double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0); + Assert::AreEqual(2.5, result, 0.001); + } + + TEST_METHOD(ParseSingleSRational_NegativeNumerator) + { + // Test parsing negative numerator: -5/2 = -2.5 + uint8_t bytes[] = { 0xFB, 0xFF, 0xFF, 0xFF, 2, 0, 0, 0 }; // -5 in two's complement + double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0); + Assert::AreEqual(-2.5, result, 0.001); + } + + TEST_METHOD(ParseSingleSRational_NegativeDenominator) + { + // Test parsing negative denominator: 5/-2 = -2.5 + uint8_t bytes[] = { 5, 0, 0, 0, 0xFE, 0xFF, 0xFF, 0xFF }; // -2 in two's complement + double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0); + Assert::AreEqual(-2.5, result, 0.001); + } + + TEST_METHOD(ParseSingleSRational_BothNegative) + { + // Test parsing both negative: -5/-2 = 2.5 + uint8_t bytes[] = { 0xFB, 0xFF, 0xFF, 0xFF, 0xFE, 0xFF, 0xFF, 0xFF }; + double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0); + Assert::AreEqual(2.5, result, 0.001); + } + + TEST_METHOD(ParseSingleSRational_ExposureBias) + { + // Test typical exposure bias value: -1/3 ≈ -0.333 + uint8_t bytes[] = { 0xFF, 0xFF, 0xFF, 0xFF, 3, 0, 0, 0 }; // -1/3 + double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0); + Assert::AreEqual(-0.333, result, 0.001); + } + + TEST_METHOD(ParseSingleSRational_ZeroDenominator) + { + // Test with zero denominator (should return 0.0) + uint8_t bytes[] = { 5, 0, 0, 0, 0, 0, 0, 0 }; + double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0); + Assert::AreEqual(0.0, result, 0.001); + } + + TEST_METHOD(ParseSingleSRational_NullPointer) + { + // Test with null pointer (should return 0.0) + double result = MetadataFormatHelper::ParseSingleSRational(nullptr, 0); + Assert::AreEqual(0.0, result, 0.001); + } + }; + + TEST_CLASS(SanitizeForFileNameTests) + { + public: + TEST_METHOD(SanitizeForFileName_ValidString) + { + // Test string without illegal characters + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Canon EOS 5D"); + Assert::AreEqual(L"Canon EOS 5D", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithColon) + { + // Test string with colon (illegal character) + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Photo:001"); + Assert::AreEqual(L"Photo_001", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithSlashes) + { + // Test string with forward and backward slashes + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Photos/2024\\January"); + Assert::AreEqual(L"Photos_2024_January", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithMultipleIllegalChars) + { + // Test string with multiple illegal characters + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L":File|Name*?.txt"); + Assert::AreEqual(L"_Test__File_Name__.txt", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithQuotes) + { + // Test string with quotes + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Photo \"Best Shot\""); + Assert::AreEqual(L"Photo _Best Shot_", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithTrailingDot) + { + // Test string with trailing dot (should be removed) + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"filename."); + Assert::AreEqual(L"filename", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithTrailingSpace) + { + // Test string with trailing space (should be removed) + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"filename "); + Assert::AreEqual(L"filename", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithMultipleTrailingDotsAndSpaces) + { + // Test string with multiple trailing dots and spaces + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"filename. . "); + Assert::AreEqual(L"filename", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithControlCharacters) + { + // Test string with control characters + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"File\x01Name\x1F"); + Assert::AreEqual(L"File_Name_", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_EmptyString) + { + // Test empty string + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L""); + Assert::AreEqual(L"", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_OnlyIllegalCharacters) + { + // Test string with only illegal characters + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"<>:\"/\\|?*"); + Assert::AreEqual(L"_________", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_OnlyTrailingCharacters) + { + // Test string with only dots and spaces (should return empty) + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L". . "); + Assert::AreEqual(L"", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_UnicodeCharacters) + { + // Test string with valid Unicode characters + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"照片_2024年"); + Assert::AreEqual(L"照片_2024年", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_MixedContent) + { + // Test realistic metadata string with multiple issues + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Copyright © 2024: John/Jane Doe. "); + Assert::AreEqual(L"Copyright © 2024_ John_Jane Doe", result.c_str()); + } + }; +} diff --git a/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.cpp b/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.cpp index a882802499..97c3f2fa2d 100644 --- a/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.cpp +++ b/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.cpp @@ -62,6 +62,12 @@ IFACEMETHODIMP CMockPowerRenameRegExEvents::OnFileTimeChanged(_In_ SYSTEMTIME fi return S_OK; } +IFACEMETHODIMP CMockPowerRenameRegExEvents::OnMetadataChanged() +{ + return S_OK; +} + + HRESULT CMockPowerRenameRegExEvents::s_CreateInstance(_Outptr_ IPowerRenameRegExEvents** ppsrree) { *ppsrree = nullptr; @@ -74,3 +80,4 @@ HRESULT CMockPowerRenameRegExEvents::s_CreateInstance(_Outptr_ IPowerRenameRegEx } return hr; } + diff --git a/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.h b/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.h index f65108b123..b68f3775e8 100644 --- a/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.h +++ b/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.h @@ -19,6 +19,7 @@ public: IFACEMETHODIMP OnReplaceTermChanged(_In_ PCWSTR replaceTerm); IFACEMETHODIMP OnFlagsChanged(_In_ DWORD flags); IFACEMETHODIMP OnFileTimeChanged(_In_ SYSTEMTIME fileTime); + IFACEMETHODIMP OnMetadataChanged(); static HRESULT s_CreateInstance(_Outptr_ IPowerRenameRegExEvents** ppsrree); @@ -39,3 +40,4 @@ public: SYSTEMTIME m_fileTime = { 0 }; long m_refCount; }; + diff --git a/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj b/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj index a6bd505342..3a3c5663aa 100644 --- a/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj +++ b/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj @@ -34,7 +34,7 @@ $(VCInstallDir)UnitTest\lib;%(AdditionalLibraryDirectories) - $(OutDir)\..\..\WinUI3Apps\PowerRenameLib.lib;comctl32.lib;pathcch.lib;kernel32.lib;user32.lib;gdi32.lib;winspool.lib;comdlg32.lib;advapi32.lib;shell32.lib;ole32.lib;oleaut32.lib;uuid.lib;odbc32.lib;odbccp32.lib;Pathcch.lib;%(AdditionalDependencies) + $(OutDir)\..\..\WinUI3Apps\PowerRenameLib.lib;comctl32.lib;pathcch.lib;windowscodecs.lib;kernel32.lib;user32.lib;gdi32.lib;winspool.lib;comdlg32.lib;advapi32.lib;shell32.lib;ole32.lib;oleaut32.lib;uuid.lib;odbc32.lib;odbccp32.lib;Pathcch.lib;%(AdditionalDependencies) @@ -49,11 +49,14 @@ + + + Create @@ -73,8 +76,30 @@ + + + true + + + true + + + true + + + true + + + true + + + + + + + diff --git a/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj.filters b/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj.filters index db5bc09af4..42b6d1d17b 100644 --- a/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj.filters +++ b/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj.filters @@ -1,6 +1,7 @@  + @@ -30,6 +31,9 @@ {d34a343a-52ef-4296-83c9-a94fa62062ff} + + {8c9f3e2d-1a4b-4e5f-9c7d-2b8a6f3e1d4c} + @@ -38,5 +42,20 @@ + + testdata + + + testdata + + + testdata + + + testdata + + + testdata + \ No newline at end of file diff --git a/src/modules/powerrename/unittests/WICMetadataExtractorTests.cpp b/src/modules/powerrename/unittests/WICMetadataExtractorTests.cpp new file mode 100644 index 0000000000..c6d1d9b16c --- /dev/null +++ b/src/modules/powerrename/unittests/WICMetadataExtractorTests.cpp @@ -0,0 +1,244 @@ +#include "pch.h" +#include "WICMetadataExtractor.h" +#include +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; +using namespace PowerRenameLib; + +namespace WICMetadataExtractorTests +{ + // Helper function to get the test data directory path + std::wstring GetTestDataPath() + { + // Get the directory where the test DLL is located + // When running with vstest, we need to get the DLL module handle + HMODULE hModule = nullptr; + GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(&GetTestDataPath), + &hModule); + + wchar_t modulePath[MAX_PATH]; + GetModuleFileNameW(hModule, modulePath, MAX_PATH); + std::filesystem::path dllPath(modulePath); + + // Navigate to the test data directory + // The test data is in the output directory alongside the DLL + std::filesystem::path testDataPath = dllPath.parent_path() / L"testdata"; + + return testDataPath.wstring(); + } + + TEST_CLASS(ExtractEXIFMetadataTests) + { + public: + TEST_METHOD(ExtractEXIF_InvalidFile_ReturnsFalse) + { + // Test that EXIF extraction fails for nonexistent file + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\nonexistent.jpg"; + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + Assert::IsFalse(result, L"EXIF extraction should fail for nonexistent file"); + } + + TEST_METHOD(ExtractEXIF_ExifTest_AllFields) + { + // Test exif_test.jpg which contains comprehensive EXIF data + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\exif_test.jpg"; + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + Assert::IsTrue(result, L"EXIF extraction should succeed"); + + // Verify all the fields that are in exif_test.jpg + Assert::IsTrue(metadata.cameraMake.has_value(), L"Camera make should be present"); + Assert::AreEqual(L"samsung", metadata.cameraMake.value().c_str(), L"Camera make should be samsung"); + + Assert::IsTrue(metadata.cameraModel.has_value(), L"Camera model should be present"); + Assert::AreEqual(L"SM-G930P", metadata.cameraModel.value().c_str(), L"Camera model should be SM-G930P"); + + Assert::IsTrue(metadata.lensModel.has_value(), L"Lens model should be present"); + Assert::AreEqual(L"Samsung Galaxy S7 Rear Camera", metadata.lensModel.value().c_str(), L"Lens model should match"); + + Assert::IsTrue(metadata.iso.has_value(), L"ISO should be present"); + Assert::AreEqual(40, static_cast(metadata.iso.value()), L"ISO should be 40"); + + Assert::IsTrue(metadata.aperture.has_value(), L"Aperture should be present"); + Assert::AreEqual(1.7, metadata.aperture.value(), 0.01, L"Aperture should be f/1.7"); + + Assert::IsTrue(metadata.shutterSpeed.has_value(), L"Shutter speed should be present"); + Assert::AreEqual(0.000625, metadata.shutterSpeed.value(), 0.000001, L"Shutter speed should be 0.000625s"); + + Assert::IsTrue(metadata.focalLength.has_value(), L"Focal length should be present"); + Assert::AreEqual(4.2, metadata.focalLength.value(), 0.1, L"Focal length should be 4.2mm"); + + Assert::IsTrue(metadata.flash.has_value(), L"Flash should be present"); + Assert::AreEqual(0u, static_cast(metadata.flash.value()), L"Flash should be 0x0"); + + Assert::IsTrue(metadata.exposureBias.has_value(), L"Exposure bias should be present"); + Assert::AreEqual(0.0, metadata.exposureBias.value(), 0.01, L"Exposure bias should be 0 EV"); + + Assert::IsTrue(metadata.author.has_value(), L"Author should be present"); + Assert::AreEqual(L"Carl Seibert (Exif)", metadata.author.value().c_str(), L"Author should match"); + + Assert::IsTrue(metadata.copyright.has_value(), L"Copyright should be present"); + Assert::IsTrue(metadata.copyright.value().find(L"Carl Seibert") != std::wstring::npos, L"Copyright should contain Carl Seibert"); + } + + TEST_METHOD(ExtractEXIF_ExifTest2_WidthHeight) + { + // Test exif_test_2.jpg which only contains width and height + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\exif_test_2.jpg"; + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + Assert::IsTrue(result, L"EXIF extraction should succeed"); + + // exif_test_2.jpg only has width and height + Assert::IsTrue(metadata.width.has_value(), L"Width should be present"); + Assert::AreEqual(1080u, static_cast(metadata.width.value()), L"Width should be 1080px"); + + Assert::IsTrue(metadata.height.has_value(), L"Height should be present"); + Assert::AreEqual(810u, static_cast(metadata.height.value()), L"Height should be 810px"); + + // Other fields should not be present + Assert::IsFalse(metadata.cameraMake.has_value(), L"Camera make should not be present in exif_test_2.jpg"); + Assert::IsFalse(metadata.cameraModel.has_value(), L"Camera model should not be present in exif_test_2.jpg"); + Assert::IsFalse(metadata.iso.has_value(), L"ISO should not be present in exif_test_2.jpg"); + } + + TEST_METHOD(ExtractEXIF_ClearCache) + { + // Test cache clearing works + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\exif_test.jpg"; + + bool result1 = extractor.ExtractEXIFMetadata(testFile, metadata); + Assert::IsTrue(result1); + + extractor.ClearCache(); + + EXIFMetadata metadata2; + bool result2 = extractor.ExtractEXIFMetadata(testFile, metadata2); + Assert::IsTrue(result2); + + // Both calls should succeed + Assert::AreEqual(metadata.cameraMake.value().c_str(), metadata2.cameraMake.value().c_str()); + } + }; + + TEST_CLASS(ExtractXMPMetadataTests) + { + public: + TEST_METHOD(ExtractXMP_InvalidFile_ReturnsFalse) + { + // Test that XMP extraction fails for nonexistent file + WICMetadataExtractor extractor; + XMPMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\nonexistent.jpg"; + bool result = extractor.ExtractXMPMetadata(testFile, metadata); + + Assert::IsFalse(result, L"XMP extraction should fail for nonexistent file"); + } + + TEST_METHOD(ExtractXMP_XmpTest_AllFields) + { + // Test xmp_test.jpg which contains comprehensive XMP data + WICMetadataExtractor extractor; + XMPMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\xmp_test.jpg"; + bool result = extractor.ExtractXMPMetadata(testFile, metadata); + + Assert::IsTrue(result, L"XMP extraction should succeed"); + + // Verify all the fields that are in xmp_test.jpg + Assert::IsTrue(metadata.title.has_value(), L"Title should be present"); + Assert::AreEqual(L"object name here", metadata.title.value().c_str(), L"Title should match"); + + Assert::IsTrue(metadata.description.has_value(), L"Description should be present"); + Assert::IsTrue(metadata.description.value().find(L"This is a metadata test file") != std::wstring::npos, + L"Description should contain expected text"); + + Assert::IsTrue(metadata.rights.has_value(), L"Rights should be present"); + Assert::AreEqual(L"metadatamatters.blog", metadata.rights.value().c_str(), L"Rights should match"); + + Assert::IsTrue(metadata.creatorTool.has_value(), L"Creator tool should be present"); + Assert::IsTrue(metadata.creatorTool.value().find(L"Adobe Photoshop Lightroom") != std::wstring::npos, + L"Creator tool should contain Lightroom"); + + Assert::IsTrue(metadata.documentID.has_value(), L"Document ID should be present"); + Assert::IsTrue(metadata.documentID.value().find(L"xmp.did:") != std::wstring::npos, + L"Document ID should start with xmp.did:"); + + Assert::IsTrue(metadata.instanceID.has_value(), L"Instance ID should be present"); + Assert::IsTrue(metadata.instanceID.value().find(L"xmp.iid:") != std::wstring::npos, + L"Instance ID should start with xmp.iid:"); + + Assert::IsTrue(metadata.subject.has_value(), L"Subject keywords should be present"); + Assert::IsTrue(metadata.subject.value().size() > 0, L"Should have at least one keyword"); + } + + TEST_METHOD(ExtractXMP_XmpTest2_BasicFields) + { + // Test xmp_test_2.jpg which only contains basic XMP fields + WICMetadataExtractor extractor; + XMPMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\xmp_test_2.jpg"; + bool result = extractor.ExtractXMPMetadata(testFile, metadata); + + Assert::IsTrue(result, L"XMP extraction should succeed"); + + // xmp_test_2.jpg only has CreatorTool, DocumentID, and InstanceID + Assert::IsTrue(metadata.creatorTool.has_value(), L"Creator tool should be present"); + Assert::IsTrue(metadata.creatorTool.value().find(L"Adobe Photoshop CS6") != std::wstring::npos, + L"Creator tool should be Photoshop CS6"); + + Assert::IsTrue(metadata.documentID.has_value(), L"Document ID should be present"); + Assert::IsTrue(metadata.documentID.value().find(L"xmp.did:") != std::wstring::npos, + L"Document ID should start with xmp.did:"); + + Assert::IsTrue(metadata.instanceID.has_value(), L"Instance ID should be present"); + Assert::IsTrue(metadata.instanceID.value().find(L"xmp.iid:") != std::wstring::npos, + L"Instance ID should start with xmp.iid:"); + + // Other fields should not be present + Assert::IsFalse(metadata.title.has_value(), L"Title should not be present in xmp_test_2.jpg"); + Assert::IsFalse(metadata.description.has_value(), L"Description should not be present in xmp_test_2.jpg"); + Assert::IsFalse(metadata.rights.has_value(), L"Rights should not be present in xmp_test_2.jpg"); + Assert::IsFalse(metadata.creator.has_value(), L"Creator should not be present in xmp_test_2.jpg"); + } + + TEST_METHOD(ExtractXMP_ClearCache) + { + // Test cache clearing works + WICMetadataExtractor extractor; + XMPMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\xmp_test.jpg"; + + bool result1 = extractor.ExtractXMPMetadata(testFile, metadata); + Assert::IsTrue(result1); + + extractor.ClearCache(); + + XMPMetadata metadata2; + bool result2 = extractor.ExtractXMPMetadata(testFile, metadata2); + Assert::IsTrue(result2); + + // Both calls should succeed + Assert::AreEqual(metadata.title.value().c_str(), metadata2.title.value().c_str()); + } + }; +} diff --git a/src/modules/powerrename/unittests/testdata/ATTRIBUTION.md b/src/modules/powerrename/unittests/testdata/ATTRIBUTION.md new file mode 100644 index 0000000000..88844e57b8 --- /dev/null +++ b/src/modules/powerrename/unittests/testdata/ATTRIBUTION.md @@ -0,0 +1,45 @@ +# Test Data Attribution + +This directory contains test image files used for PowerRename metadata extraction unit tests. These images are sourced from Wikimedia Commons and are used under the Creative Commons licenses specified below. + +## Test Files and Licenses + +### Files from Carlseibert + +**License:** [Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/) + +- `exif_test.jpg` - Uploaded by [Carlseibert](https://commons.wikimedia.org/wiki/File%3AMetadata_test_file_-_includes_data_in_IIM%2C_XMP%2C_and_Exif.jpg) on Wikimedia Commons +- `xmp_test.jpg` - Uploaded by [Carlseibert](https://commons.wikimedia.org/wiki/File%3AMetadata_test_file_-_includes_data_in_IIM%2C_XMP%2C_and_Exif.jpg) on Wikimedia Commons + +### Files from Edward Steven + +**License:** [Creative Commons Attribution-ShareAlike 2.0 Generic (CC BY-SA 2.0)](https://creativecommons.org/licenses/by-sa/2.0/) + +- `exif_test_2.jpg` - Uploaded by [Edward Steven](https://commons.wikimedia.org/wiki/File%3AAreca_hutchinsoniana.jpg) on Wikimedia Commons +- `xmp_test_2.jpg` - Uploaded by [Edward Steven](https://commons.wikimedia.org/wiki/File%3AAreca_hutchinsoniana.jpg) on Wikimedia Commons + +## Acknowledgments + +We gratefully acknowledge the contributions of Carlseibert and Edward Steven for making these images available under Creative Commons licenses. Their work enables us to test metadata extraction functionality with real-world EXIF and XMP data. + +## Usage + +These test images are used in PowerRename's unit tests to verify correct extraction of: +- EXIF metadata (camera make, model, ISO, aperture, shutter speed, etc.) +- XMP metadata (creator, title, description, copyright, etc.) +- GPS coordinates +- Date/time information + +## License Compliance + +These test images are distributed as part of the PowerToys source code repository under their original Creative Commons licenses: +- Files from Carlseibert: CC BY-SA 4.0 +- Files from Edward Steven: CC BY-SA 2.0 + +**Modifications:** These images have not been modified from their original versions downloaded from Wikimedia Commons. They are used in their original form for metadata extraction testing purposes. + +**Distribution:** These test images are included in the PowerToys source repository and comply with the terms of their respective Creative Commons licenses through proper attribution in this file. While included in the source code, these images are not distributed in end-user installation packages or releases. + +**Derivatives:** Any modifications or derivative works of these images must comply with the respective CC BY-SA license terms, including proper attribution and applying the same license to the modified versions. + +For more information about Creative Commons licenses, visit: https://creativecommons.org/licenses/ diff --git a/src/modules/powerrename/unittests/testdata/exif_test.jpg b/src/modules/powerrename/unittests/testdata/exif_test.jpg new file mode 100644 index 0000000000..5b40a5a688 Binary files /dev/null and b/src/modules/powerrename/unittests/testdata/exif_test.jpg differ diff --git a/src/modules/powerrename/unittests/testdata/exif_test_2.jpg b/src/modules/powerrename/unittests/testdata/exif_test_2.jpg new file mode 100644 index 0000000000..ec2a3ad703 Binary files /dev/null and b/src/modules/powerrename/unittests/testdata/exif_test_2.jpg differ diff --git a/src/modules/powerrename/unittests/testdata/xmp_test.jpg b/src/modules/powerrename/unittests/testdata/xmp_test.jpg new file mode 100644 index 0000000000..5b40a5a688 Binary files /dev/null and b/src/modules/powerrename/unittests/testdata/xmp_test.jpg differ diff --git a/src/modules/powerrename/unittests/testdata/xmp_test_2.jpg b/src/modules/powerrename/unittests/testdata/xmp_test_2.jpg new file mode 100644 index 0000000000..ec2a3ad703 Binary files /dev/null and b/src/modules/powerrename/unittests/testdata/xmp_test_2.jpg differ