From 0e0e6bf88c3f3d70414cbd13628218934d81de85 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Mon, 17 Aug 2026 00:57:28 +0200 Subject: [PATCH 001/118] feat(downloads): complete download acquisition and catalog browsing features --- .gitignore | 17 + GenHub/Directory.Packages.props | 6 +- .../GenHub.Core/Constants/AODMapsConstants.cs | 31 +- .../Constants/AppUpdateConstants.cs | 27 + .../GenHub.Core/Constants/CatalogConstants.cs | 69 +- .../GenHub.Core/Constants/CncLabsConstants.cs | 17 +- .../Constants/CommandLineConstants.cs | 15 +- .../CommunityOutpostCatalogConstants.cs | 3 + .../GenHub.Core/Constants/ContentConstants.cs | 27 + .../GenHub.Core/Constants/DirectoryNames.cs | 10 + .../Constants/GameContentConstants.cs | 156 + .../Constants/GitHubTopicsConstants.cs | 5 + .../Constants/ImageCacheConstants.cs | 37 + GenHub/GenHub.Core/Constants/InfoConstants.cs | 28 +- GenHub/GenHub.Core/Constants/IpcCommands.cs | 3 +- .../Constants/ManifestConstants.cs | 10 + .../GenHub.Core/Constants/ModDBConstants.cs | 57 + .../Constants/ModDBParserConstants.cs | 174 +- .../Constants/PublisherInfoConstants.cs | 62 +- .../Constants/PublisherTypeConstants.cs | 3 - .../Constants/SuperHackersConstants.cs | 3 + GenHub/GenHub.Core/Constants/UiConstants.cs | 45 + .../ContentAcquisitionProgressExtensions.cs | 86 + .../Extensions/ContentTypeExtensions.cs | 9 + .../GenHub.Core/Helpers/CommandLineParser.cs | 21 +- .../GenHub.Core/Helpers/ContentPathPolicy.cs | 107 + GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs | 135 + .../Content/IArchivePayloadProcessor.cs | 48 + .../Content/IContentDownloadCoordinator.cs | 24 + .../Content/IContentStateService.cs | 68 + .../Content/IControlBarPackageProcessor.cs | 58 + .../Interfaces/Content/ITabProvider.cs | 34 + .../Content/ITabProviderRegistry.cs | 39 + .../GameProfiles/IProfileContentService.cs | 25 + .../Manifest/IContentManifestBuilder.cs | 25 +- .../Interfaces/Parsers/IWebPageParser.cs | 45 + .../Providers/IPublisherCatalogParser.cs | 33 + .../IPublisherCatalogRefreshService.cs | 24 + .../Providers/IPublisherSubscriptionStore.cs | 69 + .../Interfaces/Providers/IVersionSelector.cs | 31 + .../Interfaces/Providers/VersionPolicy.cs | 22 + .../Interfaces/Tools/IPlaywrightService.cs | 78 + .../Messages/CloseContentDetailMessage.cs | 10 + .../Messages/ClosePublisherDetailsMessage.cs | 10 + .../Messages/ContentLibraryClearedMessage.cs | 8 + .../Messages/OpenPublisherDetailsMessage.cs | 11 + .../GenPatcherContentRegistry.cs | 64 +- .../GenPatcherDependencyBuilder.cs | 16 +- .../Content/ContentAcquisitionProgress.cs | 55 + .../Models/Content/ContentCardBadgeHelper.cs | 367 ++ .../Models/Content/ContentSearchQuery.cs | 31 + .../Models/Content/CustomTabCardDefinition.cs | 22 + .../Models/Content/CustomTabDefinition.cs | 76 + .../Models/Content/ParsedContentDetails.cs | 2 +- .../Models/Content/TabContentType.cs | 52 + .../GenHub.Core/Models/Enums/ContentState.cs | 23 + .../Models/Manifest/ContentManifest.cs | 4 +- .../Models/Manifest/ContentMetadata.cs | 14 + .../Models/Manifest/ManifestIdGenerator.cs | 33 + .../Models/Manifest/ManifestIngestionGate.cs | 4 +- .../Manifest/ManifestVariantResolver.cs | 38 +- GenHub/GenHub.Core/Models/ModDB/MapDetails.cs | 2 +- .../Models/ModDB/ModDBCategoryMapper.cs | 38 +- .../GenHub.Core/Models/ModDB/ModDBFilter.cs | 6 +- GenHub/GenHub.Core/Models/Parsers/Comment.cs | 9 +- .../Parsers/{File.cs => DownloadableFile.cs} | 16 +- .../Models/Parsers/FileSectionType.cs | 13 + .../CatalogBundleComponentDescriptor.cs | 46 + ...CatalogBundleComponentVariantDescriptor.cs | 33 + .../Models/Providers/CatalogContentItem.cs | 74 + .../Models/Providers/CatalogDependency.cs | 47 + .../Providers/CatalogManifestIdentity.cs | 314 ++ .../Providers/CatalogTabCardDefinition.cs | 29 + .../Models/Providers/CatalogTabDefinition.cs | 93 + .../Models/Providers/ContentRelease.cs | 54 + .../Models/Providers/ContentRichMetadata.cs | 57 + .../Models/Providers/ProviderDefinition.cs | 16 +- .../Models/Providers/PublisherCatalog.cs | 65 + .../Models/Providers/PublisherProfile.cs | 46 + .../Models/Providers/PublisherReferral.cs | 28 + .../Models/Providers/PublisherSubscription.cs | 109 + .../PublisherSubscriptionContainer.cs | 22 + .../Models/Providers/ReleaseArtifact.cs | 71 + .../Results/Content/ContentDiscoveryResult.cs | 8 + .../Results/Content/ContentSearchResult.cs | 29 +- .../Content/ContentStateChangedEventArgs.cs | 37 + .../Results/Content/ContentVariantInfo.cs | 35 + .../ConfigurationProviderServiceTests.cs | 8 +- .../Converters/StripHtmlConverterTests.cs | 70 + .../Extensions/ContentTypeExtensionsTests.cs | 109 + .../Content/CNCLabsMapDiscovererTests.cs | 88 +- .../Common/ArchivePayloadProcessorTests.cs | 260 ++ .../CommunityOutpostDelivererTests.cs | 380 ++ .../CommunityOutpostDiscovererTests.cs | 9 +- .../CommunityOutpostManifestFactoryTests.cs | 241 +- .../CommunityOutpostResolverTests.cs | 223 + .../GenPatcherDatCatalogParserTests.cs | 51 + .../GenPatcherDependencyBuilderTests.cs | 20 + .../AODMapsDiscovererTests.cs | 253 ++ .../Content/ContentOrchestratorTests.cs | 77 +- .../Content/GitHubInferenceHelperTests.cs | 24 + .../Features/Content/GitHubResolverTests.cs | 46 +- .../Content/ModDB/ModDBCategoryMapperTests.cs | 63 + .../Content/Parsers/ModDBPageParserTests.cs | 1656 +++++++ .../Publishers/AODMapsManifestFactoryTests.cs | 79 + .../Publishers/CNCLabsManifestFactoryTests.cs | 78 + .../Publishers/GitHubManifestFactoryTests.cs | 156 + .../Publishers/ModDBManifestFactoryTests.cs | 477 ++ .../SuperHackersManifestFactoryTests.cs | 137 + .../CatalogBundleComponentBuilderTests.cs | 148 + .../CatalogLocalFileIntegrationTests.cs | 184 + .../Services/CatalogManifestIdentityTests.cs | 224 + .../CatalogParserPublisherTypeTests.cs | 252 ++ .../Services/CatalogTabProviderTests.cs | 110 + .../Common/ControlBarPackageProcessorTests.cs | 173 + .../Content/Services/ContentValidatorTests.cs | 71 + .../Services/FileSystemDelivererTests.cs | 143 + .../GenericCatalogDiscovererDynamicTests.cs | 345 ++ .../Services/GenericCatalogResolverTests.cs | 611 +++ .../GitHub/GitHubContentDelivererTests.cs | 174 +- .../GitHub/ReleaseDescriptionHelperTests.cs | 103 + .../JsonPublisherCatalogParserTests.cs | 191 + .../Content/Services/ModDBOrderingTests.cs | 185 + .../SuperHackersProfileReconcilerTests.cs | 188 +- .../Services/TabProviderRegistryTests.cs | 71 + .../Tools/ManagedChromiumRuntimeTests.cs | 168 + .../SubscriptionConfirmationViewModelTests.cs | 314 ++ .../ContentBundleDownloadIntegrationTests.cs | 171 + .../ContentGridItemViewModelTests.cs | 365 ++ .../Downloads/ContentStateServiceTests.cs | 928 ++++ .../Filters/AODMapsFilterViewModelTests.cs | 41 + .../Filters/CNCLabsFilterViewModelTests.cs | 68 + .../Downloads/ReleaseVariantSelectionTests.cs | 389 ++ ...ContentStateServiceCatalogIdentityTests.cs | 81 + .../SuperHackersBundleIntegrationTests.cs | 499 +++ .../ViewModels/ContentDetailViewModelTests.cs | 1294 ++++++ .../DownloadsBrowserViewModelTests.cs | 641 +++ .../ViewModels/PublisherCardViewModelTests.cs | 232 - .../GameClientManifestIntegrationTests.cs | 15 +- .../GameProfiles/GameProcessManagerTests.cs | 12 +- .../DependencyResolverCatalogIdentityTests.cs | 94 + .../Services/DependencyResolverTests.cs | 361 ++ .../Services/ProfileContentServiceTests.cs | 362 ++ ...LauncherFacadeDependencyValidationTests.cs | 328 ++ .../ViewModels/DownloadsViewModelTests.cs | 50 - .../GameProfileItemViewModelTests.cs | 29 + .../ViewModels/MainViewModelTests.cs | 56 +- .../ViewModels/SettingsViewModelTests.cs | 38 + .../Features/Launching/GameLauncherTests.cs | 30 +- .../Manifest/ContentManifestBuilderTests.cs | 49 +- .../ManifestGenerationServiceTests.cs | 2 + .../Helpers/ContentPathPolicyTests.cs | 78 + .../Helpers/HtmlTextHelperTests.cs | 147 + .../ApplicationDataPathConventionTests.cs | 3 + .../DownloadModuleTests.cs | 101 +- .../SharedViewModelModuleTests.cs | 2 +- .../Services/ImageCacheServiceTests.cs | 97 + .../ReconciliationIntegrationTests.cs | 3 +- .../Manifest/ManifestIngestionGateTests.cs | 25 +- .../Manifest/ManifestVariantResolverTests.cs | 71 + .../GenHub.Tests.Core/ViewLocatorTests.cs | 92 + .../ContentPipelineIntegrationTests.cs | 3 +- .../LinuxApplicationCompositionTests.cs | 2 +- .../WindowsApplicationCompositionTests.cs | 2 +- .../Shared/CompositionRootAssertions.cs | 3 - .../Features/Shortcuts/UriSchemeRegistrar.cs | 75 + GenHub/GenHub.Windows/GenHub.Windows.csproj | 3 +- GenHub/GenHub.Windows/Program.cs | 8 +- GenHub/GenHub/App.axaml | 40 +- GenHub/GenHub/App.axaml.cs | 103 +- GenHub/GenHub/Assets/Logos/aodmaps-logo.png | Bin 0 -> 15406 bytes .../GenHub/Assets/Styles/ComboBoxStyles.axaml | 1 + .../GenHub/Assets/Styles/SidebarStyles.axaml | 20 +- .../GenHub/Common/Controls/SidebarLayout.cs | 19 +- .../GenHub/Common/ViewModels/MainViewModel.cs | 32 +- .../Dialogs/ConfirmationDialogWindow.axaml | 3 +- .../Views/Dialogs/GenericMessageWindow.axaml | 1 + .../Dialogs/UpdateOptionDialogWindow.axaml | 1 + GenHub/GenHub/Common/Views/MainView.axaml | 4 +- GenHub/GenHub/Common/Views/MainWindow.axaml | 3 +- .../GenHub/Common/Views/MainWindow.axaml.cs | 82 + .../Views/UpdateNotificationWindow.axaml | 96 +- .../Views/UpdateNotificationWindow.axaml.cs | 74 +- .../Catalog/CatalogBundleComponentBuilder.cs | 298 ++ .../Services/Catalog/CatalogDocumentReader.cs | 121 + .../Catalog/GenericCatalogDiscoverer.cs | 775 ++++ .../Catalog/GenericCatalogManifestFactory.cs | 180 + .../Catalog/GenericCatalogResolver.cs | 317 ++ .../Catalog/JsonPublisherCatalogParser.cs | 270 ++ .../Catalog/PublisherCatalogRefreshService.cs | 115 + .../Catalog/PublisherSubscriptionStore.cs | 354 ++ .../Services/Catalog/VersionSelector.cs | 88 + .../Content/Services/CatalogTabProvider.cs | 148 + .../Common/ArchivePayloadProcessor.cs | 523 +++ .../Common/ControlBarPackageProcessor.cs | 508 +++ .../CommunityOutpostDeliverer.cs | 590 ++- .../CommunityOutpostDiscoverer.cs | 26 +- .../CommunityOutpostManifestFactory.cs | 596 ++- .../CommunityOutpostResolver.cs | 141 +- .../GenPatcherDatCatalogParser.cs | 17 + .../Content/Services/ContentCacheService.cs | 72 + .../ContentDeliverers/FileSystemDeliverer.cs | 28 +- .../ContentDeliverers/HttpContentDeliverer.cs | 141 +- .../ContentDiscoverers/AODMapsDiscoverer.cs | 585 ++- .../CNCLabsMapDiscoverer.cs | 179 +- .../GitHubTopicsDiscoverer.cs | 180 +- .../ContentDiscoverers/ModDBDiscoverer.cs | 674 ++- .../Content/Services/ContentOrchestrator.cs | 627 ++- .../AODMapsContentProvider.cs | 33 +- .../CNCLabsContentProvider.cs | 32 +- .../LocalFileSystemContentProvider.cs | 29 +- .../ContentProviders/ModDBContentProvider.cs | 48 +- .../Services/ContentReconciliationService.cs | 63 +- .../ContentResolvers/AODMapsResolver.cs | 37 +- .../ContentResolvers/CNCLabsMapResolver.cs | 90 +- .../ContentResolvers/ModDBResolver.cs | 336 +- .../Content/Services/ContentStorageService.cs | 10 +- .../Content/Services/ContentValidator.cs | 37 +- .../GeneralsOnline/GeneralsOnlineDeliverer.cs | 10 +- .../GeneralsOnlineDependencyBuilder.cs | 27 +- .../GeneralsOnlineDiscoverer.cs | 10 +- .../GeneralsOnlineJsonCatalogParser.cs | 18 +- .../GeneralsOnlineManifestFactory.cs | 188 +- .../Services/GitHub/GitHubContentDeliverer.cs | 281 +- .../GitHub/GitHubReleasesDiscoverer.cs | 264 +- .../Content/Services/GitHub/GitHubResolver.cs | 38 + .../Content/Services/Helpers/AODMapsHelper.cs | 186 +- .../Content/Services/Helpers/CNCLabsHelper.cs | 44 +- .../Services/Helpers/GitHubInferenceHelper.cs | 4 + .../Helpers/ReleaseDescriptionHelper.cs | 113 + .../Content/Services/IContentCacheService.cs | 49 + .../Services/Parsers/AODMapsPageParser.cs | 27 +- .../Services/Parsers/ModDBPageParser.cs | 3128 +++++++++++++ .../Publishers/AODMapsManifestFactory.cs | 175 +- .../Publishers/CNCLabsManifestFactory.cs | 219 +- .../Publishers/GitHubManifestFactory.cs | 49 +- .../Publishers/ModDBManifestFactory.cs | 388 +- .../SuperHackersDependencyBuilder.cs | 28 +- .../Publishers/SuperHackersManifestFactory.cs | 119 +- .../Publishers/SuperHackersProvider.cs | 2 +- .../SuperHackersProfileReconciler.cs | 32 +- .../Content/Services/TabProviderRegistry.cs | 98 + .../Services/Tools/ManagedChromiumRuntime.cs | 193 + .../Services/Tools/PlaywrightService.cs | 1357 ++++++ .../Catalog/CatalogCategoryFilter.cs | 36 + .../SubscriptionConfirmationViewModel.cs | 392 ++ .../Services/ContentDownloadCoordinator.cs | 88 + .../Downloads/Services/ContentStateService.cs | 1205 +++++ .../ViewModels/AddonItemViewModel.cs | 18 + .../ViewModels/BundleComponentViewModel.cs | 522 +++ .../ViewModels/ContentDetailViewModel.cs | 3861 +++++++++++++++++ .../ViewModels/ContentGridItemViewModel.cs | 1001 +++++ .../Downloads/ViewModels/ContentTypeGroup.cs | 31 - .../CreateProfileOptionViewModel.cs | 6 + .../ViewModels/DependencyItemViewModel.cs | 33 + .../ViewModels/DependencyPreviewViewModel.cs | 122 + .../ViewModels/DownloadableItemViewModel.cs | 473 ++ ...loadsBrowserViewModel.InstallationCheck.cs | 15 + .../ViewModels/DownloadsBrowserViewModel.cs | 1651 +++++++ .../ViewModels/DownloadsViewModel.cs | 791 ---- .../Filters/AODMapsFilterViewModel.cs | 87 + .../Filters/CNCLabsFilterViewModel.cs | 223 + .../CommunityOutpostFilterViewModel.cs | 112 + .../Filters/FilterPanelViewModelBase.cs | 75 + .../Filters/GitHubFilterViewModel.cs | 139 + .../Filters/IFilterPanelViewModel.cs | 57 + .../Filters/ModDBFilterViewModel.cs | 372 ++ .../ViewModels/Filters/ModDBSection.cs | 16 + .../ViewModels/Filters/PlayerOption.cs | 8 - .../Filters/StaticPublisherFilterViewModel.cs | 111 + .../Filters/SuperHackersFilterViewModel.cs | 100 + .../ViewModels/IDownloadableRowViewModel.cs | 35 + .../ViewModels/InstallableVariant.cs | 81 + .../ViewModels/ProfileOptionViewModel.cs | 190 + .../ViewModels/ProfilePickerItemViewModel.cs | 8 + .../ViewModels/ProfileSelectionViewModel.cs | 496 +++ .../ViewModels/PublisherCardViewModel.cs | 1108 ----- .../ViewModels/PublisherItemViewModel.cs | 54 + .../ViewModels/ReleaseItemViewModel.cs | 9 + .../Downloads/ViewModels/VariantAxisGroup.cs | 60 + .../ViewModels/VariantAxisGrouping.cs | 141 + .../Downloads/ViewModels/VariantSwap.cs | 206 + .../Downloads/Views/ContentCardView.axaml | 601 +++ .../Downloads/Views/ContentCardView.axaml.cs | 17 + .../Downloads/Views/ContentDetailView.axaml | 1742 ++++++++ .../Views/ContentDetailView.axaml.cs | 17 + .../Views/DependencyPreviewView.axaml | 235 + .../Views/DependencyPreviewView.axaml.cs | 126 + .../Views/DownloadsBrowserView.axaml | 241 + .../Views/DownloadsBrowserView.axaml.cs | 17 + .../Downloads/Views/DownloadsView.axaml | 102 - .../Downloads/Views/DownloadsView.axaml.cs | 26 - .../Downloads/Views/FilterPanelView.axaml | 397 ++ .../Downloads/Views/FilterPanelView.axaml.cs | 17 + .../Views/ProfileSelectionView.axaml | 317 ++ .../Views/ProfileSelectionView.axaml.cs | 127 + .../Downloads/Views/PublisherCardView.axaml | 432 -- .../Views/PublisherCardView.axaml.cs | 26 - .../SubscriptionConfirmationDialog.axaml | 533 +++ .../SubscriptionConfirmationDialog.axaml.cs | 77 + .../GameInstallationService.cs | 12 + .../Services/DependencyResolver.cs | 389 +- .../Services/GameClientProfileService.cs | 5 +- .../Services/ProfileContentService.cs | 987 ++++- .../Services/ProfileEditorFacade.cs | 29 +- .../Services/ProfileLauncherFacade.cs | 100 +- .../ViewModels/GameProfileItemViewModel.cs | 54 +- .../GameProfileSettingsViewModel.cs | 13 + .../Views/AddLocalContentWindow.axaml | 142 +- .../Views/AddLocalContentWindow.axaml.cs | 56 +- .../Views/GameProfileContentEditorView.axaml | 11 +- .../GameProfileContentEditorView.axaml.cs | 2 +- .../GameProfileContentSettingsView.axaml | 42 +- .../GameProfileContentSettingsView.axaml.cs | 191 +- .../GameProfileGeneralSettingsView.axaml | 17 +- .../GameProfileSettingsContentView.axaml | 10 +- .../Views/GameProfileSettingsWindow.axaml | 308 +- .../GameProfiles/Views/GameSettingsView.axaml | 20 +- .../Views/Wizard/SetupWizardView.axaml | 1 + .../GitHub/Services/OctokitGitHubApiClient.cs | 6 +- .../GitHub/Views/GitHubTokenDialogView.axaml | 65 +- .../Views/GitHubTokenDialogView.axaml.cs | 22 +- .../GenHub/Features/Launching/GameLauncher.cs | 26 + .../Manifest/ContentManifestBuilder.cs | 34 +- .../Features/Manifest/ContentManifestPool.cs | 22 +- .../Manifest/ManifestGenerationService.cs | 10 +- .../Settings/ViewModels/SettingsViewModel.cs | 147 +- .../Settings/Views/SettingsView.axaml | 78 +- .../Settings/Views/SettingsView.axaml.cs | 7 +- .../Storage/Services/CasPoolManager.cs | 21 +- .../Storage/Services/CasReferenceTracker.cs | 2 +- .../Features/Storage/Services/CasService.cs | 36 +- .../Services/ProfileContentLinkerService.cs | 98 +- .../Infrastructure/Controls/ImageLoader.cs | 111 + .../Converters/BoolToBackgroundConverter.cs | 37 + .../Converters/BoolToBorderConverter.cs | 37 + .../Converters/ComparisonConverters.cs | 5 + .../ContentStateToBrushConverter.cs | 41 + .../ContentStateToPathDataConverter.cs | 38 + .../Converters/ContentStateToTextConverter.cs | 32 + .../Converters/GameTypeInitialConverter.cs | 45 + .../Converters/IndentToMarginConverter.cs | 40 + .../Converters/NotEqualToConverter.cs | 38 + .../Converters/StringToImageConverter.cs | 12 +- .../Converters/StripHtmlConverter.cs | 39 + .../ContentPipelineModule.cs | 239 +- .../DependencyInjection/DownloadModule.cs | 93 +- .../DependencyInjection/ManifestModule.cs | 2 +- .../SharedViewModelModule.cs | 13 +- .../Services/ImageCacheService.cs | 616 +++ GenHub/GenHub/SampleCatalogs/.gitattributes | 7 + GenHub/GenHub/SampleCatalogs/README.md | 200 + .../generate-test-shortcuts.ps1 | 147 + .../SampleCatalogs/generate-test-shortcuts.sh | 456 ++ .../genhub-test-catalog.catalog.json | 977 +++++ GenHub/GenHub/ViewLocator.cs | 30 +- docs/.vitepress/config.js | 6 +- docs/FlowCharts/Downloads-Flow.md | 792 ++++ docs/FlowCharts/index.md | 1 + docs/dev/catalog-identity-remediation.md | 793 ++++ docs/dev/constants.md | 200 +- docs/dev/content-card-ui-plan.md | 425 ++ docs/dev/contribution-guidelines.md | 45 + docs/dev/creator-publishing-roadmap.md | 439 ++ docs/dev/manifest-id-system.md | 178 +- docs/dev/models.md | 70 +- docs/features/content/content-pipeline.md | 427 ++ docs/features/content/creator-publishing.md | 408 ++ docs/features/content/index.md | 117 +- .../content/publisher-configuration.md | 20 +- docs/features/content/universal-parser.md | 441 ++ docs/features/downloads.md | 1165 +++++ docs/index.md | 5 +- .../download-acquisition-acceptance.md | 31 + review_report.md | 259 ++ scripts/build-check.ps1 | 249 ++ 376 files changed, 60272 insertions(+), 5727 deletions(-) create mode 100644 GenHub/GenHub.Core/Constants/GameContentConstants.cs create mode 100644 GenHub/GenHub.Core/Constants/ImageCacheConstants.cs create mode 100644 GenHub/GenHub.Core/Extensions/ContentAcquisitionProgressExtensions.cs create mode 100644 GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs create mode 100644 GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Content/IArchivePayloadProcessor.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Content/IContentDownloadCoordinator.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Content/IContentStateService.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Content/IControlBarPackageProcessor.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Content/ITabProvider.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Content/ITabProviderRegistry.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Providers/IPublisherCatalogParser.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Providers/IPublisherCatalogRefreshService.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Providers/IPublisherSubscriptionStore.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Providers/IVersionSelector.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Providers/VersionPolicy.cs create mode 100644 GenHub/GenHub.Core/Messages/CloseContentDetailMessage.cs create mode 100644 GenHub/GenHub.Core/Messages/ClosePublisherDetailsMessage.cs create mode 100644 GenHub/GenHub.Core/Messages/ContentLibraryClearedMessage.cs create mode 100644 GenHub/GenHub.Core/Messages/OpenPublisherDetailsMessage.cs create mode 100644 GenHub/GenHub.Core/Models/Content/ContentCardBadgeHelper.cs create mode 100644 GenHub/GenHub.Core/Models/Content/CustomTabCardDefinition.cs create mode 100644 GenHub/GenHub.Core/Models/Content/CustomTabDefinition.cs create mode 100644 GenHub/GenHub.Core/Models/Content/TabContentType.cs create mode 100644 GenHub/GenHub.Core/Models/Enums/ContentState.cs rename GenHub/GenHub.Core/Models/Parsers/{File.cs => DownloadableFile.cs} (58%) create mode 100644 GenHub/GenHub.Core/Models/Parsers/FileSectionType.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/CatalogBundleComponentDescriptor.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/CatalogBundleComponentVariantDescriptor.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/CatalogContentItem.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/CatalogDependency.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/CatalogManifestIdentity.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/CatalogTabCardDefinition.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/CatalogTabDefinition.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/ContentRelease.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/ContentRichMetadata.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/PublisherCatalog.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/PublisherProfile.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/PublisherReferral.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/PublisherSubscription.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/PublisherSubscriptionContainer.cs create mode 100644 GenHub/GenHub.Core/Models/Providers/ReleaseArtifact.cs create mode 100644 GenHub/GenHub.Core/Models/Results/Content/ContentStateChangedEventArgs.cs create mode 100644 GenHub/GenHub.Core/Models/Results/Content/ContentVariantInfo.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/StripHtmlConverterTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/ContentTypeExtensionsTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Common/ArchivePayloadProcessorTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostDelivererTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostResolverTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherDatCatalogParserTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentDiscoverers/AODMapsDiscovererTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ModDB/ModDBCategoryMapperTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Parsers/ModDBPageParserTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Publishers/AODMapsManifestFactoryTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Publishers/CNCLabsManifestFactoryTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Publishers/GitHubManifestFactoryTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Publishers/ModDBManifestFactoryTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Publishers/SuperHackersManifestFactoryTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CatalogBundleComponentBuilderTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CatalogLocalFileIntegrationTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CatalogManifestIdentityTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CatalogParserPublisherTypeTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CatalogTabProviderTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Common/ControlBarPackageProcessorTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/ContentValidatorTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/FileSystemDelivererTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GenericCatalogDiscovererDynamicTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GenericCatalogResolverTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GitHub/ReleaseDescriptionHelperTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/JsonPublisherCatalogParserTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/ModDBOrderingTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/TabProviderRegistryTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Tools/ManagedChromiumRuntimeTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ViewModels/SubscriptionConfirmationViewModelTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Downloads/ContentBundleDownloadIntegrationTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Downloads/ContentGridItemViewModelTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Downloads/ContentStateServiceTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Downloads/Filters/AODMapsFilterViewModelTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Downloads/Filters/CNCLabsFilterViewModelTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Downloads/ReleaseVariantSelectionTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Downloads/Services/ContentStateServiceCatalogIdentityTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Downloads/SuperHackersBundleIntegrationTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Downloads/ViewModels/ContentDetailViewModelTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Downloads/ViewModels/DownloadsBrowserViewModelTests.cs delete mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Downloads/ViewModels/PublisherCardViewModelTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/DependencyResolverCatalogIdentityTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/DependencyResolverTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/ProfileContentServiceTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/ProfileLauncherFacadeDependencyValidationTests.cs delete mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/DownloadsViewModelTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/ContentPathPolicyTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/HtmlTextHelperTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Services/ImageCacheServiceTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/ViewLocatorTests.cs create mode 100644 GenHub/GenHub.Windows/Features/Shortcuts/UriSchemeRegistrar.cs create mode 100644 GenHub/GenHub/Assets/Logos/aodmaps-logo.png create mode 100644 GenHub/GenHub/Features/Content/Services/Catalog/CatalogBundleComponentBuilder.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Catalog/CatalogDocumentReader.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Catalog/GenericCatalogDiscoverer.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Catalog/GenericCatalogManifestFactory.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Catalog/GenericCatalogResolver.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Catalog/JsonPublisherCatalogParser.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Catalog/PublisherCatalogRefreshService.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Catalog/PublisherSubscriptionStore.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Catalog/VersionSelector.cs create mode 100644 GenHub/GenHub/Features/Content/Services/CatalogTabProvider.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs create mode 100644 GenHub/GenHub/Features/Content/Services/ContentCacheService.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Helpers/ReleaseDescriptionHelper.cs create mode 100644 GenHub/GenHub/Features/Content/Services/IContentCacheService.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Parsers/ModDBPageParser.cs create mode 100644 GenHub/GenHub/Features/Content/Services/TabProviderRegistry.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Tools/ManagedChromiumRuntime.cs create mode 100644 GenHub/GenHub/Features/Content/Services/Tools/PlaywrightService.cs create mode 100644 GenHub/GenHub/Features/Content/ViewModels/Catalog/CatalogCategoryFilter.cs create mode 100644 GenHub/GenHub/Features/Content/ViewModels/Catalog/SubscriptionConfirmationViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/Services/ContentDownloadCoordinator.cs create mode 100644 GenHub/GenHub/Features/Downloads/Services/ContentStateService.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/AddonItemViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/BundleComponentViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/ContentDetailViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/ContentGridItemViewModel.cs delete mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/ContentTypeGroup.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/CreateProfileOptionViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/DependencyItemViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/DependencyPreviewViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/DownloadableItemViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/DownloadsBrowserViewModel.InstallationCheck.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/DownloadsBrowserViewModel.cs delete mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/DownloadsViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/AODMapsFilterViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/CNCLabsFilterViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/CommunityOutpostFilterViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/FilterPanelViewModelBase.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/GitHubFilterViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/IFilterPanelViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/ModDBFilterViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/ModDBSection.cs delete mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/PlayerOption.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/StaticPublisherFilterViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/Filters/SuperHackersFilterViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/IDownloadableRowViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/InstallableVariant.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/ProfileOptionViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/ProfilePickerItemViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/ProfileSelectionViewModel.cs delete mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/PublisherCardViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/PublisherItemViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/ReleaseItemViewModel.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/VariantAxisGroup.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/VariantAxisGrouping.cs create mode 100644 GenHub/GenHub/Features/Downloads/ViewModels/VariantSwap.cs create mode 100644 GenHub/GenHub/Features/Downloads/Views/ContentCardView.axaml create mode 100644 GenHub/GenHub/Features/Downloads/Views/ContentCardView.axaml.cs create mode 100644 GenHub/GenHub/Features/Downloads/Views/ContentDetailView.axaml create mode 100644 GenHub/GenHub/Features/Downloads/Views/ContentDetailView.axaml.cs create mode 100644 GenHub/GenHub/Features/Downloads/Views/DependencyPreviewView.axaml create mode 100644 GenHub/GenHub/Features/Downloads/Views/DependencyPreviewView.axaml.cs create mode 100644 GenHub/GenHub/Features/Downloads/Views/DownloadsBrowserView.axaml create mode 100644 GenHub/GenHub/Features/Downloads/Views/DownloadsBrowserView.axaml.cs delete mode 100644 GenHub/GenHub/Features/Downloads/Views/DownloadsView.axaml delete mode 100644 GenHub/GenHub/Features/Downloads/Views/DownloadsView.axaml.cs create mode 100644 GenHub/GenHub/Features/Downloads/Views/FilterPanelView.axaml create mode 100644 GenHub/GenHub/Features/Downloads/Views/FilterPanelView.axaml.cs create mode 100644 GenHub/GenHub/Features/Downloads/Views/ProfileSelectionView.axaml create mode 100644 GenHub/GenHub/Features/Downloads/Views/ProfileSelectionView.axaml.cs delete mode 100644 GenHub/GenHub/Features/Downloads/Views/PublisherCardView.axaml delete mode 100644 GenHub/GenHub/Features/Downloads/Views/PublisherCardView.axaml.cs create mode 100644 GenHub/GenHub/Features/Downloads/Views/SubscriptionConfirmationDialog.axaml create mode 100644 GenHub/GenHub/Features/Downloads/Views/SubscriptionConfirmationDialog.axaml.cs create mode 100644 GenHub/GenHub/Infrastructure/Controls/ImageLoader.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/BoolToBackgroundConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/BoolToBorderConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/ContentStateToBrushConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/ContentStateToPathDataConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/ContentStateToTextConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/GameTypeInitialConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/IndentToMarginConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/NotEqualToConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/StripHtmlConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Services/ImageCacheService.cs create mode 100644 GenHub/GenHub/SampleCatalogs/.gitattributes create mode 100644 GenHub/GenHub/SampleCatalogs/README.md create mode 100644 GenHub/GenHub/SampleCatalogs/generate-test-shortcuts.ps1 create mode 100755 GenHub/GenHub/SampleCatalogs/generate-test-shortcuts.sh create mode 100644 GenHub/GenHub/SampleCatalogs/genhub-test-catalog.catalog.json create mode 100644 docs/FlowCharts/Downloads-Flow.md create mode 100644 docs/dev/catalog-identity-remediation.md create mode 100644 docs/dev/content-card-ui-plan.md create mode 100644 docs/dev/contribution-guidelines.md create mode 100644 docs/dev/creator-publishing-roadmap.md create mode 100644 docs/features/content/content-pipeline.md create mode 100644 docs/features/content/creator-publishing.md create mode 100644 docs/features/content/universal-parser.md create mode 100644 docs/features/downloads.md create mode 100644 docs/testing/download-acquisition-acceptance.md create mode 100644 review_report.md create mode 100644 scripts/build-check.ps1 diff --git a/.gitignore b/.gitignore index 81f0c8471..56e665c53 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,12 @@ .* !.github/ !.gitignore +!.gitattributes +!**/.gitattributes +AGENTS.md +CLAUDE.md + + *.suo *.user @@ -21,6 +27,7 @@ docs/.vitepress/dist/ docs/.vitepress/cache/ # Build results +build.lock [Dd]ebug/ [Rr]elease/ @@ -171,3 +178,13 @@ _NCrunch* # Velopack releases releases/ Releases/ +.gitnexus + +# SampleCatalogs: machine-specific generated shortcuts. Never commit them. +GenHub/GenHub/SampleCatalogs/register-genhub-scheme.reg +GenHub/GenHub/SampleCatalogs/register-genhub-scheme.desktop +GenHub/GenHub/SampleCatalogs/register-genhub-scheme.app/ +GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.url +GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.desktop +GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.command +GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.webloc diff --git a/GenHub/Directory.Packages.props b/GenHub/Directory.Packages.props index f76660ae2..d8098137c 100644 --- a/GenHub/Directory.Packages.props +++ b/GenHub/Directory.Packages.props @@ -6,7 +6,7 @@ - + @@ -47,11 +47,11 @@ - + - + \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/AODMapsConstants.cs b/GenHub/GenHub.Core/Constants/AODMapsConstants.cs index 7cf4bd7af..8beb34d00 100644 --- a/GenHub/GenHub.Core/Constants/AODMapsConstants.cs +++ b/GenHub/GenHub.Core/Constants/AODMapsConstants.cs @@ -1,7 +1,7 @@ namespace GenHub.Core.Constants; /// -/// Constants for AODMaps (Age of Defense Maps) provider. +/// Constants for AODMaps (Art of Defense Maps) provider. /// public static class AODMapsConstants { @@ -15,7 +15,7 @@ public static class AODMapsConstants public const string DiscovererSourceName = "AODMaps"; /// Gets the discoverer description. - public const string DiscovererDescription = "Age of Defense Maps"; + public const string DiscovererDescription = "Art of Defense Maps"; /// Gets the resolver ID for AODMaps. public const string ResolverId = "AODMaps"; @@ -113,6 +113,9 @@ public static class AODMapsConstants /// Gets the download URL metadata key. public const string DownloadUrlMetadataKey = "downloadUrl"; + /// Gets the list page URL metadata key. + public const string ListPageUrlMetadataKey = "listPageUrl"; + /// Gets the direct download metadata key. public const string DirectDownloadMetadataKey = "directDownload"; @@ -122,6 +125,30 @@ public static class AODMapsConstants /// Gets the download count metadata key. public const string DownloadCountMetadataKey = "downloadCount"; + /// Gets the player count metadata key. + public const string PlayerCountMetadataKey = ContentConstants.PlayerCountMetadataKey; + + /// Gets the category metadata key used for download-card badges and combined filtering. + public const string CategoryMetadataKey = ContentConstants.CategoryMetadataKey; + + /// Filter / badge label for Compstomp maps. + public const string CategoryCompstomp = "Compstomp"; + + /// Filter / badge label for map packs. + public const string CategoryMapPacks = "Map Packs"; + + /// Filter / badge label for air maps. + public const string CategoryAir = "Air"; + + /// Filter / badge label for race maps. + public const string CategoryRace = "Race"; + + /// Filter / badge label for Art of Attack maps. + public const string CategoryAoa = "AOA"; + + /// Filter / badge label for Contra AOD maps. + public const string CategoryContra = "Contra"; + /// Gets the last updated metadata key. public const string LastUpdatedMetadataKey = "lastUpdated"; diff --git a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs index e7a543023..a1809fdb7 100644 --- a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs +++ b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs @@ -153,6 +153,33 @@ public static class AppUpdateConstants "3. Launch the installed version (will be in %LOCALAPPDATA%\\GenHub)\n\n" + "Update available: v{1}"; + /// + /// Default update notification title. + /// + public const string UpdateNotificationTitle = "Update Available"; + + /// + /// Update available notification message format. + /// {0}: Version. + /// + public const string UpdateAvailableMessageFormat = "A new version ({0}) is available."; + + /// + /// Branch/Artifact update notification title. + /// + public const string BranchUpdateNotificationTitle = "Branch Update Available"; + + /// + /// Branch update available notification message format. + /// {0}: Version, {1}: Branch. + /// + public const string BranchUpdateAvailableMessageFormat = "A new build ({0}) is available on branch '{1}'."; + + /// + /// "View Updates" action text. + /// + public const string ViewUpdatesAction = "View Updates"; + /// /// Delay before exit after applying update (5 seconds). /// diff --git a/GenHub/GenHub.Core/Constants/CatalogConstants.cs b/GenHub/GenHub.Core/Constants/CatalogConstants.cs index 0e0ff3f5d..2e23578b4 100644 --- a/GenHub/GenHub.Core/Constants/CatalogConstants.cs +++ b/GenHub/GenHub.Core/Constants/CatalogConstants.cs @@ -1,8 +1,26 @@ namespace GenHub.Core.Constants; /// -/// Constants for publisher catalog system. +/// Constants for the modular publisher-catalog system. /// +/// +/// Layering (see Publisher Studio architecture): +/// +/// +/// Provider Definition — static publisher metadata + catalog endpoint(s) +/// (bundled *.provider.json today; user-hosted definitions via Publisher Studio later). +/// +/// +/// Catalog — dynamic content listing (catalog.json / remote endpoint), updated on each release. +/// +/// +/// Artifacts — downloadable files referenced by catalog releases. +/// +/// +/// Anyone can author a GenHub-schema catalog, host it, and share +/// genhub://subscribe?url=.... Discovery uses +/// for catalog-direct subscriptions without per-publisher code. +/// public static class CatalogConstants { /// @@ -11,12 +29,17 @@ public static class CatalogConstants public const int CatalogSchemaVersion = 1; /// - /// Filename for subscriptions storage. + /// Filename for user subscription storage under application data. /// public const string SubscriptionFileName = "subscriptions.json"; /// - /// Resolver ID for generic catalog resolver. + /// Sidebar / discoverer category for user-subscribed catalogs (vs built-in static/dynamic). + /// + public const string SubscribedPublisherCategory = "subscribed"; + + /// + /// Resolver / pipeline ID for the generic catalog pipeline (any GenHub-schema catalog). /// public const string GenericCatalogResolverId = "generic-catalog"; @@ -29,4 +52,44 @@ public static class CatalogConstants /// Maximum catalog size in bytes (10 MB). /// public const long MaxCatalogSizeBytes = 10 * 1024 * 1024; + + /// + /// Maximum number of entries allowed when extracting publisher catalog archives. + /// + public const int MaxZipEntryCount = 50_000; + + /// + /// Maximum cumulative uncompressed size allowed when extracting publisher catalog archives (5 GB). + /// + public const long MaxZipUncompressedSizeBytes = 5L * 1024 * 1024 * 1024; + + /// + /// Resolver metadata key for serialized publisher profile JSON. + /// + public const string PublisherProfileJsonMetadataKey = "publisherProfileJson"; + + /// + /// Resolver metadata key for serialized catalog item JSON. + /// + public const string CatalogItemJsonMetadataKey = "catalogItemJson"; + + /// + /// Resolver metadata key for serialized release JSON. + /// + public const string ReleaseJsonMetadataKey = "releaseJson"; + + /// + /// Resolver metadata key for the stable catalog content id (not the display name). + /// + public const string CatalogContentIdMetadataKey = "catalogContentId"; + + /// + /// Resolver metadata key for serialized bundle component descriptors. + /// + public const string BundleComponentsJsonMetadataKey = "bundleComponentsJson"; + + /// + /// Resolver metadata key for serialized publisher referrals JSON. + /// + public const string CatalogReferralsJsonMetadataKey = "catalogReferralsJson"; } diff --git a/GenHub/GenHub.Core/Constants/CncLabsConstants.cs b/GenHub/GenHub.Core/Constants/CncLabsConstants.cs index c360b654b..c5e734c0d 100644 --- a/GenHub/GenHub.Core/Constants/CncLabsConstants.cs +++ b/GenHub/GenHub.Core/Constants/CncLabsConstants.cs @@ -117,24 +117,19 @@ public static class CNCLabsConstants public const string QueryStringIdParameter = "id"; /// - /// CSS selector for a single downloadable item container on list pages. + /// CSS selector for a single downloadable item container on list pages (2026 Bootstrap redesign). /// - public const string ListItemSelector = "div.DownloadItem"; + public const string ListItemSelector = "div.list-group-item"; /// - /// CSS selector for the hidden input that carries the map's numeric File Id. + /// CSS selector for the anchor with the display name of the map (2026 Bootstrap redesign). /// - public const string FileIdHiddenSelector = "input[type='hidden'][id$='FileIdField']"; + public const string DisplayNameAnchorSelector = "h5 a[href*='/downloads/details/']"; /// - /// CSS selector for the anchor with the display name of the map. + /// CSS selector for the element that contains the short description (2026 Bootstrap redesign). /// - public const string DisplayNameAnchorSelector = "a.DisplayName"; - - /// - /// CSS selector for the element that contains the short description. - /// - public const string DescriptionSelector = "span[id$='DescriptionLabel']"; + public const string DescriptionSelector = "div.mb-1.text-muted.small"; /// /// CSS selector for bold labels inside the item description cell (used to locate the "Author:" label). diff --git a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs index 4b0821443..b790e4190 100644 --- a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs +++ b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs @@ -1,8 +1,13 @@ namespace GenHub.Core.Constants; /// -/// Constants for command line arguments and URI schemes. +/// Constants for command line arguments and the genhub:// URI scheme. /// +/// +/// Subscription links use genhub://subscribe?url=<absolute-url>. +/// Today url is a hosted GenHub catalog.json. Publisher Studio will also share +/// Provider Definition URLs via the same scheme; GenHub will detect payload type at fetch time. +/// public static class CommandLineConstants { /// @@ -16,22 +21,22 @@ public static class CommandLineConstants public const string LaunchProfileInlinePrefix = "--launch-profile="; /// - /// URI scheme used for protocol handling. + /// Custom URI scheme registered so OS/browser links can open GenHub. /// public const string UriScheme = "genhub://"; /// - /// Command for subscribing to a catalog via URI. + /// URI path segment for content subscription (genhub://subscribe?url=...). /// public const string SubscribeCommand = "subscribe"; /// - /// Full prefix for subscription URI. + /// Full prefix for subscription URIs (genhub://subscribe). /// public const string SubscribeUriPrefix = UriScheme + SubscribeCommand; /// - /// Query parameter name for the catalog URL in a subscription URI. + /// Query parameter carrying the absolute URL of a catalog (or future provider definition). /// public const string SubscribeUrlParam = "?url="; } diff --git a/GenHub/GenHub.Core/Constants/CommunityOutpostCatalogConstants.cs b/GenHub/GenHub.Core/Constants/CommunityOutpostCatalogConstants.cs index 0ace072b1..aa53850e2 100644 --- a/GenHub/GenHub.Core/Constants/CommunityOutpostCatalogConstants.cs +++ b/GenHub/GenHub.Core/Constants/CommunityOutpostCatalogConstants.cs @@ -14,6 +14,9 @@ public static class CommunityOutpostCatalogConstants /// Default base URL for making relative URLs absolute. public const string DefaultBaseUrl = "https://legi.cc/patch"; + /// Default base URL for downloading GenPatcher content .dat packages. + public const string DefaultFilesBaseUrl = "https://legi.cc/gp2/f"; + /// Metadata key for the content code. public const string ContentCodeKey = "contentCode"; diff --git a/GenHub/GenHub.Core/Constants/ContentConstants.cs b/GenHub/GenHub.Core/Constants/ContentConstants.cs index 0bc3c907c..96d6a6db0 100644 --- a/GenHub/GenHub.Core/Constants/ContentConstants.cs +++ b/GenHub/GenHub.Core/Constants/ContentConstants.cs @@ -94,4 +94,31 @@ public static class ContentConstants /// Maximum allowed size for the content catalog in bytes (10 MB). /// public const long MaxCatalogSizeBytes = 10 * ConversionConstants.BytesPerMegabyte; + + /// + /// Shared resolver/display metadata key for map player counts. + /// Builtin and catalog publishers should set this so download cards can render a consistent badge. + /// + public const string PlayerCountMetadataKey = "playerCount"; + + /// + /// Shared resolver/display metadata key for content categories (AOA, Compstomp, ModDB category, etc.). + /// + public const string CategoryMetadataKey = "category"; + + /// + /// Display metadata key for a comma-separated list of included/required content names + /// (e.g. catalog ContentBundle dependencies resolved to friendly titles). + /// + public const string IncludesSummaryMetadataKey = "includesSummary"; + + /// + /// Number of recent releases and addons to eagerly preload extended details for. + /// + public const int PreloadRecentItemsLimit = 5; + + /// + /// Maximum concurrent background requests when preloading recent item details. + /// + public const int PreloadConcurrencyLimit = 3; } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/DirectoryNames.cs b/GenHub/GenHub.Core/Constants/DirectoryNames.cs index 47097cc18..19b341d80 100644 --- a/GenHub/GenHub.Core/Constants/DirectoryNames.cs +++ b/GenHub/GenHub.Core/Constants/DirectoryNames.cs @@ -59,4 +59,14 @@ public static class DirectoryNames /// Directory for storing tool workspaces. /// public const string ToolWorkspaces = "ToolWorkspaces"; + + /// + /// Directory for persistent Playwright browser profiles (cookies/storage for bot-protected sites). + /// + public const string BrowserProfiles = "BrowserProfiles"; + + /// + /// Directory for the app-owned Playwright Chromium runtime (not the system Chrome/Edge install). + /// + public const string BrowserRuntime = "BrowserRuntime"; } diff --git a/GenHub/GenHub.Core/Constants/GameContentConstants.cs b/GenHub/GenHub.Core/Constants/GameContentConstants.cs new file mode 100644 index 000000000..97f322ce6 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/GameContentConstants.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace GenHub.Core.Constants; + +/// +/// Constants for game content structure, archive payload normalization, and recognized game assets. +/// +public static class GameContentConstants +{ + /// + /// Maximum recursive wrapper directory stripping depth. + /// + public const int MaxWrapperNormalizationDepth = 10; + + /// + /// Supported archive file extensions. + /// + public static readonly IReadOnlyList ArchiveExtensions = + [ + ".zip", + ".7z", + ".rar", + ".dat", + ]; + + /// + /// Canonical directory names used at the game workspace root. + /// + public static readonly IReadOnlyList RecognizedGameDirectories = + [ + "Data", + "Art", + "Window", + "Audio", + "Maps", + "INI", + "Scripts", + "Textures", + "W3D", + "English", + "German", + "French", + "Italian", + "Spanish", + "Korean", + "Polish", + "Chinese", + ]; + + /// + /// Canonical file extensions for game assets, binaries, and configurations. + /// + public static readonly IReadOnlyList RecognizedGameFileExtensions = + [ + ".big", + ".exe", + ".dll", + ".str", + ".csf", + ".ini", + ".map", + ".bik", + ".asi", + ]; + + /// + /// Extensions for loose non-game documentation and metadata files. + /// + public static readonly IReadOnlyList DocumentationExtensions = + [ + ".txt", + ".url", + ".md", + ".htm", + ".html", + ".pdf", + ".lnk", + ".jpg", + ".jpeg", + ".png", + ".gif", + ".bmp", + ]; + + /// + /// System junk file or directory names to purge during payload normalization. + /// + public static readonly IReadOnlyList SystemJunkNames = + [ + ".ds_store", + "thumbs.db", + "desktop.ini", + "__macosx", + ]; + + /// + /// Subfolder aliases that denote Zero Hour specific game content. + /// + public static readonly IReadOnlyList ZeroHourSubfolderAliases = + [ + "Zero Hour", + "ZH", + "Command and Conquer Generals Zero Hour", + "Command & Conquer Generals - Zero Hour", + "Command & Conquer: Generals - Zero Hour", + "C&C Generals Zero Hour", + "ZeroHour", + ]; + + /// + /// Subfolder aliases that denote Generals specific game content. + /// + public static readonly IReadOnlyList GeneralsSubfolderAliases = + [ + "Generals", + "CCG", + "Command and Conquer Generals", + "Command & Conquer Generals", + "C&C Generals", + ]; + + /// + /// Determines whether the specified directory name is a recognized canonical game directory. + /// + /// The directory name to check. + /// true if recognized; otherwise, false. + public static bool IsRecognizedGameDirectory(string? directoryName) + { + return !string.IsNullOrEmpty(directoryName) && + RecognizedGameDirectories.Contains(directoryName, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Determines whether the specified file extension or file name represents a recognized game asset. + /// + /// The file name or extension to check. + /// true if recognized; otherwise, false. + public static bool IsRecognizedGameFile(string? fileNameOrExtension) + { + if (string.IsNullOrEmpty(fileNameOrExtension)) + { + return false; + } + + var ext = Path.GetExtension(fileNameOrExtension); + if (string.IsNullOrEmpty(ext)) + { + ext = fileNameOrExtension; + } + + return RecognizedGameFileExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/GenHub/GenHub.Core/Constants/GitHubTopicsConstants.cs b/GenHub/GenHub.Core/Constants/GitHubTopicsConstants.cs index 6cf607713..0e127d19a 100644 --- a/GenHub/GenHub.Core/Constants/GitHubTopicsConstants.cs +++ b/GenHub/GenHub.Core/Constants/GitHubTopicsConstants.cs @@ -129,4 +129,9 @@ public static class GitHubTopicsConstants /// Metadata key for primary language. /// public const string LanguageMetadataKey = "language"; + + /// + /// Metadata key for asset name. + /// + public const string AssetNameMetadataKey = "asset-name"; } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/ImageCacheConstants.cs b/GenHub/GenHub.Core/Constants/ImageCacheConstants.cs new file mode 100644 index 000000000..a10b32379 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/ImageCacheConstants.cs @@ -0,0 +1,37 @@ +namespace GenHub.Core.Constants; + +/// +/// Constants for image downloading, validation, and caching. +/// +public static class ImageCacheConstants +{ + /// + /// Maximum allowed image download payload in bytes (15 MB). + /// + public const long MaxImageDownloadSizeBytes = 15L * 1024 * 1024; + + /// + /// Maximum number of bitmap entries stored in the memory LRU cache. + /// + public const int MaxMemoryCacheEntries = 200; + + /// + /// Maximum disk cache size in bytes (250 MB). + /// + public const long MaxDiskCacheSizeBytes = 250L * 1024 * 1024; + + /// + /// Time-to-live for disk-cached images in days. + /// + public const int DiskCacheTtlDays = 30; + + /// + /// Default HTTP timeout in seconds for downloading images. + /// + public const int DefaultTimeoutSeconds = 30; + + /// + /// Maximum allowed HTTP redirects when downloading images. + /// + public const int MaxRedirects = 5; +} diff --git a/GenHub/GenHub.Core/Constants/InfoConstants.cs b/GenHub/GenHub.Core/Constants/InfoConstants.cs index e413a901c..8c553d497 100644 --- a/GenHub/GenHub.Core/Constants/InfoConstants.cs +++ b/GenHub/GenHub.Core/Constants/InfoConstants.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace GenHub.Core.Constants; /// @@ -17,11 +15,31 @@ public static class InfoConstants /// public const string FaqDefaultLanguage = "en"; + /// + /// Section ID for the quickstart guide. + /// + public const string QuickstartSectionId = "quickstart"; + + /// + /// Module name for the GenHub Guide. + /// + public const string ModuleGuide = "GenHub Guide"; + + /// + /// Module name for Zero Hour. + /// + public const string ModuleZeroHour = "Zero Hour"; + + /// + /// Module name for GeneralsOnline. + /// + public const string ModuleGeneralsOnline = "GeneralsOnline"; + /// /// The list of supported languages for the FAQ. /// - public static readonly IReadOnlyList SupportedFaqLanguages = new[] - { + public static readonly IReadOnlyList SupportedFaqLanguages = + [ "en", "de", "ph", "ar", - }; + ]; } diff --git a/GenHub/GenHub.Core/Constants/IpcCommands.cs b/GenHub/GenHub.Core/Constants/IpcCommands.cs index 1a66630f8..4096fd317 100644 --- a/GenHub/GenHub.Core/Constants/IpcCommands.cs +++ b/GenHub/GenHub.Core/Constants/IpcCommands.cs @@ -11,7 +11,8 @@ public static class IpcCommands public const string LaunchProfilePrefix = "launch-profile:"; /// - /// Command prefix used to subscribe to a catalog via IPC. + /// Command prefix used to forward a subscribe URL to the primary instance + /// (subscribe:<absolute-url>). Same payload as genhub://subscribe?url=.... /// public const string SubscribePrefix = "subscribe:"; } diff --git a/GenHub/GenHub.Core/Constants/ManifestConstants.cs b/GenHub/GenHub.Core/Constants/ManifestConstants.cs index bdfc551dc..11231f941 100644 --- a/GenHub/GenHub.Core/Constants/ManifestConstants.cs +++ b/GenHub/GenHub.Core/Constants/ManifestConstants.cs @@ -114,6 +114,16 @@ public static class ManifestConstants /// public const string ZeroHourManifestVersion = "1.04"; + /// + /// Type-only foundation requirement ID for Zero Hour game installations. + /// + public const string ZeroHourFoundationDependencyId = "1.104.any.gameinstallation.zerohour"; + + /// + /// Type-only foundation requirement ID for Generals game installations. + /// + public const string GeneralsFoundationDependencyId = "1.108.any.gameinstallation.generals"; + /// Tag for unknown authors. public const string UnknownAuthor = "unknown"; diff --git a/GenHub/GenHub.Core/Constants/ModDBConstants.cs b/GenHub/GenHub.Core/Constants/ModDBConstants.cs index 2deed935c..f8b9c8d3d 100644 --- a/GenHub/GenHub.Core/Constants/ModDBConstants.cs +++ b/GenHub/GenHub.Core/Constants/ModDBConstants.cs @@ -72,6 +72,12 @@ public static class ModDBConstants /// ModDB website URL. public const string PublisherWebsite = BaseUrl; + /// + /// On-disk Playwright browser profile name used to persist the Cloudflare clearance cookie so + /// the user only solves the bot challenge once per session (and across restarts until expiry). + /// + public const string BrowserProfileName = "moddb"; + /// Short description for publisher card display. public const string ShortDescription = "Community mods, maps, and content from ModDB"; @@ -184,6 +190,29 @@ public static class ModDBConstants /// Value for filter parameter when enabled. public const string FilterEnabledValue = "t"; + // ===== Sort Values ===== + + /// Sort: Date descending (newest first). + public const string SortDateDesc = "date-desc"; + + /// Sort: Date ascending (oldest first). + public const string SortDateAsc = "date-asc"; + + /// Sort: Visits / Popularity descending. + public const string SortVisitDesc = "visit-desc"; + + /// Sort: Rating descending. + public const string SortRatingDesc = "rating-desc"; + + /// Sort: Name ascending (A-Z). + public const string SortNameAsc = "name-asc"; + + /// Sort: Name descending (Z-A). + public const string SortNameDesc = "name-desc"; + + /// Default sort value for ModDB searches and listings (newest first). + public const string DefaultSort = SortDateDesc; + // ===== Category Values ===== // Downloads Section - Releases @@ -356,6 +385,34 @@ public static class ModDBConstants /// Metadata key for original category. public const string OriginalCategoryMetadataKey = "moddbCategory"; + /// Metadata key for identifying if content is a mod. + public const string IsModMetadataKey = "IsMod"; + + /// Metadata key for parent mod URL. + public const string ParentModUrlMetadataKey = "ParentModUrl"; + + // ===== Playwright / Scraping Constants ===== + + /// Default timeout for page navigation (ms). + public const int DefaultGotoTimeout = 30000; + + /// + /// Default timeout for waiting for a selector (ms). ModDB sits behind Cloudflare; the headed + /// browser persistent profile usually receives the clearance cookie after verification, but 15 s gives a safe margin + /// for manual challenge solves before the scraper parses whatever it has. + /// + public const int DefaultSelectorTimeout = 15000; + + /// + /// How long (ms) the listing scrape waits for the user to solve a Cloudflare challenge in the + /// visible browser before giving up. Long enough for a manual "I am not a robot" click; the + /// page stays open after the deadline so the user can finish and retry. + /// + public const int VerificationWaitTimeoutMs = 120000; + + /// Selector for content items in listing pages (Fallback). + public const string DefaultListItemSelector = "div.row.rowcontent, div.table tr"; + // ===== Error Messages ===== /// Error message for invalid URL. diff --git a/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs b/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs index 55ab7fa3c..4f61403a9 100644 --- a/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs +++ b/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs @@ -17,6 +17,9 @@ public static class ModDBParserConstants /// Selector for developer/publisher links. public const string DeveloperSelector = "a[href*='/members/'], a[href*='/company/']"; + /// Selector for the profile/mod info box that carries the real developer name. + public const string DeveloperProfileSelector = "#modsinfo a[href*='/members/'], #modsinfo a[href*='/company/'], .sidecolumn a[href*='/members/'], .sidecolumn a[href*='/company/']"; + /// Selector for release date. public const string ReleaseDateSelector = "time[datetime], .date, .released"; @@ -59,7 +62,7 @@ public static class ModDBParserConstants public const string FileMetadataValueSelector = "td:last-child"; /// Selector for the main download button on file pages. - public const string MainDownloadButtonSelector = "a.download, a.downloadarea, .downloadbutton a, a[href*='/downloads/start/']"; + public const string MainDownloadButtonSelector = "a.download, a.downloadarea, .downloadbutton a, a[href*='/downloads/start/'], a[href*='/addons/start/']"; /// Selector for download size on the button. public const string DownloadSizeSelector = ".download .size, .downloadbutton .size"; @@ -87,10 +90,13 @@ public static class ModDBParserConstants // ===== Description/Summary Selectors ===== /// Selector for full description content. - public const string FullDescriptionSelector = "#articlebrowse, .summary .content, .description .content, .modtext"; + public const string FullDescriptionSelector = "#downloaddescription, #downloadsummary, #articlebrowse .articlebody, .articlebody, #modsummary, .modtext, #profile .description, #description, #articlebrowse, .summary .content, .description .content"; + + /// Selector for the file-page body copy (not the breadcrumb .summary trail). + public const string FileDescriptionSelector = "#downloaddescription, #downloadsummary"; - /// Selector for truncated summary. - public const string SummarySelector = ".summary p, .description p"; + /// Selector for summary or description container. + public const string SummarySelector = ".description, .rubric, p[itemprop='description']"; // ===== Legacy File Selectors ===== @@ -98,28 +104,31 @@ public static class ModDBParserConstants public const string FilesTableSelector = "table.filelist, .table.files, #files"; /// Selector for individual file rows. - public const string FileRowSelector = "tr.file, .row.file, .file"; + public const string FileRowSelector = "tr.file, .row.file, .file, .row.rowcontent"; /// Selector for file name. - public const string FileNameSelector = "h5, h4, .name, .title"; + public const string FileNameSelector = "h4 a, h5 a, h3 a, .heading a, .title a, a.title, .name a, h5, h4, .name, .title"; /// Selector for file version. public const string FileVersionSelector = ".version, .ver"; /// Selector for file size. - public const string FileSizeSelector = ".size, .filesize"; + public const string FileSizeSelector = ".size, .filesize, .filesizes, span.size"; + + /// Selector for file subheading or metadata row. + public const string FileSubheadingSelector = ".subheading, span.subheading, .meta, .details, .info, p.summary, .summary"; /// Selector for file upload date. - public const string FileDateSelector = "time[datetime], .date, .uploaded"; + public const string FileDateSelector = "time[datetime], .date, .uploaded, time"; /// Selector for file category. - public const string FileCategorySelector = ".category, .type"; + public const string FileCategorySelector = ".category, .type, span.category"; /// Selector for file uploader. - public const string FileUploaderSelector = ".uploader, .author, a[href*='/members/']"; + public const string FileUploaderSelector = ".uploader, .author, a[href*='/members/'], a[href*='/company/']"; /// Selector for file download link (robust). - public const string FileDownloadSelector = "a.button.download, a[href*='/downloads/start/'], .download a"; + public const string FileDownloadSelector = "a.button, a.buttonlarge, a.download, a.btn, a[href*='/downloads/start/'], a[href*='/addons/start/'], a[href*='/downloads/'], a[href*='/addons/'], .download a, .actions a"; /// Selector for file MD5 hash. public const string FileMd5Selector = ".md5, .hash"; @@ -130,22 +139,41 @@ public static class ModDBParserConstants // ===== Videos Section Selectors ===== /// Selector for embedded video iframes. - public const string VideoSelector = "iframe[src*='youtube'], iframe[src*='vimeo'], iframe[src*='youtu.be']"; + public const string VideoSelector = "iframe[src*='youtube'], iframe[src*='youtube-nocookie'], iframe[src*='youtu.be'], iframe[src*='vimeo'], iframe[src*='dailymotion'], iframe[src*='moddb.com/media/iframe'], iframe[src*='moddb.com/media/embed'], iframe[src*='moddb.com/videos/iframe'], iframe[src*='moddb.com/videos/embed']"; + + /// Selector for video gallery containers and items. + public const string VideoGallerySelector = "#videobox, #videosbrowse, #mediabrowse, .mediarow, .mediabox"; + + /// Selector for video links. + public const string VideoLinkSelector = "a[href*='/videos/'], a[href*='youtube.com/watch'], a[href*='youtu.be/'], a[href*='vimeo.com/']"; /// Selector for video thumbnails. - public const string VideoThumbnailSelector = ".thumbnail img, .preview img"; + public const string VideoThumbnailSelector = ".thumbnail img, .preview img, img"; /// Selector for video titles. - public const string VideoTitleSelector = ".title, h3, h4"; + public const string VideoTitleSelector = ".title, h3, h4, h5, .caption"; + + /// Selector for recommendation and related content sections. + public const string RecommendationsSelector = "#recommendations, .recommendations, #related, .related, #similar, .similar, #fansalsoviewed, .fansalsoviewed, .youmayalso, [class*='recommend'], [id*='recommend'], [class*='similar'], [id*='similar']"; // ===== Images Section Selectors ===== /// Selector for image gallery container. - public const string ImageGallerySelector = ".mediarow, .screenshot, .imagebox, .gallery"; + public const string ImageGallerySelector = "#imagebox, #mediaimage, #imagebrowse, #mediabrowse, .mediarow"; + + /// + /// Selector for gallery images only. Deliberately excludes a blanket + /// img[src*='media.moddb.com'] match, which previously pulled game icons, member + /// avatars, and file-page chrome into the Media tab. + /// + public const string GalleryImageSelector = "#imagebox img, #mediaimage img, #imagebrowse img, #mediabrowse img, .mediarow img, .media .holder img, #downloadsummary img, #downloaddescription img, .preview img, a[href*='/mods/'][href*='/images/'] img"; /// Selector for individual images. public const string ImageSelector = "img"; + /// Sidebar/profile containers whose images are icons and avatars, not gallery media. + public const string ImageSidebarSelector = "#modsinfo, #downloadsprofilemenu, #profile, .sidecolumn, aside"; + /// Selector for image thumbnails. public const string ImageThumbnailSelector = ".thumbnail img, .thumb img"; @@ -197,20 +225,24 @@ public static class ModDBParserConstants // ===== Comments Section Selectors ===== - /// Selector for comments container. - public const string CommentsSelector = ".comment, .post, .comments"; + /// Selector for comments container. Do not use #commentform — that is the composer. + public const string CommentsSelector = "#commentsbrowse"; - /// Selector for individual comment rows. - public const string CommentRowSelector = ".comment, .post"; + /// + /// Selector for posted comment rows. Requires the exact rowcomment class so the + /// composer rows (rowcommentguest, rowcommentsummary, rowcommentemail) + /// and #commentform are not treated as comments. + /// + public const string CommentRowSelector = ".row.rowcomment, .rowcomment"; /// Selector for comment authors. - public const string CommentAuthorSelector = ".author, .username, a[href*='/members/']"; + public const string CommentAuthorSelector = ".author, .username, .heading a, a[href*='/members/']"; - /// Selector for comment content. - public const string CommentContentSelector = ".content, .body, .text"; + /// Selector for comment content. Avoids bare p which matches login chrome and CSS blobs. + public const string CommentContentSelector = ":scope > .commentbody, .commentbody, p.comment"; /// Selector for comment dates. - public const string CommentDateSelector = "time[datetime], .date"; + public const string CommentDateSelector = "time[datetime], time, .date, .datetime, span.subheading"; /// Selector for comment karma/votes. public const string CommentKarmaSelector = ".karma, .votes, .goodkarma, .badkarma"; @@ -245,4 +277,100 @@ public static class ModDBParserConstants /// Pattern for games URLs. public const string GamesUrlPattern = "/games/"; + + // ===== Mod Detail Page Selectors ===== + + /// Selector for the downloads section on mod pages. + public const string DownloadsSectionSelector = "#downloads, .downloads, .files"; + + /// Selector for the addons section on mod pages. + public const string AddonsSectionSelector = "#addons, .addons"; + + /// Selector for the tabs/navigation on mod pages. + public const string TabsSelector = ".tabs, .navigation, nav"; + + /// Selector for individual tab links. + public const string TabLinkSelector = "a[href*='/downloads'], a[href*='/addons']"; + + // ===== Metadata Keys (Internal/Normalized) ===== + + /// Metadata key for filename. + public const string MetadataFilename = "filename"; + + /// Alternative metadata key for filename. + public const string MetadataFileNameAlt = "file name"; + + /// Alternative metadata key for file. + public const string MetadataFileAlt = "file"; + + /// Metadata key for size. + public const string MetadataSize = "size"; + + /// Alternative metadata key for size. + public const string MetadataFileSizeAlt = "file size"; + + /// Metadata key for uploader. + public const string MetadataUploader = "uploader"; + + /// Alternative metadata key for uploaded by. + public const string MetadataUploadedBy = "uploaded by"; + + /// Alternative metadata key for author. + public const string MetadataAuthor = "author"; + + /// Metadata key for category. + public const string MetadataCategory = "category"; + + /// Alternative metadata key for file category. + public const string MetadataFileCategory = "file category"; + + /// Alternative metadata key for type. + public const string MetadataType = "type"; + + /// Metadata key for MD5 hash. + public const string MetadataMd5Hash = "md5 hash"; + + /// Metadata key for MD5 hash (alternative). + public const string MetadataMd5HashAlt = "md5hash"; + + /// Alternative metadata key for MD5 checksum. + public const string MetadataMd5Checksum = "md5 checksum"; + + /// Alternative metadata key for MD5. + public const string MetadataMd5 = "md5"; + + /// Alternative metadata key for hash. + public const string MetadataHash = "hash"; + + /// Alternative metadata key for checksum. + public const string MetadataChecksum = "checksum"; + + /// Metadata key for total downloads. + public const string MetadataTotalDownloads = "total downloads"; + + /// Alternative metadata key for download count. + public const string MetadataDownloadCount = "download count"; + + /// Metadata key for added date. + public const string MetadataAdded = "added"; + + /// Metadata key for updated date. + public const string MetadataUpdated = "updated"; + + // ===== Additional Selectors ===== + + /// Selector for fallback titles (h1, h2, etc). + public const string FallbackTitleSelector = "h2 a, h1 a, h2, h1"; + + /// Selector for file detail page title heading outside the global headerbox. + public const string FilePageTitleSelector = ".midcolumn h2, .columncenter h2, #downloadsfiles h2, #downloadsinfo h2, #downloads h2, .heading h2, .title h2, h2.title, .midcolumn h3, .heading h3"; + + /// Selector for file detail page preview images. + public const string FilePreviewImagesSelector = "#downloadmedia img, #downloadsmedia img, #preview img, #media img, .mediagallery img, .imagebox img, #imagebox img, #downloaddescription img, #downloadsummary img, a[href*='/images/'] img, .previewholder img, .media .holder img"; + + /// Selector for file description container elements. + public const string FileDescriptionContainerSelector = "#downloaddescription, #downloadsummary, #description, .description, .articlebody, #profiletotal"; + + /// Regex pattern for extracting parent mod path. + public const string ParentModPathRegex = @"(/mods/[^/]+)/(?:downloads|addons)/"; } diff --git a/GenHub/GenHub.Core/Constants/PublisherInfoConstants.cs b/GenHub/GenHub.Core/Constants/PublisherInfoConstants.cs index db801fead..2ccf29649 100644 --- a/GenHub/GenHub.Core/Constants/PublisherInfoConstants.cs +++ b/GenHub/GenHub.Core/Constants/PublisherInfoConstants.cs @@ -269,7 +269,67 @@ public static (string Name, string Website, string SupportUrl) GetPublisherInfo( GameInstallationType.Wine => (Wine.Name, Wine.Website, Wine.SupportUrl), GameInstallationType.CDISO => (CdIso.Name, CdIso.Website, CdIso.SupportUrl), GameInstallationType.Retail => (Retail.Name, Retail.Website, Retail.SupportUrl), + + // Unknown is the enum default for unrecognized installs and Lutris is a legitimate Linux + // install type; both fall back to Retail, matching InstallationTypeDisplayConverter. + GameInstallationType.Unknown => (Retail.Name, Retail.Website, Retail.SupportUrl), + GameInstallationType.Lutris => (Retail.Name, Retail.Website, Retail.SupportUrl), _ => (Retail.Name, Retail.Website, Retail.SupportUrl), // Default to retail }; } -} \ No newline at end of file + + /// + /// Gets the logo source URI for a publisher or content item based on publisher ID, provider name, or title. + /// + /// The publisher ID or provider display name. + /// The content ID, title, or manifest ID context. + /// An avares:// URI string pointing to the logo image asset, or null if unmapped. + public static string? GetPublisherLogo(string? publisherIdOrName, string? contentIdOrName = null) + { + var combined = $"{publisherIdOrName} {contentIdOrName}"; + if (string.IsNullOrWhiteSpace(combined)) + { + return null; + } + + if (combined.Contains("superhacker", StringComparison.OrdinalIgnoreCase)) + { + return TheSuperHackers.LogoSource; + } + + if (combined.Contains("communityoutpost", StringComparison.OrdinalIgnoreCase) || + combined.Contains("community outpost", StringComparison.OrdinalIgnoreCase)) + { + return CommunityOutpost.LogoSource; + } + + if (combined.Contains("generalsonline", StringComparison.OrdinalIgnoreCase) || + combined.Contains("generals online", StringComparison.OrdinalIgnoreCase)) + { + return GeneralsOnline.LogoSource; + } + + if (combined.Contains("moddb", StringComparison.OrdinalIgnoreCase)) + { + return ModDB.LogoSource; + } + + if (combined.Contains("cnclabs", StringComparison.OrdinalIgnoreCase) || + combined.Contains("cnc labs", StringComparison.OrdinalIgnoreCase)) + { + return CNCLabs.LogoSource; + } + + if (combined.Contains("aodmaps", StringComparison.OrdinalIgnoreCase)) + { + return AODMaps.LogoSource; + } + + if (combined.Contains("github", StringComparison.OrdinalIgnoreCase)) + { + return GitHub.LogoSource; + } + + return null; + } +} diff --git a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs index 27f2cd99a..0c82c9395 100644 --- a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs +++ b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs @@ -20,9 +20,6 @@ namespace GenHub.Core.Constants; /// public static class PublisherTypeConstants { - /// Combined view of all publishers. - public const string All = "all"; - /// Unknown or unspecified publisher. public const string Unknown = "unknown"; diff --git a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs index b15606e59..e30abc100 100644 --- a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs +++ b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs @@ -105,4 +105,7 @@ public static class SuperHackersConstants /// Delimiter used in manifest versions. public const string VersionDelimiter = "."; + + /// Default page size for discovery (10 items = 5 release cards). + public const int PageSize = 10; } diff --git a/GenHub/GenHub.Core/Constants/UiConstants.cs b/GenHub/GenHub.Core/Constants/UiConstants.cs index e3dc98279..bd61a5893 100644 --- a/GenHub/GenHub.Core/Constants/UiConstants.cs +++ b/GenHub/GenHub.Core/Constants/UiConstants.cs @@ -25,6 +25,21 @@ public static class UiConstants /// public const double DefaultProfileSettingsHeight = 700; + /// + /// Default width for the profile settings sidebar in pixels. + /// + public const double DefaultProfileSettingsSidebarWidth = 190; + + /// + /// Minimum width for the profile settings sidebar (shows icons only) in pixels. + /// + public const double MinProfileSettingsSidebarWidth = 68; + + /// + /// Maximum width for the profile settings sidebar in pixels. + /// + public const double MaxProfileSettingsSidebarWidth = 300; + // Status colors /// @@ -37,6 +52,36 @@ public static class UiConstants /// public const string StatusErrorColor = "#F44336"; + /// + /// color used for downloaded status indicator. + /// + public const string StatusDownloadedColor = "#4CAF50"; + + /// + /// color used for not downloaded status indicator. + /// + public const string StatusNotDownloadedColor = "#B388FF"; + + /// + /// color used for update available status indicator. + /// + public const string StatusUpdateAvailableColor = "#FFB74D"; + + /// + /// svg path data for transparent checkmark icon. + /// + public const string TransparentCheckmarkIconPath = "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"; + + /// + /// svg path data for detailed download arrow icon into tray. + /// + public const string DownloadArrowIconPath = "M5 20h14v-2H5v2zM19 9h-4V3H9v6H5l7 7 7-7z"; + + /// + /// svg path data for update sync icon. + /// + public const string UpdateSyncIconPath = "M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"; + /// /// Default theme color for Generals content. /// diff --git a/GenHub/GenHub.Core/Extensions/ContentAcquisitionProgressExtensions.cs b/GenHub/GenHub.Core/Extensions/ContentAcquisitionProgressExtensions.cs new file mode 100644 index 000000000..454f787de --- /dev/null +++ b/GenHub/GenHub.Core/Extensions/ContentAcquisitionProgressExtensions.cs @@ -0,0 +1,86 @@ +using GenHub.Core.Helpers; +using GenHub.Core.Models.Content; + +namespace GenHub.Core.Extensions; + +/// +/// Extension methods for ContentAcquisitionProgress. +/// +public static class ContentAcquisitionProgressExtensions +{ + /// + /// Formats a user-friendly progress status message with stage indicators. + /// + /// The progress object to format. + /// A formatted progress status string. + public static string FormatProgressStatus(this ContentAcquisitionProgress progress) + { + ArgumentNullException.ThrowIfNull(progress); + + // Use the new staged progress format if available + if (progress.TotalStages > 0 && progress.CurrentStage > 0) + { + string stagePart = $"{progress.CurrentStage}/{progress.TotalStages}"; + string description = !string.IsNullOrEmpty(progress.CurrentOperation) && + !string.Equals(progress.CurrentOperation, progress.StageDescription, StringComparison.Ordinal) + ? $"{progress.StageDescription}: {progress.CurrentOperation}" + : progress.StageDescription; + + // Add percentage for stages that have measurable progress + string percentPart = progress.StageProgress is > 0 and < 100 + ? $" ({progress.StageProgress:F0}%)" + : string.Empty; + + // Add bottleneck indicator if applicable + string bottleneckPart = progress.IsBottleneck && !string.IsNullOrEmpty(progress.BottleneckReason) + ? $" - {progress.BottleneckReason}" + : string.Empty; + + // Add file count if processing multiple files + string filesPart = progress.TotalFiles > 1 + ? $" [{progress.FilesProcessed}/{progress.TotalFiles}]" + : string.Empty; + + return $"{stagePart} - {description}{percentPart}{filesPart}{bottleneckPart}"; + } + + // Fallback to phase-based format + string phaseName = progress.Phase switch + { + ContentAcquisitionPhase.None => "Processing", + ContentAcquisitionPhase.Downloading => "Downloading", + ContentAcquisitionPhase.Extracting => "Extracting", + ContentAcquisitionPhase.Copying => "Copying", + ContentAcquisitionPhase.ValidatingManifest => "Validating manifest", + ContentAcquisitionPhase.ValidatingFiles => "Validating files", + ContentAcquisitionPhase.Delivering => "Installing", + ContentAcquisitionPhase.StoringInCas => "Storing", + ContentAcquisitionPhase.Completed => "Complete", + _ => "Processing", + }; + + if (!string.IsNullOrEmpty(progress.CurrentOperation)) + { + return $"{phaseName}: {progress.CurrentOperation}"; + } + + string percentText = progress.ProgressPercentage > 0 ? $"{progress.ProgressPercentage:F0}%" : string.Empty; + + if (progress.TotalBytes > 0 && progress.Phase == ContentAcquisitionPhase.Downloading) + { + string downloaded = ByteFormatHelper.FormatBytes(progress.BytesProcessed); + string total = ByteFormatHelper.FormatBytes(progress.TotalBytes); + return $"{phaseName}: {downloaded} / {total} ({percentText})"; + } + + if (progress.TotalFiles > 0) + { + int phasePercent = progress.TotalFiles > 0 + ? (int)((double)progress.FilesProcessed / progress.TotalFiles * 100) + : 0; + return $"{phaseName}: {progress.FilesProcessed}/{progress.TotalFiles} files ({phasePercent}%)"; + } + + return !string.IsNullOrEmpty(percentText) ? $"{phaseName}... {percentText}" : $"{phaseName}..."; + } +} diff --git a/GenHub/GenHub.Core/Extensions/ContentTypeExtensions.cs b/GenHub/GenHub.Core/Extensions/ContentTypeExtensions.cs index 78f686e01..e16fd1cb8 100644 --- a/GenHub/GenHub.Core/Extensions/ContentTypeExtensions.cs +++ b/GenHub/GenHub.Core/Extensions/ContentTypeExtensions.cs @@ -30,6 +30,11 @@ public static string GetDisplayName(this ContentType contentType) ContentType.ContentReferral => "Content Referral", ContentType.ModdingTool => "Tool", ContentType.Executable => "Executable", + ContentType.Skin => "Skin", + ContentType.Video => "Video", + ContentType.Replay => "Replay", + ContentType.Screensaver => "Screensaver", + ContentType.UnknownContentType => "Unknown", _ => contentType.ToString(), }; } @@ -56,6 +61,10 @@ public static string ToManifestIdString(this ContentType contentType) ContentType.ContentReferral => "contentreferral", ContentType.Mission => "mission", ContentType.Map => "map", + ContentType.Skin => "skin", + ContentType.Video => "video", + ContentType.Replay => "replay", + ContentType.Screensaver => "screensaver", ContentType.ModdingTool => "moddingtool", ContentType.Executable => "executable", ContentType.UnknownContentType => "unknown", diff --git a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs index f6b570af0..004c1bf11 100644 --- a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs +++ b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs @@ -15,9 +15,9 @@ public static class CommandLineParser /// The extracted profile identifier if present; otherwise, null. public static string? ExtractProfileId(string[] args) { - for (var i = 0; i < args.Length; i++) + for (int i = 0; i < args.Length; i++) { - var arg = args[i]; + string arg = args[i]; if (arg.Equals(CommandLineConstants.LaunchProfileArg, StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) { @@ -34,22 +34,25 @@ public static class CommandLineParser } /// - /// Extracts a subscription URL from command line arguments. - /// Supports the URI scheme format: genhub://subscribe?url=<url>. + /// Extracts the absolute URL from a genhub://subscribe?url=... startup argument. /// + /// + /// The returned value is the url query value only (not the genhub:// wrapper). + /// Callers treat it as a GenHub catalog JSON URL today; later it may also be a Provider + /// Definition URL without changing this parser. + /// /// The command line arguments. - /// The extracted catalog URL if present; otherwise, null. + /// The decoded absolute URL if present; otherwise, null. public static string? ExtractSubscriptionUrl(string[] args) { - foreach (var arg in args) + foreach (string arg in args) { if (arg.StartsWith(CommandLineConstants.SubscribeUriPrefix, StringComparison.OrdinalIgnoreCase)) { - // Simple parsing for ?url=... - var queryStart = arg.IndexOf(CommandLineConstants.SubscribeUrlParam, StringComparison.OrdinalIgnoreCase); + int queryStart = arg.IndexOf(CommandLineConstants.SubscribeUrlParam, StringComparison.OrdinalIgnoreCase); if (queryStart != -1) { - var url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..]; + string url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..]; return Uri.UnescapeDataString(url).Trim('"'); } } diff --git a/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs b/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs new file mode 100644 index 000000000..aeaeb7e8a --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs @@ -0,0 +1,107 @@ +using System; +using System.IO; +using GenHub.Core.Models.Results; + +namespace GenHub.Core.Helpers; + +/// +/// Policy and validation helper for ensuring file system paths remain safely contained +/// within a target root directory, preventing directory traversal and zip slip attacks across OS platforms. +/// +public static class ContentPathPolicy +{ + /// + /// Resolves a candidate relative path within a designated root directory, ensuring that the + /// resolved canonical path is strictly contained within that root directory. + /// + /// The trusted root directory. + /// The relative path to validate and resolve. + /// + /// An containing the normalized absolute destination path if safe, + /// or a failure result if the path escapes the root directory or contains illegal rooted/traversal components. + /// + public static OperationResult ResolveContainedFile(string? rootDirectory, string? relativePath) + { + if (string.IsNullOrWhiteSpace(rootDirectory)) + { + return OperationResult.CreateFailure("Root directory cannot be null or empty."); + } + + if (string.IsNullOrWhiteSpace(relativePath)) + { + return OperationResult.CreateFailure("Relative path cannot be null or empty."); + } + + if (Path.IsPathRooted(relativePath) || + (relativePath.Length >= 2 && relativePath[1] == ':' && char.IsLetter(relativePath[0])) || + relativePath.StartsWith("\\\\", StringComparison.Ordinal) || + relativePath.StartsWith("//", StringComparison.Ordinal)) + { + return OperationResult.CreateFailure($"Relative path cannot be rooted or absolute: {relativePath}"); + } + + // Normalize directory separators + var normalizedRelative = relativePath.Replace('/', Path.DirectorySeparatorChar) + .Replace('\\', Path.DirectorySeparatorChar) + .TrimStart(Path.DirectorySeparatorChar); + + if (string.IsNullOrWhiteSpace(normalizedRelative)) + { + return OperationResult.CreateFailure("Normalized relative path cannot be empty."); + } + + var normalizedRoot = rootDirectory.Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + var fullRoot = Path.GetFullPath(normalizedRoot); + var fullCandidate = Path.GetFullPath(Path.Combine(fullRoot, normalizedRelative)); + + var rootPrefix = fullRoot.EndsWith(Path.DirectorySeparatorChar) + ? fullRoot + : fullRoot + Path.DirectorySeparatorChar; + + if (!fullCandidate.StartsWith(rootPrefix, PathHelper.PathComparison) && + !fullCandidate.Equals(fullRoot, PathHelper.PathComparison)) + { + return OperationResult.CreateFailure( + $"Path '{relativePath}' escapes target root directory '{rootDirectory}'."); + } + + return OperationResult.CreateSuccess(fullCandidate); + } + + /// + /// Validates whether a candidate path is strictly contained within a designated root directory. + /// + /// The root directory. + /// The candidate path to check. + /// if the candidate path is contained within the root; otherwise . + public static bool IsContained(string? rootDirectory, string? candidatePath) + { + if (string.IsNullOrWhiteSpace(rootDirectory) || string.IsNullOrWhiteSpace(candidatePath)) + { + return false; + } + + try + { + var normalizedRoot = rootDirectory.Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + var normalizedCandidate = candidatePath.Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + + var fullRoot = Path.GetFullPath(normalizedRoot); + var fullCandidate = Path.GetFullPath(normalizedCandidate); + + var rootPrefix = fullRoot.EndsWith(Path.DirectorySeparatorChar) + ? fullRoot + : fullRoot + Path.DirectorySeparatorChar; + + return fullCandidate.StartsWith(rootPrefix, PathHelper.PathComparison) || + fullCandidate.Equals(fullRoot, PathHelper.PathComparison); + } + catch + { + return false; + } + } +} diff --git a/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs b/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs new file mode 100644 index 000000000..50c89cc82 --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs @@ -0,0 +1,135 @@ +using System; +using System.Net; +using System.Text.RegularExpressions; + +namespace GenHub.Core.Helpers; + +/// +/// Provides high-performance utilities for stripping HTML tags, decoding HTML entities, +/// and normalizing text descriptions for display across the application. +/// +public static partial class HtmlTextHelper +{ + /// + /// Converts an HTML snippet or formatted description into clean, normalized plain text: + /// - Replaces <br> and block element closures (</p>, </div>, etc.) with line breaks. + /// - Strips all remaining HTML tags. + /// - Decodes HTML entities (e.g., &amp;, &quot;, &gt;, &nbsp;). + /// - Normalizes whitespace and excessive blank lines. + /// - Uses the platform newline format. + /// + /// The raw HTML or formatted text string to normalize. + /// Normalized plain text, or empty string if input is null or whitespace. + public static string NormalizeHtml(string? html) + { + if (string.IsNullOrWhiteSpace(html)) + { + return string.Empty; + } + + // 1. Convert
tags to newline + var text = BrTagRegex().Replace(html, "\n"); + + // 2. Convert paragraph closing tags to double newline for paragraph separation + text = ParagraphCloseTagRegex().Replace(text, "\n\n"); + + // 3. Convert other block-level closing tags and
tags to newline + text = BlockCloseTagRegex().Replace(text, "\n"); + + // 4. Strip all remaining HTML/XML tags + text = HtmlTagRegex().Replace(text, string.Empty); + + // 5. Decode HTML entities ( , >, ", ', numeric entities, etc.) + text = WebUtility.HtmlDecode(text); + + // 6. Normalize non-breaking spaces and line endings + text = text.Replace('\u00A0', ' ') + .Replace("\r\n", "\n") + .Replace('\r', '\n'); + + // 7. Clean trailing whitespace on lines and collapse excess blank lines + text = TrailingWhitespaceBeforeNewlineRegex().Replace(text, "\n"); + text = ExcessBlankLinesRegex().Replace(text, "\n\n"); + + // 8. Trim and unify with environment newline + text = text.Trim(); + text = text.Replace("\n", Environment.NewLine); + + return text; + } + + /// + /// Converts an HTML snippet or multi-line text into a single-line summary without HTML tags, + /// collapsing all whitespace runs into a single space, and optionally truncating with an ellipsis. + /// + /// The input HTML or text string. + /// Optional maximum character length including ellipsis. + /// A single-line plain text summary. + public static string CleanToSingleLine(string? htmlOrText, int? maxLength = null) + { + if (string.IsNullOrWhiteSpace(htmlOrText)) + { + return string.Empty; + } + + // Strip HTML if tags exist, decode entities, and normalize + var text = NormalizeHtml(htmlOrText); + + // Collapse all newlines, tabs, and multiple spaces into a single space + text = MultiWhitespaceRegex().Replace(text, " ").Trim(); + + if (maxLength.HasValue && maxLength.Value > 0 && text.Length > maxLength.Value) + { + return TruncateWithEllipsis(text, maxLength.Value); + } + + return text; + } + + /// + /// Truncates a string to a specified maximum length and appends an ellipsis ("...") if truncated. + /// + /// The text to truncate. + /// The maximum allowed length (including the ellipsis). + /// The truncated text with an ellipsis if it exceeded maxLength, or the original text. + public static string TruncateWithEllipsis(string? text, int maxLength) + { + if (string.IsNullOrWhiteSpace(text) || maxLength <= 0) + { + return string.Empty; + } + + if (text.Length <= maxLength) + { + return text; + } + + if (maxLength <= 3) + { + return text[..maxLength]; + } + + return string.Concat(text.AsSpan(0, maxLength - 3), "..."); + } + + [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex BrTagRegex(); + + [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ParagraphCloseTagRegex(); + + [GeneratedRegex(@"]*>", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex BlockCloseTagRegex(); + + [GeneratedRegex(@"<[^>]+>", RegexOptions.CultureInvariant)] + private static partial Regex HtmlTagRegex(); + + [GeneratedRegex(@"[ \t]+\n", RegexOptions.CultureInvariant)] + private static partial Regex TrailingWhitespaceBeforeNewlineRegex(); + + [GeneratedRegex(@"(?:\n){3,}", RegexOptions.CultureInvariant)] + private static partial Regex ExcessBlankLinesRegex(); + + [GeneratedRegex(@"\s+", RegexOptions.CultureInvariant)] + private static partial Regex MultiWhitespaceRegex(); +} diff --git a/GenHub/GenHub.Core/Interfaces/Content/IArchivePayloadProcessor.cs b/GenHub/GenHub.Core/Interfaces/Content/IArchivePayloadProcessor.cs new file mode 100644 index 000000000..d2ae98e53 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Content/IArchivePayloadProcessor.cs @@ -0,0 +1,48 @@ +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.Enums; + +namespace GenHub.Core.Interfaces.Content; + +/// +/// Service for safely extracting archives and normalizing payload directory structures for game workspaces. +/// +public interface IArchivePayloadProcessor +{ + /// + /// Extracts all archives located within the directory safely, recursively removing archive files after extraction. + /// + /// The directory containing extracted or downloaded content. + /// Cancellation token. + /// A task representing the asynchronous extraction operation. + Task ExtractArchivesSafelyAsync(string extractedDirectory, CancellationToken cancellationToken = default); + + /// + /// Normalizes the directory structure of an extracted payload, removing extraneous wrapper directories + /// and reconciling the content root with the workspace/target directory. + /// + /// The directory containing extracted files. + /// The content type (e.g. Mod, Map, GameClient, etc.). + /// The target game type (Generals or ZeroHour). + /// Cancellation token. + /// A task representing the asynchronous normalization operation. + Task NormalizeDirectoryStructureAsync( + string extractedDirectory, + ContentType contentType, + GameType targetGame, + CancellationToken cancellationToken = default); + + /// + /// Extracts archives safely and normalizes the payload directory structure in one coordinated operation. + /// + /// The directory containing extracted or downloaded content. + /// The content type (e.g. Mod, Map, GameClient, etc.). + /// The target game type (Generals or ZeroHour). + /// Cancellation token. + /// A task representing the asynchronous processing operation. + Task ProcessPayloadAsync( + string extractedDirectory, + ContentType contentType, + GameType targetGame, + CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Content/IContentDownloadCoordinator.cs b/GenHub/GenHub.Core/Interfaces/Content/IContentDownloadCoordinator.cs new file mode 100644 index 000000000..2c3631a63 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Content/IContentDownloadCoordinator.cs @@ -0,0 +1,24 @@ +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Results.Content; + +namespace GenHub.Core.Interfaces.Content; + +/// +/// Orchestrates the high-level download flow, including acquisition, state updates, and notifications. +/// +public interface IContentDownloadCoordinator +{ + /// + /// Downloads content, updates state, and shows notifications. + /// + /// The content search result to download. + /// Progress reporter. + /// Cancellation token. + /// The acquired manifest if successful. + Task> DownloadContentAsync( + ContentSearchResult searchResult, + IProgress? progress = null, + CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Content/IContentStateService.cs b/GenHub/GenHub.Core/Interfaces/Content/IContentStateService.cs new file mode 100644 index 000000000..b9707465e --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Content/IContentStateService.cs @@ -0,0 +1,68 @@ +namespace GenHub.Core.Interfaces.Content; + +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Results.Content; + +/// +/// Service to determine the current state of content for UI display. +/// +public interface IContentStateService +{ + /// + /// Event raised when content state changes (downloaded, updated, or removed). + /// + event EventHandler? ContentStateChanged; + + /// + /// Notifies subscribers that content state has changed. + /// + /// The ID of the content that changed. + /// The new state of the content. + /// The manifest ID if available. + void NotifyStateChanged(string contentId, ContentState newState, string? manifestId = null); + + /// + /// Gets the state for a content search result. + /// + /// The content search result from discovery. + /// Cancellation token. + /// The current state of the content. + Task GetStateAsync(ContentSearchResult item, CancellationToken cancellationToken = default); + + /// + /// Gets the state by generating a prospective manifest ID from components. + /// + /// Publisher identifier. + /// Content type. + /// Content name. + /// Release date (used as version). + /// Cancellation token. + /// The current state of the content. + Task GetStateAsync( + string publisher, + ContentType contentType, + string contentName, + DateTime releaseDate, + CancellationToken cancellationToken = default); + + /// + /// Gets the local manifest ID if content is downloaded. + /// + /// The content search result. + /// Cancellation token. + /// The local manifest ID if downloaded, null otherwise. + Task GetLocalManifestIdAsync(ContentSearchResult item, CancellationToken cancellationToken = default); + + /// + /// Gets the state for a specific manifest ID, without requiring a full + /// . Useful for per-variant state lookups where + /// only the manifest identity is known. + /// + /// The manifest ID to check. + /// Cancellation token. + /// + /// if the manifest is in the pool; + /// otherwise. + /// + Task GetStateByManifestIdAsync(string manifestId, CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Content/IControlBarPackageProcessor.cs b/GenHub/GenHub.Core/Interfaces/Content/IControlBarPackageProcessor.cs new file mode 100644 index 000000000..1b416b594 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Content/IControlBarPackageProcessor.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.Manifest; + +namespace GenHub.Core.Interfaces.Content; + +/// +/// Service for detecting, isolating, converting, and packaging Control Bar content into SAGE-compatible .big archives. +/// +public interface IControlBarPackageProcessor +{ + /// + /// Checks whether the extracted directory or manifest represents a Control Bar mod or UI addon that needs repacking. + /// + /// The directory containing extracted files. + /// The content manifest. + /// True if the content is a Control Bar that requires processing. + bool IsControlBarContent(string extractedDirectory, ContentManifest manifest); + + /// + /// Processes extracted Control Bar content: isolates the requested resolution variant, converts AVIF/WebP textures to TGA, + /// repacks Art/Data folders into .big archives, ensures metadata BIG is present, and cleans up raw sources. + /// + /// The directory containing extracted files. + /// The content manifest. + /// Optional explicit variant identifier (e.g. "1080p"). + /// Cancellation token. + /// A list of generated or included .big file names. + Task> ProcessAndRepackControlBarAsync( + string extractedDirectory, + ContentManifest manifest, + string? requestedVariant = null, + CancellationToken cancellationToken = default); + + /// + /// Finds the variant BIG root directory within extracted content. + /// + /// The extracted root directory. + /// The variant identifier (e.g. "1080p"). + /// The path to the variant root directory, or null if not found. + string? FindControlBarVariantBigRoot(string extractedDirectory, string variantId); + + /// + /// Gets the normalized suffix for a variant identifier (e.g. "1080p" -> "1080"). + /// + /// The variant identifier. + /// The normalized variant suffix. + string GetControlBarVariantSuffix(string variantId); + + /// + /// Checks if a file is an allowed Control Bar .big archive for the given variant suffix. + /// + /// The file name. + /// The variant suffix. + /// True if the file is allowed. + bool IsAllowedControlBarBig(string fileName, string variantSuffix); +} diff --git a/GenHub/GenHub.Core/Interfaces/Content/ITabProvider.cs b/GenHub/GenHub.Core/Interfaces/Content/ITabProvider.cs new file mode 100644 index 000000000..29bc73e63 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Content/ITabProvider.cs @@ -0,0 +1,34 @@ +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Results.Content; + +namespace GenHub.Core.Interfaces.Content; + +/// +/// Defines dynamic custom tab extensions for the downloads browser content detail view. +/// When users inspect a game mod, map, or patch in the downloads browser detail page, publishers can add extra custom tabs (such as documentation, changelogs, sub-addons, or external links) defined in their catalog json. +/// +public interface ITabProvider +{ + /// + /// Gets the unique identifier for this tab provider instance (e.g. catalog-tabs). + /// Provider ids uniquely identify tab sources in the tab provider registry. + /// + string ProviderId { get; } + + /// + /// Evaluates whether this tab provider can supply custom tabs for a specific content item selected in the downloads browser. + /// + /// The content item search result being viewed in the downloads section. + /// True if this provider can build custom tabs for the specified downloads content item; otherwise false. + bool CanProvideTabsFor(ContentSearchResult searchResult); + + /// + /// Retrieves custom tab definitions to render as navigation tabs in the downloads browser detail view. + /// + /// The downloads content item to retrieve custom tabs for. + /// Cancellation token for asynchronous catalog fetching operations. + /// Read-only list of custom tab definitions to populate in the downloads detail view model. + Task> GetTabsAsync( + ContentSearchResult searchResult, + CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Content/ITabProviderRegistry.cs b/GenHub/GenHub.Core/Interfaces/Content/ITabProviderRegistry.cs new file mode 100644 index 000000000..4b9ef5b14 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Content/ITabProviderRegistry.cs @@ -0,0 +1,39 @@ +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Results.Content; + +namespace GenHub.Core.Interfaces.Content; + +/// +/// Registry for managing custom tab providers. +/// +public interface ITabProviderRegistry +{ + /// + /// Registers a tab provider. + /// + /// The provider to register. + void RegisterProvider(ITabProvider provider); + + /// + /// Unregisters a tab provider. + /// + /// The id of the provider to unregister. + /// True if the provider was found and removed. + bool UnregisterProvider(string providerId); + + /// + /// Gets all registered tab providers. + /// + /// Read-only list of all registered providers. + IReadOnlyList GetAllProviders(); + + /// + /// Gets all custom tabs for the given content from all registered providers. + /// + /// The content to get tabs for. + /// Cancellation token. + /// Read-only list of custom tab definitions, sorted by order. + Task> GetTabsForContentAsync( + ContentSearchResult searchResult, + CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/GameProfiles/IProfileContentService.cs b/GenHub/GenHub.Core/Interfaces/GameProfiles/IProfileContentService.cs index 9468c92c2..cd051142d 100644 --- a/GenHub/GenHub.Core/Interfaces/GameProfiles/IProfileContentService.cs +++ b/GenHub/GenHub.Core/Interfaces/GameProfiles/IProfileContentService.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using GenHub.Core.Models.GameProfile; using GenHub.Core.Models.Results; @@ -21,6 +22,18 @@ Task AddContentToProfileAsync( string manifestId, CancellationToken cancellationToken = default); + /// + /// Adds multiple acquired content items to a profile, resolving the combined dependency graph. + /// + /// The profile ID to add content to. + /// The manifest IDs of the content to add. + /// A cancellation token. + /// An operation result indicating success with details about any swapped content. + Task AddContentToProfileAsync( + string profileId, + IReadOnlyList manifestIds, + CancellationToken cancellationToken = default); + /// /// Checks for content conflicts without making changes. /// @@ -45,6 +58,18 @@ Task> CreateProfileWithContentAsync( string manifestId, CancellationToken cancellationToken = default); + /// + /// Creates a new profile with multiple acquired content items pre-enabled. + /// + /// Name for the new profile. + /// The manifest IDs of the content to enable. + /// A cancellation token. + /// An operation result containing the created profile. + Task> CreateProfileWithContentAsync( + string profileName, + IReadOnlyList manifestIds, + CancellationToken cancellationToken = default); + /// /// Validates a profile's enabled content for conflicts. /// diff --git a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs index e707f019e..b9c11c3d6 100644 --- a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs +++ b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs @@ -109,6 +109,7 @@ public interface IContentManifestBuilder /// List of compatible versions. /// Whether the dependency is exclusive. /// List of conflicting dependency IDs. + /// List of compatible game types. /// The builder instance for chaining. IContentManifestBuilder AddDependency( ManifestId id, @@ -119,7 +120,8 @@ IContentManifestBuilder AddDependency( string maxVersion = "", List? compatibleVersions = null, bool isExclusive = false, - List? conflictsWith = null); + List? conflictsWith = null, + List? compatibleGameTypes = null); /// /// Scans a directory and adds files with the specified source type. @@ -252,6 +254,27 @@ IContentManifestBuilder AddContentReference( /// The builder instance for chaining. IContentManifestBuilder AddPatchFile(string targetRelativePath, string patchSourceFile); + /// + /// Sets the relative path of the main launch executable for this manifest. + /// + /// The relative path of the entry point file. + /// The builder instance for chaining. + IContentManifestBuilder WithEntryPoint(string entryPoint); + + /// + /// Explicitly sets the manifest ID, bypassing automatic generation. + /// + /// The manifest identifier. + /// The builder instance for chaining. + IContentManifestBuilder WithId(ManifestId id); + + /// + /// Sets the human-readable display name for this manifest. + /// + /// The display name. + /// The builder instance for chaining. + IContentManifestBuilder WithName(string name); + /// /// Builds the final ContentManifest. /// diff --git a/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs b/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs index 136018297..d65f01716 100644 --- a/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs +++ b/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs @@ -1,3 +1,8 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using GenHub.Core.Models.Parsers; namespace GenHub.Core.Interfaces.Parsers; @@ -36,4 +41,44 @@ public interface IWebPageParser /// Cancellation token. /// A parsed web page with all extracted content sections. Task ParseAsync(string url, string html, CancellationToken cancellationToken = default); + + /// + /// Parses a specific file or item detail page. + /// Default implementation delegates to . + /// + /// The detail page URL. + /// Cancellation token. + /// A parsed web page containing the detailed file information. + Task ParseFileDetailAsync(string url, CancellationToken cancellationToken = default) + => ParseAsync(url, cancellationToken); + + /// + /// Parses multiple file or item detail pages in a batch. + /// Default implementation delegates to . + /// + /// The detail page URLs to parse. + /// Cancellation token. + /// A dictionary mapping each URL to its parsed web page result. + async Task> ParseFileDetailsManyAsync( + IReadOnlyList urls, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(urls); + var results = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var url in urls.Distinct(StringComparer.OrdinalIgnoreCase)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var page = await ParseFileDetailAsync(url, cancellationToken); + results[url] = page; + } + catch (Exception) + { + // soft failure per url in batch + } + } + + return results; + } } diff --git a/GenHub/GenHub.Core/Interfaces/Providers/IPublisherCatalogParser.cs b/GenHub/GenHub.Core/Interfaces/Providers/IPublisherCatalogParser.cs new file mode 100644 index 000000000..0c94b9dcc --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Providers/IPublisherCatalogParser.cs @@ -0,0 +1,33 @@ +using GenHub.Core.Models.Providers; +using GenHub.Core.Models.Results; + +namespace GenHub.Core.Interfaces.Providers; + +/// +/// Parses publisher catalog JSON into structured models. +/// +public interface IPublisherCatalogParser +{ + /// + /// Parses a catalog from JSON content. + /// + /// The raw JSON content of the catalog. + /// Cancellation token. + /// The parsed catalog or an error. + Task> ParseCatalogAsync(string catalogJson, CancellationToken cancellationToken = default); + + /// + /// Validates that a catalog conforms to the expected schema version. + /// + /// The catalog to validate. + /// Validation result with any errors. + OperationResult ValidateCatalog(PublisherCatalog catalog); + + /// + /// Verifies the catalog signature if present. + /// + /// The raw JSON content. + /// The parsed catalog with signature field. + /// True if signature is valid or not required. + bool VerifySignature(string catalogJson, PublisherCatalog catalog); +} diff --git a/GenHub/GenHub.Core/Interfaces/Providers/IPublisherCatalogRefreshService.cs b/GenHub/GenHub.Core/Interfaces/Providers/IPublisherCatalogRefreshService.cs new file mode 100644 index 000000000..83bc839eb --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Providers/IPublisherCatalogRefreshService.cs @@ -0,0 +1,24 @@ +using GenHub.Core.Models.Results; + +namespace GenHub.Core.Interfaces.Providers; + +/// +/// Service for refreshing subscribed publisher catalogs. +/// +public interface IPublisherCatalogRefreshService +{ + /// + /// Refreshes all subscribed catalogs. + /// + /// Cancellation token. + /// Summary of the refresh operation. + Task> RefreshAllAsync(CancellationToken cancellationToken = default); + + /// + /// Refreshes a specific publisher's catalog. + /// + /// The publisher identifier. + /// Cancellation token. + /// True if refreshed, false otherwise. + Task> RefreshPublisherAsync(string publisherId, CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Providers/IPublisherSubscriptionStore.cs b/GenHub/GenHub.Core/Interfaces/Providers/IPublisherSubscriptionStore.cs new file mode 100644 index 000000000..9d04d62e3 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Providers/IPublisherSubscriptionStore.cs @@ -0,0 +1,69 @@ +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Providers; +using GenHub.Core.Models.Results; + +namespace GenHub.Core.Interfaces.Providers; + +/// +/// Persists user subscriptions to creator catalogs (and later provider definitions). +/// Subscriptions enable modular discovery: any GenHub-schema catalog can appear in Downloads +/// via the generic catalog pipeline without shipping a custom discoverer. +/// +public interface IPublisherSubscriptionStore +{ + /// + /// Gets all active publisher subscriptions. + /// + /// Cancellation token. + /// List of active subscriptions. + Task>> GetSubscriptionsAsync(CancellationToken cancellationToken = default); + + /// + /// Gets a specific subscription by publisher ID. + /// + /// The publisher identifier. + /// Cancellation token. + /// The subscription if found, null otherwise. + Task> GetSubscriptionAsync(string publisherId, CancellationToken cancellationToken = default); + + /// + /// Adds a new publisher subscription. + /// + /// The subscription to add. + /// Cancellation token. + /// Operation result indicating success or failure. + Task> AddSubscriptionAsync(PublisherSubscription subscription, CancellationToken cancellationToken = default); + + /// + /// Removes a publisher subscription. + /// + /// The publisher identifier to remove. + /// Cancellation token. + /// Operation result indicating success or failure. + Task> RemoveSubscriptionAsync(string publisherId, CancellationToken cancellationToken = default); + + /// + /// Updates an existing subscription. + /// + /// The updated subscription data. + /// Cancellation token. + /// Operation result indicating success or failure. + Task> UpdateSubscriptionAsync(PublisherSubscription subscription, CancellationToken cancellationToken = default); + + /// + /// Checks if a publisher subscription exists. + /// + /// The publisher identifier. + /// Cancellation token. + /// True if subscribed, false otherwise. + Task> IsSubscribedAsync(string publisherId, CancellationToken cancellationToken = default); + + /// + /// Updates the trust level for a publisher. + /// + /// The publisher identifier. + /// The new trust level. + /// Cancellation token. + /// Operation result indicating success or failure. + Task> UpdateTrustLevelAsync(string publisherId, TrustLevel trustLevel, CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Providers/IVersionSelector.cs b/GenHub/GenHub.Core/Interfaces/Providers/IVersionSelector.cs new file mode 100644 index 000000000..a7100b52e --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Providers/IVersionSelector.cs @@ -0,0 +1,31 @@ +using GenHub.Core.Models.Providers; + +namespace GenHub.Core.Interfaces.Providers; + +/// +/// Filters content releases based on version display policy. +/// +public interface IVersionSelector +{ + /// + /// Selects releases based on the specified policy. + /// + /// All available releases. + /// The version selection policy. + /// Filtered releases according to policy. + IEnumerable SelectReleases(IEnumerable releases, VersionPolicy policy); + + /// + /// Gets the latest stable release from a collection. + /// + /// All available releases. + /// The latest stable release, or null if none exist. + ContentRelease? GetLatestStable(IEnumerable releases); + + /// + /// Gets the latest release (including prereleases) from a collection. + /// + /// All available releases. + /// The latest release, or null if none exist. + ContentRelease? GetLatest(IEnumerable releases); +} diff --git a/GenHub/GenHub.Core/Interfaces/Providers/VersionPolicy.cs b/GenHub/GenHub.Core/Interfaces/Providers/VersionPolicy.cs new file mode 100644 index 000000000..bd8b7f8d1 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Providers/VersionPolicy.cs @@ -0,0 +1,22 @@ +namespace GenHub.Core.Interfaces.Providers; + +/// +/// Defines the version filtering policy for content display. +/// +public enum VersionPolicy +{ + /// + /// Show only the latest stable release (default). + /// + LatestStableOnly, + + /// + /// Show all versions including older releases. + /// + AllVersions, + + /// + /// Include prerelease versions in addition to stable releases. + /// + IncludePrereleases, +} diff --git a/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs b/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs index f2007576a..df73f9827 100644 --- a/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs +++ b/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs @@ -6,6 +6,7 @@ using GenHub.Core.Models.Results; using Microsoft.Playwright; using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -25,6 +26,31 @@ public interface IPlaywrightService /// A new IPage instance. Task CreatePageAsync(BrowserNewContextOptions? options = null, CancellationToken cancellationToken = default); + /// + /// Creates a page in a persistent, headed browser context whose cookies and storage survive + /// across calls. Use this for bot-protected sites (e.g. ModDB's Cloudflare): the user solves + /// the challenge once, the resulting clearance cookie is persisted to disk, and subsequent + /// pages in the same session (and across app restarts, until the cookie expires) load without + /// another challenge. A real browser window is shown while the challenge is pending. + /// + /// The on-disk profile name (scoped under the app data browser-profile root). + /// Cancellation token. + /// A new in the persistent context. + Task CreatePersistentPageAsync(string profileName, CancellationToken cancellationToken = default); + + /// + /// Closes a page from and shuts down the headed Chromium + /// window when no active pages remain. Prefer this over page.CloseAsync alone so + /// callers do not leave an about:blank window open after a successful ModDB scrape. + /// + /// The persistent-context page to close. + /// + /// When , leaves the page open (e.g. so the user can finish a Cloudflare + /// challenge) without closing the browser. + /// + /// A task representing the asynchronous operation. + Task ClosePersistentPageAsync(IPage page, bool keepOpen = false); + /// /// Fetches HTML content from a URL using Playwright. /// @@ -41,6 +67,35 @@ public interface IPlaywrightService /// A parsed AngleSharp IDocument. Task FetchAndParseAsync(string url, CancellationToken cancellationToken = default); + /// + /// Fetches and parses a web page in a persistent, headed browser context whose cookies survive + /// across calls. Use this for bot-protected URLs (e.g. ModDB) so the Cloudflare clearance cookie + /// obtained from a single manual challenge solve is reused. + /// + /// The on-disk profile name (scoped under the app data browser-profile root). + /// The URL to fetch and parse. + /// Cancellation token. + /// A parsed AngleSharp IDocument. + Task FetchAndParsePersistentAsync(string profileName, string url, CancellationToken cancellationToken = default); + + /// + /// Fetches and parses multiple URLs in one persistent headed page — open once, navigate each + /// URL in order, then close. Use this for ModDB section sweeps so Chromium does not spawn a + /// new window per section (and so concurrent NewPage/Close races cannot tear down the context + /// mid-navigation). + /// + /// The on-disk profile name (scoped under the app data browser-profile root). + /// URLs to fetch in order. Duplicates are fetched once; order of first occurrence is kept. + /// Cancellation token. + /// + /// A map of URL → parsed document for every URL that loaded successfully. Failed URLs are omitted; + /// callers should treat a missing key as a soft failure for that section. + /// + Task> FetchAndParsePersistentManyAsync( + string profileName, + IReadOnlyList urls, + CancellationToken cancellationToken = default); + /// /// Downloads a file using Playwright to handle complex scenarios (like anti-bot protections). /// @@ -48,4 +103,27 @@ public interface IPlaywrightService /// Cancellation token. /// A DownloadResult indicating success or failure. Task DownloadFileAsync(DownloadConfiguration configuration, CancellationToken cancellationToken = default); + + /// + /// Executes an operation within a scoped persistent browser context session. + /// The persistent browser window stays open for the duration of the operation and closes + /// immediately when the operation completes, avoiding multiple window launches and idle delays. + /// + /// The return type of the operation. + /// The on-disk profile name. + /// The asynchronous operation to execute. + /// Cancellation token. + /// The result of the operation. + Task ExecuteInPersistentContextAsync( + string profileName, + Func> operation, + CancellationToken cancellationToken = default); + + /// + /// Asynchronously pre-warms the Playwright driver runtime in the background so subsequent + /// browser operations launch with minimal latency. + /// + /// Cancellation token. + /// A task representing the background warmup operation. + Task WarmupAsync(CancellationToken cancellationToken = default); } diff --git a/GenHub/GenHub.Core/Messages/CloseContentDetailMessage.cs b/GenHub/GenHub.Core/Messages/CloseContentDetailMessage.cs new file mode 100644 index 000000000..86df06824 --- /dev/null +++ b/GenHub/GenHub.Core/Messages/CloseContentDetailMessage.cs @@ -0,0 +1,10 @@ +using CommunityToolkit.Mvvm.Messaging.Messages; + +namespace GenHub.Core.Messages; + +/// +/// Message sent when a user wants to close the content details view. +/// +public class CloseContentDetailMessage() : ValueChangedMessage(true) +{ +} diff --git a/GenHub/GenHub.Core/Messages/ClosePublisherDetailsMessage.cs b/GenHub/GenHub.Core/Messages/ClosePublisherDetailsMessage.cs new file mode 100644 index 000000000..5965d242c --- /dev/null +++ b/GenHub/GenHub.Core/Messages/ClosePublisherDetailsMessage.cs @@ -0,0 +1,10 @@ +using CommunityToolkit.Mvvm.Messaging.Messages; + +namespace GenHub.Core.Messages; + +/// +/// Message sent when a user wants to close the publisher details view and return to the dashboard. +/// +public class ClosePublisherDetailsMessage() : ValueChangedMessage(true) +{ +} diff --git a/GenHub/GenHub.Core/Messages/ContentLibraryClearedMessage.cs b/GenHub/GenHub.Core/Messages/ContentLibraryClearedMessage.cs new file mode 100644 index 000000000..0ea08081c --- /dev/null +++ b/GenHub/GenHub.Core/Messages/ContentLibraryClearedMessage.cs @@ -0,0 +1,8 @@ +namespace GenHub.Core.Messages; + +/// +/// Announces that every acquired content manifest was removed from the local library. +/// +public sealed class ContentLibraryClearedMessage +{ +} diff --git a/GenHub/GenHub.Core/Messages/OpenPublisherDetailsMessage.cs b/GenHub/GenHub.Core/Messages/OpenPublisherDetailsMessage.cs new file mode 100644 index 000000000..9b56b1d6d --- /dev/null +++ b/GenHub/GenHub.Core/Messages/OpenPublisherDetailsMessage.cs @@ -0,0 +1,11 @@ +using CommunityToolkit.Mvvm.Messaging.Messages; + +namespace GenHub.Core.Messages; + +/// +/// Message sent when a user wants to view details for a specific publisher. +/// +/// The ID of the publisher to view. +public class OpenPublisherDetailsMessage(string publisherId) : ValueChangedMessage(publisherId) +{ +} diff --git a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs index 9a35655ea..65841317d 100644 --- a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs +++ b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs @@ -403,10 +403,62 @@ public static class GenPatcherContentRegistry }, }; + /// + /// Content code alias mappings for catalog items and legacy names. + /// + private static readonly Dictionary ContentCodeAliases = new(StringComparer.OrdinalIgnoreCase) + { + ["legionnaire-hotkeys"] = "hleg", + ["legionnairehotkeys"] = "hleg", + ["legionnaireshotkeys"] = "hleg", + ["gentool-suite-86"] = "gent", + ["gentoolsuite86"] = "gent", + ["gentool-suite-89"] = "gent", + ["gentoolsuite89"] = "gent", + ["gentool-89-suite"] = "gent", + ["gentool89suite"] = "gent", + ["gentool-suite"] = "gent", + ["gentoolsuite"] = "gent", + ["gentool"] = "gent", + ["leikezes-hotkeys"] = "hlei", + ["leikezeshotkeys"] = "hlei", + ["easy-win-hotkeys-advanced"] = "ewba", + ["easywinhotkeysadvanced"] = "ewba", + ["easy-win-hotkeys-international"] = "ewbi", + ["easywinhotkeysinternational"] = "ewbi", + ["standard-hotkeys-german"] = "hlde", + ["standardhotkeysgerman"] = "hlde", + ["hotkeys-indicators"] = "hlen", + ["hotkeysindicators"] = "hlen", + ["hlenenglish"] = "hlen", + ["hlen-english"] = "hlen", + ["communityoutpost-controlbar-pro"] = "cbpr", + ["controlbarproexile"] = "cbpr", + ["controlbarproxezon"] = "cbpx", + ["communitypatch"] = "community-patch", + ["communitypatchthesuperhackersbuild"] = "community-patch", + ["communityoutpostgameclientcommunitypatch"] = "community-patch", + ["community-patch-gameclient"] = "community-patch", + ["zerohour104"] = "10zh", + ["zerohour-104"] = "10zh", + ["generals108"] = "10gn", + ["generals-108"] = "10gn", + ["mapsartofdefense"] = "maod", + ["custommissionspack"] = "mmis", + ["mapscriptingresources"] = "mscr", + ["skirmishmappack"] = "mskr", + ["iconspack"] = "icon", + ["directxtextures"] = "drtx", + ["uncutcontent"] = "unct", + ["vc2005redistributable"] = "vc05", + ["vc2008redistributable"] = "vc08", + ["vc2010redistributable"] = "vc10", + }; + /// /// Gets metadata for a content code. /// - /// The 4-character content code. + /// The 4-character content code or catalog content id. /// Content metadata, or a dynamically generated one if the code is unknown. public static GenPatcherContentMetadata GetMetadata(string contentCode) { @@ -416,21 +468,25 @@ public static GenPatcherContentMetadata GetMetadata(string contentCode) } var normalizedCode = contentCode.Trim(); + if (ContentCodeAliases.TryGetValue(normalizedCode, out var aliasCode)) + { + normalizedCode = aliasCode; + } - // Check for known content first (case-insensitive due to dictionary comparer) + // check for known content first (case-insensitive due to dictionary comparer) if (KnownContent.TryGetValue(normalizedCode, out var metadata)) { return metadata; } - // Try to parse as a patch code (e.g., "108e", "104b") + // try to parse as a patch code (e.g., "108e", "104b") var patchMetadata = TryParsePatchCode(normalizedCode); if (patchMetadata != null) { return patchMetadata; } - // Return unknown metadata + // return unknown metadata return CreateUnknownMetadata(contentCode); } diff --git a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherDependencyBuilder.cs b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherDependencyBuilder.cs index 0c85ee03d..456fed224 100644 --- a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherDependencyBuilder.cs +++ b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherDependencyBuilder.cs @@ -427,6 +427,20 @@ private static void AddHotkeyDependencies( { AddHotkeyIndicatorDependency(dependencies); } + + // Legionnaire's Hotkeys hooks through GenTool. This is an actual runtime + // requirement, so let the resolver acquire and reconcile it with the profile. + if (metadata.ContentCode.Equals("hleg", StringComparison.OrdinalIgnoreCase)) + { + dependencies.Add(new ContentDependency + { + Id = ManifestId.Create($"1.{ManifestConstants.DefaultManifestVersion}.{CommunityOutpostConstants.PublisherType}.addon.gent"), + Name = "GenTool (required for Legionnaire's Hotkeys)", + DependencyType = ContentType.Addon, + InstallBehavior = DependencyInstallBehavior.AutoInstall, + IsOptional = false, + }); + } } /// @@ -527,4 +541,4 @@ private static void AddGenericGameDependency( dependencies.Add(CreateZeroHour104Dependency()); } } -} \ No newline at end of file +} diff --git a/GenHub/GenHub.Core/Models/Content/ContentAcquisitionProgress.cs b/GenHub/GenHub.Core/Models/Content/ContentAcquisitionProgress.cs index 8e82f1f1d..381e340e8 100644 --- a/GenHub/GenHub.Core/Models/Content/ContentAcquisitionProgress.cs +++ b/GenHub/GenHub.Core/Models/Content/ContentAcquisitionProgress.cs @@ -50,4 +50,59 @@ public class ContentAcquisitionProgress /// Gets or sets the estimated time remaining for the current phase. /// public TimeSpan EstimatedTimeRemaining { get; set; } + + /// + /// Gets or sets the current stage number (1-based). Set to 0 to disable staged progress display. + /// + public int CurrentStage { get; set; } = 0; + + /// + /// Gets or sets the total number of stages in the acquisition process. Set to 0 to disable staged progress display. + /// + public int TotalStages { get; set; } = 0; + + /// + /// Gets or sets the progress within the current stage (0-100). + /// + public double StageProgress { get; set; } + + /// + /// Gets or sets the description of the current stage. + /// + public string StageDescription { get; set; } = string.Empty; + + /// + /// Gets or sets the time elapsed since the last progress update. + /// Used to detect stalled operations and provide feedback. + /// + public TimeSpan TimeSinceLastUpdate { get; set; } + + /// + /// Gets or sets a value indicating whether the current operation is a bottleneck (e.g., hash calculation). + /// + public bool IsBottleneck { get; set; } + + /// + /// Gets or sets a message explaining why the operation is slow (if IsBottleneck is true). + /// + public string? BottleneckReason { get; set; } + + /// + /// Gets the formatted stage indicator (e.g., "2/5"). + /// + public string StageIndicator => $"{CurrentStage}/{TotalStages}"; + + /// + /// Gets a formatted progress string combining stage and percentage. + /// + public string FormattedProgress + { + get + { + var stagePart = $"{CurrentStage}/{TotalStages}"; + var percentPart = StageProgress > 0 ? $" ({StageProgress:F0}%)" : string.Empty; + var description = !string.IsNullOrEmpty(StageDescription) ? $" - {StageDescription}" : string.Empty; + return $"{stagePart}{description}{percentPart}"; + } + } } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Models/Content/ContentCardBadgeHelper.cs b/GenHub/GenHub.Core/Models/Content/ContentCardBadgeHelper.cs new file mode 100644 index 000000000..aacd45bc2 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Content/ContentCardBadgeHelper.cs @@ -0,0 +1,367 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; +using GenHub.Core.Constants; +using GenHub.Core.Extensions; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Results.Content; + +namespace GenHub.Core.Models.Content; + +/// +/// Shared helpers for promoting and reading download-card badge metadata across publishers. +/// +public static partial class ContentCardBadgeHelper +{ + /// + /// Applies a player-count value to search-result metadata and tags. + /// + /// The search result to update. + /// The player count to store. + public static void ApplyPlayerCount(ContentSearchResult result, int playerCount) + { + ArgumentNullException.ThrowIfNull(result); + if (playerCount <= 0) + { + return; + } + + var value = playerCount.ToString(CultureInfo.InvariantCulture); + result.ResolverMetadata[ContentConstants.PlayerCountMetadataKey] = value; + result.Metadata[ContentConstants.PlayerCountMetadataKey] = value; + + var tag = playerCount == 1 ? "1 Player" : $"{playerCount} Players"; + if (!result.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) + { + result.Tags.Add(tag); + } + } + + /// + /// Applies a category label to search-result metadata and tags. + /// + /// The search result to update. + /// The category display label. + public static void ApplyCategory(ContentSearchResult result, string? category) + { + ArgumentNullException.ThrowIfNull(result); + if (string.IsNullOrWhiteSpace(category)) + { + return; + } + + var trimmed = category.Trim(); + result.ResolverMetadata[ContentConstants.CategoryMetadataKey] = trimmed; + result.Metadata[ContentConstants.CategoryMetadataKey] = trimmed; + + if (!result.Tags.Contains(trimmed, StringComparer.OrdinalIgnoreCase)) + { + result.Tags.Add(trimmed); + } + } + + /// + /// Promotes conventional tags (for example 4 Players or category:AOA) into badge metadata. + /// + /// The search result to update. + public static void PromoteFromTags(ContentSearchResult result) + { + ArgumentNullException.ThrowIfNull(result); + + if (!HasMetadata(result, ContentConstants.PlayerCountMetadataKey)) + { + foreach (var tag in result.Tags) + { + var match = PlayerCountTagRegex().Match(tag); + if (match.Success && int.TryParse(match.Groups["players"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count)) + { + ApplyPlayerCount(result, count); + break; + } + } + } + + if (!HasMetadata(result, ContentConstants.CategoryMetadataKey)) + { + foreach (var tag in result.Tags) + { + var match = CategoryTagRegex().Match(tag); + if (match.Success) + { + ApplyCategory(result, match.Groups["category"].Value); + break; + } + } + } + } + + /// + /// Resolves the player-count badge text for a download card, or an empty string when unavailable. + /// + /// The search result. + /// Badge text such as 4 players. + public static string GetPlayerCountBadge(ContentSearchResult result) + { + ArgumentNullException.ThrowIfNull(result); + + if (result.ContentType is not (ContentType.Map or ContentType.MapPack or ContentType.UnknownContentType or ContentType.GameInstallation)) + { + return string.Empty; + } + + if (TryGetMetadata(result, ContentConstants.PlayerCountMetadataKey, out var raw) && + int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count) && + count > 0) + { + return count == 1 ? "1 player" : $"{count} players"; + } + + foreach (var tag in result.Tags) + { + var match = PlayerCountTagRegex().Match(tag); + if (match.Success && int.TryParse(match.Groups["players"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var taggedCount) && taggedCount > 0) + { + return taggedCount == 1 ? "1 player" : $"{taggedCount} players"; + } + } + + return string.Empty; + } + + /// + /// Resolves the category badge text for a download card, or an empty string when unavailable. + /// + /// The search result. + /// Category badge text. + public static string GetCategoryBadge(ContentSearchResult result) + { + ArgumentNullException.ThrowIfNull(result); + + if (TryGetMetadata(result, ContentConstants.CategoryMetadataKey, out var category) && + !string.IsNullOrWhiteSpace(category)) + { + return category.Trim(); + } + + foreach (var tag in result.Tags) + { + var match = CategoryTagRegex().Match(tag); + if (match.Success) + { + return match.Groups["category"].Value.Trim(); + } + } + + return string.Empty; + } + + /// + /// Resolves the best card/detail thumbnail URL: banner, then first screenshot, then icon, then publisher logo. + /// Catalog items often only set while + /// is the publisher avatar. + /// + /// The search result. + /// A thumbnail URL, or null when none is available. + public static string? GetThumbnailUrl(ContentSearchResult result) + { + ArgumentNullException.ThrowIfNull(result); + + if (!string.IsNullOrWhiteSpace(result.BannerUrl)) + { + return result.BannerUrl; + } + + var screenshot = result.ScreenshotUrls.FirstOrDefault(static url => !string.IsNullOrWhiteSpace(url)); + if (!string.IsNullOrWhiteSpace(screenshot)) + { + return screenshot; + } + + if (!string.IsNullOrWhiteSpace(result.IconUrl)) + { + return result.IconUrl; + } + + return GetPublisherLogoUrl(result); + } + + /// + /// Resolves the canonical publisher logo URI for a content search result. + /// + /// The search result. + /// A logo URI string, or null when unmapped. + public static string? GetPublisherLogoUrl(ContentSearchResult result) + { + ArgumentNullException.ThrowIfNull(result); + return PublisherInfoConstants.GetPublisherLogo(result.ProviderName, $"{result.AuthorName} {result.Id} {result.Name}"); + } + + /// + /// Checks whether a category badge text is equivalent to the given content type display or manifest string, + /// preventing duplicate badge display (for example showing both "Content Bundle" and "ContentBundle"). + /// + /// The category string. + /// The content type. + /// True if the category is equivalent to the content type; otherwise false. + public static bool IsCategoryDuplicateOfContentType(string? category, ContentType contentType) + { + if (string.IsNullOrWhiteSpace(category)) + { + return true; + } + + static string Normalize(string input) => + new(input.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); + + var normCategory = Normalize(category); + if (string.IsNullOrEmpty(normCategory)) + { + return true; + } + + var normDisplayName = Normalize(contentType.GetDisplayName()); + var normManifestName = Normalize(contentType.ToManifestIdString()); + var normEnumName = Normalize(contentType.ToString()); + + return normCategory == normDisplayName || + normCategory == normManifestName || + normCategory == normEnumName; + } + + /// + /// Reads a precomputed includes/required-content summary from search-result metadata. + /// + /// The search result. + /// Comma-separated included content names, or empty when unavailable. + public static string GetIncludesSummary(ContentSearchResult result) + { + ArgumentNullException.ThrowIfNull(result); + + if (TryGetMetadata(result, ContentConstants.IncludesSummaryMetadataKey, out var summary) && + !string.IsNullOrWhiteSpace(summary)) + { + return summary.Trim(); + } + + return string.Empty; + } + + /// + /// Applies an includes/required-content summary for glanceable card and detail display. + /// + /// The search result to update. + /// Friendly names of included or required content. + public static void ApplyIncludesSummary(ContentSearchResult result, IEnumerable includedNames) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(includedNames); + + var names = includedNames + .Where(static name => !string.IsNullOrWhiteSpace(name)) + .Select(static name => name.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (names.Count == 0) + { + return; + } + + var summary = string.Join(", ", names); + result.Metadata[ContentConstants.IncludesSummaryMetadataKey] = summary; + result.ResolverMetadata[ContentConstants.IncludesSummaryMetadataKey] = summary; + } + + /// + /// Returns the result's tags that are not already surfaced by the dedicated + /// player-count or category badges, for display as additional tag chips on a card. + /// + /// The search result. + /// + /// A list of tag strings with promoted player-count ("N Players") and category + /// ("category:X") tags removed. Tags are compared case-insensitively against the + /// resolved badge values. + /// + public static List GetCardTags(ContentSearchResult result) + { + ArgumentNullException.ThrowIfNull(result); + + var playerBadge = GetPlayerCountBadge(result); + var categoryBadge = GetCategoryBadge(result); + + var excluded = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrWhiteSpace(playerBadge)) + { + excluded.Add(playerBadge); + } + + if (!string.IsNullOrWhiteSpace(categoryBadge)) + { + excluded.Add(categoryBadge); + } + + // Also drop the raw source tags that the badges were promoted from, so chips + // do not duplicate a "3 Players" or "category:AOA" tag that already fed a badge. + foreach (var tag in result.Tags) + { + if (PlayerCountTagRegex().IsMatch(tag) || CategoryTagRegex().IsMatch(tag)) + { + excluded.Add(tag); + } + } + + return [.. result.Tags + .Where(t => !string.IsNullOrWhiteSpace(t) && !excluded.Contains(t)) + .Select(t => t.Trim())]; + } + + /// + /// Extracts a trailing YYYY-MM-DD date from a build-stamp style version tag + /// (e.g. weekly-2026-07-03), returning the date string for badge display. + /// Returns null when the version is not a date-bearing build tag. + /// + /// The raw version/tag string. + /// The date portion, or null when no trailing date is present. + public static string? ExtractDateFromTag(string? version) + { + if (string.IsNullOrWhiteSpace(version)) + { + return null; + } + + var match = BuildStampDateRegex().Match(version); + return match.Success ? match.Groups["date"].Value : null; + } + + private static bool HasMetadata(ContentSearchResult result, string key) => + result.ResolverMetadata.ContainsKey(key) || result.Metadata.ContainsKey(key); + + private static bool TryGetMetadata(ContentSearchResult result, string key, out string value) + { + if (result.ResolverMetadata.TryGetValue(key, out var resolverValue) && !string.IsNullOrWhiteSpace(resolverValue)) + { + value = resolverValue; + return true; + } + + if (result.Metadata.TryGetValue(key, out var metadataValue) && !string.IsNullOrWhiteSpace(metadataValue)) + { + value = metadataValue; + return true; + } + + value = string.Empty; + return false; + } + + [GeneratedRegex(@"^(?:players?:)?(?[1-8])\s*players?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex PlayerCountTagRegex(); + + [GeneratedRegex(@"^category:\s*(?.+)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex CategoryTagRegex(); + + [GeneratedRegex(@"(?\d{4}-\d{2}-\d{2})$", RegexOptions.CultureInvariant)] + private static partial Regex BuildStampDateRegex(); +} diff --git a/GenHub/GenHub.Core/Models/Content/ContentSearchQuery.cs b/GenHub/GenHub.Core/Models/Content/ContentSearchQuery.cs index bcd7ca322..9c94bd181 100644 --- a/GenHub/GenHub.Core/Models/Content/ContentSearchQuery.cs +++ b/GenHub/GenHub.Core/Models/Content/ContentSearchQuery.cs @@ -1,4 +1,7 @@ +using System; +using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; using GenHub.Core.Models.Enums; namespace GenHub.Core.Models.Content; @@ -125,6 +128,23 @@ public class ContentSearchQuery /// public Collection CNCLabsMapTags { get; } = []; + // ===== AODMaps-specific filters ===== + + /// + /// Gets or sets the AODMaps player count filter (2, 3, 4, 6, 8 players). + /// + public string? AODMapsPlayerCount { get; set; } + + /// + /// Gets or sets the AODMaps category filter (Compstomp, Air, Race, Map Packs). + /// + public string? AODMapsCategory { get; set; } + + /// + /// Gets or sets the AODMaps map type filter (1v1, 2v2, FFA). + /// + public string? AODMapsMapType { get; set; } + // ===== GitHub-specific filters ===== /// @@ -150,6 +170,17 @@ public string? Language set => _language = NormalizeLanguage(value); } + /// + /// Generates a deterministic cache key representing the query and all its active filters. + /// + /// A string cache key. + public string ToCacheKey() + { + var tags = Tags.Count > 0 ? string.Join(",", Tags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase)) : string.Empty; + var cncTags = CNCLabsMapTags.Count > 0 ? string.Join(",", CNCLabsMapTags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase)) : string.Empty; + return $"search::{ProviderName}::{SearchTerm}::{ContentType}::{TargetGame}::{Skip}::{Take}::{SortOrder}::{Sort}::{IncludeInstalled}::{IncludeOlderVersions}::{NumberOfPlayers}::{Page}::{ModDBCategory}::{ModDBAddonCategory}::{ModDBLicense}::{ModDBTimeframe}::{ModDBSection}::{AODMapsPlayerCount}::{AODMapsCategory}::{AODMapsMapType}::{GitHubTopic}::{GitHubAuthor}::{Language}::{tags}::{cncTags}"; + } + private static readonly Dictionary LanguageMap = new(StringComparer.OrdinalIgnoreCase) { diff --git a/GenHub/GenHub.Core/Models/Content/CustomTabCardDefinition.cs b/GenHub/GenHub.Core/Models/Content/CustomTabCardDefinition.cs new file mode 100644 index 000000000..c652738af --- /dev/null +++ b/GenHub/GenHub.Core/Models/Content/CustomTabCardDefinition.cs @@ -0,0 +1,22 @@ +namespace GenHub.Core.Models.Content; + +/// +/// A display-ready publisher card shown inside a custom content detail tab. +/// +public class CustomTabCardDefinition +{ + /// Gets or sets the card heading. + public string Title { get; set; } = string.Empty; + + /// Gets or sets supporting text for the card. + public string Description { get; set; } = string.Empty; + + /// Gets or sets an optional local or remote image URL. + public string? ImageUrl { get; set; } + + /// Gets or sets an optional compact label displayed above the card title. + public string? Label { get; set; } + + /// Gets or sets the card accent colour in a format understood by Avalonia. + public string AccentColor { get; set; } = "#303D59"; +} diff --git a/GenHub/GenHub.Core/Models/Content/CustomTabDefinition.cs b/GenHub/GenHub.Core/Models/Content/CustomTabDefinition.cs new file mode 100644 index 000000000..78517b191 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Content/CustomTabDefinition.cs @@ -0,0 +1,76 @@ +namespace GenHub.Core.Models.Content; + +/// +/// Defines a custom tab for content detail pages. +/// +public class CustomTabDefinition +{ + /// + /// Gets or sets the unique identifier for this tab. + /// + public required string TabId { get; set; } + + /// + /// Gets or sets the display name shown in the tab header. + /// + public required string Header { get; set; } + + /// + /// Gets or sets the icon name or path for the tab (optional). + /// + public string? Icon { get; set; } + + /// + /// Gets or sets the order/priority of the tab (lower numbers appear first). + /// + public int Order { get; set; } = 100; + + /// + /// Gets or sets the tab content type. + /// + public TabContentType ContentType { get; set; } = TabContentType.Custom; + + /// + /// Gets or sets the data source URL for the tab content (optional). + /// Can be a catalog URL, API endpoint, or web page URL. + /// + public string? DataSourceUrl { get; set; } + + /// + /// Gets or sets the content template identifier. + /// Used to determine which UI template to use for rendering. + /// + public string? ContentTemplate { get; set; } + + /// + /// Gets or sets introductory copy displayed above the tab cards. + /// + public string? Intro { get; set; } + + /// + /// Gets or sets the display cards supplied by the publisher for this tab. + /// + public List Cards { get; set; } = []; + + /// + /// Gets or sets custom metadata for the tab. + /// Can be used to pass additional configuration to the tab renderer. + /// + public Dictionary Metadata { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the tab should be visible. + /// Can be used for conditional visibility based on content availability. + /// + public bool IsVisible { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the tab content should be lazy-loaded. + /// + public bool LazyLoad { get; set; } = true; + + /// + /// Gets or sets the function to load tab data dynamically. + /// + public Func? DataLoader { get; set; } +} diff --git a/GenHub/GenHub.Core/Models/Content/ParsedContentDetails.cs b/GenHub/GenHub.Core/Models/Content/ParsedContentDetails.cs index b5904f266..e534c7686 100644 --- a/GenHub/GenHub.Core/Models/Content/ParsedContentDetails.cs +++ b/GenHub/GenHub.Core/Models/Content/ParsedContentDetails.cs @@ -36,4 +36,4 @@ public record ParsedContentDetails( string? FileType = null, float? Rating = null, string? RefererUrl = null, - List? AdditionalFiles = null); + List? AdditionalFiles = null); diff --git a/GenHub/GenHub.Core/Models/Content/TabContentType.cs b/GenHub/GenHub.Core/Models/Content/TabContentType.cs new file mode 100644 index 000000000..b853a7ebb --- /dev/null +++ b/GenHub/GenHub.Core/Models/Content/TabContentType.cs @@ -0,0 +1,52 @@ +namespace GenHub.Core.Models.Content; + +/// +/// Defines the type of content a custom tab displays. +/// +public enum TabContentType +{ + /// + /// Custom content with a specific template. + /// + Custom, + + /// + /// List of downloadable files. + /// + Files, + + /// + /// List of related addons or mods. + /// + Addons, + + /// + /// Video gallery. + /// + Videos, + + /// + /// Image gallery. + /// + Images, + + /// + /// User reviews and ratings. + /// + Reviews, + + /// + /// News articles and updates. + /// + Articles, + + /// + /// HTML or markdown content. + /// + RichText, + + /// + /// External web content (iframe). + /// + WebView, +} diff --git a/GenHub/GenHub.Core/Models/Enums/ContentState.cs b/GenHub/GenHub.Core/Models/Enums/ContentState.cs new file mode 100644 index 000000000..c69305f69 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Enums/ContentState.cs @@ -0,0 +1,23 @@ +namespace GenHub.Core.Models.Enums; + +/// +/// Content state for UI display - determines which button to show. +/// +public enum ContentState +{ + /// + /// Content has not been downloaded yet. Show "Download" button. + /// + NotDownloaded, + + /// + /// Content exists locally but a newer version is available (same publisher+name, newer date). + /// Show "Update" button. + /// + UpdateAvailable, + + /// + /// Content is downloaded and up-to-date. Show "Add to Profile" dropdown. + /// + Downloaded, +} diff --git a/GenHub/GenHub.Core/Models/Manifest/ContentManifest.cs b/GenHub/GenHub.Core/Models/Manifest/ContentManifest.cs index 0624cc42f..b8d7a1a97 100644 --- a/GenHub/GenHub.Core/Models/Manifest/ContentManifest.cs +++ b/GenHub/GenHub.Core/Models/Manifest/ContentManifest.cs @@ -13,8 +13,8 @@ public class ContentManifest { private List _variants = []; - /// Gets or sets the manifest format version. - public string ManifestVersion { get; set; } = ManifestConstants.DefaultManifestVersion; + /// Gets or sets the manifest format/schema version. + public string SchemaVersion { get; set; } = ManifestConstants.DefaultManifestVersion; /// Gets or sets the unique identifier for this content package. public ManifestId Id { get; set; } diff --git a/GenHub/GenHub.Core/Models/Manifest/ContentMetadata.cs b/GenHub/GenHub.Core/Models/Manifest/ContentMetadata.cs index 7073cb59a..639933b7d 100644 --- a/GenHub/GenHub.Core/Models/Manifest/ContentMetadata.cs +++ b/GenHub/GenHub.Core/Models/Manifest/ContentMetadata.cs @@ -68,4 +68,18 @@ public class ContentMetadata /// Used when creating profile-specific manifests from variant content. /// public string? SelectedVariantId { get; set; } + + /// + /// Gets or sets the stable group key shared by every manifest that is a variant of the same + /// release (e.g. all five Control Bar Pro resolutions). The downloads browser groups sibling + /// cards by this id so they render as a single card with a variant picker instead of N + /// unrelated cards. Null/empty for single-variant content. + /// + public string? VariantGroupId { get; set; } + + /// + /// Gets or sets the display name of the variant family (e.g. "Control Bar Pro (Xezon)"), + /// shown as the card title when multiple variants share a . + /// + public string? VariantFamilyName { get; set; } } diff --git a/GenHub/GenHub.Core/Models/Manifest/ManifestIdGenerator.cs b/GenHub/GenHub.Core/Models/Manifest/ManifestIdGenerator.cs index 6fff1d461..bdf7df79e 100644 --- a/GenHub/GenHub.Core/Models/Manifest/ManifestIdGenerator.cs +++ b/GenHub/GenHub.Core/Models/Manifest/ManifestIdGenerator.cs @@ -57,6 +57,39 @@ public static string GeneratePublisherContentId( return $"{fullVersion}.{safePublisher}.{contentTypeString}.{safeName}"; } + /// + /// Generates a manifest ID for publisher-provided content using release date as version. + /// Used for publishers like ModDB, CNCLabs, AODMaps that don't have semantic versioning. + /// Format: schemaVersion.dateVersion.publisher.contentType.contentName (exactly 5 segments). + /// + /// Publisher identifier (e.g., 'moddb', 'cnclabs'). + /// The type of content being identified. + /// Human readable content name. + /// The release date to use as version (formatted as yyyyMMdd). + /// A normalized manifest identifier. + /// Thrown when or is empty or whitespace. + public static string GeneratePublisherContentId( + string publisherId, + ContentType contentType, + string contentName, + DateTime releaseDate) + { + if (string.IsNullOrWhiteSpace(publisherId)) + throw new ArgumentException("Publisher ID cannot be empty", nameof(publisherId)); + if (string.IsNullOrWhiteSpace(contentName)) + throw new ArgumentException("Content name cannot be empty", nameof(contentName)); + if (releaseDate == DateTime.MinValue) + throw new ArgumentException("Release date cannot be DateTime.MinValue", nameof(releaseDate)); + + var safePublisher = Normalize(publisherId); + var contentTypeString = contentType.ToManifestIdString(); + var safeName = Normalize(contentName); + var dateVersion = releaseDate.ToString("yyyyMMdd"); + var fullVersion = $"{ManifestConstants.DefaultManifestFormatVersion}.{dateVersion}"; + + return $"{fullVersion}.{safePublisher}.{contentTypeString}.{safeName}"; + } + /// /// Generates a manifest ID for a game installation. /// Format: schemaVersion.userVersion.publisher.contentType.contentName (exactly 5 segments). diff --git a/GenHub/GenHub.Core/Models/Manifest/ManifestIngestionGate.cs b/GenHub/GenHub.Core/Models/Manifest/ManifestIngestionGate.cs index bf6c8c9e9..b0a241074 100644 --- a/GenHub/GenHub.Core/Models/Manifest/ManifestIngestionGate.cs +++ b/GenHub/GenHub.Core/Models/Manifest/ManifestIngestionGate.cs @@ -47,7 +47,7 @@ public static bool TryAccept(ContentManifest? manifest, out string? rejectionRea var declaresVariants = manifest.Variants.Count > 0; var declaresVariantFormat = int.TryParse( - manifest.ManifestVersion, + manifest.SchemaVersion, NumberStyles.None, CultureInfo.InvariantCulture, out var declaredFormat) @@ -60,7 +60,7 @@ public static bool TryAccept(ContentManifest? manifest, out string? rejectionRea var cause = declaresVariants ? $"declares {manifest.Variants.Count} artifact variant(s)" - : $"declares manifest format version {manifest.ManifestVersion}"; + : $"declares manifest format version {manifest.SchemaVersion}"; rejectionReason = $"Manifest '{manifest.Id.Value}' {cause}, which requires manifest format version " + diff --git a/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs b/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs index ddb22794a..9e3c29161 100644 --- a/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs +++ b/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; +using GenHub.Core.Constants; using GenHub.Core.Utilities; namespace GenHub.Core.Models.Manifest; @@ -135,7 +136,8 @@ public static EntryPointResolution ResolveEntryPoint( var executable = files .Where(f => f.IsExecutable - && ExecutableFileClassifier.IsLegacyLaunchCandidateFromName(f.RelativePath)) + && (ExecutableFileClassifier.IsLegacyLaunchCandidateFromName(f.RelativePath) + || IsPrimaryGameExecutable(f.RelativePath))) .ToList(); if (executable.Count == 1) { @@ -145,7 +147,9 @@ public static EntryPointResolution ResolveEntryPoint( if (executable.Count == 0) { var legacy = files - .Where(f => ExecutableFileClassifier.IsLegacyLaunchCandidateFromName(f.RelativePath)) + .Where(f => + ExecutableFileClassifier.IsLegacyLaunchCandidateFromName(f.RelativePath) + || IsPrimaryGameExecutable(f.RelativePath)) .ToList(); if (legacy.Count == 1) @@ -153,6 +157,15 @@ public static EntryPointResolution ResolveEntryPoint( return EntryPointResolution.Resolved(legacy[0].RelativePath, "only launch candidate by extension"); } + var primaryFromLegacy = legacy + .Where(f => IsPrimaryGameExecutable(f.RelativePath)) + .ToList(); + + if (primaryFromLegacy.Count == 1) + { + return EntryPointResolution.Resolved(primaryFromLegacy[0].RelativePath, "primary game executable candidate"); + } + return EntryPointResolution.Failed( legacy.Count == 0 ? $"Manifest '{manifest.Id}' contains no launchable file." @@ -160,12 +173,33 @@ public static EntryPointResolution ResolveEntryPoint( files); } + var primaryExecutables = executable + .Where(f => IsPrimaryGameExecutable(f.RelativePath)) + .ToList(); + + if (primaryExecutables.Count == 1) + { + return EntryPointResolution.Resolved(primaryExecutables[0].RelativePath, "primary game executable candidate"); + } + return EntryPointResolution.Failed( $"Manifest '{manifest.Id}' marks {executable.Count} files as requiring execute permission and " + "declares no entry point, so the launch target is ambiguous.", files); } + private static bool IsPrimaryGameExecutable(string relativePath) + { + var fileName = System.IO.Path.GetFileName(relativePath.Replace('\\', '/')); + return string.Equals(fileName, GameClientConstants.SuperHackersZeroHourExecutable, StringComparison.OrdinalIgnoreCase) + || string.Equals(fileName, GameClientConstants.SuperHackersGeneralsExecutable, StringComparison.OrdinalIgnoreCase) + || string.Equals(fileName, GameClientConstants.GeneralsExecutable, StringComparison.OrdinalIgnoreCase) + || string.Equals(fileName, GameClientConstants.SteamGameDatExecutable, StringComparison.OrdinalIgnoreCase) + || string.Equals(fileName, GameClientConstants.GameExecutable, StringComparison.OrdinalIgnoreCase) + || string.Equals(fileName, GameClientConstants.GeneralsOnline60HzExecutable, StringComparison.OrdinalIgnoreCase) + || string.Equals(fileName, GameClientConstants.GeneralsOnlineDefaultExecutable, StringComparison.OrdinalIgnoreCase); + } + private static bool PathsMatch(string left, string right) => string.Equals( left.Replace('\\', '/').TrimStart('/'), diff --git a/GenHub/GenHub.Core/Models/ModDB/MapDetails.cs b/GenHub/GenHub.Core/Models/ModDB/MapDetails.cs index 347634147..979071455 100644 --- a/GenHub/GenHub.Core/Models/ModDB/MapDetails.cs +++ b/GenHub/GenHub.Core/Models/ModDB/MapDetails.cs @@ -37,4 +37,4 @@ public record MapDetails( string? FileType = null, float? Rating = null, string? RefererUrl = null, - List? AdditionalFiles = null); + List? AdditionalFiles = null); diff --git a/GenHub/GenHub.Core/Models/ModDB/ModDBCategoryMapper.cs b/GenHub/GenHub.Core/Models/ModDB/ModDBCategoryMapper.cs index cf2fc5920..c0f1e9a1f 100644 --- a/GenHub/GenHub.Core/Models/ModDB/ModDBCategoryMapper.cs +++ b/GenHub/GenHub.Core/Models/ModDB/ModDBCategoryMapper.cs @@ -19,8 +19,8 @@ public static ContentType MapCategory(string? categoryCode) // Releases (Mods) "2" => ContentType.Mod, // Full Version "3" => ContentType.Mod, // Demo - "4" => ContentType.Patch, // Patch - "28" => ContentType.Patch, // Script + "4" => ContentType.Mod, // Patch (mod release/update) + "28" => ContentType.Mod, // Script (mod script/release) "29" => ContentType.Addon, // Trainer // Media @@ -60,11 +60,11 @@ public static ContentType MapCategory(string? categoryCode) "131" => ContentType.Addon, // Model Pack // Addons - Skins - "112" => ContentType.Skin, // Player Skin - "133" => ContentType.Skin, // Prop Skin - "113" => ContentType.Skin, // Vehicle Skin - "114" => ContentType.Skin, // Weapon Skin - "134" => ContentType.Skin, // Skin Pack + "112" => ContentType.Addon, // Player Skin + "133" => ContentType.Addon, // Prop Skin + "113" => ContentType.Addon, // Vehicle Skin + "114" => ContentType.Addon, // Weapon Skin + "134" => ContentType.Addon, // Skin Pack // Addons - Audio "117" => ContentType.Addon, // Music @@ -75,8 +75,8 @@ public static ContentType MapCategory(string? categoryCode) // Addons - Graphics "124" => ContentType.Addon, // Decal "136" => ContentType.Addon, // Effects GFX - "125" => ContentType.Skin, // GUI - "126" => ContentType.Skin, // HUD + "125" => ContentType.Addon, // GUI + "126" => ContentType.Addon, // HUD "128" => ContentType.Addon, // Sprite "129" => ContentType.Addon, // Texture @@ -103,10 +103,15 @@ public static ContentType MapCategoryByName(string? categoryName) { var s when s.Contains("full version") => ContentType.Mod, var s when s.Contains("demo") => ContentType.Mod, - var s when s.Contains("patch") => ContentType.Patch, - var s when s.Contains("script") => ContentType.Patch, + var s when s.Contains("patch") => ContentType.Mod, + var s when s.Contains("script") => ContentType.Mod, var s when s.Contains("trainer") => ContentType.Addon, + var s when s.Contains("tool") => ContentType.ModdingTool, + var s when s.Contains("sdk") => ContentType.ModdingTool, + var s when s.Contains("ide") => ContentType.ModdingTool, + var s when s.Contains("source code") => ContentType.ModdingTool, + var s when s.Contains("trailer") => ContentType.Video, var s when s.Contains("movie") => ContentType.Video, var s when s.Contains("video") => ContentType.Video, @@ -116,17 +121,12 @@ var s when s.Contains("singleplayer map") => ContentType.Map, var s when s.Contains("map") => ContentType.Map, var s when s.Contains("prefab") => ContentType.Map, - var s when s.Contains("skin") => ContentType.Skin, - var s when s.Contains("gui") => ContentType.Skin, - var s when s.Contains("hud") => ContentType.Skin, + var s when s.Contains("skin") => ContentType.Addon, + var s when s.Contains("gui") => ContentType.Addon, + var s when s.Contains("hud") => ContentType.Addon, var s when s.Contains("language") => ContentType.LanguagePack, - var s when s.Contains("tool") => ContentType.ModdingTool, - var s when s.Contains("sdk") => ContentType.ModdingTool, - var s when s.Contains("ide") => ContentType.ModdingTool, - var s when s.Contains("source code") => ContentType.ModdingTool, - _ => ContentType.Addon, }; } diff --git a/GenHub/GenHub.Core/Models/ModDB/ModDBFilter.cs b/GenHub/GenHub.Core/Models/ModDB/ModDBFilter.cs index a977a5f08..88232974f 100644 --- a/GenHub/GenHub.Core/Models/ModDB/ModDBFilter.cs +++ b/GenHub/GenHub.Core/Models/ModDB/ModDBFilter.cs @@ -1,3 +1,7 @@ +using System; +using System.Collections.Generic; +using GenHub.Core.Constants; + namespace GenHub.Core.Models.ModDB; /// @@ -21,7 +25,7 @@ public class ModDBFilter public string? Licence { get; set; } /// Gets or sets the sort parameter. - public string? Sort { get; set; } + public string? Sort { get; set; } = ModDBConstants.DefaultSort; /// Gets or sets the page number (1-based). public int Page { get; set; } = 1; diff --git a/GenHub/GenHub.Core/Models/Parsers/Comment.cs b/GenHub/GenHub.Core/Models/Parsers/Comment.cs index 645e1fd5a..dd1532318 100644 --- a/GenHub/GenHub.Core/Models/Parsers/Comment.cs +++ b/GenHub/GenHub.Core/Models/Parsers/Comment.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; + namespace GenHub.Core.Models.Parsers; /// @@ -8,9 +11,13 @@ namespace GenHub.Core.Models.Parsers; /// The comment date (optional). /// The karma/vote score (optional). /// Whether the comment is from the content creator (optional). +/// Indentation depth level for reply threads (optional). +/// Child replies to this comment (optional). public record Comment( string? Author = null, string? Content = null, DateTime? Date = null, int? Karma = null, - bool? IsCreator = null) : ContentSection(SectionType.Comment, "Comment"); + bool? IsCreator = null, + int IndentLevel = 0, + IReadOnlyList? Replies = null) : ContentSection(SectionType.Comment, "Comment"); diff --git a/GenHub/GenHub.Core/Models/Parsers/File.cs b/GenHub/GenHub.Core/Models/Parsers/DownloadableFile.cs similarity index 58% rename from GenHub/GenHub.Core/Models/Parsers/File.cs rename to GenHub/GenHub.Core/Models/Parsers/DownloadableFile.cs index 99a75964e..0df14e67a 100644 --- a/GenHub/GenHub.Core/Models/Parsers/File.cs +++ b/GenHub/GenHub.Core/Models/Parsers/DownloadableFile.cs @@ -15,7 +15,13 @@ namespace GenHub.Core.Models.Parsers; /// Number of comments (optional). /// The thumbnail image URL (optional). /// Number of downloads (optional). -public record File( +/// The file section type (Downloads or Addons). +/// The release date (optional, may differ from upload date). +/// The web page details URL (optional). +/// The full description or release notes (optional). +/// List of preview image URLs (optional). +/// The actual file archive name (optional). +public record DownloadableFile( string Name, string? Version = null, long? SizeBytes = null, @@ -27,4 +33,10 @@ public record File( string? Md5Hash = null, int? CommentCount = null, string? ThumbnailUrl = null, - int? DownloadCount = null) : ContentSection(SectionType.File, Name); + int? DownloadCount = null, + FileSectionType FileSectionType = FileSectionType.Downloads, + DateTime? ReleaseDate = null, + string? DetailsUrl = null, + string? Description = null, + System.Collections.Generic.IReadOnlyList? PreviewImages = null, + string? Filename = null) : ContentSection(SectionType.File, Name); diff --git a/GenHub/GenHub.Core/Models/Parsers/FileSectionType.cs b/GenHub/GenHub.Core/Models/Parsers/FileSectionType.cs new file mode 100644 index 000000000..9fddb1afb --- /dev/null +++ b/GenHub/GenHub.Core/Models/Parsers/FileSectionType.cs @@ -0,0 +1,13 @@ +namespace GenHub.Core.Models.Parsers; + +/// +/// Represents the type of file section, distinguishing between main releases and addon files. +/// +public enum FileSectionType +{ + /// Files from the main releases/downloads section. + Downloads, + + /// Files from the addons section. + Addons, +} diff --git a/GenHub/GenHub.Core/Models/Providers/CatalogBundleComponentDescriptor.cs b/GenHub/GenHub.Core/Models/Providers/CatalogBundleComponentDescriptor.cs new file mode 100644 index 000000000..cf83a369c --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/CatalogBundleComponentDescriptor.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// A downloadable member of a , serialized onto +/// the bundle search result so the card can render per-component identity and variant pickers +/// without depending on sibling cards being visible in the current grid. +/// +public sealed class CatalogBundleComponentDescriptor +{ + /// Gets or sets the publisher id of the component. + [JsonPropertyName("publisherId")] + public string PublisherId { get; set; } = string.Empty; + + /// Gets or sets the catalog content id of the component. + [JsonPropertyName("contentId")] + public string ContentId { get; set; } = string.Empty; + + /// Gets or sets the display name of the component. + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Gets or sets the content type name (e.g. GameClient, Addon). + [JsonPropertyName("contentType")] + public string ContentType { get; set; } = string.Empty; + + /// Gets or sets a value indicating whether the component is optional. + [JsonPropertyName("isOptional")] + public bool IsOptional { get; set; } + + /// + /// Gets or sets a value indicating whether this is a base-game installation constraint + /// rather than downloadable catalog content. + /// + [JsonPropertyName("isBaseGame")] + public bool IsBaseGame { get; set; } + + /// Gets or sets the serialized catalog item JSON used to acquire this component. + [JsonPropertyName("catalogItemJson")] + public string CatalogItemJson { get; set; } = string.Empty; + + /// Gets or sets installable variants (one entry for non-variant content). + [JsonPropertyName("variants")] + public List Variants { get; set; } = []; +} diff --git a/GenHub/GenHub.Core/Models/Providers/CatalogBundleComponentVariantDescriptor.cs b/GenHub/GenHub.Core/Models/Providers/CatalogBundleComponentVariantDescriptor.cs new file mode 100644 index 000000000..5837b6ba2 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/CatalogBundleComponentVariantDescriptor.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// One installable option of a bundle component (a resolution, language, or the sole artifact). +/// +public sealed class CatalogBundleComponentVariantDescriptor +{ + /// Gets or sets the variant label shown in the dropdown (empty for non-variant content). + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + /// Gets or sets the variant axis (e.g. resolution). + [JsonPropertyName("axis")] + public string Axis { get; set; } = string.Empty; + + /// Gets or sets a value indicating whether this option is the default selection. + [JsonPropertyName("isDefault")] + public bool IsDefault { get; set; } + + /// Gets or sets the discoverer catalog ID for this variant. + [JsonPropertyName("catalogId")] + public string CatalogId { get; set; } = string.Empty; + + /// Gets or sets the serialized release JSON the resolver should use. + [JsonPropertyName("releaseJson")] + public string ReleaseJson { get; set; } = string.Empty; + + /// Gets or sets the download size in bytes. + [JsonPropertyName("downloadSize")] + public long DownloadSize { get; set; } +} diff --git a/GenHub/GenHub.Core/Models/Providers/CatalogContentItem.cs b/GenHub/GenHub.Core/Models/Providers/CatalogContentItem.cs new file mode 100644 index 000000000..a361fdb39 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/CatalogContentItem.cs @@ -0,0 +1,74 @@ +using System.Text.Json.Serialization; +using GenHub.Core.Models.Enums; + +namespace GenHub.Core.Models.Providers; + +/// +/// A content item entry within a publisher catalog. +/// Represents a mod, map, addon, or other content with one or more releases. +/// +public class CatalogContentItem +{ + /// + /// Gets or sets the unique content identifier within this publisher's catalog. + /// Combined with publisher ID to form the full manifest ID. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the human-readable content name. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the content description. + /// + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + /// + /// Gets or sets the content type (Mod, Map, Addon, etc.). + /// + [JsonPropertyName("contentType")] + public ContentType ContentType { get; set; } = ContentType.Mod; + + /// + /// Gets or sets the target game for this content. + /// + [JsonPropertyName("targetGame")] + public GameType TargetGame { get; set; } = GameType.ZeroHour; + + /// + /// Gets or sets the list of releases (versions) for this content. + /// + [JsonPropertyName("releases")] + public List Releases { get; set; } = []; + + /// + /// Gets or sets rich presentation metadata (banners, screenshots, videos). + /// + [JsonPropertyName("metadata")] + public ContentRichMetadata? Metadata { get; set; } + + /// + /// Gets or sets tags for categorization and search. + /// + [JsonPropertyName("tags")] + public List Tags { get; set; } = []; + + /// + /// Gets or sets native pipeline / publisher type that must process this item after download. + /// When omitted, generic catalog factory handles extraction. + /// + [JsonPropertyName("publisherType")] + public string? PublisherType { get; set; } + + /// + /// Gets or sets a value indicating whether this item is presented as a standalone card in catalog discovery. + /// Default is true. When set to false, the item is retained as a catalog component for bundle composition but hidden from the main downloads grid. + /// + [JsonPropertyName("isStandalone")] + public bool IsStandalone { get; set; } = true; +} diff --git a/GenHub/GenHub.Core/Models/Providers/CatalogDependency.cs b/GenHub/GenHub.Core/Models/Providers/CatalogDependency.cs new file mode 100644 index 000000000..df36fb63b --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/CatalogDependency.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// Represents a dependency on content from another publisher. +/// +public class CatalogDependency +{ + /// + /// Gets or sets the publisher ID of the dependency. + /// + [JsonPropertyName("publisherId")] + public string PublisherId { get; set; } = string.Empty; + + /// + /// Gets or sets the content ID within the publisher's catalog. + /// + [JsonPropertyName("contentId")] + public string ContentId { get; set; } = string.Empty; + + /// + /// Gets or sets the version constraint (e.g., ">=1.0.0", "^2.0", "1.5.0"). + /// + [JsonPropertyName("versionConstraint")] + public string? VersionConstraint { get; set; } + + /// + /// Gets or sets a value indicating whether the dependency is optional. + /// + [JsonPropertyName("isOptional")] + public bool IsOptional { get; set; } + + /// + /// Gets or sets the content type of the dependency (e.g., "GameInstallation", "Mod"). + /// When omitted, the resolver infers the type: a dependency declared by a GameClient + /// on its base game is treated as a . + /// + [JsonPropertyName("contentType")] + public string? ContentType { get; set; } + + /// + /// Gets or sets a hint for where to find this dependency (catalog URL). + /// + [JsonPropertyName("catalogUrl")] + public string? CatalogUrl { get; set; } +} diff --git a/GenHub/GenHub.Core/Models/Providers/CatalogManifestIdentity.cs b/GenHub/GenHub.Core/Models/Providers/CatalogManifestIdentity.cs new file mode 100644 index 000000000..7882835d5 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/CatalogManifestIdentity.cs @@ -0,0 +1,314 @@ +using System.Security.Cryptography; +using System.Text; +using GenHub.Core.Constants; +using GenHub.Core.Helpers; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; + +namespace GenHub.Core.Models.Providers; + +/// +/// Shared catalog identity helpers so discoverer search-result IDs, acquired manifest IDs, +/// and declared dependency IDs are generated from the same inputs. +/// +public static class CatalogManifestIdentity +{ + /// + /// Builds a 5-segment publisher content ID from catalog coordinates. + /// + /// Catalog publisher id (e.g. genhub-test-publishers). + /// The catalog item's content type. + /// Stable catalog content id, not the display name. + /// Release version or version constraint (operators are stripped). + /// A normalized manifest identifier. + public static string CreateContentId( + string publisherId, + ContentType contentType, + string catalogContentId, + string? version) + { + return ManifestIdGenerator.GeneratePublisherContentId( + publisherId, + contentType, + catalogContentId, + ExtractVersionNumber(version)); + } + + /// + /// Builds a variant-specific catalog ID by folding the variant label into the content-name segment. + /// + /// Catalog publisher id. + /// The catalog item's content type. + /// Stable catalog content id. + /// Variant label (e.g. 720p). + /// Release version. + /// Optional variant axis name (e.g. Quality). + /// A normalized manifest identifier unique to this variant. + public static string CreateVariantContentId( + string publisherId, + ContentType contentType, + string catalogContentId, + string variantLabel, + string? version, + string? variantAxis = null) + { + var variantSuffix = string.IsNullOrWhiteSpace(variantAxis) + ? variantLabel + : $"{variantAxis}-{variantLabel}"; + + return CreateContentId( + publisherId, + contentType, + $"{catalogContentId}-{variantSuffix}", + version); + } + + /// + /// Resolves the declared publisher type / native pipeline for a catalog item. + /// Returns an allowlisted publisher type or defaults to . + /// + /// The catalog content item. + /// The normalized publisher type string. + public static string ResolveDeclaredPublisherType(CatalogContentItem? item) + { + if (item != null && !string.IsNullOrWhiteSpace(item.PublisherType)) + { + var raw = item.PublisherType.Trim(); + if (raw.Equals(CatalogConstants.GenericCatalogResolverId, StringComparison.OrdinalIgnoreCase) || + raw.Equals(PublisherTypeConstants.TheSuperHackers, StringComparison.OrdinalIgnoreCase) || + raw.Equals(CommunityOutpostConstants.PublisherType, StringComparison.OrdinalIgnoreCase) || + raw.Equals(PublisherTypeConstants.GeneralsOnline, StringComparison.OrdinalIgnoreCase) || + raw.Equals(PublisherTypeConstants.GitHub, StringComparison.OrdinalIgnoreCase) || + raw.Equals(PublisherTypeConstants.ModDB, StringComparison.OrdinalIgnoreCase)) + { + return raw.ToLowerInvariant(); + } + } + + return CatalogConstants.GenericCatalogResolverId; + } + + /// + /// Converts a hyphen- or dot-separated slug into a human-readable title. + /// + /// The raw content identifier slug. + /// A title-cased display name. + public static string HumanizeContentId(string contentId) + { + if (string.IsNullOrWhiteSpace(contentId)) + { + return string.Empty; + } + + var words = contentId.Split(['-', '.', '_'], StringSplitOptions.RemoveEmptyEntries); + return string.Join(" ", words.Select(w => + w.Length > 0 ? char.ToUpperInvariant(w[0]) + w[1..] : w)); + } + + /// + /// Strips constraint operators (>=, ^, etc.) so version hashing matches the + /// release version the discoverer used. + /// + /// A raw version or constraint string. + /// The bare version token, or 0 when empty. + public static string StripVersionConstraint(string? constraint) + { + if (string.IsNullOrWhiteSpace(constraint)) + { + return "0"; + } + + var value = constraint.Trim(); + while (value.Length > 0 && value[0] is '>' or '<' or '=' or '^' or '~') + { + value = value[1..].TrimStart(); + } + + return string.IsNullOrWhiteSpace(value) ? "0" : value; + } + + /// + /// Converts a version or constraint into the integer segment used by manifest IDs. + /// Handles semantic versions (1.04 -> 104, 1.3 -> 103), date-based versions (2026.07.31 -> 20260731, + /// 2026-08-02 -> 20260802), weekly tags (weekly-2026-07-31 -> 20260731), and direct integers. + /// + /// Release version or constraint. + /// A deterministic non-negative integer. + public static int ExtractVersionNumber(string? version) + { + var cleanVersion = StripVersionConstraint(version).Trim(); + if (string.IsNullOrWhiteSpace(cleanVersion) || cleanVersion == "0") + { + return 0; + } + + if (cleanVersion.StartsWith("weekly-", StringComparison.OrdinalIgnoreCase)) + { + cleanVersion = cleanVersion["weekly-".Length..].Trim(); + } + else + { + cleanVersion = cleanVersion.TrimStart('v', 'V').Trim(); + } + + try + { + // 1. Try date parse with delimiters: "2026.07.31", "2026-08-02", "02-08-2026", "2026/08/02" + if (cleanVersion.Contains('.') || cleanVersion.Contains('-') || cleanVersion.Contains('/')) + { + var delims = new[] { '.', '-', '/' }; + var parts = cleanVersion.Split(delims, StringSplitOptions.RemoveEmptyEntries); + + // YYYY-MM-DD or YYYY.MM.DD or semver X.Y.Z (3 parts) + if (parts.Length == 3 && + int.TryParse(parts[0], out var p0) && + int.TryParse(parts[1], out var p1) && + int.TryParse(parts[2], out var p2)) + { + // Check if parts[0] is year (e.g. 2026.07.31 or 2026-08-02) + if (p0 >= 1990 && p0 <= 2100 && p1 >= 1 && p1 <= 12 && p2 >= 1 && p2 <= 31) + { + return (p0 * 10000) + (p1 * 100) + p2; + } + + // Check if parts[2] is year (e.g. 02-08-2026 -> day 2, month 8, year 2026) + if (p2 >= 1990 && p2 <= 2100 && p1 >= 1 && p1 <= 12 && p0 >= 1 && p0 <= 31) + { + return (p2 * 10000) + (p1 * 100) + p0; + } + + // Standard 3-part semantic version (e.g. 1.0.0 -> 10000, 1.2.3 -> 10203) + if (p0 >= 0 && p1 >= 0 && p2 >= 0) + { + var val = ((long)p0 * 10000) + ((long)p1 * 100) + p2; + if (val <= int.MaxValue) + { + return (int)val; + } + } + } + + // Standard 4-part semantic version (e.g. 1.0.0.0 -> 10000) + if (parts.Length == 4 && + int.TryParse(parts[0], out var m0) && m0 >= 0 && + int.TryParse(parts[1], out var m1) && m1 >= 0 && + int.TryParse(parts[2], out var m2) && m2 >= 0 && + int.TryParse(parts[3], out var m3) && m3 >= 0) + { + var val = ((long)m0 * 10000) + ((long)m1 * 100) + m2; + if (val <= int.MaxValue) + { + return (int)val; + } + } + + // Standard 2-part semantic version (e.g. 1.04 -> 104, 1.3 -> 103, 8.9 -> 809) + if (parts.Length == 2 && + int.TryParse(parts[0], out var major) && major >= 0 && + int.TryParse(parts[1], out var minor) && minor >= 0) + { + var normalized = $"{major}{minor.ToString().PadLeft(2, '0')}"; + if (int.TryParse(normalized, out var dotted) && dotted >= 0) + { + return dotted; + } + } + } + + // 2. Try Generals Online / underscore composite format (e.g. 081326_QFE2) + if (cleanVersion.Contains('_')) + { + var goVersion = GameVersionHelper.GetGeneralsOnlineManifestIdComponent(cleanVersion); + if (goVersion > 0) + { + return goVersion; + } + } + + // 3. Try integer directly (e.g. 20260802, 104, 0, 5) + if (int.TryParse(cleanVersion, out var intVersion) && intVersion >= 0) + { + return intVersion; + } + } + catch + { + // Fall through to hash-based approach + } + + var bytes = Encoding.UTF8.GetBytes(cleanVersion); + var hash = MD5.HashData(bytes); + return (int)((uint)BitConverter.ToInt32(hash, 0) % 1_000_000); + } + + /// + /// Detects a semantic base-game dependency (EA/any Zero Hour or Generals installation). + /// + /// The catalog dependency. + /// when this is a GameInstallation type constraint. + public static bool IsBaseGameDependency(CatalogDependency dependency) + { + ArgumentNullException.ThrowIfNull(dependency); + + if (!string.IsNullOrWhiteSpace(dependency.ContentType) && + Enum.TryParse(dependency.ContentType, ignoreCase: true, out var declared) && + declared == ContentType.GameInstallation) + { + return true; + } + + var publisher = dependency.PublisherId ?? string.Empty; + var contentId = dependency.ContentId ?? string.Empty; + var isEaOrAny = publisher.Equals("ea", StringComparison.OrdinalIgnoreCase) || + publisher.Equals("any", StringComparison.OrdinalIgnoreCase); + if (!isEaOrAny) + { + return false; + } + + return contentId.Equals("zerohour", StringComparison.OrdinalIgnoreCase) || + contentId.Equals("generals", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Resolves the content type a catalog dependency should use when minting its manifest ID. + /// + /// The catalog dependency. + /// The content item that declared the dependency. + /// Optional catalog index keyed by content id. + /// The content type to encode in the dependency ID. + public static ContentType ResolveDependencyContentType( + CatalogDependency dependency, + CatalogContentItem parent, + IReadOnlyDictionary? catalogItems = null) + { + ArgumentNullException.ThrowIfNull(dependency); + ArgumentNullException.ThrowIfNull(parent); + + if (!string.IsNullOrWhiteSpace(dependency.ContentType) && + Enum.TryParse(dependency.ContentType, ignoreCase: true, out var declared)) + { + return declared; + } + + if (IsBaseGameDependency(dependency)) + { + return ContentType.GameInstallation; + } + + if (catalogItems != null && + !string.IsNullOrWhiteSpace(dependency.ContentId) && + catalogItems.TryGetValue(dependency.ContentId, out var sibling)) + { + return sibling.ContentType; + } + + // A game client's undeclared leftover dependency is on the base game it requires. + if (parent.ContentType == ContentType.GameClient) + { + return ContentType.GameInstallation; + } + + return ContentType.Mod; + } +} diff --git a/GenHub/GenHub.Core/Models/Providers/CatalogTabCardDefinition.cs b/GenHub/GenHub.Core/Models/Providers/CatalogTabCardDefinition.cs new file mode 100644 index 000000000..891c47677 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/CatalogTabCardDefinition.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// Defines a display card supplied by a publisher for a catalog custom tab. +/// +public class CatalogTabCardDefinition +{ + /// Gets or sets the card heading. + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + /// Gets or sets supporting text for the card. + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + /// Gets or sets an optional local or remote image URL. + [JsonPropertyName("imageUrl")] + public string? ImageUrl { get; set; } + + /// Gets or sets an optional compact label displayed above the card title. + [JsonPropertyName("label")] + public string? Label { get; set; } + + /// Gets or sets the card accent colour in a format understood by Avalonia. + [JsonPropertyName("accentColor")] + public string AccentColor { get; set; } = "#303D59"; +} diff --git a/GenHub/GenHub.Core/Models/Providers/CatalogTabDefinition.cs b/GenHub/GenHub.Core/Models/Providers/CatalogTabDefinition.cs new file mode 100644 index 000000000..6728c996b --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/CatalogTabDefinition.cs @@ -0,0 +1,93 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// Defines a custom tab in a publisher's catalog. +/// This is the JSON representation that publishers use in their catalog.json files. +/// +public class CatalogTabDefinition +{ + /// + /// Gets or sets the unique identifier for this tab. + /// + [JsonPropertyName("tabId")] + public string TabId { get; set; } = string.Empty; + + /// + /// Gets or sets the display name shown in the tab header. + /// + [JsonPropertyName("header")] + public string Header { get; set; } = string.Empty; + + /// + /// Gets or sets the icon name or path for the tab (optional). + /// + [JsonPropertyName("icon")] + public string? Icon { get; set; } + + /// + /// Gets or sets the order/priority of the tab (lower numbers appear first). + /// + [JsonPropertyName("order")] + public int Order { get; set; } = 100; + + /// + /// Gets or sets the tab content type. + /// Valid values: "custom", "files", "addons", "videos", "images", "reviews", "articles", "richtext", "webview". + /// + [JsonPropertyName("contentType")] + public string ContentType { get; set; } = "custom"; + + /// + /// Gets or sets the data source URL for the tab content (optional). + /// Can be a catalog URL, API endpoint, or web page URL. + /// + [JsonPropertyName("dataSourceUrl")] + public string? DataSourceUrl { get; set; } + + /// + /// Gets or sets the content template identifier. + /// Used to determine which UI template to use for rendering. + /// + [JsonPropertyName("contentTemplate")] + public string? ContentTemplate { get; set; } + + /// + /// Gets or sets introductory copy displayed above the tab cards. + /// + [JsonPropertyName("intro")] + public string? Intro { get; set; } + + /// + /// Gets or sets the display cards supplied by the publisher for this tab. + /// + [JsonPropertyName("cards")] + public List Cards { get; set; } = []; + + /// + /// Gets or sets custom metadata for the tab. + /// Can be used to pass additional configuration to the tab renderer. + /// + [JsonPropertyName("metadata")] + public Dictionary Metadata { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the tab should be visible by default. + /// + [JsonPropertyName("isVisible")] + public bool IsVisible { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the tab content should be lazy-loaded. + /// + [JsonPropertyName("lazyLoad")] + public bool LazyLoad { get; set; } = true; + + /// + /// Gets or sets content IDs this tab applies to (optional). + /// If empty, applies to all content from this publisher. + /// + [JsonPropertyName("appliesTo")] + public List AppliesTo { get; set; } = []; +} diff --git a/GenHub/GenHub.Core/Models/Providers/ContentRelease.cs b/GenHub/GenHub.Core/Models/Providers/ContentRelease.cs new file mode 100644 index 000000000..02a13ac76 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/ContentRelease.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// Represents a specific version/release of a content item. +/// +public class ContentRelease +{ + /// + /// Gets or sets the semantic version string (e.g., "1.0.0", "2.1.0-beta"). + /// + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; + + /// + /// Gets or sets the release date. + /// + [JsonPropertyName("releaseDate")] + public DateTime ReleaseDate { get; set; } = DateTime.UtcNow; + + /// + /// Gets or sets a value indicating whether this is a prerelease version. + /// Prereleases are hidden by default unless user opts in. + /// + [JsonPropertyName("isPrerelease")] + public bool IsPrerelease { get; set; } + + /// + /// Gets or sets a value indicating whether this is the latest stable release. + /// Used for "Latest Only" version filtering. + /// + [JsonPropertyName("isLatest")] + public bool IsLatest { get; set; } + + /// + /// Gets or sets the changelog/release notes. + /// Supports markdown formatting. + /// + [JsonPropertyName("changelog")] + public string? Changelog { get; set; } + + /// + /// Gets or sets the downloadable artifacts for this release. + /// + [JsonPropertyName("artifacts")] + public List Artifacts { get; set; } = []; + + /// + /// Gets or sets dependencies required by this release. + /// + [JsonPropertyName("dependencies")] + public List Dependencies { get; set; } = []; +} diff --git a/GenHub/GenHub.Core/Models/Providers/ContentRichMetadata.cs b/GenHub/GenHub.Core/Models/Providers/ContentRichMetadata.cs new file mode 100644 index 000000000..bdc16dfe1 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/ContentRichMetadata.cs @@ -0,0 +1,57 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// Rich presentation metadata for content display in the UI. +/// +public class ContentRichMetadata +{ + /// + /// Gets or sets the banner image URL for content detail pages. + /// + [JsonPropertyName("bannerUrl")] + public string? BannerUrl { get; set; } + + /// + /// Gets or sets a collection of screenshot URLs. + /// + [JsonPropertyName("screenshotUrls")] + public List ScreenshotUrls { get; set; } = []; + + /// + /// Gets or sets a video URL (YouTube, Vimeo, direct MP4). + /// + [JsonPropertyName("videoUrl")] + public string? VideoUrl { get; set; } + + /// + /// Gets or sets a documentation or wiki URL. + /// + [JsonPropertyName("documentationUrl")] + public string? DocumentationUrl { get; set; } + + /// + /// Gets or sets the author display name (if different from publisher). + /// + [JsonPropertyName("author")] + public string? Author { get; set; } + + /// + /// Gets or sets the license type (MIT, GPL, etc.). + /// + [JsonPropertyName("license")] + public string? License { get; set; } + + /// + /// Gets or sets an optional category label shown on download cards and usable by filters. + /// + [JsonPropertyName("category")] + public string? Category { get; set; } + + /// + /// Gets or sets an optional player-count value shown on download cards. + /// + [JsonPropertyName("playerCount")] + public int? PlayerCount { get; set; } +} diff --git a/GenHub/GenHub.Core/Models/Providers/ProviderDefinition.cs b/GenHub/GenHub.Core/Models/Providers/ProviderDefinition.cs index 1a3ec04a9..3deafda94 100644 --- a/GenHub/GenHub.Core/Models/Providers/ProviderDefinition.cs +++ b/GenHub/GenHub.Core/Models/Providers/ProviderDefinition.cs @@ -6,10 +6,20 @@ namespace GenHub.Core.Models.Providers; /// -/// Defines a content provider loaded from external JSON configuration. -/// This model supports both "static" publishers (like GeneralsOnline, CommunityOutpost) -/// and "dynamic" author-based publishers (like GitHub topics, ModDB authors). +/// Defines a content provider's static configuration (Tier 1 — publisher metadata + endpoints). /// +/// +/// +/// Loaded today from bundled Providers/*.provider.json (GeneralsOnline, CommunityOutpost, …) +/// and drives built-in discoverers/parsers (catalog format, timeouts, version scheme). +/// +/// +/// This is not what genhub://subscribe persists today — subscribe stores a +/// to a URL. Publisher Studio +/// will later publish user-hosted definitions so users subscribe to a stable definition URL that +/// references catalog endpoint(s), keeping the same Downloads/generic-catalog pipeline for content. +/// +/// public class ProviderDefinition { /// diff --git a/GenHub/GenHub.Core/Models/Providers/PublisherCatalog.cs b/GenHub/GenHub.Core/Models/Providers/PublisherCatalog.cs new file mode 100644 index 000000000..a1974ab08 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/PublisherCatalog.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// Root model for a publisher's content catalog (Tier 2 — content listings). +/// +/// +/// +/// Creators host this JSON at any HTTPS URL (GitHub Releases/Pages, CDN, etc.). Users subscribe +/// via genhub://subscribe?url=<this-file>. Schema is validated by +/// JsonPublisherCatalogParser; discovery uses GenericCatalogDiscoverer so new +/// publishers need no GenHub code changes. +/// +/// +/// Distinct from (Tier 1 — static publisher config / catalog +/// endpoint). Bundled providers ship as *.provider.json; Publisher Studio will generate +/// user-hosted definitions that point at one or more catalogs of this shape. +/// +/// +public class PublisherCatalog +{ + /// + /// Gets or sets the schema version for catalog format compatibility. + /// + [JsonPropertyName("$schemaVersion")] + public int SchemaVersion { get; set; } = 1; + + /// + /// Gets or sets the publisher identity and branding information. + /// + [JsonPropertyName("publisher")] + public PublisherProfile Publisher { get; set; } = new(); + + /// + /// Gets or sets the list of content items available from this publisher. + /// + [JsonPropertyName("content")] + public List Content { get; set; } = []; + + /// + /// Gets or sets when the catalog was last updated. + /// + [JsonPropertyName("lastUpdated")] + public DateTime LastUpdated { get; set; } + + /// + /// Gets or sets an optional SHA256 signature for catalog integrity verification. + /// + [JsonPropertyName("signature")] + public string? Signature { get; set; } + + /// + /// Gets or sets referrals to other publishers (cross-publisher discovery). + /// + [JsonPropertyName("referrals")] + public List Referrals { get; set; } = []; + + /// + /// Gets or sets custom tabs for content detail pages. + /// Publishers can define custom tabs to display additional content. + /// + [JsonPropertyName("customTabs")] + public List CustomTabs { get; set; } = []; +} diff --git a/GenHub/GenHub.Core/Models/Providers/PublisherProfile.cs b/GenHub/GenHub.Core/Models/Providers/PublisherProfile.cs new file mode 100644 index 000000000..1c28da766 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/PublisherProfile.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// Publisher identity and branding information within a catalog. +/// +public class PublisherProfile +{ + /// + /// Gets or sets the unique publisher identifier (e.g., "my-mods", "general-steve"). + /// Used in manifest ID generation and subscription matching. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the human-readable publisher name. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the publisher's website URL. + /// + [JsonPropertyName("website")] + public string? Website { get; set; } + + /// + /// Gets or sets the publisher's avatar/logo URL. + /// + [JsonPropertyName("avatarUrl")] + public string? AvatarUrl { get; set; } + + /// + /// Gets or sets the support URL (Discord, GitHub Issues, etc.). + /// + [JsonPropertyName("supportUrl")] + public string? SupportUrl { get; set; } + + /// + /// Gets or sets the publisher's contact email. + /// + [JsonPropertyName("contactEmail")] + public string? ContactEmail { get; set; } +} diff --git a/GenHub/GenHub.Core/Models/Providers/PublisherReferral.cs b/GenHub/GenHub.Core/Models/Providers/PublisherReferral.cs new file mode 100644 index 000000000..861563de3 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/PublisherReferral.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// Represents a referral to another publisher's catalog. +/// Enables cross-publisher discovery and recommendations. +/// +public class PublisherReferral +{ + /// + /// Gets or sets the referred publisher's ID. + /// + [JsonPropertyName("publisherId")] + public string PublisherId { get; set; } = string.Empty; + + /// + /// Gets or sets the URL to the referred publisher's catalog. + /// + [JsonPropertyName("catalogUrl")] + public string CatalogUrl { get; set; } = string.Empty; + + /// + /// Gets or sets a descriptive note about the referral. + /// + [JsonPropertyName("note")] + public string? Note { get; set; } +} diff --git a/GenHub/GenHub.Core/Models/Providers/PublisherSubscription.cs b/GenHub/GenHub.Core/Models/Providers/PublisherSubscription.cs new file mode 100644 index 000000000..6be3eb34d --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/PublisherSubscription.cs @@ -0,0 +1,109 @@ +using System.Text.Json.Serialization; +using CommunityToolkit.Mvvm.ComponentModel; +using GenHub.Core.Models.Enums; + +namespace GenHub.Core.Models.Providers +{ + /// + /// A user's saved follow of a third-party content source, persisted in subscriptions.json. + /// + /// + /// + /// Not the same as GenHub.Core.Models.Content.PublisherSubscription, which stores + /// update-notification preferences for built-in publishers inside user settings. + /// + /// + /// Catalog-direct (current): points at a hosted GenHub + /// JSON. Downloads uses the generic catalog pipeline to browse + /// and install that content — no GenHub code change per creator. + /// + /// + /// Provider Definition (Publisher Studio, forthcoming): when + /// is set, GenHub will fetch publisher metadata and resolve catalog endpoint(s) from the + /// definition (stable subscribe link; catalogs can move). may then be + /// a resolved/cached endpoint rather than the share link itself. + /// + /// + public class PublisherSubscription : ObservableObject + { + private TrustLevel _trustLevel = TrustLevel.Untrusted; + + /// + /// Gets or sets the unique publisher identifier (from catalog / definition). + /// + [JsonPropertyName("publisherId")] + public string PublisherId { get; set; } = string.Empty; + + /// + /// Gets or sets the human-readable publisher name. + /// + [JsonPropertyName("publisherName")] + public string PublisherName { get; set; } = string.Empty; + + /// + /// Gets or sets the URL used to fetch the publisher's content catalog JSON. + /// + /// + /// For catalog-direct subscriptions this is the shared genhub://subscribe?url=... target. + /// For definition-based subscriptions this is the catalog endpoint resolved from the definition. + /// + [JsonPropertyName("catalogUrl")] + public string CatalogUrl { get; set; } = string.Empty; + + /// + /// Gets or sets an optional URL to a Provider Definition (publisher metadata + catalog endpoints). + /// + /// + /// Null for catalog-direct subscriptions. When Publisher Studio ships shareable definitions, + /// this becomes the primary subscribe target; catalogs are discovered from the definition. + /// + [JsonPropertyName("definitionUrl")] + public string? DefinitionUrl { get; set; } + + /// + /// Gets or sets when the subscription was added. + /// + [JsonPropertyName("added")] + public DateTime Added { get; set; } = DateTime.UtcNow; + + /// + /// Gets or sets the trust level for this publisher. + /// + [JsonPropertyName("trustLevel")] + public TrustLevel TrustLevel + { + get => _trustLevel; + set => SetProperty(ref _trustLevel, value); + } + + /// + /// Gets or sets a value indicating whether to auto-update content from this publisher. + /// + [JsonPropertyName("autoUpdate")] + public bool AutoUpdate { get; set; } = true; + + /// + /// Gets or sets a value indicating whether to notify on new releases. + /// + [JsonPropertyName("notifyNewReleases")] + public bool NotifyNewReleases { get; set; } = true; + + /// + /// Gets or sets the cached catalog hash for change detection. + /// + [JsonPropertyName("cachedCatalogHash")] + public string? CachedCatalogHash { get; set; } + + /// + /// Gets or sets when the catalog was last fetched. + /// + [JsonPropertyName("lastFetched")] + public DateTime? LastFetched { get; set; } + + /// + /// Gets or sets the publisher's avatar URL for sidebar display. + /// + [JsonPropertyName("avatarUrl")] + public string? AvatarUrl { get; set; } + } +} diff --git a/GenHub/GenHub.Core/Models/Providers/PublisherSubscriptionContainer.cs b/GenHub/GenHub.Core/Models/Providers/PublisherSubscriptionContainer.cs new file mode 100644 index 000000000..8de253fc0 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/PublisherSubscriptionContainer.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// Root model for subscriptions.json — the user's list of followed creator catalogs +/// (and later provider definitions) under application data. +/// +public class PublisherSubscriptionContainer +{ + /// + /// Gets or sets the format version for subscription file compatibility. + /// + [JsonPropertyName("version")] + public int Version { get; set; } = 1; + + /// + /// Gets or sets the list of publisher subscriptions. + /// + [JsonPropertyName("subscriptions")] + public List Subscriptions { get; set; } = []; +} diff --git a/GenHub/GenHub.Core/Models/Providers/ReleaseArtifact.cs b/GenHub/GenHub.Core/Models/Providers/ReleaseArtifact.cs new file mode 100644 index 000000000..e789afd42 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/ReleaseArtifact.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Providers; + +/// +/// Represents a downloadable file artifact within a release. +/// +public class ReleaseArtifact +{ + /// + /// Gets or sets the artifact filename (e.g., "MyMod-1.0.0.zip"). + /// + [JsonPropertyName("filename")] + public string Filename { get; set; } = string.Empty; + + /// + /// Gets or sets the direct download URL. + /// Supports GitHub Releases, ModDB, generic HTTP, Google Drive, Dropbox, etc. + /// + [JsonPropertyName("downloadUrl")] + public string DownloadUrl { get; set; } = string.Empty; + + /// + /// Gets or sets the file size in bytes. + /// + [JsonPropertyName("size")] + public long Size { get; set; } + + /// + /// Gets or sets the SHA256 hash for integrity verification. + /// + [JsonPropertyName("sha256")] + public string Sha256 { get; set; } = string.Empty; + + /// + /// Gets or sets the MIME type of the artifact. + /// + [JsonPropertyName("contentType")] + public string? ContentType { get; set; } + + /// + /// Gets or sets a value indicating whether this is the primary artifact. + /// When multiple artifacts exist, the primary one is downloaded by default. + /// + [JsonPropertyName("isPrimary")] + public bool IsPrimary { get; set; } + + /// + /// Gets or sets the variant axis this artifact belongs to (e.g. "resolution", "language", + /// "game-type"). When two or more artifacts in a release share an axis, the generic catalog + /// discoverer splits them into sibling cards under one variant group so the user can pick. + /// Omit for single-artifact or non-variant releases. + /// + [JsonPropertyName("variantAxis")] + public string? VariantAxis { get; set; } + + /// + /// Gets or sets the human-readable label for this artifact's variant (e.g. "1080p", + /// "1920x1080", "English"). Shown in the card's variant dropdown. + /// + [JsonPropertyName("variant")] + public string? Variant { get; set; } + + /// + /// Gets or sets a value indicating whether this artifact is the recommended default for its + /// axis. Exactly one artifact per axis should be marked default; the discoverer selects it as + /// the initially chosen variant in the dropdown. + /// + [JsonPropertyName("isDefaultVariant")] + public bool IsDefaultVariant { get; set; } +} diff --git a/GenHub/GenHub.Core/Models/Results/Content/ContentDiscoveryResult.cs b/GenHub/GenHub.Core/Models/Results/Content/ContentDiscoveryResult.cs index b02752d9f..cb1000810 100644 --- a/GenHub/GenHub.Core/Models/Results/Content/ContentDiscoveryResult.cs +++ b/GenHub/GenHub.Core/Models/Results/Content/ContentDiscoveryResult.cs @@ -1,3 +1,6 @@ +using System.Collections.Generic; +using GenHub.Core.Models.Content; + namespace GenHub.Core.Models.Results.Content; /// @@ -19,4 +22,9 @@ public class ContentDiscoveryResult /// Gets or initializes the total number of items available, if known. /// public int? TotalItems { get; init; } + + /// + /// Gets a value indicating whether discovery was blocked by a bot/Cloudflare challenge requiring user verification. + /// + public bool ChallengeDetected { get; init; } } diff --git a/GenHub/GenHub.Core/Models/Results/Content/ContentSearchResult.cs b/GenHub/GenHub.Core/Models/Results/Content/ContentSearchResult.cs index ce1a17f6e..3f3b17e0c 100644 --- a/GenHub/GenHub.Core/Models/Results/Content/ContentSearchResult.cs +++ b/GenHub/GenHub.Core/Models/Results/Content/ContentSearchResult.cs @@ -78,6 +78,13 @@ public class ContentSearchResult /// Gets or sets the source URL for resolution (e.g., specific mod page URL). public string? SourceUrl { get; set; } + /// + /// Gets or sets the direct URL selected from a content-details file list. + /// The resolver retains as the detail page and uses this value to + /// select the requested artifact without parsing the page again. + /// + public string? SelectedDownloadUrl { get; set; } + /// Gets additional metadata for resolvers. public IDictionary ResolverMetadata { get; } = new Dictionary(); @@ -106,4 +113,24 @@ public void UpdateId(string newId) { Id = newId; } -} \ No newline at end of file + + /// + /// Gets or sets the stable group key shared by every card that is a variant of the same + /// release. When two or more results share a non-null/non-empty , + /// the downloads browser collapses them into a single card with a variant picker. Null/empty + /// for single-variant content. + /// + public string? VariantGroupId { get; set; } + + /// + /// Gets or sets the display name of the variant family (e.g. "Control Bar Pro (Xezon)"), + /// shown as the collapsed card's title when groups siblings. + /// + public string? VariantFamilyName { get; set; } + + /// + /// Gets or sets the selectable variants exposed for this content. Populated when this card is the + /// primary representation of a variant group; null for single-variant content. + /// + public IList? Variants { get; set; } +} diff --git a/GenHub/GenHub.Core/Models/Results/Content/ContentStateChangedEventArgs.cs b/GenHub/GenHub.Core/Models/Results/Content/ContentStateChangedEventArgs.cs new file mode 100644 index 000000000..49513a6c0 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Results/Content/ContentStateChangedEventArgs.cs @@ -0,0 +1,37 @@ +namespace GenHub.Core.Models.Results.Content; + +using GenHub.Core.Models.Enums; + +/// +/// Event arguments for content state changes. +/// +public class ContentStateChangedEventArgs : EventArgs +{ + /// + /// Gets the ID of the content that changed state. + /// + public string ContentId { get; } + + /// + /// Gets the new state of the content. + /// + public ContentState NewState { get; } + + /// + /// Gets the manifest ID if available. + /// + public string? ManifestId { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The ID of the content that changed. + /// The new state of the content. + /// The manifest ID if available. + public ContentStateChangedEventArgs(string contentId, ContentState newState, string? manifestId = null) + { + ContentId = contentId; + NewState = newState; + ManifestId = manifestId; + } +} diff --git a/GenHub/GenHub.Core/Models/Results/Content/ContentVariantInfo.cs b/GenHub/GenHub.Core/Models/Results/Content/ContentVariantInfo.cs new file mode 100644 index 000000000..4396138a5 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Results/Content/ContentVariantInfo.cs @@ -0,0 +1,35 @@ +namespace GenHub.Core.Models.Results.Content; + +/// +/// Describes one selectable variant of a content release as surfaced to the downloads browser. +/// Carries the stable identity needed to group sibling cards and to resolve install state per +/// variant, independent of the on-disk manifest model. +/// +public class ContentVariantInfo +{ + /// + /// Gets or sets the variant identifier within its family (e.g. "1080p", "zerohour", "english"). + /// + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the display name of the variant (e.g. "1080p (Recommended)"). + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the variant discriminator type (e.g. "resolution", "language", "game-type"). + /// + public string VariantType { get; set; } = string.Empty; + + /// + /// Gets or sets the manifest id this variant resolves to once downloaded, when known. + /// May be empty for variants that are only named at discovery time. + /// + public string ManifestId { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether this is the recommended/default variant. + /// + public bool IsDefault { get; set; } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs index 49836a2dc..ac6509099 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs @@ -731,7 +731,7 @@ public void GetContentDirectories_WithNullUserSetting_ReturnsDefaults() { // Arrange var appDataPath = "/app/data/path"; - var userSettings = new UserSettings { ContentDirectories = [] }; + var userSettings = new UserSettings { ContentDirectories = new() }; _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); _mockAppConfig.Setup(x => x.GetConfiguredDataPath()).Returns(appDataPath); @@ -768,13 +768,13 @@ public void GetGitHubDiscoveryRepositories_WithUserSetting_ReturnsUserSetting() } /// - /// Verifies that GetGitHubDiscoveryRepositories returns defaults when user setting is null. + /// Verifies that GetGitHubDiscoveryRepositories returns defaults when user setting is empty. /// [Fact] - public void GetGitHubDiscoveryRepositories_WithNullUserSetting_ReturnsDefaults() + public void GetGitHubDiscoveryRepositories_WithEmptyUserSetting_ReturnsDefaults() { // Arrange - var userSettings = new UserSettings { GitHubDiscoveryRepositories = [] }; + var userSettings = new UserSettings { GitHubDiscoveryRepositories = new() }; _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); var provider = CreateProvider(); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/StripHtmlConverterTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/StripHtmlConverterTests.cs new file mode 100644 index 000000000..58f3e7b85 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/StripHtmlConverterTests.cs @@ -0,0 +1,70 @@ +using System; +using System.Globalization; +using GenHub.Infrastructure.Converters; +using Xunit; + +namespace GenHub.Tests.Core.Converters; + +/// +/// Unit tests for . +/// +public sealed class StripHtmlConverterTests +{ + private readonly StripHtmlConverter _converter = new(); + + /// + /// Verifies Convert strips HTML tags and normalizes text. + /// + [Fact] + public void Convert_WithHtmlMarkup_StripsTags() + { + var input = "

