diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cfd0ad0..dc53425 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -59,7 +59,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -87,6 +87,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 3dbb6c0..9b512b6 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -15,11 +15,11 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 with: submodules: recursive - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v5 with: dotnet-version: 10.0.x - name: Restore dependencies diff --git a/PhotoLocator/Controls/CropControl.xaml.cs b/PhotoLocator/Controls/CropControl.xaml.cs index e5451a5..02b4770 100644 --- a/PhotoLocator/Controls/CropControl.xaml.cs +++ b/PhotoLocator/Controls/CropControl.xaml.cs @@ -87,19 +87,19 @@ bool SetProperty(ref T field, T newValue, [CallerMemberName] string? property return true; } - public SolidColorBrush? CropBorderColor { get => field; private set => SetProperty(ref field, value); } + public SolidColorBrush? CropBorderColor { get; private set => SetProperty(ref field, value); } - public GridLength CropLeftOffset { get => field; set => SetProperty(ref field, value); } + public GridLength CropLeftOffset { get; set => SetProperty(ref field, value); } - public GridLength CropWidth { get => field; set => SetProperty(ref field, value); } + public GridLength CropWidth { get; set => SetProperty(ref field, value); } - public GridLength CropRightOffset { get => field; set => SetProperty(ref field, value); } + public GridLength CropRightOffset { get; set => SetProperty(ref field, value); } - public GridLength CropTopOffset { get => field; set => SetProperty(ref field, value); } + public GridLength CropTopOffset { get; set => SetProperty(ref field, value); } - public GridLength CropHeight { get => field; set => SetProperty(ref field, value); } + public GridLength CropHeight { get; set => SetProperty(ref field, value); } - public GridLength CropBottomOffset { get => field; set => SetProperty(ref field, value); } + public GridLength CropBottomOffset { get; set => SetProperty(ref field, value); } public Rect CropRectangle { diff --git a/PhotoLocator/Helpers/NominatimLocationSearcher.cs b/PhotoLocator/Helpers/NominatimLocationSearcher.cs new file mode 100644 index 0000000..a01bd6c --- /dev/null +++ b/PhotoLocator/Helpers/NominatimLocationSearcher.cs @@ -0,0 +1,76 @@ +using MapControl; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace PhotoLocator.Helpers +{ + public sealed class NominatimLocationSearcher + { + static readonly Uri _searchEndpoint = new("https://nominatim.openstreetmap.org/search"); + static HttpClient? _sharedHttpClient; + + static HttpClient CreateHttpClient() + { + var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("PhotoLocator"); + return httpClient; + } + + readonly HttpClient _httpClient; + + public NominatimLocationSearcher(HttpClient? httpClient = null) + { + _httpClient = httpClient ?? (_sharedHttpClient ??= CreateHttpClient()); + } + + public async Task> SearchAsync(string searchText, int resultLimit = 10, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(searchText); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resultLimit); + + var requestUri = new UriBuilder(_searchEndpoint) + { + Query = $"q={Uri.EscapeDataString(searchText)}&format=jsonv2&limit={resultLimit}" + }.Uri; + + var results = await _httpClient.GetFromJsonAsync(requestUri, cancellationToken) + .ConfigureAwait(false); + + if (results is null) + return []; + + return results + .Where(result => + result.DisplayName is not null && + double.TryParse(result.Latitude, NumberStyles.Float, CultureInfo.InvariantCulture, out _) && + double.TryParse(result.Longitude, NumberStyles.Float, CultureInfo.InvariantCulture, out _)) + .Select(result => new LocationWithName( + result.DisplayName!, + new Location( + double.Parse(result.Latitude!, CultureInfo.InvariantCulture), + double.Parse(result.Longitude!, CultureInfo.InvariantCulture)))) + .ToArray(); + } + + sealed class NominatimResult + { + [JsonPropertyName("display_name")] + public string? DisplayName { get; set; } + + [JsonPropertyName("lat")] + public string? Latitude { get; set; } + + [JsonPropertyName("lon")] + public string? Longitude { get; set; } + } + } + + public sealed record LocationWithName(string DisplayName, Location Location); +} diff --git a/PhotoLocator/MainViewModel.cs b/PhotoLocator/MainViewModel.cs index fa7a6f6..8bd00f5 100644 --- a/PhotoLocator/MainViewModel.cs +++ b/PhotoLocator/MainViewModel.cs @@ -46,6 +46,7 @@ public sealed class MainViewModel : INotifyPropertyChanged, IDisposable, IMainVi CancellationTokenSource? _loadCancellation; CancellationTokenSource? _previewCancellation; CancellationTokenSource? _processCancellation; + CancellationTokenSource? _locationSearchCancellation; FileSystemWatcher? _fileSystemWatcher; double _loadImagesProgress; bool _titleUpdatePending; @@ -53,6 +54,8 @@ public sealed class MainViewModel : INotifyPropertyChanged, IDisposable, IMainVi readonly List<(string Path, BitmapSource Picture)> _pictureCache = []; readonly HashSet _gpsTraceFiles = []; Location? _sunAndMoonMapCenter; + NominatimLocationSearcher? _locationSearcher; + LocationWithName? _previewedLocationSearchResult; public event PropertyChangedEventHandler? PropertyChanged; @@ -149,6 +152,93 @@ public Location? MapCenter set => SetProperty(ref field, value); } + public bool IsLocationSearchVisible + { + get; + set + { + if (SetProperty(ref field, value) && !value) + { + _locationSearchCancellation?.Cancel(); + _previewedLocationSearchResult = null; + SelectedLocationSearchResult = null; + } + } + } + + public string LocationSearchText + { + get; + set + { + if (SetProperty(ref field, value)) + SearchLocationsAsync(value).WithExceptionLogging(); + } + } = string.Empty; + + public ObservableCollection LocationSearchResults { get; } = []; + + public LocationWithName? SelectedLocationSearchResult + { + get; + set + { + if (!SetProperty(ref field, value) || value is null) + return; + MapCenter = value.Location; + if (!IsMapVisible) + ViewModeCommand?.Execute(null); + IsLocationSearchVisible = false; + } + } + + public void PreviewLocationSearchResult(LocationWithName result) + { + if (ReferenceEquals(_previewedLocationSearchResult, result)) + return; + _previewedLocationSearchResult = result; + MapCenter = result.Location; + } + + async Task SearchLocationsAsync(string searchText) + { +#pragma warning disable CA1849 // Call async methods when in an async method + _locationSearchCancellation?.Cancel(); +#pragma warning restore CA1849 // Call async methods when in an async method + if (string.IsNullOrWhiteSpace(searchText)) + return; + _locationSearchCancellation?.Dispose(); + _locationSearchCancellation = null; + + Mouse.OverrideCursor = Cursors.AppStarting; + var cancellation = new CancellationTokenSource(); + var ct = cancellation.Token; + _locationSearchCancellation = cancellation; + try + { + await Task.Delay(1000, ct); + _locationSearcher ??= new NominatimLocationSearcher(); + var results = await _locationSearcher.SearchAsync(searchText, 10, ct); + if (ReferenceEquals(_locationSearchCancellation, cancellation)) + { + LocationSearchResults.Clear(); + foreach (var result in results) + LocationSearchResults.Add(result); + } + } + finally + { + if (ReferenceEquals(_locationSearchCancellation, cancellation)) + { + _locationSearchCancellation = null; + cancellation.Dispose(); + Mouse.OverrideCursor = null; + } + } + } + + public ICommand LocationSearchCommand => new RelayCommand(o => IsLocationSearchVisible = !IsLocationSearchVisible); + public Location? SavedLocation { get; @@ -245,6 +335,8 @@ public PictureItemViewModel? SelectedItem public IEnumerable GetSelectedItems(bool filesOnly) { + IsLocationSearchVisible = false; + IsCropControlVisible = false; var firstChecked = SelectedItem is not null && SelectedItem.IsChecked && (SelectedItem.IsFile || !filesOnly) ? SelectedItem : null; @@ -461,6 +553,8 @@ public async Task UpdatePreviewPictureAsync(string? skipTo = null) public async Task RunProcessWithProgressBarAsync(Func, CancellationToken, Task> body, string text, PictureItemViewModel? focusItem = null) { using var cursor = new MouseCursorOverride(); + IsLocationSearchVisible = false; + IsCropControlVisible = false; ProgressBarIsIndeterminate = false; ProgressBarValue = 0; TaskbarProgressState = TaskbarItemProgressState.Normal; @@ -1400,6 +1494,9 @@ public void Dispose() _processCancellation?.Cancel(); _processCancellation?.Dispose(); _processCancellation = null; + _locationSearchCancellation?.Cancel(); + _locationSearchCancellation?.Dispose(); + _locationSearchCancellation = null; DisposeFileSystemWatcher(); } } diff --git a/PhotoLocator/MainWindow.xaml b/PhotoLocator/MainWindow.xaml index 38513e6..009d73e 100644 --- a/PhotoLocator/MainWindow.xaml +++ b/PhotoLocator/MainWindow.xaml @@ -33,6 +33,7 @@ + @@ -121,6 +122,25 @@