diff --git a/src/PipManager.PackageSearch/IPackageSearchService.cs b/src/PipManager.PackageSearch/IPackageSearchService.cs new file mode 100644 index 0000000..a02ca0b --- /dev/null +++ b/src/PipManager.PackageSearch/IPackageSearchService.cs @@ -0,0 +1,8 @@ +using PipManager.PackageSearch.Wrappers.Query; + +namespace PipManager.PackageSearch; + +public interface IPackageSearchService +{ + public Task Query(string name, int page=1); +} \ No newline at end of file diff --git a/src/PipManager.PackageSearch/PackageSearchService.cs b/src/PipManager.PackageSearch/PackageSearchService.cs new file mode 100644 index 0000000..ed27dc2 --- /dev/null +++ b/src/PipManager.PackageSearch/PackageSearchService.cs @@ -0,0 +1,65 @@ +using HtmlAgilityPack; +using PipManager.PackageSearch.Wrappers.Query; + +namespace PipManager.PackageSearch; + +public class PackageSearchService(HttpClient httpClient) : IPackageSearchService +{ + public Dictionary<(string, int), QueryWrapper> QueryCaches { get; set; } = []; + public async Task Query(string name, int page=1) + { + if(QueryCaches.ContainsKey((name, page))) + { + return QueryCaches[(name, page)]; + } + var htmlContent = ""; + try + { + htmlContent = await httpClient.GetStringAsync($"https://pypi.org/search/?q={name}&page={page}"); + } + catch (Exception exception) when (exception is TaskCanceledException || exception is HttpRequestException) + { + return new QueryWrapper + { + Status = QueryStatus.Timeout + }; + } + var htmlDocument = new HtmlDocument(); + htmlDocument.LoadHtml(htmlContent); + + var queryWrapper = new QueryWrapper + { + ResultCount = htmlDocument.DocumentNode.SelectSingleNode("/html/body/main/div/div/div[2]/form/div[1]/div[1]/p/strong").InnerText + }; + queryWrapper.Status = queryWrapper.ResultCount != "0" ? QueryStatus.Success : QueryStatus.NoResults; + if (queryWrapper.Status == QueryStatus.NoResults) + { + return queryWrapper; + } + var pageNode = htmlDocument.DocumentNode.SelectSingleNode("/html/body/main/div/div/div[2]/form/div[3]/div"); + queryWrapper.MaxPageNumber = pageNode == null ? 0 : int.Parse(pageNode.ChildNodes[^4].InnerText); + + try + { + var resultList = htmlDocument.DocumentNode.SelectSingleNode("/html/body/main/div/div/div[2]/form/div[3]/ul").ChildNodes.Where(result => result.InnerLength != 15).Select(result => result.ChildNodes[1]); + queryWrapper.Results = []; + foreach (var resultItem in resultList) + { + queryWrapper.Results.Add(new QueryListItemModel + { + Name = resultItem.ChildNodes[1].ChildNodes[1].InnerText, + Version = resultItem.ChildNodes[1].ChildNodes[3].InnerText, + Description = resultItem.ChildNodes[3].InnerText, + UpdateTime = DateTime.ParseExact(resultItem.ChildNodes[1].ChildNodes[5].ChildNodes[0].Attributes["datetime"].Value, "yyyy-MM-ddTHH:mm:sszzz", null, System.Globalization.DateTimeStyles.RoundtripKind) + }); + } + } + catch (ArgumentOutOfRangeException) + { + QueryCaches.Add((name, page), queryWrapper); + return queryWrapper; + } + QueryCaches.Add((name, page), queryWrapper); + return queryWrapper; + } +} \ No newline at end of file diff --git a/src/PipManager.PackageSearch/PipManager.PackageSearch.csproj b/src/PipManager.PackageSearch/PipManager.PackageSearch.csproj new file mode 100644 index 0000000..f48c402 --- /dev/null +++ b/src/PipManager.PackageSearch/PipManager.PackageSearch.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/src/PipManager.PackageSearch/Wrappers/Query/QueryListItemModel.cs b/src/PipManager.PackageSearch/Wrappers/Query/QueryListItemModel.cs new file mode 100644 index 0000000..4541186 --- /dev/null +++ b/src/PipManager.PackageSearch/Wrappers/Query/QueryListItemModel.cs @@ -0,0 +1,10 @@ +namespace PipManager.PackageSearch.Wrappers.Query; + +public class QueryListItemModel +{ + public required string Name { get; set; } + public required string Version { get; set; } + public required string Description { get; set; } + public DateTime UpdateTime { get; set; } +} + diff --git a/src/PipManager.PackageSearch/Wrappers/Query/QueryStatus.cs b/src/PipManager.PackageSearch/Wrappers/Query/QueryStatus.cs new file mode 100644 index 0000000..30c85b2 --- /dev/null +++ b/src/PipManager.PackageSearch/Wrappers/Query/QueryStatus.cs @@ -0,0 +1,11 @@ + + +namespace PipManager.PackageSearch.Wrappers.Query; + +public enum QueryStatus +{ + Success, + NoResults, + Timeout +} + diff --git a/src/PipManager.PackageSearch/Wrappers/Query/QueryWrapper.cs b/src/PipManager.PackageSearch/Wrappers/Query/QueryWrapper.cs new file mode 100644 index 0000000..8c7ec26 --- /dev/null +++ b/src/PipManager.PackageSearch/Wrappers/Query/QueryWrapper.cs @@ -0,0 +1,10 @@ +namespace PipManager.PackageSearch.Wrappers.Query; + +public class QueryWrapper +{ + public QueryStatus Status { get; set; } + public string? ResultCount { get; set; } + public List? Results { get; set;} + public int MaxPageNumber { get; set; } +} + diff --git a/src/PipManager.sln b/src/PipManager.sln index bce829e..dee2b6d 100644 --- a/src/PipManager.sln +++ b/src/PipManager.sln @@ -10,6 +10,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "解决方案项", "解决 ..\README.md = ..\README.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PipManager.PackageSearch", "PipManager.PackageSearch\PipManager.PackageSearch.csproj", "{759DBA08-4418-474E-BD6F-773829A81368}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -20,6 +22,10 @@ Global {4C085660-9DCE-403F-89A7-E36C9405BBC0}.Debug|Any CPU.Build.0 = Debug|Any CPU {4C085660-9DCE-403F-89A7-E36C9405BBC0}.Release|Any CPU.ActiveCfg = Release|Any CPU {4C085660-9DCE-403F-89A7-E36C9405BBC0}.Release|Any CPU.Build.0 = Release|Any CPU + {759DBA08-4418-474E-BD6F-773829A81368}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {759DBA08-4418-474E-BD6F-773829A81368}.Debug|Any CPU.Build.0 = Debug|Any CPU + {759DBA08-4418-474E-BD6F-773829A81368}.Release|Any CPU.ActiveCfg = Release|Any CPU + {759DBA08-4418-474E-BD6F-773829A81368}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/PipManager/App.xaml.cs b/src/PipManager/App.xaml.cs index 435127c..fbca6b3 100644 --- a/src/PipManager/App.xaml.cs +++ b/src/PipManager/App.xaml.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using PipManager.PackageSearch; using PipManager.Services; using PipManager.Services.Action; using PipManager.Services.Configuration; @@ -75,6 +76,7 @@ public partial class App services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Pages services.AddSingleton(); diff --git a/src/PipManager/Languages/Lang.Designer.cs b/src/PipManager/Languages/Lang.Designer.cs index 58fc2e7..020d10b 100644 --- a/src/PipManager/Languages/Lang.Designer.cs +++ b/src/PipManager/Languages/Lang.Designer.cs @@ -1419,6 +1419,51 @@ public static string Search_DefaultVersion { } } + /// + /// 查找类似 No Description 的本地化字符串。 + /// + public static string Search_List_NoDescription { + get { + return ResourceManager.GetString("Search_List_NoDescription", resourceCulture); + } + } + + /// + /// 查找类似 About {0} results found 的本地化字符串。 + /// + public static string Search_List_TotalResultNumber { + get { + return ResourceManager.GetString("Search_List_TotalResultNumber", resourceCulture); + } + } + + /// + /// 查找类似 Update Time: 的本地化字符串。 + /// + public static string Search_List_UpdateTime { + get { + return ResourceManager.GetString("Search_List_UpdateTime", resourceCulture); + } + } + + /// + /// 查找类似 No results 的本地化字符串。 + /// + public static string Search_Query_NoResults { + get { + return ResourceManager.GetString("Search_Query_NoResults", resourceCulture); + } + } + + /// + /// 查找类似 Timeout 的本地化字符串。 + /// + public static string Search_Query_Timeout { + get { + return ResourceManager.GetString("Search_Query_Timeout", resourceCulture); + } + } + /// /// 查找类似 Specify Version 的本地化字符串。 /// diff --git a/src/PipManager/Languages/Lang.resx b/src/PipManager/Languages/Lang.resx index 2d693f7..3eaa32c 100644 --- a/src/PipManager/Languages/Lang.resx +++ b/src/PipManager/Languages/Lang.resx @@ -655,4 +655,19 @@ Other sources are faster to connect to, but may be incomplete. Download + + No Description + + + Update Time: + + + About {0} results found + + + No results + + + Timeout + \ No newline at end of file diff --git a/src/PipManager/Languages/Lang.zh-cn.resx b/src/PipManager/Languages/Lang.zh-cn.resx index f095e3c..ad92978 100644 --- a/src/PipManager/Languages/Lang.zh-cn.resx +++ b/src/PipManager/Languages/Lang.zh-cn.resx @@ -655,4 +655,19 @@ 下载 + + 暂无描述 + + + 最后更新于: + + + 约 {0} 个结果 + + + 无结果 + + + 查询超时 + \ No newline at end of file diff --git a/src/PipManager/PipManager.csproj b/src/PipManager/PipManager.csproj index cd8db9f..4ab8584 100644 --- a/src/PipManager/PipManager.csproj +++ b/src/PipManager/PipManager.csproj @@ -101,4 +101,8 @@ + + + + diff --git a/src/PipManager/ViewModels/Pages/Search/SearchViewModel.cs b/src/PipManager/ViewModels/Pages/Search/SearchViewModel.cs index dfd590d..08fabc5 100644 --- a/src/PipManager/ViewModels/Pages/Search/SearchViewModel.cs +++ b/src/PipManager/ViewModels/Pages/Search/SearchViewModel.cs @@ -1,12 +1,36 @@ -using Serilog; +using PipManager.Languages; +using PipManager.PackageSearch; +using PipManager.PackageSearch.Wrappers.Query; +using PipManager.Services.Mask; +using PipManager.Services.Toast; +using Serilog; +using System.Collections.ObjectModel; +using System.Reflection.Metadata; using Wpf.Ui.Controls; namespace PipManager.ViewModels.Pages.Search; -public partial class SearchViewModel : ObservableObject, INavigationAware +public partial class SearchViewModel(IPackageSearchService packageSearchService, IToastService toastService, IMaskService maskService) : ObservableObject, INavigationAware { private bool _isInitialized; + [ObservableProperty] + private ObservableCollection _queryList = []; + [ObservableProperty] + private string _queryPackageName = ""; + [ObservableProperty] + private string _totalResultNumber = ""; + [ObservableProperty] + private bool _successQueried = false; + [ObservableProperty] + private bool _reachesFirstPage = true; + [ObservableProperty] + private bool _reachesLastPage = false; + [ObservableProperty] + private int _currentPage = 1; + [ObservableProperty] + private int _maxPage = 0; + public void OnNavigatedTo() { if (!_isInitialized) @@ -22,4 +46,90 @@ private void InitializeViewModel() _isInitialized = true; Log.Information("[Search] Initialized"); } + + [RelayCommand] + public async Task ToPreviousPage() + { + if(CurrentPage == 1) + { + return; + } + maskService.Show(); + var result = await packageSearchService.Query(QueryPackageName, CurrentPage-1); + Process(result); + maskService.Hide(); + CurrentPage--; + DeterminePageReaches(); + } + + [RelayCommand] + public async Task ToNextPage() + { + if(CurrentPage == MaxPage) + { + return; + } + maskService.Show(); + var result = await packageSearchService.Query(QueryPackageName, CurrentPage+1); + Process(result); + maskService.Hide(); + CurrentPage++; + DeterminePageReaches(); + } + + private void DeterminePageReaches() + { + ReachesFirstPage = CurrentPage == 1; + ReachesLastPage = CurrentPage == MaxPage; + } + + private void Process(QueryWrapper queryWrapper) + { + if (queryWrapper.Status == QueryStatus.Success) + { + foreach (var resultItem in queryWrapper.Results!) + { + if (string.IsNullOrEmpty(resultItem.Description)) + { + resultItem.Description = Lang.Search_List_NoDescription; + } + } + QueryList = new ObservableCollection(queryWrapper.Results!); + TotalResultNumber = queryWrapper.ResultCount!; + SuccessQueried = true; + MaxPage = queryWrapper.MaxPageNumber; + DeterminePageReaches(); + } + else + { + if (queryWrapper.Status == QueryStatus.NoResults) + { + toastService.Error(Lang.Search_Query_NoResults); + } + else if (queryWrapper.Status == QueryStatus.Timeout) + { + toastService.Error(Lang.Search_Query_Timeout); + } + QueryList.Clear(); + TotalResultNumber = ""; + SuccessQueried = false; + MaxPage = 0; + } + } + + [RelayCommand] + public async Task Search(string? parameter) + { + if (parameter != null && !string.IsNullOrEmpty(parameter)) + { + QueryList.Clear(); + TotalResultNumber = ""; + SuccessQueried = false; + MaxPage = 0; + CurrentPage = 1; + QueryPackageName = parameter; + var result = await packageSearchService.Query(parameter, 1); + Process(result); + } + } } \ No newline at end of file diff --git a/src/PipManager/Views/Pages/Search/SearchPage.xaml b/src/PipManager/Views/Pages/Search/SearchPage.xaml index c65a0be..a024338 100644 --- a/src/PipManager/Views/Pages/Search/SearchPage.xaml +++ b/src/PipManager/Views/Pages/Search/SearchPage.xaml @@ -5,6 +5,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:lang="clr-namespace:PipManager.Languages" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:query="clr-namespace:PipManager.PackageSearch.Wrappers.Query;assembly=PipManager.PackageSearch" xmlns:search="clr-namespace:PipManager.Views.Pages.Search" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="SearchPage" @@ -19,83 +20,107 @@ mc:Ignorable="d"> - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + Icon="{ui:SymbolIcon ArrowRight24}" + IsEnabled="{Binding ViewModel.ReachesLastPage, Converter={StaticResource InverseBool}}" /> + + + + + + + + \ No newline at end of file