Test content with links.

"; + var result = _converter.Convert(input, typeof(string), null, CultureInfo.InvariantCulture); + + Assert.Equal("Test content with links.", result); + } + + /// + /// Verifies Convert with integer parameter truncates and single-lines text. + /// + [Fact] + public void Convert_WithMaxLenParameter_CleansToSingleLineAndTruncates() + { + var input = "

First line

\n\n

Second line with a lot of details here.

"; + var result = _converter.Convert(input, typeof(string), 25, CultureInfo.InvariantCulture); + + Assert.Equal("First line Second line...", result); + } + + /// + /// Verifies Convert with string parameter parses integer and truncates. + /// + [Fact] + public void Convert_WithStringParameter_ParsesAndTruncates() + { + var input = "

First line

\n\n

Second line with a lot of details here.

"; + var result = _converter.Convert(input, typeof(string), "25", CultureInfo.InvariantCulture); + + Assert.Equal("First line Second line...", result); + } + + /// + /// Verifies Convert handles non-string input by returning value untouched. + /// + [Fact] + public void Convert_NonStringValue_ReturnsOriginalValue() + { + var result = _converter.Convert(42, typeof(int), null, CultureInfo.InvariantCulture); + Assert.Equal(42, result); + } + + /// + /// Verifies ConvertBack throws NotSupportedException. + /// + [Fact] + public void ConvertBack_ThrowsNotSupportedException() + { + Assert.Throws(() => + _converter.ConvertBack("test", typeof(string), null, CultureInfo.InvariantCulture)); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/ContentTypeExtensionsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/ContentTypeExtensionsTests.cs new file mode 100644 index 000000000..dc8adbe87 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/ContentTypeExtensionsTests.cs @@ -0,0 +1,109 @@ +using System; +using GenHub.Core.Extensions; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Extensions; + +/// +/// Unit tests for . +/// +public class ContentTypeExtensionsTests +{ + /// + /// Tests that GetDisplayName returns expected user-friendly display names for all content types. + /// + /// The content type. + /// The expected display name. + [Theory] + [InlineData(ContentType.GameInstallation, "Game Installation")] + [InlineData(ContentType.GameClient, "GameClient")] + [InlineData(ContentType.Mod, "Mods")] + [InlineData(ContentType.Patch, "Patch")] + [InlineData(ContentType.Addon, "Addons")] + [InlineData(ContentType.MapPack, "Maps")] + [InlineData(ContentType.Map, "Map")] + [InlineData(ContentType.Mission, "Mission")] + [InlineData(ContentType.LanguagePack, "Language Pack")] + [InlineData(ContentType.ContentBundle, "Content Bundle")] + [InlineData(ContentType.PublisherReferral, "Publisher Referral")] + [InlineData(ContentType.ContentReferral, "Content Referral")] + [InlineData(ContentType.ModdingTool, "Tool")] + [InlineData(ContentType.Executable, "Executable")] + [InlineData(ContentType.Skin, "Skin")] + [InlineData(ContentType.Video, "Video")] + [InlineData(ContentType.Replay, "Replay")] + [InlineData(ContentType.Screensaver, "Screensaver")] + [InlineData(ContentType.UnknownContentType, "Unknown")] + public void GetDisplayName_ReturnsExpectedDisplayName(ContentType contentType, string expectedDisplayName) + { + var result = contentType.GetDisplayName(); + Assert.Equal(expectedDisplayName, result); + } + + /// + /// Tests that ToManifestIdString returns stable lowercase string representations for all content types. + /// + /// The content type. + /// The expected manifest ID segment string. + [Theory] + [InlineData(ContentType.GameInstallation, "gameinstallation")] + [InlineData(ContentType.GameClient, "gameclient")] + [InlineData(ContentType.Mod, "mod")] + [InlineData(ContentType.Patch, "patch")] + [InlineData(ContentType.Addon, "addon")] + [InlineData(ContentType.MapPack, "mappack")] + [InlineData(ContentType.LanguagePack, "languagepack")] + [InlineData(ContentType.ContentBundle, "contentbundle")] + [InlineData(ContentType.PublisherReferral, "publisherreferral")] + [InlineData(ContentType.ContentReferral, "contentreferral")] + [InlineData(ContentType.Mission, "mission")] + [InlineData(ContentType.Map, "map")] + [InlineData(ContentType.Skin, "skin")] + [InlineData(ContentType.Video, "video")] + [InlineData(ContentType.Replay, "replay")] + [InlineData(ContentType.Screensaver, "screensaver")] + [InlineData(ContentType.ModdingTool, "moddingtool")] + [InlineData(ContentType.Executable, "executable")] + [InlineData(ContentType.UnknownContentType, "unknown")] + public void ToManifestIdString_ReturnsExpectedManifestIdString(ContentType contentType, string expectedManifestString) + { + var result = contentType.ToManifestIdString(); + Assert.Equal(expectedManifestString, result); + } + + /// + /// Tests that no valid ContentType enum value produces 'unknown' except UnknownContentType. + /// + [Fact] + public void ToManifestIdString_AllValidEnums_DoNotReturnUnknown() + { + foreach (ContentType contentType in Enum.GetValues()) + { + if (contentType == ContentType.UnknownContentType) + { + continue; + } + + var manifestString = contentType.ToManifestIdString(); + Assert.NotEqual("unknown", manifestString); + } + } + + /// + /// Tests that IsStandalone returns true only for standalone tools and executables. + /// + /// The content type. + /// The expected boolean value. + [Theory] + [InlineData(ContentType.ModdingTool, true)] + [InlineData(ContentType.Executable, true)] + [InlineData(ContentType.Mod, false)] + [InlineData(ContentType.Addon, false)] + [InlineData(ContentType.GameClient, false)] + public void IsStandalone_ReturnsExpectedValue(ContentType contentType, bool expectedStandalone) + { + var result = contentType.IsStandalone(); + Assert.Equal(expectedStandalone, result); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CNCLabsMapDiscovererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CNCLabsMapDiscovererTests.cs index 6843eef2f..5590077a8 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CNCLabsMapDiscovererTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CNCLabsMapDiscovererTests.cs @@ -1,4 +1,4 @@ -using GenHub.Core.Constants; +using GenHub.Core.Constants; using GenHub.Core.Models.Content; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Results; @@ -147,18 +147,26 @@ public async Task DiscoverAsync_WithFilters_ParsesListAndProjectsResults() ContentType = GenHub.Core.Models.Enums.ContentType.Map, }; - // IMPORTANT: Adjust this HTML to match your CNCLabsConstants.* selectors. - // The idea is: each list item has a hidden input for the id AND a link with name + href. + // IMPORTANT: This HTML mirrors the 2026 Bootstrap redesign of cnclabs.com list pages. var listHtml = @" -
- - - COOP GLA vs CHI - Call of Dragon - -
- This is another custom scripted co-op mission map. 1 or 2 humans players as GLA against 1 China… - Author: El_Chapo +
+
+
+
+
COOP GLA vs CHI - Call of Dragon
+ Multiplayer-only + 2 Players +
+

