diff --git a/.github/workflows/gitnexus.yml b/.github/workflows/gitnexus.yml index 23d62900e..99b5a338a 100644 --- a/.github/workflows/gitnexus.yml +++ b/.github/workflows/gitnexus.yml @@ -33,6 +33,8 @@ jobs: name: GitNexus Index & Artifact runs-on: ubuntu-latest timeout-minutes: 10 + env: + NODE_OPTIONS: "--dns-result-order=ipv4first" steps: - name: Checkout Code diff --git a/.gitignore b/.gitignore index 7237ca5d8..f749850c7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ .* !.github/ !.gitignore +!.gitattributes +!**/.gitattributes !.agents/ !.claude/ diff --git a/GenHub/Directory.Packages.props b/GenHub/Directory.Packages.props index 8c7e0e70c..f42c712b2 100644 --- a/GenHub/Directory.Packages.props +++ b/GenHub/Directory.Packages.props @@ -5,8 +5,7 @@ - - + @@ -56,4 +55,4 @@ - + \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/AODMapsConstants.cs b/GenHub/GenHub.Core/Constants/AODMapsConstants.cs index 56d978056..87c372241 100644 --- a/GenHub/GenHub.Core/Constants/AODMapsConstants.cs +++ b/GenHub/GenHub.Core/Constants/AODMapsConstants.cs @@ -3,7 +3,7 @@ namespace GenHub.Core.Constants; /// -/// Constants for AODMaps (Age of Defense Maps) provider. +/// Constants for AODMaps (Art of Defense Maps) provider. /// [SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] [SuppressMessage("Minor Code Smell", "S101:Types should be named in PascalCase", Justification = "Domain acronym")] @@ -19,7 +19,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"; @@ -117,6 +117,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"; @@ -126,6 +129,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 6b5bd656f..7b360fc25 100644 --- a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs +++ b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs @@ -173,6 +173,28 @@ 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}'."; + /// /// Update available notification title for release channel. /// @@ -214,7 +236,7 @@ public static class AppUpdateConstants public const string UpdateFailedNotificationFormat = "Failed to install update: {0}"; /// - /// View updates action button text. + /// "View Updates" action text. /// public const string ViewUpdatesAction = "View Updates"; 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/CommunityOutpostCatalogConstants.cs b/GenHub/GenHub.Core/Constants/CommunityOutpostCatalogConstants.cs index 260c6d2cc..5b91b843a 100644 --- a/GenHub/GenHub.Core/Constants/CommunityOutpostCatalogConstants.cs +++ b/GenHub/GenHub.Core/Constants/CommunityOutpostCatalogConstants.cs @@ -15,7 +15,10 @@ public static class CommunityOutpostCatalogConstants public const string UnknownVersion = "unknown"; /// Default base URL for making relative URLs absolute. - public const string DefaultBaseUrl = "https://legi.cc/patch"; + public const string DefaultBaseUrl = CommunityOutpostConstants.BaseUrl + "/patch"; + + /// Default base URL for downloading GenPatcher content .dat packages. + public const string DefaultFilesBaseUrl = CommunityOutpostConstants.BaseUrl + "/gp2/f"; /// Metadata key for the content code. public const string ContentCodeKey = "contentCode"; diff --git a/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs b/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs index 93780ed46..5dd0475fb 100644 --- a/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs +++ b/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs @@ -10,9 +10,14 @@ namespace GenHub.Core.Constants; /// Endpoint URLs and timeouts are configured via data-driven configuration. /// See Providers/communityoutpost.provider.json for runtime-configurable values. /// -[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Default base URL for Community Outpost service")] public static class CommunityOutpostConstants { + /// + /// Base URL for Community Outpost / GenPatcher service. + /// + public const string BaseUrl = "https://legi.cc"; + /// /// The publisher ID for Community Outpost. /// @@ -53,6 +58,11 @@ public static class CommunityOutpostConstants /// public const string ContentName = "Community Patch"; + /// + /// Tag and content code for Community Patch items. + /// + public const string CommunityPatchTag = "community-patch"; + /// /// Description for the discoverer. /// 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 4938758ab..037870073 100644 --- a/GenHub/GenHub.Core/Constants/DirectoryNames.cs +++ b/GenHub/GenHub.Core/Constants/DirectoryNames.cs @@ -93,4 +93,19 @@ 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"; + + /// + /// Directory for game movie assets. + /// + public const string Movies = "Movies"; } diff --git a/GenHub/GenHub.Core/Constants/GameClientConstants.cs b/GenHub/GenHub.Core/Constants/GameClientConstants.cs index 6937d2e2c..45c4b5c5a 100644 --- a/GenHub/GenHub.Core/Constants/GameClientConstants.cs +++ b/GenHub/GenHub.Core/Constants/GameClientConstants.cs @@ -78,6 +78,12 @@ public static class GameClientConstants /// Primary Generals Vanilla Patch archive filename. public const string GeneralsPatchBig = "Patch.big"; + /// Generals Vanilla security archive filename. + public const string GeneralsSecurityBig = "gensec.big"; + + /// Zero Hour archive extension suffix. + public const string ZeroHourArchiveExtensionSuffix = "ZH.big"; + // ===== GeneralsOnline Client Detection ===== /// GeneralsOnline 60Hz client executable name. diff --git a/GenHub/GenHub.Core/Constants/GameContentConstants.cs b/GenHub/GenHub.Core/Constants/GameContentConstants.cs new file mode 100644 index 000000000..a082d20f8 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/GameContentConstants.cs @@ -0,0 +1,161 @@ +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; + + /// + /// File name for the Electronic Arts intro logo movie. + /// + public const string EaLogoBikFileName = "EA_LOGO.BIK"; + + /// + /// 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/GenLauncherConstants.cs b/GenHub/GenHub.Core/Constants/GenLauncherConstants.cs index 6576ee203..2443b4be4 100644 --- a/GenHub/GenHub.Core/Constants/GenLauncherConstants.cs +++ b/GenHub/GenHub.Core/Constants/GenLauncherConstants.cs @@ -25,6 +25,11 @@ public static class GenLauncherConstants /// public const string GibExtension = ".gib"; + /// + /// Contra mod inactive .big file extension. + /// + public const string CtrExtension = ".ctr"; + /// /// Standard .big file extension. /// @@ -44,4 +49,13 @@ public static class GenLauncherConstants OriginalFileSuffix, TempCopySuffix, ]; + + /// + /// Extensions for inactive BIG archive files used by mods/launchers. + /// + public static readonly string[] InactiveBigExtensions = + [ + GibExtension, + CtrExtension, + ]; } 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 de315df05..7c89353a4 100644 --- a/GenHub/GenHub.Core/Constants/InfoConstants.cs +++ b/GenHub/GenHub.Core/Constants/InfoConstants.cs @@ -19,6 +19,11 @@ public static class InfoConstants /// public const string FaqDefaultLanguage = "en"; + /// + /// Section ID for the quickstart guide. + /// + public const string QuickstartSectionId = "quickstart"; + /// /// Module name for GenHub Guide. /// @@ -47,8 +52,8 @@ public static class InfoConstants /// /// 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/ManifestConstants.cs b/GenHub/GenHub.Core/Constants/ManifestConstants.cs index d58959ce7..f72cc010b 100644 --- a/GenHub/GenHub.Core/Constants/ManifestConstants.cs +++ b/GenHub/GenHub.Core/Constants/ManifestConstants.cs @@ -124,6 +124,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 30096acb0..4d90739b8 100644 --- a/GenHub/GenHub.Core/Constants/ModDBConstants.cs +++ b/GenHub/GenHub.Core/Constants/ModDBConstants.cs @@ -38,22 +38,73 @@ public static class ModDBConstants /// Downloads section for Zero Hour. public const string ZeroHourDownloadsUrl = ZeroHourBaseUrl + "/downloads"; + /// Mods URL path segment. + public const string ModsSegment = "/mods/"; + + /// Downloads URL path segment. + public const string DownloadsSegment = "/downloads/"; + + /// Downloads section name. + public const string DownloadsSection = "downloads"; + + /// Mods section name. + public const string ModsSection = "mods"; + + /// Addons URL path segment. + public const string AddonsSegment = "/addons/"; + + /// Addons section name. + public const string AddonsSection = "addons"; + + /// Articles URL path segment. + public const string ArticlesSegment = "/articles"; + + /// Games URL path segment. + public const string GamesSegment = "/games/"; + + /// Games section name. + public const string GamesSection = "games"; + + /// News URL path segment. + public const string NewsSegment = "/news"; + + /// Tutorials URL path segment. + public const string TutorialsSegment = "/tutorials"; + + /// Videos URL path segment. + public const string VideosSegment = "/videos"; + + /// Images URL path segment. + public const string ImagesSegment = "/images"; + + /// Reviews URL path segment. + public const string ReviewsSegment = "/reviews"; + + /// Placeholder blank gif image filename. + public const string BlankGifFileName = "blank.gif"; + /// Addons section for Generals. public const string GeneralsAddonsUrl = GeneralsBaseUrl + "/addons"; /// Addons section for Zero Hour. public const string ZeroHourAddonsUrl = ZeroHourBaseUrl + "/addons"; + /// Media RSS XML namespace URI. + public const string MediaRssNamespace = "http://search.yahoo.com/mrss/"; + // ===== Publisher Info ===== + /// Canonical identifier string for ModDB. + public const string ModDbKey = "moddb"; + /// Publisher prefix for ModDB content (to be combined with author: moddb-{author}). - public const string PublisherPrefix = "moddb"; + public const string PublisherPrefix = ModDbKey; /// Publisher type identifier for ModDB content pipeline. - public const string PublisherType = "moddb"; + public const string PublisherType = ModDbKey; /// Publisher ID for the ModDB service. - public const string PublisherId = "moddb"; + public const string PublisherId = ModDbKey; /// Display name for the publisher. public const string PublisherDisplayName = "ModDB"; @@ -75,6 +126,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"; @@ -187,6 +244,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 @@ -359,6 +439,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. @@ -407,6 +515,29 @@ public static class ModDBConstants /// Timeframe: Year or older. public const string TimeframeYearOrOlder = "5"; + // ===== Managed Chromium Runtime Notifications ===== + + /// Title for the Chromium runtime installation toast. + public const string ChromiumInstallTitle = "Installing Chromium Runtime"; + + /// Initial message when downloading the managed Chromium runtime. + public const string ChromiumDownloadingMessage = "Downloading Chromium (~240 MB)... Please wait."; + + /// Message while extracting and configuring the managed Chromium runtime. + public const string ChromiumExtractingMessage = "Extracting and configuring Chromium runtime..."; + + /// Title when the Chromium runtime installation completes successfully. + public const string ChromiumReadyTitle = "Chromium Runtime Ready"; + + /// Message when the Chromium runtime installation completes successfully. + public const string ChromiumReadyMessage = "Chromium runtime installed successfully."; + + /// Title when the Chromium runtime installation fails. + public const string ChromiumInstallFailedTitle = "Chromium Installation Failed"; + + /// Message when the Chromium runtime installation fails. + public const string ChromiumInstallFailedMessage = "GenHub could not install its managed Chromium runtime. Check your network connection and try again."; + // ===== Content Tags ===== /// Content tags for search and categorization. diff --git a/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs b/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs index 55ab7fa3c..91ce3b6e1 100644 --- a/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs +++ b/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs @@ -17,9 +17,15 @@ 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"; + /// Attribute name for HTML datetime attribute. + public const string DateTimeAttribute = "datetime"; + /// Selector for game name. public const string GameNameSelector = ".game, .parentgame"; @@ -59,7 +65,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 +93,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 +107,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 +142,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 +228,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 +280,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 43b03d702..b88bff564 100644 --- a/GenHub/GenHub.Core/Constants/PublisherInfoConstants.cs +++ b/GenHub/GenHub.Core/Constants/PublisherInfoConstants.cs @@ -271,7 +271,87 @@ 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 primary = MatchLogo(publisherIdOrName); + var secondary = MatchLogo(contentIdOrName); + + // If primary matched generic GitHub, but secondary matched a specific publisher, prefer the specific publisher + if (primary == GitHub.LogoSource && secondary != null && secondary != GitHub.LogoSource) + { + return secondary; + } + + return primary ?? secondary; + } + + private static string? MatchLogo(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + { + return null; + } + + if (input.Contains("communityoutpost", StringComparison.OrdinalIgnoreCase) || + input.Contains("community outpost", StringComparison.OrdinalIgnoreCase) || + input.Contains("community-outpost", StringComparison.OrdinalIgnoreCase)) + { + return CommunityOutpost.LogoSource; + } + + if (input.Contains("superhacker", StringComparison.OrdinalIgnoreCase)) + { + return TheSuperHackers.LogoSource; + } + + if (input.Contains("generalsonline", StringComparison.OrdinalIgnoreCase) || + input.Contains("generals online", StringComparison.OrdinalIgnoreCase) || + input.Contains("generals-online", StringComparison.OrdinalIgnoreCase)) + { + return GeneralsOnline.LogoSource; + } + + if (input.Contains("moddb", StringComparison.OrdinalIgnoreCase) || + input.Contains("mod db", StringComparison.OrdinalIgnoreCase) || + input.Contains("mod-db", StringComparison.OrdinalIgnoreCase)) + { + return ModDB.LogoSource; + } + + if (input.Contains("cnclabs", StringComparison.OrdinalIgnoreCase) || + input.Contains("cnc labs", StringComparison.OrdinalIgnoreCase) || + input.Contains("cnc-labs", StringComparison.OrdinalIgnoreCase)) + { + return CNCLabs.LogoSource; + } + + if (input.Contains("aodmaps", StringComparison.OrdinalIgnoreCase) || + input.Contains("aod maps", StringComparison.OrdinalIgnoreCase) || + input.Contains("aod-maps", StringComparison.OrdinalIgnoreCase)) + { + return AODMaps.LogoSource; + } + + if (input.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 160115a2b..e4e3b9a00 100644 --- a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs +++ b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs @@ -22,9 +22,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/SettingsConstants.cs b/GenHub/GenHub.Core/Constants/SettingsConstants.cs index ac1d945c9..e78b62e92 100644 --- a/GenHub/GenHub.Core/Constants/SettingsConstants.cs +++ b/GenHub/GenHub.Core/Constants/SettingsConstants.cs @@ -55,6 +55,11 @@ public static class SettingsConstants /// public const string SectionUpdates = "updates"; + /// + /// Section ID for Catalog Subscriptions. + /// + public const string SectionSubscriptions = "subscriptions"; + /// /// Section ID for Danger Zone. /// diff --git a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs index d5d3dffce..473438dca 100644 --- a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs +++ b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs @@ -120,4 +120,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..e1421d0da 100644 --- a/GenHub/GenHub.Core/Constants/UiConstants.cs +++ b/GenHub/GenHub.Core/Constants/UiConstants.cs @@ -25,6 +25,26 @@ 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; + + /// + /// Progressive item render delay in milliseconds for streaming cards into the download browser grid. + /// + public const int ProgressiveItemRenderDelayMs = 20; + // Status colors /// @@ -37,6 +57,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..767214839 --- /dev/null +++ b/GenHub/GenHub.Core/Extensions/ContentAcquisitionProgressExtensions.cs @@ -0,0 +1,92 @@ +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); + + if (progress.TotalStages > 0 && progress.CurrentStage > 0) + { + return FormatStagedProgress(progress); + } + + var phaseName = GetPhaseName(progress.Phase); + return FormatPhaseProgress(progress, phaseName); + } + + private static string FormatStagedProgress(ContentAcquisitionProgress progress) + { + 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; + + string percentPart = progress.StageProgress is > 0 and < 100 + ? $" ({progress.StageProgress:F0}%)" + : string.Empty; + + string bottleneckPart = progress.IsBottleneck && !string.IsNullOrEmpty(progress.BottleneckReason) + ? $" - {progress.BottleneckReason}" + : string.Empty; + + string filesPart = progress.TotalFiles > 1 + ? $" [{progress.FilesProcessed}/{progress.TotalFiles}]" + : string.Empty; + + return $"{stagePart} - {description}{percentPart}{filesPart}{bottleneckPart}"; + } + + private static string GetPhaseName(ContentAcquisitionPhase phase) => 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", + }; + + private static string FormatPhaseProgress(ContentAcquisitionProgress progress, string phaseName) + { + 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 !string.IsNullOrEmpty(percentText) + ? $"{phaseName}: {downloaded} / {total} ({percentText})" + : $"{phaseName}: {downloaded} / {total}"; + } + + if (progress.TotalFiles > 0) + { + int phasePercent = (int)((double)progress.FilesProcessed / progress.TotalFiles * 100); + 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/ContentPathPolicy.cs b/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs new file mode 100644 index 000000000..d80379257 --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs @@ -0,0 +1,194 @@ +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)); + + if (!IsContainedInternal(fullRoot, fullCandidate)) + { + 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); + + return IsContainedInternal(fullRoot, fullCandidate); + } + catch (ArgumentException) + { + return false; + } + catch (NotSupportedException) + { + return false; + } + catch (PathTooLongException) + { + return false; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + private static bool IsContainedInternal(string fullRoot, string fullCandidate) + { + var rootPrefix = fullRoot.EndsWith(Path.DirectorySeparatorChar) + ? fullRoot + : fullRoot + Path.DirectorySeparatorChar; + + if (!fullCandidate.StartsWith(rootPrefix, PathHelper.PathComparison) && + !fullCandidate.Equals(fullRoot, PathHelper.PathComparison)) + { + return false; + } + + var realRoot = ResolveRealPath(fullRoot); + var realCandidate = ResolveRealPath(fullCandidate); + + var realRootPrefix = realRoot.EndsWith(Path.DirectorySeparatorChar) + ? realRoot + : realRoot + Path.DirectorySeparatorChar; + + return realCandidate.StartsWith(realRootPrefix, PathHelper.PathComparison) || + realCandidate.Equals(realRoot, PathHelper.PathComparison); + } + + private static string ResolveRealPath(string path) + { + try + { + var current = path; + while (!string.IsNullOrEmpty(current)) + { + var resolved = TryResolveFileSystemLink(current, path); + if (resolved != null) + { + return resolved; + } + + if (File.Exists(current)) + { + break; + } + + current = Path.GetDirectoryName(current); + } + } + catch + { + // Fallback to path if resolution fails + } + + return path; + } + + private static string? TryResolveFileSystemLink(string current, string originalPath) + { + FileSystemInfo? info = null; + if (File.Exists(current)) + { + info = new FileInfo(current); + } + else if (Directory.Exists(current)) + { + info = new DirectoryInfo(current); + } + + if (info?.LinkTarget == null) + { + return null; + } + + var target = info.ResolveLinkTarget(returnFinalTarget: true); + if (target == null) + { + return null; + } + + var relativeSuffix = Path.GetRelativePath(current, originalPath); + return relativeSuffix == "." + ? target.FullName + : Path.GetFullPath(Path.Combine(target.FullName, relativeSuffix)); + } +} diff --git a/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs b/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs new file mode 100644 index 000000000..b19f11d94 --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs @@ -0,0 +1,145 @@ +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; + } + + // 0. Remove script and style elements along with their contents + var text = ScriptTagRegex().Replace(html, string.Empty); + text = StyleTagRegex().Replace(text, string.Empty); + + // 1. Convert
tags to newline + text = BrTagRegex().Replace(text, "\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(@"]*>[\s\S]*?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ScriptTagRegex(); + + [GeneratedRegex(@"]*>[\s\S]*?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex StyleTagRegex(); + + [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..ffd5fa79b --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Content/IArchivePayloadProcessor.cs @@ -0,0 +1,120 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.Content; +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. + /// Optional content type to constrain executable archive extraction. + /// Cancellation token. + /// A task representing the asynchronous extraction operation. + Task ExtractArchivesSafelyAsync( + string extractedDirectory, + ContentType? contentType = null, + CancellationToken cancellationToken = default) => + ExtractArchivesSafelyAsync(extractedDirectory, contentType, progress: null, cancellationToken); + + /// + /// Extracts all archives located within the directory safely with progress reporting, recursively removing archive files after extraction. + /// + /// The directory containing extracted or downloaded content. + /// Optional content type to constrain executable archive extraction. + /// Optional progress reporter for extraction progress updates. + /// Cancellation token. + /// A task representing the asynchronous extraction operation. + Task ExtractArchivesSafelyAsync( + string extractedDirectory, + ContentType? contentType, + IProgress? progress, + 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); + + /// + /// Normalizes the directory structure of an extracted payload, removing extraneous wrapper directories + /// and reconciling the content root with the workspace/target directory with inactive archive normalization control. + /// + /// The directory containing extracted files. + /// The content type (e.g. Mod, Map, GameClient, etc.). + /// The target game type (Generals or ZeroHour). + /// Whether to convert inactive mod archives (.gib, .ctr, .skw) to .big. + /// Cancellation token. + /// A task representing the asynchronous normalization operation. + Task NormalizeDirectoryStructureAsync( + string extractedDirectory, + ContentType contentType, + GameType targetGame, + bool normalizeInactiveArchives, + 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); + + /// + /// Extracts archives safely and normalizes the payload directory structure in one coordinated operation with inactive archive normalization control. + /// + /// The directory containing extracted or downloaded content. + /// The content type (e.g. Mod, Map, GameClient, etc.). + /// The target game type (Generals or ZeroHour). + /// Whether to convert inactive mod archives (.gib, .ctr, .skw) to .big. + /// Cancellation token. + /// A task representing the asynchronous processing operation. + Task ProcessPayloadAsync( + string extractedDirectory, + ContentType contentType, + GameType targetGame, + bool normalizeInactiveArchives, + CancellationToken cancellationToken = default); + + /// + /// Extracts archives safely and normalizes the payload directory structure in one coordinated operation with progress reporting. + /// + /// The directory containing extracted or downloaded content. + /// The content type (e.g. Mod, Map, GameClient, etc.). + /// The target game type (Generals or ZeroHour). + /// Whether to convert inactive mod archives (.gib, .ctr, .skw) to .big. + /// Optional progress reporter for extraction progress updates. + /// Cancellation token. + /// A task representing the asynchronous processing operation. + Task ProcessPayloadAsync( + string extractedDirectory, + ContentType contentType, + GameType targetGame, + bool normalizeInactiveArchives, + IProgress? progress, + 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/IPublisherManifestFactory.cs b/GenHub/GenHub.Core/Interfaces/Content/IPublisherManifestFactory.cs index 1ca68db84..176c9b5e1 100644 --- a/GenHub/GenHub.Core/Interfaces/Content/IPublisherManifestFactory.cs +++ b/GenHub/GenHub.Core/Interfaces/Content/IPublisherManifestFactory.cs @@ -1,3 +1,8 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.Content; using GenHub.Core.Models.Manifest; namespace GenHub.Core.Interfaces.Content; @@ -52,6 +57,28 @@ public interface IPublisherManifestFactory Task> CreateManifestsFromExtractedContentAsync( ContentManifest originalManifest, string extractedDirectory, + CancellationToken cancellationToken = default) => + CreateManifestsFromExtractedContentAsync(originalManifest, extractedDirectory, progress: null, cancellationToken); + + /// + /// Creates enriched manifests from extracted content with progress reporting. + /// + /// + /// The manifest from the resolver, containing download URLs but no file hashes. + /// + /// + /// Directory where the deliverer extracted the package files. + /// + /// Optional progress reporter for processing updates. + /// Cancellation token. + /// + /// One or more manifests with file hashes and sizes. Multi-variant content + /// (e.g., separate Generals and Zero Hour executables) may return multiple manifests. + /// + Task> CreateManifestsFromExtractedContentAsync( + ContentManifest originalManifest, + string extractedDirectory, + IProgress? progress, CancellationToken cancellationToken = default); /// 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 58985d1e0..9362f5a25 100644 --- a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs +++ b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs @@ -116,6 +116,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, @@ -126,7 +127,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. @@ -282,6 +284,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..2d75645fe 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,56 @@ 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 (HttpRequestException) + { + // soft failure per url in batch + } + catch (IOException) + { + // soft failure per url in batch + } + catch (InvalidOperationException) + { + // soft failure per url in batch + } + catch (FormatException) + { + // 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/Interfaces/UserData/IUserDataTracker.cs b/GenHub/GenHub.Core/Interfaces/UserData/IUserDataTracker.cs index 61aff6eaa..bc71e9b92 100644 --- a/GenHub/GenHub.Core/Interfaces/UserData/IUserDataTracker.cs +++ b/GenHub/GenHub.Core/Interfaces/UserData/IUserDataTracker.cs @@ -139,6 +139,14 @@ Task> CleanupProfileAsync( Task> GetTotalUserDataSizeAsync( CancellationToken cancellationToken = default); + /// + /// Gets the profile ID that currently has active user data materialized on disk, if any. + /// + /// Cancellation token. + /// The active profile ID or null if no profile user data is active. + Task> GetActiveProfileIdAsync( + CancellationToken cancellationToken = default); + /// /// Deletes ALL tracked user data files, manifests, and indexes. /// This is a destructive operation used for "Delete All Data" functionality. 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 ad0432121..d3812eb02 100644 --- a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs +++ b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs @@ -11,6 +11,7 @@ namespace GenHub.Core.Models.CommunityOutpost; /// public static class GenPatcherContentRegistry { + private const string CommunityPatchCode = "community-patch"; private const string ResolutionVariantType = "resolution"; private const string LanguageVariantType = "language"; private const string Pattern720 = "*720*"; @@ -65,9 +66,9 @@ public static class GenPatcherContentRegistry private static readonly Dictionary KnownContent = new(StringComparer.OrdinalIgnoreCase) { // Community Patch (TheSuperHackers Build from legi.cc/patch) - ["community-patch"] = new GenPatcherContentMetadata + [CommunityPatchCode] = new GenPatcherContentMetadata { - ContentCode = "community-patch", + ContentCode = CommunityPatchCode, DisplayName = "Community Patch (TheSuperHackers Build)", Description = "The latest TheSuperHackers patch build for Zero Hour. Includes bug fixes, balance changes, and quality of life improvements.", ContentType = ContentType.GameClient, @@ -410,10 +411,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"] = CommunityPatchCode, + ["communitypatchthesuperhackersbuild"] = CommunityPatchCode, + ["communityoutpostgameclientcommunitypatch"] = CommunityPatchCode, + ["community-patch-gameclient"] = CommunityPatchCode, + ["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) { @@ -423,21 +476,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 0824f25b1..9d746aedc 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..ee4cce61e --- /dev/null +++ b/GenHub/GenHub.Core/Models/Content/ContentCardBadgeHelper.cs @@ -0,0 +1,405 @@ +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)) + { + PromotePlayerCountFromTags(result); + } + + if (!HasMetadata(result, ContentConstants.CategoryMetadataKey)) + { + PromoteCategoryFromTags(result); + } + } + + /// + /// 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) + { + if (string.IsNullOrWhiteSpace(tag)) + { + continue; + } + + 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) + { + if (string.IsNullOrWhiteSpace(tag)) + { + continue; + } + + var match = CategoryTagRegex().Match(tag); + if (match.Success) + { + var val = match.Groups["category"].Value.Trim(); + if (!string.IsNullOrWhiteSpace(val)) + { + return val; + } + } + } + + return string.Empty; + } + + /// + /// Resolves the best card/detail thumbnail URL: banner, then first screenshot, then icon. + /// Returns null when no thumbnail is present so cards can fall back to publisher logo placeholders cleanly. + /// + /// 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 null; + } + + /// + /// 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.Trim()); + } + + // 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 (string.IsNullOrWhiteSpace(tag)) + { + continue; + } + + var trimmedTag = tag.Trim(); + if (PlayerCountTagRegex().IsMatch(trimmedTag) || CategoryTagRegex().IsMatch(trimmedTag)) + { + excluded.Add(trimmedTag); + } + } + + return [.. result.Tags + .Select(t => t?.Trim() ?? string.Empty) + .Where(t => !string.IsNullOrWhiteSpace(t) && !excluded.Contains(t))]; + } + + /// + /// 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; + } + + private static void PromotePlayerCountFromTags(ContentSearchResult result) + { + 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; + } + } + } + + private static void PromoteCategoryFromTags(ContentSearchResult result) + { + foreach (var tag in result.Tags) + { + if (string.IsNullOrWhiteSpace(tag)) + { + continue; + } + + var match = CategoryTagRegex().Match(tag); + if (match.Success) + { + var cat = match.Groups["category"].Value.Trim(); + if (!string.IsNullOrWhiteSpace(cat)) + { + ApplyCategory(result, cat); + break; + } + } + } + } + + [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(@"^(?:(?:weekly|nightly|daily|build|dev|snapshot|stamp)[-_])?(?\d{4}-\d{2}-\d{2})$", RegexOptions.IgnoreCase | 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 d3239277c..2a0d663b0 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.Constants; using GenHub.Core.Models.Enums; @@ -126,6 +129,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 ===== /// @@ -151,6 +171,20 @@ 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() + { + static string Escape(string? value) => System.Uri.EscapeDataString(value ?? string.Empty); + var tags = Tags.Count > 0 ? string.Join(",", Tags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).Select(Escape)) : string.Empty; + var cncTags = CNCLabsMapTags.Count > 0 ? string.Join(",", CNCLabsMapTags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).Select(Escape)) : string.Empty; + var minDate = MinDate?.ToString("o", System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; + var maxDate = MaxDate?.ToString("o", System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; + return $"search::{Escape(ProviderName)}::{Escape(SearchTerm)}::{Escape(AuthorName)}::{ContentType}::{TargetGame}::{Skip}::{Take}::{SortOrder}::{Escape(Sort)}::{IncludeInstalled}::{IncludeOlderVersions}::{NumberOfPlayers}::{Page}::{minDate}::{maxDate}::{Escape(ModDBCategory)}::{Escape(ModDBAddonCategory)}::{Escape(ModDBLicense)}::{Escape(ModDBTimeframe)}::{Escape(ModDBSection)}::{Escape(AODMapsPlayerCount)}::{Escape(AODMapsCategory)}::{Escape(AODMapsMapType)}::{Escape(GitHubTopic)}::{Escape(GitHubAuthor)}::{Escape(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/GameInstallations/GameInstallation.cs b/GenHub/GenHub.Core/Models/GameInstallations/GameInstallation.cs index e38beed31..20781cd7c 100644 --- a/GenHub/GenHub.Core/Models/GameInstallations/GameInstallation.cs +++ b/GenHub/GenHub.Core/Models/GameInstallations/GameInstallation.cs @@ -15,46 +15,27 @@ namespace GenHub.Core.Models.GameInstallations; /// /// Represents a detected or user-registered game installation (Steam, EA App, etc). /// -public class GameInstallation : IGameInstallation +/// The installation path. +/// The installation type. +/// Optional logger instance. +public class GameInstallation( + string installationPath, + GameInstallationType installationType, + ILogger? logger = null) : IGameInstallation { - private readonly ILogger? _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The installation path. - /// The installation type. - /// Optional logger instance. - public GameInstallation( - string installationPath, - GameInstallationType installationType, - ILogger? logger = null) - { - InstallationPath = installationPath; - InstallationType = installationType; - DetectedAt = DateTime.UtcNow; - AvailableClientsInternal = []; - _logger = logger; - - _logger?.LogDebug( - "Created GameInstallation: Path={InstallationPath}, Type={InstallationType}", - InstallationPath, - InstallationType); - } - /// /// Gets or sets the unique identifier for this installation. /// public string Id { get; set; } = Guid.NewGuid().ToString(); /// Gets or sets the installation type. - public GameInstallationType InstallationType { get; set; } + public GameInstallationType InstallationType { get; set; } = installationType; /// Gets or sets the available game clients for this installation. public List AvailableGameClients { get; set; } = []; /// Gets the base installation directory path. - public string InstallationPath { get; private set; } = string.Empty; + public string InstallationPath { get; private set; } = installationPath; /// Gets or sets a value indicating whether the vanilla game is installed. public bool HasGenerals { get; set; } @@ -103,7 +84,7 @@ public GameInstallation( public GameClient? ZeroHourClient => AvailableGameClients.FirstOrDefault(c => c.GameType == GameType.ZeroHour); /// Gets the internal list of available game clients for population. - internal List AvailableClientsInternal { get; } + internal List AvailableClientsInternal { get; } = []; /// /// Sets the paths for Generals and Zero Hour. @@ -124,7 +105,7 @@ public void SetPaths(string? generalsPath, string? zeroHourPath) ZeroHourPath = zeroHourPath; } - _logger?.LogDebug("Set paths for {InstallationType}: Generals={HasGenerals}, ZeroHour={HasZeroHour}", InstallationType, HasGenerals, HasZeroHour); + logger?.LogDebug("Set paths for {InstallationType}: Generals={HasGenerals}, ZeroHour={HasZeroHour}", InstallationType, HasGenerals, HasZeroHour); } /// @@ -140,7 +121,7 @@ public void PopulateGameClients(IEnumerable clients) AvailableGameClients.Clear(); AvailableGameClients.AddRange(AvailableClientsInternal); - _logger?.LogInformation("Populated {Count} clients for {Id}", AvailableClientsInternal.Count, Id); + logger?.LogInformation("Populated {Count} clients for {Id}", AvailableClientsInternal.Count, Id); } /// @@ -156,8 +137,8 @@ public void Fetch() { try { - _logger?.LogDebug("Initializing installation scan - Current state: HasGenerals={HasGenerals}, HasZeroHour={HasZeroHour}", HasGenerals, HasZeroHour); - _logger?.LogDebug("Fetching game installations for {InstallationPath}", InstallationPath); + logger?.LogDebug("Initializing installation scan - Current state: HasGenerals={HasGenerals}, HasZeroHour={HasZeroHour}", HasGenerals, HasZeroHour); + logger?.LogDebug("Fetching game installations for {InstallationPath}", InstallationPath); bool foundGenerals = false; bool foundZeroHour = false; @@ -181,10 +162,10 @@ public void Fetch() // Log warnings only if absolutely nothing found if (!foundGenerals && !foundZeroHour) { - _logger?.LogWarning("No game executables found in {InstallationPath} or standard subdirectories", InstallationPath); + logger?.LogWarning("No game executables found in {InstallationPath} or standard subdirectories", InstallationPath); } - _logger?.LogInformation( + logger?.LogInformation( "Installation fetch completed for {InstallationPath}: Generals={HasGenerals}, ZeroHour={HasZeroHour}", InstallationPath, HasGenerals, @@ -192,7 +173,7 @@ public void Fetch() } catch (Exception ex) { - _logger?.LogError(ex, "Failed to fetch installation at {InstallationPath}", InstallationPath); + logger?.LogError(ex, "Failed to fetch installation at {InstallationPath}", InstallationPath); } } @@ -263,7 +244,7 @@ private static bool HasZeroHourArchiveOrExecutableSignature(string path) if (Directory.Exists(path)) { var directoryInfo = new DirectoryInfo(path); - if (directoryInfo.EnumerateFiles().Any(f => f.Name.EndsWith("ZH.big", StringComparison.OrdinalIgnoreCase))) + if (directoryInfo.EnumerateFiles(RetailArchiveConstants.ArchiveSearchPattern, RetailArchiveConstants.ArchiveSearch).Any(f => f.Name.EndsWith(GameClientConstants.ZeroHourArchiveExtensionSuffix, StringComparison.OrdinalIgnoreCase))) { return true; } @@ -283,7 +264,7 @@ private static bool HasZeroHourArchiveOrExecutableSignature(string path) private static bool HasGeneralsArchiveSignature(string path) { - return Path.Combine(path, "gensec.big").FileExistsCaseInsensitive() || + return Path.Combine(path, GameClientConstants.GeneralsSecurityBig).FileExistsCaseInsensitive() || Path.Combine(path, GameClientConstants.GeneralsIniBig).FileExistsCaseInsensitive() || Path.Combine(path, GameClientConstants.GeneralsPatchBig).FileExistsCaseInsensitive() || Path.Combine(path, GameClientConstants.SuperHackersGeneralsExecutable).FileExistsCaseInsensitive(); @@ -304,63 +285,76 @@ private static bool IsZeroHourNamedDirectory(string path) private void FetchSubdirectoryInstallations(ref bool foundGenerals, ref bool foundZeroHour) { - if (!foundGenerals) + FetchGeneralsSubdirectoryInstallation(ref foundGenerals); + FetchZeroHourSubdirectoryInstallation(ref foundZeroHour); + } + + private void FetchGeneralsSubdirectoryInstallation(ref bool foundGenerals) + { + if (foundGenerals) { - ReadOnlySpan generalsSubdirs = - [ - GameClientConstants.GeneralsDirectoryName, - GameClientConstants.GeneralsRetailDirectoryName, - ]; + return; + } - if (TryFindSubdirectoryInstallation(generalsSubdirs, GameClientConstants.GeneralsExecutable, out var generalsPath)) - { - HasGenerals = true; - GeneralsPath = generalsPath; - foundGenerals = true; - _logger?.LogDebug("Found Generals installation at {GeneralsPath}", GeneralsPath); - } + ReadOnlySpan generalsSubdirs = + [ + GameClientConstants.GeneralsDirectoryName, + GameClientConstants.GeneralsRetailDirectoryName, + ]; + + if (TryFindSubdirectoryInstallation(generalsSubdirs, GameClientConstants.GeneralsExecutable, out var generalsPath)) + { + HasGenerals = true; + GeneralsPath = generalsPath; + foundGenerals = true; + logger?.LogDebug("Found Generals installation at {GeneralsPath}", GeneralsPath); } + } - if (!foundZeroHour) + private void FetchZeroHourSubdirectoryInstallation(ref bool foundZeroHour) + { + if (foundZeroHour) { - ReadOnlySpan zhSubdirs = - [ - GameClientConstants.ZeroHourDirectoryName, - GameClientConstants.ZeroHourDirectoryNameAmpersandHyphen, - GameClientConstants.ZeroHourRetailDirectoryName, - GameClientConstants.ZeroHourDirectoryNameAbbreviated, - GameClientConstants.ZeroHourDirectoryNameColonVariant, - ]; - - if (TryFindSubdirectoryInstallation(zhSubdirs, GameClientConstants.ZeroHourExecutable, out var zeroHourPath)) - { - HasZeroHour = true; - ZeroHourPath = zeroHourPath; - foundZeroHour = true; - _logger?.LogDebug("Found Zero Hour installation at {ZeroHourPath}", ZeroHourPath); - } + return; + } + + ReadOnlySpan zhSubdirs = + [ + GameClientConstants.ZeroHourDirectoryName, + GameClientConstants.ZeroHourDirectoryNameAmpersandHyphen, + GameClientConstants.ZeroHourRetailDirectoryName, + GameClientConstants.ZeroHourDirectoryNameAbbreviated, + GameClientConstants.ZeroHourDirectoryNameColonVariant, + ]; + + if (TryFindSubdirectoryInstallation(zhSubdirs, GameClientConstants.ZeroHourExecutable, out var zeroHourPath)) + { + HasZeroHour = true; + ZeroHourPath = zeroHourPath; + foundZeroHour = true; + logger?.LogDebug("Found Zero Hour installation at {ZeroHourPath}", ZeroHourPath); } } private bool TryFindSubdirectoryInstallation( ReadOnlySpan candidateSubdirectories, string executableName, - [NotNullWhen(true)] out string? foundPath) + [NotNullWhen(true)] out string? matchingPath) { foreach (var subDir in candidateSubdirectories) { - if (InstallationPath.TryGetDirectoryCaseInsensitive(subDir, out var subDirPath)) + if (InstallationPath.TryGetDirectoryCaseInsensitive(subDir, out var candidatePath)) { - var exePath = Path.Combine(subDirPath, executableName); - if (exePath.FileExistsCaseInsensitive()) + var candidateExe = Path.Combine(candidatePath, executableName); + if (candidateExe.FileExistsCaseInsensitive()) { - foundPath = subDirPath; + matchingPath = candidatePath; return true; } } } - foundPath = null; + matchingPath = null; return false; } @@ -380,13 +374,13 @@ private void FetchRootInstallation(ref bool foundGenerals, ref bool foundZeroHou HasZeroHour = true; ZeroHourPath = InstallationPath; foundZeroHour = true; - _logger?.LogDebug("Found Zero Hour installation at root {ZeroHourPath}", ZeroHourPath); + logger?.LogDebug("Found Zero Hour installation at root {ZeroHourPath}", ZeroHourPath); } if (!foundGenerals && hasGenSignature) { var isStrictGeneralsOnlySignature = - Path.Combine(InstallationPath, "gensec.big").FileExistsCaseInsensitive() || + Path.Combine(InstallationPath, GameClientConstants.GeneralsSecurityBig).FileExistsCaseInsensitive() || Path.Combine(InstallationPath, GameClientConstants.SuperHackersGeneralsExecutable).FileExistsCaseInsensitive(); var isZeroHour = isZhNamed || hasZhSignature; @@ -395,7 +389,7 @@ private void FetchRootInstallation(ref bool foundGenerals, ref bool foundZeroHou HasGenerals = true; GeneralsPath = InstallationPath; foundGenerals = true; - _logger?.LogDebug("Found Generals installation at root {GeneralsPath}", GeneralsPath); + logger?.LogDebug("Found Generals installation at root {GeneralsPath}", GeneralsPath); } } @@ -414,14 +408,14 @@ private void AssignRootFallback(ref bool foundGenerals, ref bool foundZeroHour) HasZeroHour = true; ZeroHourPath = InstallationPath; foundZeroHour = true; - _logger?.LogDebug("Found Zero Hour installation at root based on directory name {ZeroHourPath}", ZeroHourPath); + logger?.LogDebug("Found Zero Hour installation at root based on directory name {ZeroHourPath}", ZeroHourPath); } else { HasGenerals = true; GeneralsPath = InstallationPath; foundGenerals = true; - _logger?.LogDebug("Found Generals installation at root {GeneralsPath}", GeneralsPath); + logger?.LogDebug("Found Generals installation at root {GeneralsPath}", GeneralsPath); } } } diff --git a/GenHub/GenHub.Core/Models/GameProfile/LaunchProgress.cs b/GenHub/GenHub.Core/Models/GameProfile/LaunchProgress.cs index 73cb7d477..0e913c022 100644 --- a/GenHub/GenHub.Core/Models/GameProfile/LaunchProgress.cs +++ b/GenHub/GenHub.Core/Models/GameProfile/LaunchProgress.cs @@ -24,4 +24,16 @@ public int PercentComplete /// Gets or sets the workspace cleanup confirmation data when awaiting user decision. public WorkspaceCleanupConfirmation? CleanupConfirmation { get; set; } + + /// Gets or sets a value indicating whether workspace files are actively being initialized or materialized. + public bool IsInitializingWorkspace { get; set; } + + /// Gets or sets the total number of files to process during workspace initialization. + public int? TotalFiles { get; set; } + + /// Gets or sets the number of files processed so far during workspace initialization. + public int? FilesProcessed { get; set; } + + /// Gets or sets the current file being processed during workspace initialization. + public string? CurrentFile { get; set; } } diff --git a/GenHub/GenHub.Core/Models/Manifest/ContentManifest.cs b/GenHub/GenHub.Core/Models/Manifest/ContentManifest.cs index 0624cc42f..8da4e7fae 100644 --- a/GenHub/GenHub.Core/Models/Manifest/ContentManifest.cs +++ b/GenHub/GenHub.Core/Models/Manifest/ContentManifest.cs @@ -13,8 +13,9 @@ 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. + [JsonPropertyName("ManifestVersion")] + 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..2ef13d020 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", System.Globalization.CultureInfo.InvariantCulture); + 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). @@ -218,17 +251,23 @@ private static int ExtractVersionFromTag(string? tag) { // Fallback to simple digit extraction if strict normalization fails // (e.g. for complex tags like "beta-1-final") - var digits = DigitsRegex().Replace(tag, string.Empty); + return ExtractDigitsAsInt(tag); + } + } + + private static int ExtractDigitsAsInt(string? s) + { + if (string.IsNullOrWhiteSpace(s)) + return 0; - if (string.IsNullOrEmpty(digits)) - return 0; + var digits = DigitsRegex().Replace(s, string.Empty); + if (string.IsNullOrEmpty(digits)) + return 0; - // Take first 9 digits to avoid overflow - if (digits.Length > 9) - digits = digits[..9]; + if (digits.Length > 9) + digits = digits[..9]; - return int.TryParse(digits, out var version) ? version : 0; - } + return int.TryParse(digits, out var version) ? version : 0; } /// 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 fc609db20..2dcfc20a5 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; @@ -120,50 +121,24 @@ public static EntryPointResolution ResolveEntryPoint( if (!string.IsNullOrWhiteSpace(declared)) { - // A declared entry point that is not in the file list is a manifest defect. - // Failing here is far more diagnosable than failing at Process.Start. - var matchedFile = files.FirstOrDefault(f => PathsMatch(f.RelativePath, declared)); - - return matchedFile is not null - ? EntryPointResolution.Resolved(matchedFile.RelativePath, "declared entry point") - : EntryPointResolution.Failed( - $"Manifest '{manifest.Id}' declares entry point '{declared}', which is not among its " - + $"{files.Count} file(s).", - files); + return ResolveDeclaredEntryPoint(manifest, files, declared); } var executable = files .Where(f => f.IsExecutable - && ExecutableFileClassifier.IsLegacyLaunchCandidateFromName(f.RelativePath)) + && (ExecutableFileClassifier.IsLegacyLaunchCandidateFromName(f.RelativePath) + || IsPrimaryGameExecutable(f.RelativePath))) .ToList(); + if (executable.Count == 1) { return EntryPointResolution.Resolved(executable[0].RelativePath, "only file requiring execute permission"); } - if (executable.Count == 0) - { - var legacy = files - .Where(f => ExecutableFileClassifier.IsLegacyLaunchCandidateFromName(f.RelativePath)) - .ToList(); - - if (legacy.Count == 1) - { - return EntryPointResolution.Resolved(legacy[0].RelativePath, "only launch candidate by extension"); - } - - return EntryPointResolution.Failed( - legacy.Count == 0 - ? $"Manifest '{manifest.Id}' contains no launchable file." - : $"Manifest '{manifest.Id}' contains {legacy.Count} possible launch targets and declares no entry point.", - files); - } - - 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); + return executable.Count == 0 + ? ResolveLegacyCandidates(manifest, files) + : ResolvePrimaryExecutable(manifest, files, executable); } /// @@ -177,4 +152,83 @@ public static bool PathsMatch(string left, string right) => left.Replace('\\', '/').TrimStart('/'), right.Replace('\\', '/').TrimStart('/'), StringComparison.OrdinalIgnoreCase); + + private static EntryPointResolution ResolveDeclaredEntryPoint( + ContentManifest manifest, + IReadOnlyList files, + string declared) + { + var matchedFile = files.FirstOrDefault(f => PathsMatch(f.RelativePath, declared)); + + return matchedFile is not null + ? EntryPointResolution.Resolved(matchedFile.RelativePath, "declared entry point") + : EntryPointResolution.Failed( + $"Manifest '{manifest.Id}' declares entry point '{declared}', which is not among its " + + $"{files.Count} file(s).", + files); + } + + private static EntryPointResolution ResolveLegacyCandidates( + ContentManifest manifest, + IReadOnlyList files) + { + var legacy = files + .Where(f => + ExecutableFileClassifier.IsLegacyLaunchCandidateFromName(f.RelativePath) + || IsPrimaryGameExecutable(f.RelativePath)) + .ToList(); + + if (legacy.Count == 1) + { + 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." + : $"Manifest '{manifest.Id}' contains {legacy.Count} possible launch targets and declares no entry point.", + files); + } + + private static EntryPointResolution ResolvePrimaryExecutable( + ContentManifest manifest, + IReadOnlyList files, + IReadOnlyList executable) + { + 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) + || string.Equals(fileName, GameClientConstants.ContraExecutable, StringComparison.OrdinalIgnoreCase); + } } 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..f3308513e 100644 --- a/GenHub/GenHub.Core/Models/ModDB/ModDBCategoryMapper.cs +++ b/GenHub/GenHub.Core/Models/ModDB/ModDBCategoryMapper.cs @@ -20,7 +20,7 @@ public static ContentType MapCategory(string? categoryCode) "2" => ContentType.Mod, // Full Version "3" => ContentType.Mod, // Demo "4" => ContentType.Patch, // Patch - "28" => ContentType.Patch, // Script + "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") || s.Contains("hotfix") => ContentType.Patch, + 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..294d28125 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/CatalogManifestIdentity.cs @@ -0,0 +1,351 @@ +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; + } + + cleanVersion = cleanVersion.StartsWith("weekly-", StringComparison.OrdinalIgnoreCase) + ? cleanVersion["weekly-".Length..].Trim() + : cleanVersion.TrimStart('v', 'V').Trim(); + + try + { + if (TryParseDelimitedVersion(cleanVersion, out var delimitedResult)) + { + return delimitedResult; + } + + if (cleanVersion.Contains('_')) + { + var goVersion = GameVersionHelper.GetGeneralsOnlineManifestIdComponent(cleanVersion); + if (goVersion > 0) + { + return goVersion; + } + } + + if (int.TryParse(cleanVersion, out var intVersion) && intVersion >= 0) + { + return intVersion; + } + } + catch (FormatException) + { + // Fall through to hash-based approach + } + catch (OverflowException) + { + // Fall through to hash-based approach + } + + var bytes = Encoding.UTF8.GetBytes(cleanVersion); + var hash = SHA256.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; + } + + private static bool TryParseDelimitedVersion(string cleanVersion, out int result) + { + result = 0; + if (!cleanVersion.Contains('.') && !cleanVersion.Contains('-') && !cleanVersion.Contains('/')) + { + return false; + } + + var delims = new[] { '.', '-', '/' }; + var parts = cleanVersion.Split(delims, StringSplitOptions.RemoveEmptyEntries); + + return TryParseThreePartVersion(parts, out result) || + TryParseFourPartVersion(parts, out result) || + TryParseTwoPartVersion(parts, out result); + } + + private static bool TryParseThreePartVersion(string[] parts, out int result) + { + result = 0; + 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)) + { + return false; + } + + // 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) + { + result = (p0 * 10000) + (p1 * 100) + p2; + return true; + } + + // 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) + { + result = (p2 * 10000) + (p1 * 100) + p0; + return true; + } + + // Standard 3-part semantic version (e.g. 1.0.0 -> 10000, 1.2.3 -> 10203) + if (p0 >= 0 && p1 >= 0 && p1 < 100 && p2 >= 0 && p2 < 100) + { + var val = ((long)p0 * 10000) + ((long)p1 * 100) + p2; + if (val <= int.MaxValue) + { + result = (int)val; + return true; + } + } + + return false; + } + + private static bool TryParseFourPartVersion(string[] parts, out int result) + { + result = 0; + if (parts.Length != 4 || + !int.TryParse(parts[0], out var m0) || m0 < 0 || + !int.TryParse(parts[1], out var m1) || m1 < 0 || m1 >= 100 || + !int.TryParse(parts[2], out var m2) || m2 < 0 || m2 >= 100 || + !int.TryParse(parts[3], out var m3) || m3 < 0 || m3 >= 100) + { + return false; + } + + var val = ((long)m0 * 1_000_000) + ((long)m1 * 10_000) + ((long)m2 * 100) + m3; + if (val <= int.MaxValue) + { + result = (int)val; + return true; + } + + return false; + } + + private static bool TryParseTwoPartVersion(string[] parts, out int result) + { + result = 0; + if (parts.Length != 2 || + !int.TryParse(parts[0], out var major) || major < 0 || + !int.TryParse(parts[1], out var minor) || minor < 0 || minor >= 100) + { + return false; + } + + var normalized = $"{major}{minor.ToString().PadLeft(2, '0')}"; + if (int.TryParse(normalized, out var dotted) && dotted >= 0) + { + result = dotted; + return true; + } + + return false; + } +} 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..bd75e534a --- /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; } + + /// + /// 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..ad3511e89 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Providers/PublisherSubscription.cs @@ -0,0 +1,108 @@ +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.Core/Models/UserData/UserDataIndex.cs b/GenHub/GenHub.Core/Models/UserData/UserDataIndex.cs index c411cb9a1..452a8e806 100644 --- a/GenHub/GenHub.Core/Models/UserData/UserDataIndex.cs +++ b/GenHub/GenHub.Core/Models/UserData/UserDataIndex.cs @@ -39,4 +39,9 @@ public class UserDataIndex /// Enables quick lookup of all profiles using a manifest. /// public Dictionary> ManifestInstallations { get; set; } = []; + + /// + /// Gets or sets the ID of the currently active profile whose user data is materialized. + /// + public string? ActiveProfileId { get; set; } } diff --git a/GenHub/GenHub.Core/Services/Content/LocalContentService.cs b/GenHub/GenHub.Core/Services/Content/LocalContentService.cs index 2912aa9a5..7aa014c9e 100644 --- a/GenHub/GenHub.Core/Services/Content/LocalContentService.cs +++ b/GenHub/GenHub.Core/Services/Content/LocalContentService.cs @@ -23,7 +23,8 @@ public class LocalContentService( IManifestGenerationService manifestGenerationService, IContentStorageService contentStorageService, IContentReconciliationService reconciliationService, - ILogger logger) : ILocalContentService + ILogger logger, + IArchivePayloadProcessor? archivePayloadProcessor = null) : ILocalContentService { /// /// The publisher name for locally-generated content. @@ -91,6 +92,15 @@ public async Task> CreateLocalContentManifestAs directoryPath, contentType); + if (archivePayloadProcessor != null) + { + await archivePayloadProcessor.NormalizeDirectoryStructureAsync( + directoryPath, + contentType, + targetGame, + cancellationToken); + } + // Use the existing manifest generation service var builder = await manifestGenerationService.CreateContentManifestAsync( contentDirectory: directoryPath, diff --git a/GenHub/GenHub.Core/Utilities/ExecutableFileClassifier.cs b/GenHub/GenHub.Core/Utilities/ExecutableFileClassifier.cs index b7de8baed..5cb62c5d2 100644 --- a/GenHub/GenHub.Core/Utilities/ExecutableFileClassifier.cs +++ b/GenHub/GenHub.Core/Utilities/ExecutableFileClassifier.cs @@ -1,6 +1,7 @@ using System; using System.Buffers.Binary; using System.IO; +using GenHub.Core.Constants; namespace GenHub.Core.Utilities; @@ -104,6 +105,16 @@ public static bool RequiresExecutePermission(string path, string? absolutePath) var extension = Path.GetExtension(path); + if (MatchesAny(extension, LibraryExtensions) || MatchesAny(extension, GenLauncherConstants.AllSuffixes)) + { + return false; + } + + if (MatchesAny(extension, RunnableExtensions)) + { + return true; + } + // Extensionless: the shape of a native binary, but also of a README. Content is // the only reliable way to tell them apart, so use it whenever we have it. if (string.IsNullOrEmpty(extension)) @@ -111,12 +122,8 @@ public static bool RequiresExecutePermission(string path, string? absolutePath) return absolutePath is null || HasExecutePermissionHeader(absolutePath); } - if (MatchesAny(extension, LibraryExtensions)) - { - return false; - } - - return MatchesAny(extension, RunnableExtensions); + // For non-standard extensions, sniff magic bytes if on disk + return !string.IsNullOrEmpty(absolutePath) && HasExecutePermissionHeader(absolutePath); } /// @@ -159,15 +166,24 @@ public static bool IsLegacyLaunchCandidate(string path, string? absolutePath) var extension = Path.GetExtension(path); - // Extensionless native binaries and Windows executables only. Notably not .dat: - // the Steam layout launches game.dat through a proxy, but that is a launch - // *strategy* chosen by the Steam integration, not a property of the file. + if (MatchesAny(extension, LibraryExtensions) || MatchesAny(extension, GenLauncherConstants.AllSuffixes)) + { + return false; + } + + if (extension.Equals(".exe", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Extensionless native binaries if (string.IsNullOrEmpty(extension)) { return absolutePath is null || HasExecutableMagicBytes(absolutePath); } - return extension.Equals(".exe", StringComparison.OrdinalIgnoreCase); + // For non-standard extensions (e.g. custom mod PE binaries), sniff magic bytes if on disk + return !string.IsNullOrEmpty(absolutePath) && HasExecutableMagicBytes(absolutePath); } /// 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 d0fff137c..ffc95db4c 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs @@ -784,7 +784,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); @@ -846,13 +846,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..f163c0603 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/StripHtmlConverterTests.cs @@ -0,0 +1,82 @@ +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_WithScriptAndStyleTags_StripsContents() + { + var input = "

Hello World

"; + var result = _converter.Convert(input, typeof(string), null, CultureInfo.InvariantCulture); + + Assert.Equal("Hello World", 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 9434147bd..599f6e5e4 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_ParsesListAndProjectsResultsAsync() 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_SetsHasMoreItemsTrueAsync() + { + // 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..c2accc100 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Common/ArchivePayloadProcessorTests.cs @@ -0,0 +1,827 @@ +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_ExtractsAllEntriesAndDeletesZipAsync() + { + // 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_FlattensToRootAsync() + { + // 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_FlattensModWrapperAlongsideReadmeAsync() + { + // 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_PromotesMatchingTargetGameFolderAsync() + { + // 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_PreservesSingleMapDirectoryAsync() + { + // 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_FlattensOuterWrapperOnlyAsync() + { + // 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_PurgesSystemJunkAsync() + { + // 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_ThrowsInvalidDataExceptionAsync() + { + // 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); + } + + /// + /// Verifies that a self-extracting .exe archive for a Mod is extracted safely and the source .exe is removed. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_SelfExtractingExeMod_ExtractsAndDeletesExeAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var sfxExePath = Path.Combine(_stagingDirectory, "ShockWaveV1201.exe"); + using (var archive = ZipFile.Open(sfxExePath, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry("!ShockWave.big"); + using var writer = new StreamWriter(entry.Open()); + await writer.WriteAsync("BIG data payload"); + } + + var processor = CreateProcessor(); + + // Act + await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.Mod); + + // Assert + Assert.False(File.Exists(sfxExePath)); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "!ShockWave.big"))); + } + + /// + /// Verifies that executable files for tools or executables are never extracted or deleted even if they are zip containers. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_ExecutableTool_DoesNotExtractOrDeleteExeAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var toolExePath = Path.Combine(_stagingDirectory, "WorldBuilder.exe"); + using (var archive = ZipFile.Open(toolExePath, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry("internal.dll"); + using var writer = new StreamWriter(entry.Open()); + await writer.WriteAsync("dll"); + } + + var processor = CreateProcessor(); + + // Act + await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.ModdingTool); + + // Assert: Tool executable is preserved intact and NOT extracted + Assert.True(File.Exists(toolExePath)); + Assert.False(File.Exists(Path.Combine(_stagingDirectory, "internal.dll"))); + } + + /// + /// Verifies that non-archive game.dat files are skipped and preserved. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_GameDatBinary_PreservedWithoutThrowingAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var gameDatPath = Path.Combine(_stagingDirectory, "game.dat"); + await File.WriteAllTextAsync(gameDatPath, "MZ_Binary_Executable_Payload_Not_Archive"); + + var processor = CreateProcessor(); + + // Act + await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.Patch); + + // Assert + Assert.True(File.Exists(gameDatPath)); + } + + /// + /// Verifies that valid .dat archives (e.g. 10zh.dat) are extracted. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_ValidDatArchive_ExtractsAndDeletesDatAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var datArchivePath = Path.Combine(_stagingDirectory, "10zh.dat"); + using (var archive = ZipFile.Open(datArchivePath, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry("ZH/game.dat"); + using var writer = new StreamWriter(entry.Open()); + await writer.WriteAsync("ZH game binary"); + } + + var processor = CreateProcessor(); + + // Act + await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.Patch); + + // Assert + Assert.False(File.Exists(datArchivePath)); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "ZH", "game.dat"))); + } + + /// + /// Verifies that inactive .gib mod files are renamed to .big during normalization. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_GibFiles_NormalizesToBigAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var gibPath = Path.Combine(_stagingDirectory, "!ShwAudio.gib"); + var bigHeader = new byte[] { (byte)'B', (byte)'I', (byte)'G', (byte)'F', 0x00, 0x10, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00 }; + await File.WriteAllBytesAsync(gibPath, bigHeader); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour); + + // Assert + Assert.False(File.Exists(gibPath)); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "!ShwAudio.big"))); + } + + /// + /// Verifies that inactive .ctr mod files (e.g. Contra) are renamed to .big during default normalization. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_CtrFiles_NormalizesToBigAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var ctrPath = Path.Combine(_stagingDirectory, "!ContraXBeta2_INI.ctr"); + var bigHeader = new byte[] { (byte)'B', (byte)'I', (byte)'G', (byte)'F', 0x00, 0x10, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00 }; + await File.WriteAllBytesAsync(ctrPath, bigHeader); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour); + + // Assert + Assert.False(File.Exists(ctrPath)); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "!ContraXBeta2_INI.big"))); + } + + /// + /// Verifies that when normalizeInactiveArchives is false, .ctr and .gib files are preserved intact for Launcher Flow. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_WithNormalizeInactiveArchivesFalse_PreservesCtrAndGibFilesAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var ctrPath = Path.Combine(_stagingDirectory, "!ContraXBeta2_INI.ctr"); + var gibPath = Path.Combine(_stagingDirectory, "!ROTRAudio.gib"); + await File.WriteAllTextAsync(ctrPath, "Contra INI"); + await File.WriteAllTextAsync(gibPath, "ROTR Audio"); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour, normalizeInactiveArchives: false); + + // Assert + Assert.True(File.Exists(ctrPath)); + Assert.True(File.Exists(gibPath)); + Assert.False(File.Exists(Path.Combine(_stagingDirectory, "!ContraXBeta2_INI.big"))); + Assert.False(File.Exists(Path.Combine(_stagingDirectory, "!ROTRAudio.big"))); + } + + /// + /// Verifies that self-extracting executable archives (e.g. ShockWaveV1201.exe with PE header followed by ZIP central directory) + /// are detected and extracted safely for mod content types. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_SelfExtractingExeArchive_ExtractsAndDeletesExeAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var sfxExePath = Path.Combine(_stagingDirectory, "ShockWaveV1201.exe"); + + using (var memoryStream = new MemoryStream()) + { + var peHeader = new byte[512]; + peHeader[0] = 0x4D; // 'M' + peHeader[1] = 0x5A; // 'Z' + memoryStream.Write(peHeader, 0, peHeader.Length); + + using (var zipArchive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true)) + { + { + var entry1 = zipArchive.CreateEntry("Data/INI/ShockWave.ini"); + using var writer1 = new StreamWriter(entry1.Open()); + await writer1.WriteAsync("ModName=ShockWave"); + } + + { + var entry2 = zipArchive.CreateEntry("!ShwAudio.gib"); + using var writer2 = new StreamWriter(entry2.Open()); + await writer2.WriteAsync("Audio content"); + } + } + + await File.WriteAllBytesAsync(sfxExePath, memoryStream.ToArray()); + } + + var processor = CreateProcessor(); + + // Act + await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.Mod); + + // Assert + Assert.False(File.Exists(sfxExePath)); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "ShockWave.ini"))); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "!ShwAudio.gib"))); + } + + /// + /// Verifies that Smart Install Maker SFX executables (e.g. ShockWave) are safely extracted and normalized. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_WithSmartInstallMakerExecutable_ExtractsAndNormalizesSuccessfully() + { + var casPath = @"A:\Steam\steamapps\common\.genhub-cas\objects\f4\f45e14d6b4a1e6e6feaa2ad737528b385586ad81ab7535bf9a330972db834c4e"; + if (!File.Exists(casPath)) + { + return; + } + + var testDir = Path.Combine(_stagingDirectory, "sim_test_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(testDir); + + var installerPath = Path.Combine(testDir, "ShockWaveV1201.exe"); + File.Copy(casPath, installerPath, overwrite: true); + + var processor = CreateProcessor(); + + // 1. Extract archive safely + await processor.ExtractArchivesSafelyAsync(testDir, ContentType.Mod); + + // 2. Original installer .exe should have been deleted after extraction + Assert.False(File.Exists(installerPath), "Installer executable should be removed after successful extraction."); + + // 3. Normalize directory structure + await processor.NormalizeDirectoryStructureAsync(testDir, ContentType.Mod, GameType.ZeroHour); + + // 4. Verify extracted and normalized game files exist with full uncompressed size + var textureBigPath = Path.Combine(testDir, "!ShwTextures.big"); + Assert.True(File.Exists(textureBigPath), "Expected !ShwTextures.big to exist after normalization."); + var textureInfo = new FileInfo(textureBigPath); + Assert.True(textureInfo.Length > 60_000_000, $"Expected full textures >60MB, got {textureInfo.Length} bytes."); + + Assert.True( + File.Exists(Path.Combine(testDir, "!!0ShwPtchIcon.big")), + "Expected !!0ShwPtchIcon.big to exist."); + Assert.True( + File.Exists(Path.Combine(testDir, "!ShwAudio.big")), + "Expected !ShwAudio.big to exist."); + Assert.True( + File.Exists(Path.Combine(testDir, "ShockWaveLauncher.exe")), + "Expected ShockWaveLauncher.exe to exist."); + } + + /// + /// Verifies that payloads containing nested archives exceeding maximum extraction depth throw InvalidDataException. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_ExceedsMaxNestedDepth_ThrowsInvalidDataExceptionAsync() + { + // Arrange: create 6 layers of nested zips + Directory.CreateDirectory(_stagingDirectory); + var currentZip = Path.Combine(_stagingDirectory, "nested_level_6.zip"); + { + using var archive = ZipFile.Open(currentZip, ZipArchiveMode.Create); + using var writer = new StreamWriter(archive.CreateEntry("Data/test.ini").Open()); + await writer.WriteAsync("data=1"); + } + + for (var i = 5; i >= 1; i--) + { + var nextZip = Path.Combine(_stagingDirectory, $"nested_level_{i}.zip"); + using (var archive = ZipFile.Open(nextZip, ZipArchiveMode.Create)) + { + archive.CreateEntryFromFile(currentZip, Path.GetFileName(currentZip)); + } + + File.Delete(currentZip); + currentZip = nextZip; + } + + var processor = CreateProcessor(); + + // Act & Assert + await Assert.ThrowsAsync(() => + processor.ExtractArchivesSafelyAsync(_stagingDirectory)); + } + + /// + /// Verifies that wrapper promotion with colliding files preserving both files when content differs. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_WrapperCollisionWithDifferentContent_PreservesBothFilesAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var wrapperDir = Path.Combine(_stagingDirectory, "WrapperFolder"); + Directory.CreateDirectory(Path.Combine(wrapperDir, "Data")); + + // File at root + await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "Readme.txt"), "Root Readme content"); + + // File inside wrapper with same name but different content + await File.WriteAllTextAsync(Path.Combine(wrapperDir, "Readme.txt"), "Wrapper Readme content"); + await File.WriteAllTextAsync(Path.Combine(wrapperDir, "Data", "GameData.ini"), "data=1"); + + 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, "Readme_1.txt"))); + var rootText = await File.ReadAllTextAsync(Path.Combine(_stagingDirectory, "Readme.txt")); + var wrapperText = await File.ReadAllTextAsync(Path.Combine(_stagingDirectory, "Readme_1.txt")); + Assert.Contains("Readme content", rootText); + Assert.Contains("Readme content", wrapperText); + Assert.NotEqual(rootText, wrapperText); + } + + /// + /// Verifies that archive normalization safely distinguishes between real BIG archives and MZ disguised executables. + /// Real BIG archives become .big, whereas MZ executables become .exe and are never named .big. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task NormalizeDirectoryStructureAsync_WithDisguisedExecutableAndBigArchive_NormalizesSafelyAsync() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + + // Disguised executable (MZ header) named generals.ctr + var exeCtrPath = Path.Combine(_stagingDirectory, "generals.ctr"); + var mzBytes = new byte[] { (byte)'M', (byte)'Z', 0x90, 0x00, 0x03, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00 }; + await File.WriteAllBytesAsync(exeCtrPath, mzBytes); + + // Real BIG archive (BIGF header) named !Contra.ctr + var bigCtrPath = Path.Combine(_stagingDirectory, "!Contra.ctr"); + var bigBytes = new byte[] { (byte)'B', (byte)'I', (byte)'G', (byte)'F', 0x00, 0x10, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00 }; + await File.WriteAllBytesAsync(bigCtrPath, bigBytes); + + var processor = CreateProcessor(); + + // Act + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour, normalizeInactiveArchives: true); + + // Assert + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "!Contra.big")), "!Contra.ctr with BIGF magic should become !Contra.big"); + Assert.False(File.Exists(Path.Combine(_stagingDirectory, "!Contra.ctr")), "!Contra.ctr should no longer exist"); + + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "generals.exe")), "generals.ctr with MZ magic should become generals.exe"); + Assert.False(File.Exists(Path.Combine(_stagingDirectory, "generals.big")), "generals.ctr MUST NEVER become generals.big"); + Assert.False(File.Exists(Path.Combine(_stagingDirectory, "generals.ctr")), "generals.ctr should no longer exist"); + } + + /// + /// Verifies that Smart Install Maker executables with BZip2 and ZLib streams, uninstaller entries, and .ctr archives + /// are successfully unpacked and normalized without requiring external assets. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExtractArchivesSafelyAsync_WithSyntheticSmartInstallMakerExecutable_ExtractsAndNormalizesSuccessfully() + { + // Arrange + Directory.CreateDirectory(_stagingDirectory); + var bigHeader = new byte[] { (byte)'B', (byte)'I', (byte)'G', (byte)'F', 0x10, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00 }; + var bigPayload = System.Text.Encoding.ASCII.GetBytes("TestIniDataInsideBig"); + var bigContent = bigHeader.Concat(bigPayload).ToArray(); + + var exeHeader = new byte[] { (byte)'M', (byte)'Z', 0x90, 0x00, 0x03, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00 }; + var exePayload = System.Text.Encoding.ASCII.GetBytes("LauncherCode"); + var exeContent = exeHeader.Concat(exePayload).ToArray(); + + var iniContent = System.Text.Encoding.ASCII.GetBytes("GameData=1\r\nVersion=1.0\r\n"); + + var syntheticSimBytes = CreateSyntheticSmartInstallMakerExecutable( + [ + ("!ContraData.ctr", bigContent, true), + ("Contra_Launcher.exe", exeContent, true), + ("Data/INI/GameData.ini", iniContent, false), + ], + includeUninstallerEntry: true); + + var installerPath = Path.Combine(_stagingDirectory, "ContraXBeta2Setup.exe"); + await File.WriteAllBytesAsync(installerPath, syntheticSimBytes); + + var processor = CreateProcessor(); + + // Act: 1. Extract archive safely + await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.Mod); + + // Assert: installer executable should be deleted after successful extraction + Assert.False(File.Exists(installerPath), "Installer executable should be deleted after extraction."); + + // Act: 2. Normalize directory structure + await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour, normalizeInactiveArchives: true); + + // Assert: extracted files exist and .ctr is normalized to .big + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "!ContraData.big")), "Expected !ContraData.ctr to be normalized to !ContraData.big"); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Contra_Launcher.exe")), "Expected Contra_Launcher.exe to exist"); + Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "GameData.ini")), "Expected Data/INI/GameData.ini to exist"); + Assert.False(File.Exists(Path.Combine(_stagingDirectory, "ModUninstaller.exe")), "Uninstaller executable should not be extracted"); + } + + /// + public void Dispose() + { + if (Directory.Exists(_stagingDirectory)) + { + Directory.Delete(_stagingDirectory, recursive: true); + } + } + + private static ArchivePayloadProcessor CreateProcessor() + { + return new ArchivePayloadProcessor(new Mock>().Object); + } + + private static byte[] CreateSyntheticSmartInstallMakerExecutable( + (string Name, byte[] Content, bool UseBzip2)[] files, + bool includeUninstallerEntry = true) + { + using var ms = new MemoryStream(); + + // 1. DOS Header (64 bytes) + var dosHeader = new byte[64]; + dosHeader[0] = (byte)'M'; + dosHeader[1] = (byte)'Z'; + BitConverter.GetBytes(0x80).CopyTo(dosHeader, 0x3C); // e_lfanew = 0x80 + ms.Write(dosHeader, 0, 64); + + // Pad to 0x80 (128 bytes) + while (ms.Length < 0x80) + { + ms.WriteByte(0); + } + + // 2. PE Header at 0x80 + ms.Write([(byte)'P', (byte)'E', 0, 0]); + var coffHeader = new byte[20]; + BitConverter.GetBytes((ushort)0x14C).CopyTo(coffHeader, 0); + BitConverter.GetBytes((ushort)1).CopyTo(coffHeader, 2); + BitConverter.GetBytes((ushort)0).CopyTo(coffHeader, 16); + BitConverter.GetBytes((ushort)0x102).CopyTo(coffHeader, 18); + ms.Write(coffHeader, 0, 20); + + // Section header (40 bytes): Name=.text, VirtualSize=0x200, VirtualAddress=0x1000, SizeOfRawData=0x200, PointerToRawData=0x200 + var secHeader = new byte[40]; + System.Text.Encoding.ASCII.GetBytes(".text").CopyTo(secHeader, 0); + BitConverter.GetBytes(0x200).CopyTo(secHeader, 8); + BitConverter.GetBytes(0x1000).CopyTo(secHeader, 12); + BitConverter.GetBytes(0x200).CopyTo(secHeader, 16); + BitConverter.GetBytes(0x200).CopyTo(secHeader, 20); + ms.Write(secHeader, 0, 40); + + // Pad to Raw End = 0x200 + 0x200 = 0x400 (1024 bytes) + while (ms.Length < 0x400) + { + ms.WriteByte(0); + } + + // Overlay starts at 0x400 (1024) + var simSig = new byte[] { 0x77, 0x77, 0x67, 0x54, 0x29, 0x48, 0x35, 0x14 }; + ms.Write(simSig, 0, simSig.Length); + + // Prepare compressed payloads + using var payloadMs = new MemoryStream(); + var uninstallerText = System.Text.Encoding.Latin1.GetBytes("UninstallerStubText"); + payloadMs.Write(uninstallerText, 0, uninstallerText.Length); + + var tableRecords = new List<(string Name, uint UncompSize, uint Offset, uint CompSize)>(); + + if (includeUninstallerEntry) + { + tableRecords.Add(("ModUninstaller.exe", 100, 0, (uint)uninstallerText.Length)); + } + + foreach (var (name, content, useBzip2) in files) + { + var offset = (uint)payloadMs.Length; + byte[] compressed; + if (useBzip2) + { + using var bzMs = new MemoryStream(); + using (var bz = SharpCompress.Compressors.BZip2.BZip2Stream.Create(bzMs, SharpCompress.Compressors.CompressionMode.Compress, decompressConcatenated: false, leaveOpen: false)) + { + bz.Write(content, 0, content.Length); + } + + compressed = bzMs.ToArray(); + } + else + { + using var defMs = new MemoryStream(); + defMs.WriteByte(0x78); + defMs.WriteByte(0xDA); + using (var def = new DeflateStream(defMs, CompressionLevel.Optimal, leaveOpen: false)) + { + def.Write(content, 0, content.Length); + } + + compressed = defMs.ToArray(); + } + + payloadMs.Write(compressed, 0, compressed.Length); + tableRecords.Add((name, (uint)content.Length, offset, (uint)compressed.Length)); + } + + // Prepare table data + using var tableMs = new MemoryStream(); + tableMs.Write(new byte[40]); // initial padding + foreach (var (name, uncomp, offset, comp) in tableRecords) + { + var recordHeader = new byte[40]; + BitConverter.GetBytes(uncomp).CopyTo(recordHeader, 0); + BitConverter.GetBytes(offset).CopyTo(recordHeader, 4); + BitConverter.GetBytes(comp).CopyTo(recordHeader, 8); + tableMs.Write(recordHeader, 0, 40); + + var nameBytes = System.Text.Encoding.Latin1.GetBytes(name + "\0"); + tableMs.Write(nameBytes, 0, nameBytes.Length); + tableMs.Write(new byte[40]); // separator padding + } + + var compressedTable = Array.Empty(); + using (var defTableMs = new MemoryStream()) + { + defTableMs.WriteByte(0x78); + defTableMs.WriteByte(0xDA); + using (var def = new DeflateStream(defTableMs, CompressionLevel.Optimal, leaveOpen: false)) + { + var tableRaw = tableMs.ToArray(); + def.Write(tableRaw, 0, tableRaw.Length); + } + + compressedTable = defTableMs.ToArray(); + } + + // Block 0: Dummy Info Block + byte[] dummyData = [0x78, 0xDA, 0x01, 0x00, 0x00, 0xFF, 0xFF]; + using var writer = new BinaryWriter(ms, System.Text.Encoding.UTF8, leaveOpen: true); + writer.Write((short)1); + writer.Write(dummyData.Length + 5); + writer.Write(0); + writer.Write((byte)1); + writer.Write(dummyData); + + // Block 1: Table Block (second to last) + writer.Write((int)1); + writer.Write(compressedTable.Length + 5); + writer.Write(0); + writer.Write((byte)1); + writer.Write(compressedTable); + + // Block 2: Payload Block (last) + var payloadBytes = payloadMs.ToArray(); + writer.Write((int)2); + writer.Write(payloadBytes.Length + 5); + writer.Write(0); + writer.Write((byte)1); + writer.Write(payloadBytes); + + return ms.ToArray(); + } +} 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..0fed22013 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostDelivererTests.cs @@ -0,0 +1,362 @@ +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_ReturnsSuccessAsync() + { + // 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_FallsBackToGp2FilesEndpointAsync() + { + // 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_RejectsHtmlAndFallsBackToArchiveAsync() + { + // 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( + It.Is(u => u.Host.Contains("github.com")), + 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( + It.Is(u => u.Host.Contains("github.com")), + 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_ProcessesAndMergesIndicatorsDependencyAsync() + { + // 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 8a9fa81b4..82e726f9f 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_GeneratesCorrectIdForCommunityPatchAsync() // 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 b976ece3e..c3d836dd0 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 @@ -1,4 +1,5 @@ using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Content; using GenHub.Core.Models.CommunityOutpost; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; @@ -22,6 +23,7 @@ public class CommunityOutpostManifestFactoryTests : IDisposable { private readonly Mock> _loggerMock; private readonly Mock _hashProviderMock; + private readonly Mock _controlBarProcessorMock; private readonly CommunityOutpostManifestFactory _factory; private readonly string _tempDir; @@ -32,11 +34,12 @@ public CommunityOutpostManifestFactoryTests() { _loggerMock = new Mock>(); _hashProviderMock = new Mock(); + _controlBarProcessorMock = new Mock(); _hashProviderMock.Setup(x => x.ComputeFileHashAsync(It.IsAny(), It.IsAny())) .ReturnsAsync("abc123hash"); - _factory = new CommunityOutpostManifestFactory(_loggerMock.Object, _hashProviderMock.Object, null!); + _factory = new CommunityOutpostManifestFactory(_loggerMock.Object, _hashProviderMock.Object, _controlBarProcessorMock.Object); _tempDir = Path.Combine(Path.GetTempPath(), "GenHubTest_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDir); } 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 index 5b223f092..a2baa30cc 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostResolverTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostResolverTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using GenHub.Core.Constants; using GenHub.Core.Interfaces.Manifest; @@ -17,9 +18,9 @@ namespace GenHub.Tests.Core.Features.Content.CommunityOutpost; /// -/// Tests for CommunityOutpostResolver to verify manifest generation and ingestion gate compatibility. +/// Unit tests for . /// -public class CommunityOutpostResolverTests +public sealed class CommunityOutpostResolverTests { private readonly Mock _providerLoaderMock; @@ -30,7 +31,7 @@ public CommunityOutpostResolverTests() { _providerLoaderMock = new Mock(); - var providerDefinition = new ProviderDefinition + var provider = new ProviderDefinition { ProviderId = CommunityOutpostConstants.PublisherId, PublisherType = CommunityOutpostConstants.PublisherType, @@ -42,8 +43,148 @@ public CommunityOutpostResolverTests() }; _providerLoaderMock - .Setup(l => l.GetProvider(CommunityOutpostConstants.PublisherId)) - .Returns(providerDefinition); + .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_PopulatesSelectedVariantIdAndTagsAsync() + { + // 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_PreservesVersionZeroAsync() + { + // 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_SetsValidDefaultVersionAsync() + { + // 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); } /// @@ -92,7 +233,7 @@ public async Task ResolveAsync_CommunityPatch_GeneratesManifestAcceptedByIngesti Times.Once); var manifest = result.Data; - Assert.Equal(ManifestConstants.DefaultManifestVersion, manifest.ManifestVersion); + Assert.Equal(ManifestConstants.DefaultManifestVersion, manifest.SchemaVersion); Assert.Equal("27-08-2026", manifest.Version); Assert.Equal(ContentType.GameClient, manifest.ContentType); Assert.Equal(GameType.ZeroHour, manifest.TargetGame); @@ -163,7 +304,7 @@ public async Task ResolveAsync_BaseGamePatch_GeneratesManifestAcceptedByIngestio Times.Once); var manifest = result.Data; - Assert.Equal(ManifestConstants.DefaultManifestVersion, manifest.ManifestVersion); + Assert.Equal(ManifestConstants.DefaultManifestVersion, manifest.SchemaVersion); Assert.Equal(version, manifest.Version); var accepted = ManifestIngestionGate.TryAccept(manifest, out var rejectionReason); @@ -210,13 +351,52 @@ public async Task ResolveAsync_AddonContent_GeneratesManifestAcceptedByIngestion Assert.NotNull(result.Data); var manifest = result.Data; - Assert.Equal(ManifestConstants.DefaultManifestVersion, manifest.ManifestVersion); + Assert.Equal(ManifestConstants.DefaultManifestVersion, manifest.SchemaVersion); var accepted = ManifestIngestionGate.TryAccept(manifest, out var rejectionReason); Assert.True(accepted, $"Manifest should be accepted by ManifestIngestionGate, but was rejected with: {rejectionReason}"); Assert.Null(rejectionReason); } + 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; + } + private static Mock CreateBuilderMock( ManifestId manifestId, string name, @@ -231,33 +411,9 @@ private static Mock CreateBuilderMock( Version = version, ContentType = contentType, TargetGame = targetGame, - ManifestVersion = ManifestConstants.DefaultManifestVersion, + SchemaVersion = ManifestConstants.DefaultManifestVersion, }; - var builderMock = new Mock(); - builderMock.Setup(m => m.WithBasicInfo(It.IsAny(), It.IsAny(), It.IsAny())).Returns(builderMock.Object); - builderMock.Setup(m => m.WithContentType(It.IsAny(), It.IsAny())).Returns(builderMock.Object); - builderMock.Setup(m => m.WithPublisher(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Returns(builderMock.Object); - builderMock.Setup(m => m.WithMetadata(It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny?>(), It.IsAny())).Returns(builderMock.Object); - builderMock.Setup(m => m.WithInstallationInstructions(It.IsAny())).Returns(builderMock.Object); - builderMock.Setup(m => m.AddDependency( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny?>(), - It.IsAny(), - It.IsAny?>())).Returns(builderMock.Object); - builderMock.Setup(m => m.AddRemoteFileAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())).ReturnsAsync(builderMock.Object); - builderMock.Setup(m => m.Build()).Returns(manifest); - - return builderMock; + return CreateBuilderMock(manifest); } } 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..0d97b5f7c --- /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_PopulatesVariantsForControlBarProAsync() + { + // 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 165e2c1f1..49efd1014 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..7a69ab71f --- /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_UsesCategoryRelativeThumbnailAndPlayerBadgeMetadataAsync() + { + 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_ExtractsAuthorAndRichDescriptionAsync() + { + 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_UsesAodMapsPublisherLogoAsync() + { + 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_FiltersToMatchingMapsFromCategoryPageAsync() + { + 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_ExtractsMultiParagraphDescriptionAndAuthorAsync() + { + 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_UsesContraAodUrlAsync() + { + 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 9bc16d292..80ae3c458 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; private readonly Mock _installationCasPoolServiceMock; private readonly Mock> _loggerMock; + private readonly Mock _factoryResolverMock; /// /// 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_SuccessfullyAsyn _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_ReportsMonotonicStagesAsync() + { + // 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); @@ -476,6 +540,65 @@ await Assert.ThrowsAnyAsync( () => orchestrator.AcquireContentAsync(searchResult, progress: null, cts.Token)); } + /// + /// Verifies that ResolveManifestAsync successfully resolves manifests with hyphenated and unhyphenated IDs. + /// + /// The resolver ID registered in the container. + /// The resolver ID on the search result. + /// A task representing the asynchronous operation. + [Theory] + [InlineData("community-outpost", "communityoutpost")] + [InlineData("community-outpost", "community-outpost")] + [InlineData("community-outpost", "CommunityOutpost")] + [InlineData("AODMaps", "aodmaps")] + [InlineData("AODMaps", "AODMaps")] + public async Task ResolveManifestAsync_ResolvesNormalizedAndAliasedResolvers_SuccessfullyAsync( + string registeredResolverId, + string lookupResolverId) + { + // Arrange + var resolverMock = new Mock(); + resolverMock.SetupGet(r => r.ResolverId).Returns(registeredResolverId); + + var manifest = new ContentManifest + { + Id = "1.0.genhub.mod.test", + Name = "Test Mod", + }; + + var searchResult = new ContentSearchResult + { + Id = "test-item", + Name = "Test Item", + ResolverId = lookupResolverId, + }; + + resolverMock.Setup(r => r.ResolveAsync(searchResult, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + + _contentValidatorMock.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny())) + .ReturnsAsync(new ValidationResult(manifest.Id, [])); + + var orchestrator = new ContentOrchestrator( + _loggerMock.Object, + [], + [], + [resolverMock.Object], + _cacheMock.Object, + _contentValidatorMock.Object, + _manifestPoolMock.Object, + _installationServiceMock.Object, + _installationCasPoolServiceMock.Object); + + // Act + var result = await orchestrator.ResolveManifestAsync(searchResult); + + // Assert + Assert.True(result.Success, $"Resolution failed for lookup '{lookupResolverId}' on registered '{registeredResolverId}': {result.FirstError}"); + Assert.NotNull(result.Data); + Assert.Equal(manifest.Id, result.Data.Id); + } + /// /// Verifies that SearchAsync deduplicates results by manifest ID, preferring specialized providers. /// @@ -593,4 +716,9 @@ public async Task ResolveManifestAsync_MatchesResolverWithHyphenAndCaseVariation Assert.NotNull(result.Data); Assert.Equal("GenTool", result.Data.Name); } + + 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/GitHubContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs index 8ecfe2931..112d9df4e 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs @@ -1,6 +1,7 @@ using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.GitHub; using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Providers; using GenHub.Core.Models.Results; @@ -9,6 +10,7 @@ using GenHub.Features.Content.Services.GitHub; using Microsoft.Extensions.Logging; using Moq; +using ContentType = GenHub.Core.Models.Enums.ContentType; namespace GenHub.Tests.Core.Features.Content; @@ -22,6 +24,7 @@ public class GitHubContentProviderTests private readonly Mock _delivererMock; private readonly Mock _validatorMock; private readonly Mock> _loggerMock; + private readonly Mock _archiveProcessorMock; private readonly GitHubContentProvider _provider; /// @@ -34,6 +37,7 @@ public GitHubContentProviderTests() _delivererMock = new Mock(); _validatorMock = new Mock(); _loggerMock = new Mock>(); + _archiveProcessorMock = new Mock(); // Setup mocks to be correctly identified by the provider _discovererMock.Setup(d => d.SourceName).Returns("GitHub"); @@ -62,7 +66,8 @@ public GitHubContentProviderTests() [_delivererMock.Object], _loggerMock.Object, _validatorMock.Object, - instructionsMock.Object); + instructionsMock.Object, + _archiveProcessorMock.Object); } /// @@ -139,5 +144,6 @@ public async Task PrepareContentAsync_CallsDelivererAndValidator_SuccessfullyAsy // The base class should orchestrate the calls _delivererMock.Verify(d => d.CanDeliver(It.IsAny()), Times.AtLeastOnce()); _delivererMock.Verify(d => d.DeliverContentAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Once()); + _archiveProcessorMock.Verify(a => a.ProcessPayloadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); } } 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 a3bc76ae7..df38bb33c 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_ReturnsFailureAsync() 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_ResolvesOnlyThatAssetAsync() + { + 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..01c9d625b --- /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.Patch)] + [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.Patch)] + [InlineData("v1.01 Patch", ContentType.Patch)] + [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..a630b8b5c --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Parsers/ModDBPageParserTests.cs @@ -0,0 +1,1691 @@ +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_ExtractsArchiveNameAndAddonStartUrlAsync() + { + // 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_ExtractsOnPageCommentsWithoutParentSweepAsync() + { + // 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_ExtractsSingleplayerMapCategoryAsync() + { + // 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_UsesPersistentProfileForDownloadsAndAddonsAsync() + { + // 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_FetchesOnlyFileDetailPageAndSkipsSectionSweepAsync() + { + // 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_FetchesOnlyFileDetailPageAsync() + { + // 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_ParsesThreadHierarchyAndCleansActionTextAsync() + { + // 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_DoesNotPolluteParentContentAsync() + { + // 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_IsNotTreatedAsReviewAsync() + { + // 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_IsNotTreatedAsCommentsAsync() + { + 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_AreNotGalleryMediaAsync() + { + 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_ExtractsUniqueGalleryShotsAsync() + { + 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?.StartsWith("data:", StringComparison.OrdinalIgnoreCase) == true); + 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_FormatsCamelCaseAndFilenamesAsync() + { + 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_DedupesSameBinaryAsync() + { + 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_ParsesMd5ChecksumTotalDownloadsAndUploaderAsync() + { + 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_ExtractsSubheadingSizeAndLinksAsync() + { + 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_ExtractsTitlePlatformThumbnailAndEmbedUrlAsync() + { + // 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