This is another custom scripted co-op mission map. 1 or 2 humans players as GLA against 1 China…

+
+ El_Chapo + 2397 downloads +
+
+
+
234.2 KB
+
"; @@ -184,16 +192,70 @@ COOP GLA vs CHI - Call of Dragon Assert.Equal(string.Format(CNCLabsConstants.MapIdFormat, 3239), item.Id); Assert.Equal("COOP GLA vs CHI - Call of Dragon", item.Name); - Assert.Equal("This is another custom scripted co-op mission map. 1 or 2 humans players as GLA against 1 China…", item.Description); Assert.Equal("El_Chapo", item.AuthorName); Assert.Equal(GenHub.Core.Models.Enums.ContentType.Map, item.ContentType); Assert.Equal(GameType.Generals, item.TargetGame); Assert.Equal(CNCLabsConstants.ResolverId, item.ResolverId); Assert.True(item.RequiresResolution); Assert.Equal(CNCLabsConstants.SourceName, item.ProviderName); - Assert.Equal("/downloads/details.aspx?id=3239", item.SourceUrl); + Assert.Equal("https://www.cnclabs.com/downloads/details/3239/", item.SourceUrl); Assert.True(item.ResolverMetadata.ContainsKey(CNCLabsConstants.MapIdMetadataKey)); Assert.Equal("3239", item.ResolverMetadata[CNCLabsConstants.MapIdMetadataKey]); + Assert.Equal("This is another custom scripted co-op mission map. 1 or 2 humans players as GLA against 1 China…", item.Description); + + // Badges parsed from span.badge become tags, and the player-count badge is promoted + // into badge metadata by PromoteFromTags. + Assert.Contains("Multiplayer-only", item.Tags); + Assert.Contains("2 Players", item.Tags); + Assert.Equal("2", item.ResolverMetadata[GenHub.Core.Constants.ContentConstants.PlayerCountMetadataKey]); + } + + /// + /// Verifies that + /// correctly identifies more items when the "Next" link is present. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WithNextLink_SetsHasMoreItemsTrue() + { + // Arrange + var query = new ContentSearchQuery + { + TargetGame = GameType.Generals, + ContentType = GenHub.Core.Models.Enums.ContentType.Map, + Page = 1, + }; + + var html = @" + +
+
+
Test Map
+
+
+ +"; + + using var http = CreateHttpClient(_ => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(html), + }); + + var sut = CreateSut(http); + + // Act + var result = await sut.DiscoverAsync(query); + + // Assert + Assert.True(result.Success); + Assert.True(result.Data!.HasMoreItems); } // ---- helpers ---------------------------------------------------------- diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Common/ArchivePayloadProcessorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Common/ArchivePayloadProcessorTests.cs new file mode 100644 index 000000000..a767e8cbb --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Common/ArchivePayloadProcessorTests.cs @@ -0,0 +1,260 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Threading.Tasks; +using GenHub.Core.Models.Enums; +using GenHub.Features.Content.Services.Common; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Common; + +/// +/// Unit tests for archive payload processing and directory structure normalization. +/// +public sealed class ArchivePayloadProcessorTests : IDisposable +{ + private readonly string _stagingDirectory = Path.Combine(Path.GetTempPath(), "GenHubPayloadTests", Guid.NewGuid().ToString("N")); + + /// + /// Verifies that extracting a valid ZIP archive unpacks all entries and removes the archive file. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_ValidZip_ExtractsAllEntriesAndDeletesZip() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var zipPath = Path.Combine(_stagingDirectory, "test.zip"); + using (var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + using (var writer1 = new StreamWriter(archive.CreateEntry("Data/INI/GameData.ini").Open())) + { + await writer1.WriteAsync("GameData=1"); + } + + using (var writer2 = new StreamWriter(archive.CreateEntry("Art/Textures/test.tga").Open())) + { + await writer2.WriteAsync("Texture"); + } + } + + var processor = CreateProcessor(); + + // Act + await processor.ExtractArchivesSafelyAsync(_stagingDirectory); + + // Assert + Assert.False(File.Exists(zipPath)); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "GameData.ini"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Art", "Textures", "test.tga"))); + } + + /// + /// Verifies that multi-level nested wrapper directories (e.g. ModDB mods like C&C Generals Undone) + /// are recursively flattened so game assets end up directly at the workspace root. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_MultiLevelSingleWrapper_FlattensToRoot() + { + // Arrange + var nestedDir = Path.Combine(_stagingDirectory, "C&C Generals Undone v1.0", "C&C Generals Undone v1.0"); + Directory.CreateDirectory(Path.Combine(nestedDir, "Art", "Textures")); + Directory.CreateDirectory(Path.Combine(nestedDir, "Data", "INI")); + Directory.CreateDirectory(Path.Combine(nestedDir, "Window")); + + await File.WriteAllTextAsync(Path.Combine(nestedDir, "Readme.txt"), "Generals Undone Readme"); + await File.WriteAllTextAsync(Path.Combine(nestedDir, "Art", "Textures", "test.tga"), "texture data"); + await File.WriteAllTextAsync(Path.Combine(nestedDir, "Data", "INI", "GameData.ini"), "data"); + await File.WriteAllTextAsync(Path.Combine(nestedDir, "Window", "MainMenu.wnd"), "window"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour); + + // Assert + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Readme.txt"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Art", "Textures", "test.tga"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "GameData.ini"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Window", "MainMenu.wnd"))); + + // Old wrapper paths should no longer exist + Assert.False(Directory.Exists(Path.Combine(_stagingDirectory, "C&C Generals Undone v1.0"))); + } + + /// + /// Verifies that loose documentation files at root alongside a single mod wrapper directory + /// are reconciled by promoting the mod contents to the root and keeping the documentation files. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_LooseReadmeWithModWrapper_FlattensModWrapperAlongsideReadme() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "Readme.txt"), "Important instructions"); + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "ModDB_Link.url"), "https://www.moddb.com"); + + var modDir = Path.Combine(_stagingDirectory, "GeneralsUndone"); + Directory.CreateDirectory(Path.Combine(modDir, "Data", "INI")); + Directory.CreateDirectory(Path.Combine(modDir, "Art", "Textures")); + await File.WriteAllTextAsync(Path.Combine(modDir, "Data", "INI", "GameData.ini"), "inidata"); + await File.WriteAllTextAsync(Path.Combine(modDir, "Art", "Textures", "unit.tga"), "tgadata"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour); + + // Assert + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Readme.txt"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "ModDB_Link.url"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "GameData.ini"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Art", "Textures", "unit.tga"))); + Assert.False(Directory.Exists(modDir)); + } + + /// + /// Verifies that game-specific subdirectories matching the target game (e.g. "Zero Hour") + /// are promoted to the payload root. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_GameSpecificSubdirectory_PromotesMatchingTargetGameFolder() + { + // Arrange + var zhDir = Path.Combine(_stagingDirectory, "Zero Hour", "Data", "INI"); + Directory.CreateDirectory(zhDir); + await File.WriteAllTextAsync(Path.Combine(zhDir, "ZHData.ini"), "zh config"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour); + + // Assert + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "ZHData.ini"))); + Assert.False(Directory.Exists(Path.Combine(_stagingDirectory, "Zero Hour"))); + } + + /// + /// Verifies that single map directories for ContentType.Map are preserved with their map folder. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_MapContent_PreservesSingleMapDirectory() + { + // Arrange + var mapDir = Path.Combine(_stagingDirectory, "Lemuria"); + Directory.CreateDirectory(mapDir); + await File.WriteAllTextAsync(Path.Combine(mapDir, "Lemuria.map"), "map payload"); + await File.WriteAllTextAsync(Path.Combine(mapDir, "Lemuria.tga"), "preview payload"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Map, GameType.ZeroHour); + + // Assert + Assert.True(Directory.Exists(mapDir)); + Assert.True(File.Exists(Path.Combine(mapDir, "Lemuria.map"))); + Assert.True(File.Exists(Path.Combine(mapDir, "Lemuria.tga"))); + } + + /// + /// Verifies that double-wrapped map archives (e.g. MapDownload/MapName/MapName.map) + /// strip only the outer wrapper while preserving the inner map folder. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_MapContentWithDoubleWrapper_FlattensOuterWrapperOnly() + { + // Arrange + var outerWrapper = Path.Combine(_stagingDirectory, "MapDownloadWrapper"); + var mapDir = Path.Combine(outerWrapper, "Lemuria"); + Directory.CreateDirectory(mapDir); + await File.WriteAllTextAsync(Path.Combine(mapDir, "Lemuria.map"), "map payload"); + await File.WriteAllTextAsync(Path.Combine(mapDir, "Lemuria.tga"), "preview payload"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Map, GameType.ZeroHour); + + // Assert + Assert.False(Directory.Exists(outerWrapper)); + Assert.True(Directory.Exists(Path.Combine(_stagingDirectory, "Lemuria"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Lemuria", "Lemuria.map"))); + } + + /// + /// Verifies that system junk files (.DS_Store, Thumbs.db, desktop.ini, __MACOSX) + /// are purged during normalization. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_PurgesSystemJunk() + { + // Arrange + Directory.CreateDirectory(Path.Combine(_stagingDirectory, "__MACOSX")); + Directory.CreateDirectory(Path.Combine(_stagingDirectory, "Data")); + + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, ".DS_Store"), "junk"); + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "Thumbs.db"), "junk"); + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "desktop.ini"), "junk"); + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "__MACOSX", "._something"), "junk"); + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "Data", "GameData.ini"), "real data"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour); + + // Assert + Assert.False(File.Exists(Path.Combine(_stagingDirectory, ".DS_Store"))); + Assert.False(File.Exists(Path.Combine(_stagingDirectory, "Thumbs.db"))); + Assert.False(File.Exists(Path.Combine(_stagingDirectory, "desktop.ini"))); + Assert.False(Directory.Exists(Path.Combine(_stagingDirectory, "__MACOSX"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "GameData.ini"))); + } + + /// + /// Verifies that an HTML error page pretending to be an archive is rejected. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_HtmlErrorPayload_ThrowsInvalidDataException() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var fakeZip = Path.Combine(_stagingDirectory, "broken.zip"); + await File.WriteAllTextAsync(fakeZip, "Error 404 Not Found"); + + var processor = CreateProcessor(); + + // Act & Assert + var ex = await Assert.ThrowsAsync( + () => processor.ExtractArchivesSafelyAsync(_stagingDirectory)); + Assert.Contains("HTML", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Cleans up test staging directory. + /// + public void Dispose() + { + if (Directory.Exists(_stagingDirectory)) + { + Directory.Delete(_stagingDirectory, recursive: true); + } + } + + private static ArchivePayloadProcessor CreateProcessor() + { + return new ArchivePayloadProcessor(new Mock>().Object); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostDelivererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostDelivererTests.cs new file mode 100644 index 000000000..f28512970 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostDelivererTests.cs @@ -0,0 +1,380 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.GameInstallations; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Features.Content.Services.CommunityOutpost; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.CommunityOutpost; + +/// +/// Unit tests for . +/// +public sealed class CommunityOutpostDelivererTests +{ + /// + /// Verifies that ValidateContentAsync succeeds when a manifest contains a valid archive file. + /// + /// A task that completes when the operation finishes. + [Fact] + public async Task ValidateContentAsync_ValidDatArchive_ReturnsSuccess() + { + // Arrange + var downloadService = new Mock(); + var converter = new CompressedImageToTgaConverter(NullLogger.Instance); + var deliverer = new CommunityOutpostDeliverer( + downloadService.Object, + converter, + NullLogger.Instance); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.0.communityoutpost.addon.hlen"), + Name = "Hotkeys Indicators", + ContentType = ContentType.Addon, + TargetGame = GameType.ZeroHour, + Publisher = new PublisherInfo { PublisherType = CommunityOutpostConstants.PublisherType }, + Files = + [ + new ManifestFile + { + RelativePath = "hlen.dat", + DownloadUrl = "https://legi.cc/gp2/f/hlen.dat", + }, + ], + }; + + // Act + var result = await deliverer.ValidateContentAsync(manifest, CancellationToken.None); + + // Assert + Assert.True(result.Success); + Assert.True(result.Data); + } + + /// + /// Verifies that DeliverContentAsync falls back to the /gp2/f/ endpoint when the primary /patch/ URL fails. + /// + /// A task that completes when the operation finishes. + [Fact] + public async Task DeliverContentAsync_PatchUrlFailure_FallsBackToGp2FilesEndpoint() + { + // Arrange + var tempDirectory = Path.Combine(Path.GetTempPath(), "GenHubTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + + try + { + var downloadService = new Mock(); + var converter = new CompressedImageToTgaConverter(NullLogger.Instance); + var deliverer = new CommunityOutpostDeliverer( + downloadService.Object, + converter, + NullLogger.Instance); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.0.communityoutpost.addon.gent"), + Name = "GenTool", + ContentType = ContentType.Addon, + TargetGame = GameType.ZeroHour, + Publisher = new PublisherInfo { PublisherType = CommunityOutpostConstants.PublisherType }, + Metadata = new ContentMetadata { Tags = ["contentCode:gent"] }, + Files = + [ + new ManifestFile + { + RelativePath = "gent.zip", + DownloadUrl = "https://legi.cc/patch/gent.zip", + }, + ], + }; + + // Create a small valid zip file for the fallback download + var validZipBytes = CreateDummyZipArchive(); + + downloadService + .Setup(d => d.DownloadFileAsync( + new Uri("https://legi.cc/patch/gent.zip"), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(DownloadResult.CreateFailure("404 Not Found")); + + downloadService + .Setup(d => d.DownloadFileAsync( + new Uri("https://legi.cc/gp2/f/gent.dat"), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback?, CancellationToken>((_, dest, _, _, _) => + { + File.WriteAllBytes(dest, validZipBytes); + }) + .ReturnsAsync(DownloadResult.CreateSuccess("content.zip", validZipBytes.Length, TimeSpan.FromMilliseconds(100))); + + // Act + var result = await deliverer.DeliverContentAsync(manifest, tempDirectory, null, CancellationToken.None); + + // Assert + Assert.True(result.Success, result.FirstError); + downloadService.Verify( + d => d.DownloadFileAsync( + new Uri("https://legi.cc/gp2/f/gent.dat"), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Once); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + try + { + Directory.Delete(tempDirectory, recursive: true); + } + catch + { + } + } + } + } + + /// + /// Verifies that when the primary download URL returns an HTML error page with HTTP 200, + /// DeliverContentAsync rejects the HTML and successfully falls back to a valid archive URL. + /// + /// A task that completes when the operation finishes. + [Fact] + public async Task DeliverContentAsync_HtmlResponseOnPrimaryUrl_RejectsHtmlAndFallsBackToArchive() + { + // Arrange + var tempDirectory = Path.Combine(Path.GetTempPath(), "GenHubTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + + try + { + var downloadService = new Mock(); + var converter = new CompressedImageToTgaConverter(NullLogger.Instance); + var deliverer = new CommunityOutpostDeliverer( + downloadService.Object, + converter, + NullLogger.Instance); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.20260802.communityoutpost.gameclient.communitypatch"), + Name = "Community Patch", + ContentType = ContentType.GameClient, + TargetGame = GameType.ZeroHour, + Publisher = new PublisherInfo { PublisherType = CommunityOutpostConstants.PublisherType }, + Metadata = new ContentMetadata { Tags = ["contentCode:community-patch"] }, + Files = + [ + new ManifestFile + { + RelativePath = "community-patch.zip", + DownloadUrl = "https://legi.cc/gp2/f/community-patch.zip", + }, + ], + }; + + var validZipBytes = CreateDummyZipArchive(); + var htmlErrorBytes = System.Text.Encoding.UTF8.GetBytes("404 Not Found"); + + // Primary URL writes HTML error body + downloadService + .Setup(d => d.DownloadFileAsync( + new Uri("https://legi.cc/gp2/f/community-patch.zip"), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback?, CancellationToken>((_, dest, _, _, _) => + { + File.WriteAllBytes(dest, htmlErrorBytes); + }) + .ReturnsAsync(DownloadResult.CreateSuccess("content.zip", htmlErrorBytes.Length, TimeSpan.FromMilliseconds(50))); + + // Fallback GitHub URL writes valid zip archive + downloadService + .Setup(d => d.DownloadFileAsync( + new Uri("https://github.com/TheSuperHackers/GeneralsGameCode/releases/download/weekly-2026-07-31/generalszh-weekly-2026-07-31.zip"), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback?, CancellationToken>((_, dest, _, _, _) => + { + File.WriteAllBytes(dest, validZipBytes); + }) + .ReturnsAsync(DownloadResult.CreateSuccess("content.zip", validZipBytes.Length, TimeSpan.FromMilliseconds(100))); + + // Act + var result = await deliverer.DeliverContentAsync(manifest, tempDirectory, null, CancellationToken.None); + + // Assert + Assert.True(result.Success, result.FirstError); + downloadService.Verify( + d => d.DownloadFileAsync( + new Uri("https://github.com/TheSuperHackers/GeneralsGameCode/releases/download/weekly-2026-07-31/generalszh-weekly-2026-07-31.zip"), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Once); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + try + { + Directory.Delete(tempDirectory, recursive: true); + } + catch + { + } + } + } + } + + /// + /// Verifies that when delivering content resolved from a generic catalog without explicit manifest dependencies, + /// DeliverContentAsync discovers and merges registry-defined AutoInstall dependencies (such as hlen for hleg). + /// + /// A task that completes when the operation finishes. + [Fact] + public async Task DeliverContentAsync_GenericCatalogHotkeysWithoutExplicitDependencies_ProcessesAndMergesIndicatorsDependency() + { + // Arrange + var tempDirectory = Path.Combine(Path.GetTempPath(), "GenHubTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + + try + { + var downloadService = new Mock(); + var converter = new CompressedImageToTgaConverter(NullLogger.Instance); + var deliverer = new CommunityOutpostDeliverer( + downloadService.Object, + converter, + NullLogger.Instance); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.20260701.communityoutpost.addon.hleg"), + Name = "Legionnaire's Hotkeys", + ContentType = ContentType.Addon, + TargetGame = GameType.ZeroHour, + Publisher = new PublisherInfo { PublisherType = CommunityOutpostConstants.PublisherType }, + Metadata = new ContentMetadata { Tags = ["contentCode:hleg"] }, + Dependencies = [], + Files = + [ + new ManifestFile + { + RelativePath = "hleg.dat", + DownloadUrl = "https://legi.cc/gp2/f/hleg.dat", + }, + ], + }; + + var validZipBytes = CreateDummyZipArchive(); + + downloadService + .Setup(d => d.DownloadFileAsync( + It.Is(u => u.ToString().Contains("hleg")), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback?, CancellationToken>((_, dest, _, _, _) => + { + File.WriteAllBytes(dest, validZipBytes); + }) + .ReturnsAsync(DownloadResult.CreateSuccess("hleg.dat", validZipBytes.Length, TimeSpan.FromMilliseconds(50))); + + downloadService + .Setup(d => d.DownloadFileAsync( + It.Is(u => u.ToString().Contains("hlen")), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback?, CancellationToken>((_, dest, _, _, _) => + { + File.WriteAllBytes(dest, validZipBytes); + }) + .ReturnsAsync(DownloadResult.CreateSuccess("hlen.dat", validZipBytes.Length, TimeSpan.FromMilliseconds(50))); + + downloadService + .Setup(d => d.DownloadFileAsync( + It.Is(u => u.ToString().Contains("gent")), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback?, CancellationToken>((_, dest, _, _, _) => + { + File.WriteAllBytes(dest, validZipBytes); + }) + .ReturnsAsync(DownloadResult.CreateSuccess("gent.dat", validZipBytes.Length, TimeSpan.FromMilliseconds(50))); + + // Act + var result = await deliverer.DeliverContentAsync(manifest, tempDirectory, null, CancellationToken.None); + + // Assert + Assert.True(result.Success, result.FirstError); + downloadService.Verify( + d => d.DownloadFileAsync( + It.Is(u => u.ToString().Contains("hlen.dat")), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.AtLeastOnce); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + try + { + Directory.Delete(tempDirectory, recursive: true); + } + catch + { + } + } + } + } + + private static byte[] CreateDummyZipArchive() + { + using var memoryStream = new MemoryStream(); + using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true)) + { + var entry = archive.CreateEntry("dummy.txt"); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream); + writer.Write("test payload"); + } + + return memoryStream.ToArray(); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostDiscovererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostDiscovererTests.cs index 79cc0a612..33dfd7370 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostDiscovererTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostDiscovererTests.cs @@ -10,6 +10,7 @@ using Moq; using Moq.Protected; using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; namespace GenHub.Tests.Core.Features.Content.CommunityOutpost; @@ -67,7 +68,7 @@ public void CommunityPatchIdFormat_SpecificationDocumentation() // Arrange var versionDate = "2026-01-28"; var providerName = CommunityOutpostConstants.PublisherType; - var expectedId = $"1.{versionDate.Replace("-", string.Empty)}.{providerName}.gameclient.community-patch"; + var expectedId = CatalogManifestIdentity.CreateContentId(providerName, ContentType.GameClient, "community-patch", versionDate); // Act var segments = expectedId.Split('.'); @@ -78,7 +79,7 @@ public void CommunityPatchIdFormat_SpecificationDocumentation() Assert.Equal("20260128", segments[1]); // user version (date) Assert.Equal("communityoutpost", segments[2]); // publisher Assert.Equal("gameclient", segments[3]); // content type - Assert.Equal("community-patch", segments[4]); // content name + Assert.Equal("communitypatch", segments[4]); // content name } /// @@ -137,13 +138,13 @@ public async Task DiscoverAsync_GeneratesCorrectIdForCommunityPatch() // Assert Assert.True(result.Success, $"Discovery failed: {result.FirstError}"); Assert.NotEmpty(result.Data.Items); - var patch = result.Data.Items.FirstOrDefault(i => i.Id.Contains("community-patch")); + var patch = result.Data.Items.FirstOrDefault(i => i.Id.Contains("communitypatch") || i.Id.Contains("community-patch")); Assert.NotNull(patch); var idParts = patch.Id.Split('.'); Assert.Equal(5, idParts.Length); Assert.Equal("1", idParts[0]); Assert.Equal("communityoutpost", idParts[2]); Assert.Equal("gameclient", idParts[3]); - Assert.Equal("community-patch", idParts[4]); + Assert.Equal("communitypatch", idParts[4]); } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs index 8126cb280..cb8c3fee8 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs @@ -36,7 +36,10 @@ public CommunityOutpostManifestFactoryTests() _hashProviderMock.Setup(x => x.ComputeFileHashAsync(It.IsAny(), It.IsAny())) .ReturnsAsync("abc123hash"); - _factory = new CommunityOutpostManifestFactory(_loggerMock.Object, _hashProviderMock.Object, null!); + var payloadProcessor = new GenHub.Features.Content.Services.Common.ArchivePayloadProcessor( + new Mock>().Object); + + _factory = new CommunityOutpostManifestFactory(_loggerMock.Object, _hashProviderMock.Object, payloadProcessor); _tempDir = Path.Combine(Path.GetTempPath(), "GenHubTest_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDir); } @@ -139,4 +142,240 @@ public async Task CreateManifestsFromExtractedContentAsync_WithNoVariants_Should Assert.Equal("1.0.communityoutpost.addon.gent", manifests[0].Id.Value); Assert.Single(manifests[0].Files); } + + /// + /// Verifies that a runtime dependency not bundled as a BIG file remains in the final manifest. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_LegionnairesHotkeys_PreservesGenToolDependency() + { + // Arrange + File.WriteAllText(Path.Combine(_tempDir, "!HotkeysLegionnaireZH.big"), "mock content"); + var originalManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.communityoutpost.addon.hleg"), + Name = "Legionnaire's Hotkeys", + ContentType = GenHub.Core.Models.Enums.ContentType.Addon, + Publisher = new PublisherInfo { PublisherType = "communityoutpost" }, + Metadata = new ContentMetadata { Tags = ["contentCode:hleg"] }, + }; + + // Act + var manifest = Assert.Single(await _factory.CreateManifestsFromExtractedContentAsync(originalManifest, _tempDir)); + + // Assert + Assert.Contains(manifest.Dependencies, dependency => + dependency.Id.Value.EndsWith(".gent", StringComparison.OrdinalIgnoreCase) && + dependency.InstallBehavior == DependencyInstallBehavior.AutoInstall); + } + + /// + /// Flat Control Bar layouts place BIGs at the extract root even when a ZH folder exists + /// from merged language dependencies — storage must use the root, not ZH. + /// + [Fact] + public void GetManifestDirectory_RootRelativeFiles_IgnoresSiblingZhFolder() + { + var zhDir = Path.Combine(_tempDir, "ZH"); + Directory.CreateDirectory(zhDir); + File.WriteAllText(Path.Combine(_tempDir, "340_ControlBarPro1080ZH.big"), "mock"); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.0.communityoutpost.addon.cbpx-1080p"), + Name = "Control Bar Pro (Xezon) - 1080p (Recommended)", + TargetGame = GameType.ZeroHour, + Files = + [ + new ManifestFile + { + RelativePath = "340_ControlBarPro1080ZH.big", + SourcePath = Path.Combine(_tempDir, "340_ControlBarPro1080ZH.big"), + }, + ], + }; + + var directory = _factory.GetManifestDirectory(manifest, _tempDir); + + Assert.Equal(Path.GetFullPath(_tempDir), Path.GetFullPath(directory)); + } + + /// + /// Verifies that unextracted zip archives in the staging directory are automatically unpacked. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_ZipArchivePresent_ExtractsFilesAndDeletesZip() + { + // Arrange + var zipPath = Path.Combine(_tempDir, "community-patch.zip"); + using (var zip = System.IO.Compression.ZipFile.Open(zipPath, System.IO.Compression.ZipArchiveMode.Create)) + { + var entry1 = zip.CreateEntry("generals.exe"); + using (var writer = new StreamWriter(entry1.Open())) + { + writer.Write("mock exe content"); + } + + var entry2 = zip.CreateEntry("Patch.big"); + using (var writer = new StreamWriter(entry2.Open())) + { + writer.Write("mock big content"); + } + } + + var originalManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.communityoutpost.gameclient.communitypatch"), + Name = "Community Patch", + ContentType = GenHub.Core.Models.Enums.ContentType.GameClient, + TargetGame = GameType.ZeroHour, + Publisher = new PublisherInfo { PublisherType = "communityoutpost" }, + Metadata = new ContentMetadata { Tags = ["contentCode:community-patch"] }, + }; + + // Act + var manifests = await _factory.CreateManifestsFromExtractedContentAsync(originalManifest, _tempDir); + var manifest = Assert.Single(manifests); + + // Assert + Assert.False(File.Exists(zipPath), "Archive file should have been deleted after extraction."); + Assert.Contains(manifest.Files, f => f.RelativePath.Equals("generals.exe", StringComparison.OrdinalIgnoreCase) && f.IsExecutable); + Assert.Contains(manifest.Files, f => f.RelativePath.Equals("Patch.big", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Verifies that when a single variant is requested via SelectedVariantId, only that variant manifest is created. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_WithSelectedVariantId_BuildsOnlyRequestedVariant() + { + // Arrange + File.WriteAllText(Path.Combine(_tempDir, "340_ControlBarPro1080ZH.big"), "mock big content"); + + var originalManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.communityoutpost.addon.cbpr1080p"), + Name = "Control Bar Pro (ExiLe)", + ContentType = GenHub.Core.Models.Enums.ContentType.Addon, + TargetGame = GameType.ZeroHour, + Publisher = new PublisherInfo { PublisherType = "communityoutpost" }, + Metadata = new ContentMetadata + { + Tags = ["contentCode:cbpr"], + SelectedVariantId = "1080p", + }, + }; + + // Act + var manifests = await _factory.CreateManifestsFromExtractedContentAsync(originalManifest, _tempDir); + + // Assert + var manifest = Assert.Single(manifests); + Assert.Equal("1.0.communityoutpost.addon.cbpr-1080p", manifest.Id.Value); + Assert.Equal("communityoutpost.addon.cbpr", manifest.Metadata?.VariantGroupId); + Assert.Equal("1080p", manifest.Metadata?.SelectedVariantId); + Assert.Contains("1080p", manifest.Name); + } + + /// + /// Verifies that when a single variant is requested via tags, only that variant manifest is created. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_WithRequestedVariantTag_BuildsOnlyRequestedVariant() + { + // Arrange + File.WriteAllText(Path.Combine(_tempDir, "340_ControlBarPro1440ZH.big"), "mock big content"); + + var originalManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.communityoutpost.addon.cbpr"), + Name = "Control Bar Pro (ExiLe)", + ContentType = GenHub.Core.Models.Enums.ContentType.Addon, + TargetGame = GameType.ZeroHour, + Publisher = new PublisherInfo { PublisherType = "communityoutpost" }, + Metadata = new ContentMetadata + { + Tags = ["contentCode:cbpr", "requestedVariant:1440p"], + }, + }; + + // Act + var manifests = await _factory.CreateManifestsFromExtractedContentAsync(originalManifest, _tempDir); + + // Assert + var manifest = Assert.Single(manifests); + Assert.Equal("1.0.communityoutpost.addon.cbpr-1440p", manifest.Id.Value); + Assert.Equal("communityoutpost.addon.cbpr", manifest.Metadata?.VariantGroupId); + Assert.Equal("1440p", manifest.Metadata?.SelectedVariantId); + } + + /// + /// Verifies that when manifest ID has a hyphenated variant suffix, only that variant manifest is created. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_WithHyphenatedVariantInId_BuildsOnlyRequestedVariant() + { + // Arrange + File.WriteAllText(Path.Combine(_tempDir, "340_ControlBarPro1080ZH.big"), "mock big content"); + + var originalManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.communityoutpost.addon.cbpr-1080p"), + Name = "Control Bar Pro (ExiLe) - 1080p (Recommended)", + ContentType = GenHub.Core.Models.Enums.ContentType.Addon, + TargetGame = GameType.ZeroHour, + Publisher = new PublisherInfo { PublisherType = "communityoutpost" }, + Metadata = new ContentMetadata + { + Tags = ["contentCode:cbpr"], + }, + }; + + // Act + var manifests = await _factory.CreateManifestsFromExtractedContentAsync(originalManifest, _tempDir); + + // Assert + var manifest = Assert.Single(manifests); + Assert.Equal("1.0.communityoutpost.addon.cbpr-1080p", manifest.Id.Value); + Assert.Equal("communityoutpost.addon.cbpr", manifest.Metadata?.VariantGroupId); + Assert.Equal("1080p", manifest.Metadata?.SelectedVariantId); + } + + /// + /// Verifies that when original manifest has an empty version, a valid default version is set on the created manifest. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_WithEmptyVersion_SetsValidDefaultVersion() + { + // Arrange + File.WriteAllText(Path.Combine(_tempDir, "340_ControlBarPro1080ZH.big"), "mock big content"); + + var originalManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.communityoutpost.addon.cbpx-1080p"), + Name = "Control Bar Pro (Xezon) - 1080p (Recommended)", + Version = string.Empty, + ContentType = GenHub.Core.Models.Enums.ContentType.Addon, + TargetGame = GameType.ZeroHour, + Publisher = new PublisherInfo { PublisherType = "communityoutpost" }, + Metadata = new ContentMetadata + { + Tags = ["contentCode:cbpx"], + }, + }; + + // Act + var manifests = await _factory.CreateManifestsFromExtractedContentAsync(originalManifest, _tempDir); + + // Assert + var manifest = Assert.Single(manifests); + Assert.False(string.IsNullOrWhiteSpace(manifest.Version)); + Assert.Equal("1.0", manifest.Version); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostResolverTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostResolverTests.cs new file mode 100644 index 000000000..231733907 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostResolverTests.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Providers; +using GenHub.Core.Models.Results.Content; +using GenHub.Features.Content.Services.CommunityOutpost; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.CommunityOutpost; + +/// +/// Unit tests for . +/// +public sealed class CommunityOutpostResolverTests +{ + private readonly Mock _providerLoaderMock; + + /// + /// Initializes a new instance of the class. + /// + public CommunityOutpostResolverTests() + { + _providerLoaderMock = new Mock(); + + var provider = new ProviderDefinition + { + ProviderId = CommunityOutpostConstants.PublisherId, + PublisherType = CommunityOutpostConstants.PublisherType, + DisplayName = CommunityOutpostConstants.PublisherName, + }; + + _providerLoaderMock + .Setup(p => p.GetProvider(CommunityOutpostConstants.PublisherId)) + .Returns(provider); + } + + /// + /// Verifies that resolving a variant item populates SelectedVariantId and variant tags. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task ResolveAsync_WithVariantSearchResult_PopulatesSelectedVariantIdAndTags() + { + // Arrange + var searchResult = new ContentSearchResult + { + Id = "1.0.communityoutpost.addon.cbpr-1080p", + Name = "Control Bar Pro (ExiLe) - 1080p (Recommended)", + ProviderName = "communityoutpost", + ContentType = ContentType.Addon, + TargetGame = GameType.ZeroHour, + SourceUrl = "https://example.com/cbpr.dat", + }; + searchResult.ResolverMetadata["contentCode"] = "cbpr"; + searchResult.ResolverMetadata["selectedVariant"] = "1080p"; + + var builtManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.communityoutpost.addon.cbpr1080p"), + Name = "Control Bar Pro (ExiLe)", + ContentType = ContentType.Addon, + TargetGame = GameType.ZeroHour, + Files = [new ManifestFile { RelativePath = "cbpr.dat" }], + }; + + var builderMock = CreateBuilderMock(builtManifest); + var resolver = new CommunityOutpostResolver( + () => builderMock.Object, + _providerLoaderMock.Object, + NullLogger.Instance); + + // Act + var result = await resolver.ResolveAsync(searchResult, CancellationToken.None); + + // Assert + Assert.True(result.Success); + var manifest = result.Data; + Assert.NotNull(manifest); + Assert.Equal("1080p", manifest.Metadata?.SelectedVariantId); + Assert.Contains(manifest.Metadata?.Tags ?? [], t => t == "requestedVariant:1080p"); + Assert.Contains(manifest.Metadata?.Tags ?? [], t => t == "selectedVariant:1080p"); + Assert.Contains(manifest.Metadata?.Tags ?? [], t => t == "contentCode:cbpr"); + } + + /// + /// Verifies that resolving an item with catalog version 0 generates version 0 manifest. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task ResolveAsync_WithVersionZeroId_PreservesVersionZero() + { + // Arrange + var searchResult = new ContentSearchResult + { + Id = "1.0.communityoutpost.addon.gent", + Name = "GenTool", + ProviderName = "communityoutpost", + ContentType = ContentType.Addon, + TargetGame = GameType.ZeroHour, + Version = "1.0", + SourceUrl = "https://example.com/gent.zip", + }; + searchResult.ResolverMetadata["contentCode"] = "gent"; + + var builtManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.communityoutpost.addon.gent"), + Name = "GenTool", + ContentType = ContentType.Addon, + TargetGame = GameType.ZeroHour, + Files = [new ManifestFile { RelativePath = "gent.zip" }], + }; + + var builderMock = CreateBuilderMock(builtManifest); + var resolver = new CommunityOutpostResolver( + () => builderMock.Object, + _providerLoaderMock.Object, + NullLogger.Instance); + + // Act + var result = await resolver.ResolveAsync(searchResult, CancellationToken.None); + + // Assert + Assert.True(result.Success); + var manifest = result.Data; + Assert.NotNull(manifest); + Assert.StartsWith("1.0.communityoutpost.addon.", manifest.Id.Value); + } + + /// + /// Verifies that resolving an item with null or empty version falls back to a valid default version. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task ResolveAsync_WithNullVersion_SetsValidDefaultVersion() + { + // Arrange + var searchResult = new ContentSearchResult + { + Id = "file:https://example.com/cbpx.dat", + Name = "Control Bar Pro (Xezon) - 1080p (Recommended)", + ProviderName = "communityoutpost", + ContentType = ContentType.Addon, + TargetGame = GameType.ZeroHour, + Version = string.Empty, + SourceUrl = "https://example.com/cbpx.dat", + }; + searchResult.ResolverMetadata["contentCode"] = "cbpx"; + searchResult.ResolverMetadata["selectedVariant"] = "1080p"; + + var builtManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.communityoutpost.addon.cbpx1080p"), + Name = "Control Bar Pro (Xezon)", + ContentType = ContentType.Addon, + TargetGame = GameType.ZeroHour, + Files = [new ManifestFile { RelativePath = "cbpx.dat" }], + }; + + var builderMock = CreateBuilderMock(builtManifest); + var resolver = new CommunityOutpostResolver( + () => builderMock.Object, + _providerLoaderMock.Object, + NullLogger.Instance); + + // Act + var result = await resolver.ResolveAsync(searchResult, CancellationToken.None); + + // Assert + Assert.True(result.Success); + var manifest = result.Data; + Assert.NotNull(manifest); + Assert.False(string.IsNullOrWhiteSpace(manifest.Version)); + Assert.Equal("1.0", manifest.Version); + } + + private static Mock CreateBuilderMock(ContentManifest builtManifest) + { + var builderMock = new Mock(); + builderMock.Setup(b => b.WithBasicInfo(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(builderMock.Object); + builderMock.Setup(b => b.WithContentType(It.IsAny(), It.IsAny())) + .Returns(builderMock.Object); + builderMock.Setup(b => b.WithName(It.IsAny())) + .Returns(builderMock.Object); + builderMock.Setup(b => b.WithId(It.IsAny())) + .Returns(builderMock.Object); + builderMock.Setup(b => b.WithPublisher( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(builderMock.Object); + builderMock.Setup(b => b.WithMetadata( + It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny?>(), It.IsAny())) + .Returns(builderMock.Object); + builderMock.Setup(b => b.WithInstallationInstructions(It.IsAny())) + .Returns(builderMock.Object); + builderMock.Setup(b => b.AddRemoteFileAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(builderMock.Object); + builderMock.Setup(b => b.AddDependency( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>())) + .Returns(builderMock.Object); + builderMock.Setup(b => b.Build()).Returns(builtManifest); + return builderMock; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherDatCatalogParserTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherDatCatalogParserTests.cs new file mode 100644 index 000000000..ee5f7240b --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherDatCatalogParserTests.cs @@ -0,0 +1,51 @@ +using System.Linq; +using System.Threading.Tasks; +using GenHub.Core.Models.Providers; +using GenHub.Features.Content.Services.CommunityOutpost; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace GenHub.Tests.Core.Features.Content.CommunityOutpost; + +/// +/// Tests for . +/// +public class GenPatcherDatCatalogParserTests +{ + /// + /// Verifies that ParseAsync populates variants for Control Bar Pro metadata. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task ParseAsync_PopulatesVariantsForControlBarPro() + { + // arrange + var parser = new GenPatcherDatCatalogParser(NullLogger.Instance); + var catalogContent = "2.13 ;;\r\ncbpr 005000000 Mirror1 https://example.com/cbpr.zip"; + var provider = new ProviderDefinition + { + ProviderId = "communityoutpost", + PublisherType = "communityoutpost", + DisplayName = "Community Outpost", + }; + + // act + var result = await parser.ParseAsync(catalogContent, provider); + + // assert + Assert.True(result.Success); + Assert.NotNull(result.Data); + var items = result.Data.ToList(); + Assert.Single(items); + + var item = items[0]; + Assert.Equal("communityoutpost.addon.cbpr", item.VariantGroupId); + Assert.Equal("Control Bar Pro (ExiLe)", item.VariantFamilyName); + Assert.NotNull(item.Variants); + Assert.Equal(5, item.Variants.Count); + + var firstVariant = item.Variants.First(v => v.Id == "1080p"); + Assert.True(firstVariant.IsDefault); + Assert.Equal("1.0.communityoutpost.addon.cbpr-1080p", firstVariant.ManifestId); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherDependencyBuilderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherDependencyBuilderTests.cs index f90c1d1d5..4d85bd654 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherDependencyBuilderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherDependencyBuilderTests.cs @@ -194,6 +194,26 @@ public void GetDependencies_Hotkeys_RequiresIndicatorsPack(string contentCode) Assert.Contains(dependencies, d => d.Id.Value.EndsWith(".hlen") && d.DependencyType == ContentType.Addon); } + /// + /// Verifies that Legionnaire's Hotkeys automatically reconciles its GenTool runtime requirement. + /// + [Fact] + public void GetDependencies_LegionnairesHotkeys_AutoInstallsGenTool() + { + // Arrange + var metadata = GenPatcherContentRegistry.GetMetadata("hleg"); + + // Act + var dependencies = GenPatcherDependencyBuilder.GetDependencies("hleg", metadata); + + // Assert + Assert.Contains(dependencies, dependency => + dependency.Id.Value.EndsWith(".gent", StringComparison.OrdinalIgnoreCase) && + dependency.DependencyType == ContentType.Addon && + dependency.InstallBehavior == DependencyInstallBehavior.AutoInstall && + !dependency.IsOptional); + } + /// /// Verifies that control bars are marked as exclusive (conflict with each other). /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentDiscoverers/AODMapsDiscovererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentDiscoverers/AODMapsDiscovererTests.cs new file mode 100644 index 000000000..7d0425cf1 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentDiscoverers/AODMapsDiscovererTests.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Content; +using GenHub.Features.Content.Services.ContentDiscoverers; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Features.Content.ContentDiscoverers; + +/// +/// Regression tests for AODMaps discovery metadata displayed by download cards. +/// +public sealed class AODMapsDiscovererTests +{ + /// + /// Verifies gallery thumbnails resolve relative to their AOD category page and player metadata is retained. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task DiscoverAsync_AoaGalleryMap_UsesCategoryRelativeThumbnailAndPlayerBadgeMetadata() + { + var result = await DiscoverAsync( + CreateGalleryHtml(includeImage: true, downloadId: "4P_1_1"), + new ContentSearchQuery + { + AODMapsCategory = AODMapsConstants.CategoryAoa, + Take = 10, + }); + + var item = Assert.Single(result.Data!.Items); + Assert.Equal("https://aodmaps.com/AOA/preview.png", item.IconUrl); + Assert.Equal(string.Empty, item.Version); + Assert.Equal("4", item.ResolverMetadata[AODMapsConstants.PlayerCountMetadataKey]); + Assert.Equal(AODMapsConstants.CategoryAoa, item.ResolverMetadata[AODMapsConstants.CategoryMetadataKey]); + Assert.Contains("4 Players", item.Tags); + Assert.Contains(AODMapsConstants.CategoryAoa, item.Tags); + Assert.Equal("Community Art of Attack map for 4 players.", item.Description); + } + + /// + /// Verifies gallery maps with author and special AI/rule notes produce rich metadata and tags. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task DiscoverAsync_GalleryMapWithAuthorAndNotes_ExtractsAuthorAndRichDescription() + { + var html = """ + + + + """; + + var result = await DiscoverAsync( + html, + new ContentSearchQuery + { + Take = 10, + }); + + var item = Assert.Single(result.Data!.Items); + Assert.Equal("Pasha", item.AuthorName); + Assert.Contains("author:pasha", item.Tags); + Assert.Equal("Community Art of Defense map for 6 players by Pasha. Notes: No Laser, EMP, AI USA.", item.Description); + } + + /// + /// Verifies a missing source thumbnail has a usable publisher logo instead of an empty card image. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task DiscoverAsync_MapWithoutThumbnail_UsesAodMapsPublisherLogo() + { + var result = await DiscoverAsync( + CreateGalleryHtml(includeImage: false, downloadId: "4P_1_1"), + new ContentSearchQuery + { + AODMapsCategory = AODMapsConstants.CategoryAoa, + Take = 10, + }); + + var item = Assert.Single(result.Data!.Items); + Assert.Equal(PublisherInfoConstants.AODMaps.LogoSource, item.IconUrl); + } + + /// + /// Verifies combined category + player filters use the category page and keep only matching maps. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task DiscoverAsync_CategoryAndPlayerCount_FiltersToMatchingMapsFromCategoryPage() + { + var html = """ + + + + """; + + CapturingHandler? handler = null; + var result = await DiscoverAsync( + html, + new ContentSearchQuery + { + AODMapsCategory = AODMapsConstants.CategoryAoa, + AODMapsPlayerCount = "4 Players", + Take = 10, + }, + h => handler = h); + + var item = Assert.Single(result.Data!.Items); + Assert.Equal("Four Player Map", item.Name); + Assert.Equal("4", item.ResolverMetadata[AODMapsConstants.PlayerCountMetadataKey]); + Assert.Equal(AODMapsConstants.CategoryAoa, item.ResolverMetadata[AODMapsConstants.CategoryMetadataKey]); + Assert.NotNull(handler); + Assert.Contains(AODMapsConstants.AoaMapsUrl, handler!.RequestedUrls, StringComparer.OrdinalIgnoreCase); + Assert.DoesNotContain(handler.RequestedUrls, url => url.Contains("/Players/", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Verifies map maker items extract multi-paragraph descriptions, hints, and author metadata. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task DiscoverAsync_MapMakerItem_ExtractsMultiParagraphDescriptionAndAuthor() + { + var html = """ + +
+
+

- [AOD] Phantom Attack V2 fixed lag by SaMPoSa

+ - Type: Survival & Hold The Line - Difficultly: Extreme Brutal - Number of Players: 3 Players + +

-No need to restart the map.

+

-Every 6 Waves are Base Attacks.

+ DOWNLOAD the Map +
+
+ + """; + + var result = await DiscoverAsync( + html, + new ContentSearchQuery + { + Take = 10, + }); + + var item = Assert.Single(result.Data!.Items); + Assert.Equal("[AOD] Phantom Attack V2 fixed lag by SaMPoSa", item.Name); + Assert.Equal("SaMPoSa", item.AuthorName); + Assert.Contains("author:samposa", item.Tags); + Assert.Contains("Type: Survival & Hold The Line - Difficultly: Extreme Brutal - Number of Players: 3 Players", item.Description); + Assert.Contains("No need to restart the map", item.Description); + Assert.Contains("Every 6 Waves are Base Attacks", item.Description); + } + + /// + /// Verifies the Contra filter label resolves to the Contra AOD gallery instead of New Maps. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task DiscoverAsync_ContraCategory_UsesContraAodUrl() + { + CapturingHandler? handler = null; + var result = await DiscoverAsync( + CreateGalleryHtml(includeImage: false, downloadId: "6P_9_1"), + new ContentSearchQuery + { + AODMapsCategory = AODMapsConstants.CategoryContra, + Take = 10, + }, + h => handler = h); + + Assert.True(result.Success); + Assert.NotNull(handler); + Assert.Contains(AODMapsConstants.ContraAodUrl, handler!.RequestedUrls, StringComparer.OrdinalIgnoreCase); + } + + private static async Task> DiscoverAsync( + string html, + ContentSearchQuery query, + Action? configureHandler = null) + { + var handler = new CapturingHandler(html); + configureHandler?.Invoke(handler); + + var httpClientFactory = new Mock(); + httpClientFactory + .Setup(factory => factory.CreateClient(AODMapsConstants.DiscovererSourceName)) + .Returns(new HttpClient(handler)); + + var discoverer = new AODMapsDiscoverer( + httpClientFactory.Object, + new Mock>().Object); + + return await discoverer.DiscoverAsync(query); + } + + private static string CreateGalleryHtml(bool includeImage, string downloadId) + { + var image = includeImage ? "\"Map" : string.Empty; + return $""" + + + + """; + } + + private sealed class CapturingHandler(string html) : HttpMessageHandler + { + public List RequestedUrls { get; } = []; + + /// + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestedUrls.Add(request.RequestUri?.AbsoluteUri ?? string.Empty); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(html), + }); + } + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs index 615bd5884..74fd4c4cb 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs @@ -10,6 +10,7 @@ using GenHub.Core.Models.Results.Content; using GenHub.Core.Models.Validation; using GenHub.Features.Content.Services; +using GenHub.Features.Content.Services.Publishers; using Microsoft.Extensions.Logging; using Moq; using ContentType = GenHub.Core.Models.Enums.ContentType; @@ -28,6 +29,7 @@ public class ContentOrchestratorTests private readonly Mock _installationServiceMock = default!; private readonly Mock _installationCasPoolServiceMock = default!; private readonly Mock> _loggerMock = default!; + private readonly Mock _factoryResolverMock = default!; /// /// Initializes a new instance of the class. @@ -40,6 +42,9 @@ public ContentOrchestratorTests() _installationServiceMock = new Mock(); _installationCasPoolServiceMock = new Mock(); _loggerMock = new Mock>(); + _factoryResolverMock = new Mock( + new List(), + new Mock>().Object); } /// @@ -75,7 +80,8 @@ public async Task SearchAsync_AggregatesResultsFromMultipleProviders_Successfull _contentValidatorMock.Object, _manifestPoolMock.Object, _installationServiceMock.Object, - _installationCasPoolServiceMock.Object); + _installationCasPoolServiceMock.Object, + _factoryResolverMock.Object); // Act var result = await orchestrator.SearchAsync(new ContentSearchQuery()); @@ -136,18 +142,74 @@ public async Task AcquireContentAsync_ValidatesAndStoresContent_Successfully() _contentValidatorMock.Object, _manifestPoolMock.Object, _installationServiceMock.Object, - _installationCasPoolServiceMock.Object); + _installationCasPoolServiceMock.Object, + _factoryResolverMock.Object); // Act var result = await orchestrator.AcquireContentAsync(searchResult); // Assert - Assert.True(result.Success); + Assert.True(result.Success, result.FirstError); Assert.Equal(manifest, result.Data); _manifestPoolMock.Verify(m => m.AddManifestAsync(manifest, It.IsAny(), It.IsAny>(), It.IsAny()), Times.Once); _contentValidatorMock.Verify(v => v.ValidateManifestAsync(manifest, It.IsAny()), Times.Once); } + /// + /// Verifies late provider callbacks cannot move acquisition progress backwards. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task AcquireContentAsync_ProviderReportsOutOfOrderPhases_ReportsMonotonicStages() + { + // Arrange + var searchResult = new ContentSearchResult { Id = "1.0.genhub.mod.progress", Name = "Progress Test", ProviderName = "TestProvider" }; + var manifest = new ContentManifest { Id = "1.0.genhub.mod.progress", Name = "Progress Test" }; + var providerMock = new Mock(); + providerMock.Setup(p => p.SourceName).Returns("TestProvider"); + providerMock.Setup(p => p.GetValidatedContentAsync(searchResult.Id, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + providerMock.Setup(p => p.PrepareContentAsync(manifest, It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns((ContentManifest _, string _, IProgress? progress, CancellationToken _) => + { + progress?.Report(new ContentAcquisitionProgress { Phase = ContentAcquisitionPhase.Extracting, ProgressPercentage = 70 }); + progress?.Report(new ContentAcquisitionProgress { Phase = ContentAcquisitionPhase.Downloading, ProgressPercentage = 50 }); + return Task.FromResult(OperationResult.CreateSuccess(manifest)); + }); + + _contentValidatorMock.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny())) + .ReturnsAsync(new ValidationResult(manifest.Id, [])); + _contentValidatorMock.Setup(v => v.ValidateAllAsync(It.IsAny(), manifest, It.IsAny>(), It.IsAny())) + .ReturnsAsync(new ValidationResult(manifest.Id, [])); + _manifestPoolMock.Setup(m => m.IsManifestAcquiredAsync(manifest.Id, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var progressEvents = new List(); + var progress = new SynchronousProgress(progressEvents.Add); + + var orchestrator = new ContentOrchestrator( + _loggerMock.Object, + [providerMock.Object], + [], + [], + _cacheMock.Object, + _contentValidatorMock.Object, + _manifestPoolMock.Object, + _installationServiceMock.Object, + _installationCasPoolServiceMock.Object, + _factoryResolverMock.Object); + + // Act + var result = await orchestrator.AcquireContentAsync(searchResult, progress); + await Task.Delay(100); + + // Assert + Assert.True(result.Success, result.FirstError); + Assert.NotEmpty(progressEvents); + Assert.Equal(5, progressEvents[^1].CurrentStage); + Assert.True(progressEvents.Select(e => e.CurrentStage).SequenceEqual(progressEvents.Select(e => e.CurrentStage).Order())); + } + /// /// Stops GameClient acquisition when storage settings cannot be saved safely. /// @@ -204,6 +266,7 @@ public async Task AcquireContentAsync_WhenGameClientPoolCannotBeEnsured_ReturnsF It.IsAny>(), It.IsAny())) .ReturnsAsync(false); + var orchestrator = new ContentOrchestrator( _loggerMock.Object, [providerMock.Object], @@ -213,7 +276,8 @@ public async Task AcquireContentAsync_WhenGameClientPoolCannotBeEnsured_ReturnsF _contentValidatorMock.Object, _manifestPoolMock.Object, _installationServiceMock.Object, - _installationCasPoolServiceMock.Object); + _installationCasPoolServiceMock.Object, + _factoryResolverMock.Object); var result = await orchestrator.AcquireContentAsync(searchResult); @@ -475,4 +539,9 @@ public async Task AcquireContentAsync_WhenInstallationDetectionCancels_Propagate await Assert.ThrowsAnyAsync( () => orchestrator.AcquireContentAsync(searchResult, progress: null, cts.Token)); } + + private sealed class SynchronousProgress(Action report) : IProgress + { + public void Report(T value) => report(value); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubInferenceHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubInferenceHelperTests.cs index 254c55dc8..808df737d 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubInferenceHelperTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubInferenceHelperTests.cs @@ -31,6 +31,30 @@ public void InferContentType_ReturnsExpectedContentType(string repo, string? rel Assert.True(isInferred, "Inference result should be marked as inferred for heuristic matches."); } + /// + /// GeneralsGameCode releases must be classified as GameClient explicitly (not inferred), + /// so that SuperHackersManifestFactory.CanHandle accepts the resolved manifest. + /// + [Fact] + public void InferContentType_GeneralsGameCode_ReturnsExplicitGameClient() + { + var (type, isInferred) = GitHubInferenceHelper.InferContentType("GeneralsGameCode", "weekly-2026-07-24"); + Assert.Equal(ContentType.GameClient, type); + Assert.False(isInferred); + } + + /// + /// When no known topic is present the topic lookup returns an inferred Addon guess; + /// callers must treat IsInferred == true as "run the name-based fallback". + /// + [Fact] + public void InferContentTypeFromTopics_UnknownTopics_ReturnsInferredAddon() + { + var (type, isInferred) = GitHubInferenceHelper.InferContentTypeFromTopics(new[] { "some-unrelated-topic" }); + Assert.Equal(ContentType.Addon, type); + Assert.True(isInferred); + } + /// /// Verifies returns the expected game type and marks it as inferred. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubResolverTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubResolverTests.cs index a6e824d64..9b2455210 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubResolverTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubResolverTests.cs @@ -120,6 +120,50 @@ public async Task ResolveAsync_MissingMetadata_ReturnsFailure() Assert.False(result.Success); } + /// + /// Verifies that an explicitly selected release asset produces a manifest for that asset only. + /// + /// A task representing the test. + [Fact] + public async Task ResolveAsync_WithSelectedReleaseAsset_ResolvesOnlyThatAsset() + { + var discoveredItem = CreateItem("v1.0"); + discoveredItem.ResolverMetadata["asset-name"] = "generalszh-weekly.zip"; + var release = new GitHubRelease + { + TagName = "v1.0", + Assets = + [ + new GitHubReleaseAsset { Name = "generals-weekly.zip", BrowserDownloadUrl = "https://example.test/generals.zip" }, + new GitHubReleaseAsset { Name = "generalszh-weekly.zip", BrowserDownloadUrl = "https://example.test/zerohour.zip" }, + ], + }; + _apiClientMock + .Setup(client => client.GetReleaseByTagAsync("owner", "repo", "v1.0", It.IsAny())) + .ReturnsAsync(release); + SetupBuilder(release); + + var result = await _resolver.ResolveAsync(discoveredItem); + + Assert.True(result.Success); + _manifestBuilderMock.Verify( + builder => builder.AddRemoteFileAsync( + "generalszh-weekly.zip", + "https://example.test/zerohour.zip", + ContentSourceType.RemoteDownload, + It.IsAny(), + It.IsAny()), + Times.Once); + _manifestBuilderMock.Verify( + builder => builder.AddRemoteFileAsync( + "generals-weekly.zip", + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + /// /// Disposes of the test resources. /// @@ -158,4 +202,4 @@ private void SetupBuilder(GitHubRelease release) _manifestBuilderMock.Setup(m => m.AddRemoteFileAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(_manifestBuilderMock.Object); _manifestBuilderMock.Setup(m => m.Build()).Returns(new ContentManifest { Version = release.TagName }); } -} \ No newline at end of file +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ModDB/ModDBCategoryMapperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ModDB/ModDBCategoryMapperTests.cs new file mode 100644 index 000000000..73e18587f --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ModDB/ModDBCategoryMapperTests.cs @@ -0,0 +1,63 @@ +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.ModDB; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.ModDB; + +/// +/// Unit tests for . +/// +public class ModDBCategoryMapperTests +{ + /// + /// Verifies that MapCategory maps ModDB category codes correctly, especially mapping patches and scripts to Mod. + /// + /// The category code to map. + /// The expected content type. + [Theory] + [InlineData("2", ContentType.Mod)] + [InlineData("3", ContentType.Mod)] + [InlineData("4", ContentType.Mod)] + [InlineData("28", ContentType.Mod)] + [InlineData("29", ContentType.Addon)] + [InlineData("7", ContentType.Video)] + [InlineData("8", ContentType.Video)] + [InlineData("101", ContentType.Map)] + [InlineData("102", ContentType.Map)] + [InlineData("112", ContentType.Addon)] + [InlineData("125", ContentType.Addon)] + [InlineData("126", ContentType.Addon)] + [InlineData("20", ContentType.ModdingTool)] + [InlineData("30", ContentType.LanguagePack)] + public void MapCategory_MapsCategoryCodesCorrectly(string categoryCode, ContentType expected) + { + var result = ModDBCategoryMapper.MapCategory(categoryCode); + Assert.Equal(expected, result); + } + + /// + /// Verifies that MapCategoryByName maps category names correctly, mapping patch and script names to Mod. + /// + /// The category name to map. + /// The expected content type. + [Theory] + [InlineData("Full Version", ContentType.Mod)] + [InlineData("Demo", ContentType.Mod)] + [InlineData("Patch", ContentType.Mod)] + [InlineData("v1.01 Patch", ContentType.Mod)] + [InlineData("Script", ContentType.Mod)] + [InlineData("Multiplayer Map", ContentType.Map)] + [InlineData("Singleplayer Map", ContentType.Map)] + [InlineData("Player Skin", ContentType.Addon)] + [InlineData("GUI", ContentType.Addon)] + [InlineData("HUD", ContentType.Addon)] + [InlineData("Mapping Tool", ContentType.ModdingTool)] + [InlineData("Language Pack", ContentType.LanguagePack)] + [InlineData("Trailer", ContentType.Video)] + public void MapCategoryByName_MapsNamesCorrectly(string categoryName, ContentType expected) + { + var result = ModDBCategoryMapper.MapCategoryByName(categoryName); + Assert.Equal(expected, result); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Parsers/ModDBPageParserTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Parsers/ModDBPageParserTests.cs new file mode 100644 index 000000000..cab1ec29b --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Parsers/ModDBPageParserTests.cs @@ -0,0 +1,1656 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AngleSharp; +using AngleSharp.Dom; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Tools; +using GenHub.Core.Models.Parsers; +using GenHub.Features.Content.Services.Parsers; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Features.Content.Parsers; + +/// +/// Regression tests for the current ModDB detail markup and Cloudflare-aware section loading. +/// +public sealed class ModDBPageParserTests +{ + /// + /// Verifies the current game-addon detail page maps its metadata and /addons/start route into + /// a usable file rather than returning an empty download URL. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task ParseAsync_CurrentAddonDetailMarkup_ExtractsArchiveNameAndAddonStartUrl() + { + // Arrange + var playwright = CreatePlaywrightMock(); + var pageUrl = "https://www.moddb.com/games/cc-generals-zero-hour/addons/lemuria-2026-fixes"; + var doc = await CreateDocumentAsync(""" + +
+
Filename
Lemuria_2026_Fixes.rar
+ +
Uploader
BagaturKhan
+
Added
+
Size
1.07mb (1,125,450 bytes)
+ +
+ + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseAsync(pageUrl); + + // Assert + var file = Assert.Single(parsed.Sections.OfType()); + Assert.Equal("Lemuria_2026_Fixes.rar", file.Name); + Assert.Equal("https://www.moddb.com/addons/start/302328", file.DownloadUrl); + Assert.Equal("Singleplayer Map", file.Category); + Assert.Equal(1_125_450, file.SizeBytes); + } + + /// + /// Game-scoped FileDetail URLs (from the ModDB downloads listing) have no parent /mods/ page + /// to sweep, so the detail view must still populate Community from comments on the file page + /// itself instead of leaving only a single Releases row. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task ParseAsync_GameFileDetail_ExtractsOnPageCommentsWithoutParentSweep() + { + // Arrange + const string pageUrl = "https://www.moddb.com/games/cc-generals-zero-hour/downloads/genbigeditbig-editor"; + var playwright = CreatePlaywrightMock(); + var doc = await CreateDocumentAsync(""" + +
+
Filename
GenBigEdit.zip
+
Size
174.33mb (182,801,143 bytes)
+ +
+
+
+
mah_boi May 30 2026
+
Please, provide us the source code of this program.
+
+
+ + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseAsync(pageUrl); + + // Assert + Assert.Equal(PageType.FileDetail, parsed.PageType); + var file = Assert.Single(parsed.Sections.OfType()); + Assert.Equal("GenBigEdit.zip", file.Name); + Assert.Equal("https://www.moddb.com/downloads/start/310120", file.DownloadUrl); + + var comment = Assert.Single(parsed.Sections.OfType()); + Assert.Equal("mah_boi", comment.Author); + Assert.Equal("Please, provide us the source code of this program.", comment.Content); + + // Must not attempt a parent-mod section sweep for /games/... FileDetail URLs (fetches only the single URL). + playwright.Verify( + service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.Is>(urls => urls.Count == 1 && urls[0] == pageUrl), + It.IsAny()), + Times.Once); + } + + /// + /// Verifies an addons-list row retains its ModDB category so a map does not become a generic + /// add-on later in the resolver and manifest pipeline. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task ParseAsync_AddonsListRow_ExtractsSingleplayerMapCategory() + { + // Arrange + const string pageUrl = "https://www.moddb.com/games/cc-generals-zero-hour/addons"; + var playwright = CreatePlaywrightMock(); + var doc = await CreateDocumentAsync(""" + +
+

Lemuria 2026

+ Singleplayer Map + 1.07 MB + Download +
+ + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseAsync(pageUrl); + + // Assert + var file = Assert.Single(parsed.Sections.OfType()); + Assert.Equal("Singleplayer Map", file.Category); + Assert.Equal(FileSectionType.Addons, file.FileSectionType); + Assert.Equal("https://www.moddb.com/addons/start/302328", file.DownloadUrl); + } + + /// + /// Verifies that rich ModDB sections use the verified persistent Chromium profile instead of + /// a separate headless browser that loses Cloudflare clearance. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task ParseAsync_ModDetail_UsesPersistentProfileForDownloadsAndAddons() + { + // Arrange + const string pageUrl = "https://www.moddb.com/mods/example-mod"; + var documents = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [pageUrl] = await CreateDocumentAsync("

Example Mod

"), + [pageUrl + "/downloads"] = await CreateDocumentAsync(""" +

Example Release

4 MBDownload
+ """), + [pageUrl + "/addons"] = await CreateDocumentAsync(""" +

Example Addon

2 MBDownload
+ """), + [pageUrl + "/videos"] = await CreateDocumentAsync(""), + [pageUrl + "/images"] = await CreateDocumentAsync(""), + [pageUrl + "/reviews"] = await CreateDocumentAsync(""), + [pageUrl + "/articles"] = await CreateDocumentAsync(""), + }; + var playwright = CreatePlaywrightMock(); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .Returns((string _, IReadOnlyList urls, CancellationToken _) => + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var url in urls) + { + if (documents.TryGetValue(url, out var d)) + { + result[url] = d; + } + } + + return Task.FromResult>(result); + }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseAsync(pageUrl); + + // Assert + var files = parsed.Sections.OfType().ToList(); + Assert.Contains(files, file => file.Name == "Example Release" && file.DownloadUrl == "https://www.moddb.com/downloads/start/100"); + Assert.Contains(files, file => file.Name == "Example Addon" && file.DownloadUrl == "https://www.moddb.com/addons/start/200"); + playwright.Verify( + service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.Is>(urls => + urls.Contains(pageUrl) && urls.Contains(pageUrl + "/downloads") && urls.Contains(pageUrl + "/addons")), + It.IsAny()), + Times.Once); + playwright.Verify(service => service.FetchAndParseAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies the file-only acquisition path resolves a FileDetail download without fetching the + /// parent mod's downloads/addons/videos/images/reviews/articles sections (the seven-page sweep + /// that previously fired on every card download). + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task ParseFileDetailAsync_FetchesOnlyFileDetailPageAndSkipsSectionSweep() + { + // Arrange: the FileDetail page already carries a real (non-guest) icon, so the parent-mod + // icon fallback fetch is skipped too — exactly one fetch total. + const string pageUrl = "https://www.moddb.com/mods/genspeed/downloads/genspeed-v25"; + var playwright = CreatePlaywrightMock(); + playwright + .Setup(service => service.FetchAndParsePersistentAsync( + ModDBConstants.BrowserProfileName, + pageUrl, + It.IsAny())) + .ReturnsAsync(await CreateDocumentAsync(""" + +

GenSpeed v2.5

+
+
+
Filename
GenSpeed-v2.5.zip
+
Size
65.04mb (68,197,650 bytes)
+ +
+ + """)); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseFileDetailAsync(pageUrl); + + // Assert: exactly one DownloadableFile, no section sweep, icon from the FileDetail page. + var file = Assert.Single(parsed.Sections.OfType()); + Assert.Equal("GenSpeed v2.5", file.Name); + Assert.Equal("GenSpeed-v2.5.zip", file.Filename); + Assert.Equal("https://www.moddb.com/downloads/start/311183", file.DownloadUrl); + Assert.Equal(68_197_650, file.SizeBytes); + Assert.Equal("https://static.moddb.com/mods/genspeed/icon.png", parsed.Context.IconUrl); + + playwright.Verify( + service => service.FetchAndParsePersistentAsync( + ModDBConstants.BrowserProfileName, + It.Is(url => url != pageUrl), + It.IsAny()), + Times.Never); + } + + /// + /// Verifies that ParseFileDetailAsync performs only a single page fetch for file details without + /// secondary parent mod fetches or section sweeps. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task ParseFileDetailAsync_WithGuestIcon_FetchesOnlyFileDetailPage() + { + // Arrange + const string pageUrl = "https://www.moddb.com/mods/genspeed/downloads/genspeed-v25"; + var fetchedUrls = new List(); + var playwright = CreatePlaywrightMock(); + playwright + .Setup(service => service.FetchAndParsePersistentAsync( + ModDBConstants.BrowserProfileName, + It.IsAny(), + It.IsAny())) + .Returns((string _, string url, CancellationToken _) => + { + fetchedUrls.Add(url); + return Task.FromResult(CreateDocumentAsync(""" + +

GenSpeed v2.5

+
+
+
Filename
GenSpeed-v2.5.zip
+ +
+ + """).GetAwaiter().GetResult()); + }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseFileDetailAsync(pageUrl); + + // Assert: exactly one fetch (FileDetail), never parent mod or section pages. + Assert.Equal(new[] { pageUrl }, fetchedUrls); + Assert.Contains(parsed.Sections.OfType(), f => f.Filename == "GenSpeed-v2.5.zip"); + } + + /// + /// Verifies that comment parsing creates nested reply threads with correct author attribution + /// and cleans out ModDB action text like 'Reply Good karma Bad karma+1 vote'. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ParseAsync_NestedComments_ParsesThreadHierarchyAndCleansActionText() + { + // Arrange + const string pageUrl = "https://www.moddb.com/mods/example-mod/comments"; + var playwright = CreatePlaywrightMock(); + var doc = await CreateDocumentAsync(""" + +
+
+
Scorpionwins Jul 31 2026
+
+ How to activate additional weapons? +
Reply Good karma Bad karma+1 vote
+
+
+
+
BagaturKhan Jul 31 2026
+
+ If you are talking about stolen tech, train your infiltrator. +
Reply Good karma Bad karma+1 vote
+
+
+
+
+
+ + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseAsync(pageUrl); + + // Assert + var topLevelComments = parsed.Sections.OfType().ToList(); + var parentComment = Assert.Single(topLevelComments); + Assert.Equal("Scorpionwins", parentComment.Author); + Assert.Equal("How to activate additional weapons?", parentComment.Content); + Assert.Equal(0, parentComment.IndentLevel); + + var reply = Assert.Single(parentComment.Replies!); + Assert.Equal("BagaturKhan", reply.Author); + Assert.Equal("If you are talking about stolen tech, train your infiltrator.", reply.Content); + Assert.Equal(1, reply.IndentLevel); + } + + /// + /// Verifies reply markup nested inside .commentbody does not inflate the parent content + /// into a huge whitespace block (the layout bug seen in the Community tab). + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ParseAsync_NestedCommentsInsideCommentBody_DoesNotPolluteParentContent() + { + // Arrange + const string pageUrl = "https://www.moddb.com/mods/example-mod/comments"; + var playwright = CreatePlaywrightMock(); + var doc = await CreateDocumentAsync(""" + +
+
+
Scorpionwins Jul 31 2026
+
+ + + How to activate additional weapons? +
Reply Good karma Bad karma+1 vote
+
+
+
BagaturKhan Jul 31 2026
+
+ Train your infiltrator. +
Reply
+
+
+
+
+
+
+ + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseAsync(pageUrl); + + // Assert + var parentComment = Assert.Single(parsed.Sections.OfType()); + Assert.Equal("How to activate additional weapons?", parentComment.Content); + Assert.DoesNotContain("BagaturKhan", parentComment.Content); + Assert.DoesNotContain("infiltrator", parentComment.Content, StringComparison.OrdinalIgnoreCase); + + var reply = Assert.Single(parentComment.Replies!); + Assert.Equal("BagaturKhan", reply.Author); + Assert.Equal("Train your infiltrator.", reply.Content); + } + + /// + /// Verifies rating widgets without author/body are not surfaced as empty Community review cards. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ParseAsync_BareRatingWidget_IsNotTreatedAsReview() + { + // Arrange + const string pageUrl = "https://www.moddb.com/mods/example-mod/reviews"; + var playwright = CreatePlaywrightMock(); + var doc = await CreateDocumentAsync(""" + +
9.0people found this helpful
+
+ Alice +
Solid patch for ROTR.
+ 8.5 +
+ + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseAsync(pageUrl); + + // Assert + var review = Assert.Single(parsed.Sections.OfType()); + Assert.Equal("Alice", review.Author); + Assert.Equal("Solid patch for ROTR.", review.Content); + } + + /// + /// The live ModDB composer (#commentform plus guest/email rows and injected CSS) must + /// not appear as Community comments. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ParseAsync_CommentComposer_IsNotTreatedAsComments() + { + const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone"; + var playwright = CreatePlaywrightMock(); + var doc = await CreateDocumentAsync(""" + +

C&C Generals Undone file

+

C&C Generals Undone

+
+
Filename
GeneralsUndone_v1.0.zip
+ +
+
+
+
+

Your comment will be anonymous unless you join the community. +

+
+
+
+
+
+ + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + var parsed = await parser.ParseAsync(pageUrl); + + Assert.Empty(parsed.Sections.OfType()); + } + + /// + /// File-page chrome (game icon, developer avatar, download title art) must not appear in Media. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ParseAsync_FileDetailChromeImages_AreNotGalleryMedia() + { + const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone"; + var playwright = CreatePlaywrightMock(); + var doc = await CreateDocumentAsync(""" + +

C&C Generals Undone

+
+
Filename
GeneralsUndone_v1.0.zip
+ +
+ + C&C Generals Undone + + + + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + var parsed = await parser.ParseAsync(pageUrl); + + Assert.Empty(parsed.Sections.OfType()); + } + + /// + /// The images tab should yield unique gallery shots, not share icons or duplicate featured thumbs. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ParseAsync_ImagesPage_ExtractsUniqueGalleryShots() + { + const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/images"; + var playwright = CreatePlaywrightMock(); + var doc = await CreateDocumentAsync(""" + +
+ + View media + + + View media + +
+ ICBM POWTruck +
+ Share on Facebook +
+ + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + var parsed = await parser.ParseAsync(pageUrl); + + var images = parsed.Sections.OfType().ToList(); + Assert.Equal(2, images.Count); + Assert.Contains(images, image => image.Title.Contains("ICBM", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(images, image => image.Title.Contains("Spectre", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(images, image => image.Title.Contains("Share", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(images, image => image.ThumbnailUrl != null && image.ThumbnailUrl.StartsWith("data:", StringComparison.OrdinalIgnoreCase)); + Assert.All(images, image => Assert.DoesNotContain("crop_", image.ThumbnailUrl ?? string.Empty)); + Assert.All(images, image => Assert.DoesNotContain("/cache/", image.ThumbnailUrl ?? string.Empty)); + } + + /// + /// Image titles with CamelCase or raw filenames should be formatted with clean spaces. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ParseAsync_ImageTitles_FormatsCamelCaseAndFilenames() + { + const string pageUrl = "https://www.moddb.com/mods/test-mod/images"; + var playwright = CreatePlaywrightMock(); + var doc = await CreateDocumentAsync(""" + + + + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + var parsed = await parser.ParseAsync(pageUrl); + + var images = parsed.Sections.OfType().ToList(); + Assert.Equal(2, images.Count); + Assert.Equal("Life Of BRRRRTTT", images[0].Title); + Assert.Equal("BASSBASSBASSASS", images[1].Title); + Assert.Equal("https://media.moddb.com/images/mods/1/73/72174/LifeOfBRRRRTTT.png", images[0].ThumbnailUrl); + Assert.Equal("https://media.moddb.com/images/mods/1/73/72174/LifeOfBRRRRTTT.png", images[0].FullSizeUrl); + } + + /// + /// FileDetail filename plus the parent downloads listing of the same start URL must collapse + /// to one release, keeping the human listing name. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ParseAsync_FileDetailAndParentDownloads_DedupesSameBinary() + { + const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone"; + const string parentUrl = "https://www.moddb.com/mods/cc-generals-undone"; + var documents = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [pageUrl] = await CreateDocumentAsync(""" + +

C&C Generals Undone file

+

C&C Generals Undone

+ register + + Games : C&C: Generals Zero Hour : Mods : C&C Generals Undone : Files +

This is the first version of Undone, and I know it's still very much in development.

+
+
Filename
GeneralsUndone_v1.0.zip
+ +
+ + """), + [parentUrl] = await CreateDocumentAsync("

C&C Generals Undone

"), + [parentUrl + "/downloads"] = await CreateDocumentAsync(""" +
+

C&C Generals Undone

+ 289.6 MB + Download +
+
+

Generals Undone v1.01 Patch

+ 1 MB + Download +
+ """), + [parentUrl + "/addons"] = await CreateDocumentAsync(""), + [parentUrl + "/videos"] = await CreateDocumentAsync(""), + [parentUrl + "/images"] = await CreateDocumentAsync(""), + [parentUrl + "/reviews"] = await CreateDocumentAsync(""), + [parentUrl + "/articles"] = await CreateDocumentAsync(""), + }; + var playwright = CreatePlaywrightMock(); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .Returns((string _, IReadOnlyList urls, CancellationToken _) => + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var url in urls) + { + if (documents.TryGetValue(url, out var doc)) + { + result[url] = doc; + } + } + + return Task.FromResult>(result); + }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + var parsed = await parser.ParseAsync(pageUrl); + + var files = parsed.Sections.OfType().ToList(); + Assert.Equal(2, files.Count); + Assert.Contains(files, file => file.Name == "C&C Generals Undone" && file.DownloadUrl == "https://www.moddb.com/downloads/start/313719"); + Assert.Contains(files, file => file.Name == "Generals Undone v1.01 Patch"); + Assert.DoesNotContain(files, file => file.Name == "GeneralsUndone_v1.0.zip"); + Assert.Equal("C&C Generals Undone", parsed.Context.Title); + Assert.Equal("WhiteSkull#9044", parsed.Context.Developer); + Assert.Contains("first version of Undone", parsed.Context.Description, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Games :", parsed.Context.Description, StringComparison.Ordinal); + } + + /// + /// Verifies that ParseFileDetailAsync correctly parses metadata when ModDB uses alternative label names + /// such as "File Name", "File Size", "Uploaded By", "MD5 Checksum", and "Total Downloads". + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task ParseFileDetailAsync_WithAlternativeLabels_ParsesMd5ChecksumTotalDownloadsAndUploader() + { + const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/generals-undone-v101-patch"; + var playwright = CreatePlaywrightMock(); + playwright + .Setup(service => service.FetchAndParsePersistentAsync( + ModDBConstants.BrowserProfileName, + pageUrl, + It.IsAny())) + .ReturnsAsync(await CreateDocumentAsync(""" + +
+
File Name
GeneralsUndone_v1.01.csf
+
Category
Patch
+
Uploaded By
WhiteSkull#9044
+
File Size
289.6mb (303,663,235 bytes)
+
MD5 Checksum
6e5b1fd58fc7a58cf21af86933116942
+
Total Downloads
185
+ +
+ + """)); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + var parsed = await parser.ParseFileDetailAsync(pageUrl); + + var file = Assert.Single(parsed.Sections.OfType()); + Assert.Equal("GeneralsUndone_v1.01.csf", file.Filename); + Assert.Equal("Patch", file.Category); + Assert.Equal("WhiteSkull#9044", file.Uploader); + Assert.Equal(303_663_235, file.SizeBytes); + Assert.Equal("6e5b1fd58fc7a58cf21af86933116942", file.Md5Hash); + Assert.Equal(185, file.DownloadCount); + Assert.Equal("https://www.moddb.com/downloads/start/313720", file.DownloadUrl); + } + + /// + /// Verifies that ModDB download listing rows with subheading metadata (size in subheading, button class) + /// extract size, category, uploader, details URL, and download URL correctly. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task ParseAsync_ModernModDBDownloadsListing_ExtractsSubheadingSizeAndLinks() + { + const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone"; + const string parentUrl = "https://www.moddb.com/mods/cc-generals-undone"; + var documents = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [pageUrl] = await CreateDocumentAsync(""" + +

C&C Generals Undone file

+

C&C Generals Undone

+ +

First release of Undone.

+
+
Filename
GeneralsUndone_v1.0.zip
+
Category
Full Version
+
Size
289.6mb (303,663,235 bytes)
+
MD5 Hash
6e5b3fcf30fc7a58ef21af869551bb942
+ +
+ + """), + [parentUrl] = await CreateDocumentAsync("

C&C Generals Undone

"), + [parentUrl + "/downloads"] = await CreateDocumentAsync(""" +
+
+

C&C Generals Undone

+ - Full Version, 289.6mb +
+
+ Download +
+
+
+
+

Generals Undone v1.01 Patch

+ - Patch, 1 MB +
+
+ Download +
+
+ """), + [parentUrl + "/addons"] = await CreateDocumentAsync(""), + [parentUrl + "/videos"] = await CreateDocumentAsync(""), + [parentUrl + "/images"] = await CreateDocumentAsync(""), + [parentUrl + "/reviews"] = await CreateDocumentAsync(""), + [parentUrl + "/articles"] = await CreateDocumentAsync(""), + }; + var playwright = CreatePlaywrightMock(); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .Returns((string _, IReadOnlyList urls, CancellationToken _) => + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var url in urls) + { + if (documents.TryGetValue(url, out var doc)) + { + result[url] = doc; + } + } + + return Task.FromResult>(result); + }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + var parsed = await parser.ParseAsync(pageUrl); + + var files = parsed.Sections.OfType().ToList(); + Assert.Equal(2, files.Count); + + var mainRelease = Assert.Single(files, f => f.Name == "C&C Generals Undone"); + Assert.Equal("GeneralsUndone_v1.0.zip", mainRelease.Filename); + Assert.Equal("https://www.moddb.com/downloads/start/313719", mainRelease.DownloadUrl); + Assert.Equal("https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone", mainRelease.DetailsUrl); + Assert.Equal("Full Version", mainRelease.Category); + Assert.Equal(303_663_235, mainRelease.SizeBytes); + Assert.Equal("6e5b3fcf30fc7a58ef21af869551bb942", mainRelease.Md5Hash); + + var patchRelease = Assert.Single(files, f => f.Name == "Generals Undone v1.01 Patch"); + Assert.Equal("https://www.moddb.com/mods/cc-generals-undone/downloads/generals-undone-v101-patch", patchRelease.DetailsUrl); + Assert.Equal("Patch", patchRelease.Category); + Assert.Equal(1048576, patchRelease.SizeBytes); + Assert.Equal("1 MB", patchRelease.SizeDisplay); + } + + /// + /// Verifies that embedded YouTube iframes on mod pages have their title, thumbnail, platform, + /// and normalized embed URL properly extracted. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task ParseAsync_WithYouTubeIframe_ExtractsTitlePlatformThumbnailAndEmbedUrl() + { + // Arrange + const string pageUrl = "https://www.moddb.com/mods/korean-war-2"; + var doc = await CreateDocumentAsync(""" + +

Korean War 2

+
+ +
+
+

Gameplay Teaser

+ +
+ + """); + var playwright = CreatePlaywrightMock(); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseAsync(pageUrl); + + // Assert + var videos = parsed.Sections.OfType