diff --git a/Builder.cs b/Builder.cs
new file mode 100644
index 0000000..216963f
--- /dev/null
+++ b/Builder.cs
@@ -0,0 +1,765 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using System.Xml;
+using Newtonsoft.Json;
+using SabreTools.RedumpLib.Data;
+using SabreTools.RedumpLib.Web;
+
+namespace SabreTools.RedumpLib
+{
+ public static class Builder
+ {
+ #region Creation
+
+ ///
+ /// Create a SubmissionInfo from a JSON file path
+ ///
+ /// Path to the SubmissionInfo JSON
+ /// Filled SubmissionInfo on success, null on error
+ public static SubmissionInfo? CreateFromFile(string? path)
+ {
+ // If the path is invalid
+ if (string.IsNullOrWhiteSpace(path))
+ return null;
+
+ // If the file doesn't exist
+ if (!File.Exists(path))
+ return null;
+
+ // Try to open and deserialize the file
+ try
+ {
+ byte[] data = File.ReadAllBytes(path);
+ string dataString = Encoding.UTF8.GetString(data);
+ return JsonConvert.DeserializeObject(dataString);
+ }
+ catch
+ {
+ // We don't care what the exception was
+ return null;
+ }
+ }
+
+ ///
+ /// Create a new SubmissionInfo object from a disc page
+ ///
+ /// String containing the HTML disc data
+ /// Filled SubmissionInfo object on success, null on error
+ /// Not currently working
+ private static SubmissionInfo? CreateFromID(string discData)
+ {
+ var info = new SubmissionInfo()
+ {
+ CommonDiscInfo = new CommonDiscInfoSection(),
+ VersionAndEditions = new VersionAndEditionsSection(),
+ };
+
+ // No disc data means we can't parse it
+ if (string.IsNullOrWhiteSpace(discData))
+ return null;
+
+ try
+ {
+ // Load the current disc page into an XML document
+ var redumpPage = new XmlDocument() { PreserveWhitespace = true };
+ redumpPage.LoadXml(discData);
+
+ // If the current page isn't valid, we can't parse it
+ if (!redumpPage.HasChildNodes)
+ return null;
+
+ // Get the body node, if possible
+ var bodyNode = redumpPage["html"]?["body"];
+ if (bodyNode == null || !bodyNode.HasChildNodes)
+ return null;
+
+ // Loop through and get the main node, if possible
+ XmlNode? mainNode = null;
+ foreach (XmlNode? tempNode in bodyNode.ChildNodes)
+ {
+ // Invalid nodes are skipped
+ if (tempNode == null)
+ continue;
+
+ // We only care about div elements
+ if (!string.Equals(tempNode.Name, "div", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ // We only care if it has attributes
+ if (tempNode.Attributes == null)
+ continue;
+
+ // The main node has a class of "main"
+ if (string.Equals(tempNode.Attributes["class"]?.Value, "main", StringComparison.OrdinalIgnoreCase))
+ {
+ mainNode = tempNode;
+ break;
+ }
+ }
+
+ // If the main node is invalid, we can't do anything
+ if (mainNode == null || !mainNode.HasChildNodes)
+ return null;
+
+ // Try to find elements as we're going
+ foreach (XmlNode? childNode in mainNode.ChildNodes)
+ {
+ // Invalid nodes are skipped
+ if (childNode == null)
+ continue;
+
+ // The title is the only thing in h1 tags
+ if (string.Equals(childNode.Name, "h1", StringComparison.OrdinalIgnoreCase))
+ info.CommonDiscInfo.Title = childNode.InnerText;
+
+ // Most things are div elements but can be hard to parse out
+ else if (!string.Equals(childNode.Name, "div", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ // Only 2 of the internal divs have classes attached and one is not used here
+ if (childNode.Attributes != null && string.Equals(childNode.Attributes["class"]?.Value, "game",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ // If we don't have children nodes, skip this one over
+ if (!childNode.HasChildNodes)
+ continue;
+
+ // The game node contains multiple other elements
+ foreach (XmlNode? gameNode in childNode.ChildNodes)
+ {
+ // Invalid nodes are skipped
+ if (gameNode == null)
+ continue;
+
+ // Table elements contain multiple other parts of information
+ if (string.Equals(gameNode.Name, "table", StringComparison.OrdinalIgnoreCase))
+ {
+ // All tables have some attribute we can use
+ if (gameNode.Attributes == null)
+ continue;
+
+ // The gameinfo node contains most of the major information
+ if (string.Equals(gameNode.Attributes["class"]?.Value, "gameinfo",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ // If we don't have children nodes, skip this one over
+ if (!gameNode.HasChildNodes)
+ continue;
+
+ // Loop through each of the rows
+ foreach (XmlNode? gameInfoNode in gameNode.ChildNodes)
+ {
+ // Invalid nodes are skipped
+ if (gameInfoNode == null)
+ continue;
+
+ // If we run into anything not a row, ignore it
+ if (!string.Equals(gameInfoNode.Name, "tr", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ // If we don't have the required nodes, ignore it
+ if (gameInfoNode["th"] == null || gameInfoNode["td"] == null)
+ continue;
+
+ var gameInfoNodeHeader = gameInfoNode["th"];
+ var gameInfoNodeData = gameInfoNode["td"];
+
+ if (gameInfoNodeHeader == null || gameInfoNodeData == null)
+ {
+ // No-op for invalid data
+ }
+ else if (string.Equals(gameInfoNodeHeader.InnerText, "System", StringComparison.OrdinalIgnoreCase))
+ {
+ info.CommonDiscInfo.System = Extensions.ToRedumpSystem(gameInfoNodeData["a"]?.InnerText ?? string.Empty);
+ }
+ else if (string.Equals(gameInfoNodeHeader.InnerText, "Media", StringComparison.OrdinalIgnoreCase))
+ {
+ info.CommonDiscInfo.Media = Extensions.ToDiscType(gameInfoNodeData.InnerText);
+ }
+ else if (string.Equals(gameInfoNodeHeader.InnerText, "Category", StringComparison.OrdinalIgnoreCase))
+ {
+ info.CommonDiscInfo.Category = Extensions.ToDiscCategory(gameInfoNodeData.InnerText);
+ }
+ else if (string.Equals(gameInfoNodeHeader.InnerText, "Region", StringComparison.OrdinalIgnoreCase))
+ {
+ // TODO: COMPLETE
+ }
+ else if (string.Equals(gameInfoNodeHeader.InnerText, "Languages", StringComparison.OrdinalIgnoreCase))
+ {
+ // TODO: COMPLETE
+ }
+ else if (string.Equals(gameInfoNodeHeader.InnerText, "Edition", StringComparison.OrdinalIgnoreCase))
+ {
+ info.VersionAndEditions.OtherEditions = gameInfoNodeData.InnerText;
+ }
+ else if (string.Equals(gameInfoNodeHeader.InnerText, "Added", StringComparison.OrdinalIgnoreCase))
+ {
+ if (DateTime.TryParse(gameInfoNodeData.InnerText, out DateTime added))
+ info.Added = added;
+ }
+ else if (string.Equals(gameInfoNodeHeader.InnerText, "Last modified", StringComparison.OrdinalIgnoreCase))
+ {
+ if (DateTime.TryParse(gameInfoNodeData.InnerText, out DateTime lastModified))
+ info.LastModified = lastModified;
+ }
+ }
+ }
+
+ // The gamecomments node contains way more than it implies
+ if (string.Equals(gameNode.Attributes["class"]?.Value, "gamecomments", StringComparison.OrdinalIgnoreCase))
+ {
+ // TODO: COMPLETE
+ }
+
+ // TODO: COMPLETE
+ }
+
+ // The only other supported elements are divs
+ else if (!string.Equals(gameNode.Name, "div", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ // Check the div for dumper info
+ // TODO: COMPLETE
+ }
+ }
+
+ // Figure out what the div contains, if possible
+ // TODO: COMPLETE
+ }
+ }
+ catch
+ {
+ return null;
+ }
+
+ return info;
+ }
+
+ ///
+ /// Fill out an existing SubmissionInfo object based on a disc page
+ ///
+ /// RedumpWebClient for making the connection
+ /// Existing SubmissionInfo object to fill
+ /// Redump disc ID to retrieve
+ /// True to include all pullable information, false to do bare minimum
+#if NET40
+ public static bool FillFromId(RedumpWebClient wc, SubmissionInfo info, int id, bool includeAllData)
+#elif NETFRAMEWORK
+ public async static Task FillFromId(RedumpWebClient wc, SubmissionInfo info, int id, bool includeAllData)
+#else
+ public async static Task FillFromId(RedumpHttpClient wc, SubmissionInfo info, int id, bool includeAllData)
+#endif
+ {
+ // Ensure that required sections exist
+ info = EnsureAllSections(info);
+#if NET40
+ var discData = wc.DownloadSingleSiteID(id);
+#elif NETFRAMEWORK
+ var discData = await Task.Run(() => wc.DownloadSingleSiteID(id));
+#else
+ var discData = await wc.DownloadSingleSiteID(id);
+#endif
+ if (string.IsNullOrEmpty(discData))
+ return false;
+
+ // Title, Disc Number/Letter, Disc Title
+ var match = Constants.TitleRegex.Match(discData);
+ if (match.Success)
+ {
+ string title = WebUtility.HtmlDecode(match.Groups[1].Value);
+
+ // If we have parenthesis, title is everything before the first one
+ int firstParenLocation = title.IndexOf(" (");
+ if (firstParenLocation >= 0)
+ {
+ info.CommonDiscInfo!.Title = title.Substring(0, firstParenLocation);
+ var subMatches = Constants.DiscNumberLetterRegex.Matches(title);
+ foreach (Match subMatch in subMatches.Cast())
+ {
+ var subMatchValue = subMatch.Groups[1].Value;
+
+ // Disc number or letter
+ if (subMatchValue.StartsWith("Disc"))
+ info.CommonDiscInfo.DiscNumberLetter = subMatchValue.Remove(0, "Disc ".Length);
+
+ // Issue number
+ else if (subMatchValue.All(c => char.IsNumber(c)))
+ info.CommonDiscInfo.Title += $" ({subMatchValue})";
+
+ // Disc title
+ else
+ info.CommonDiscInfo.DiscTitle = subMatchValue;
+ }
+ }
+ // Otherwise, leave the title as-is
+ else
+ {
+ info.CommonDiscInfo!.Title = title;
+ }
+ }
+
+ // Foreign Title
+ match = Constants.ForeignTitleRegex.Match(discData);
+ if (match.Success)
+ info.CommonDiscInfo!.ForeignTitleNonLatin = WebUtility.HtmlDecode(match.Groups[1].Value);
+ else
+ info.CommonDiscInfo!.ForeignTitleNonLatin = null;
+
+ // Category
+ match = Constants.CategoryRegex.Match(discData);
+ if (match.Success)
+ info.CommonDiscInfo.Category = Extensions.ToDiscCategory(match.Groups[1].Value);
+ else
+ info.CommonDiscInfo.Category = DiscCategory.Games;
+
+ // Region
+ if (info.CommonDiscInfo.Region == null)
+ {
+ match = Constants.RegionRegex.Match(discData);
+ if (match.Success)
+ info.CommonDiscInfo.Region = Extensions.ToRegion(match.Groups[1].Value);
+ }
+
+ // Languages
+ var matches = Constants.LanguagesRegex.Matches(discData);
+ if (matches.Count > 0)
+ {
+ var tempLanguages = new List();
+ foreach (Match submatch in matches.Cast())
+ {
+ tempLanguages.Add(Extensions.ToLanguage(submatch.Groups[1].Value));
+ }
+
+ info.CommonDiscInfo.Languages = tempLanguages.Where(l => l != null).ToArray();
+ }
+
+ // Serial
+ if (includeAllData)
+ {
+ // TODO: Re-enable if there's a way of verifying against a disc
+ //match = Constants.SerialRegex.Match(discData);
+ //if (match.Success)
+ // info.CommonDiscInfo.Serial = $"(VERIFY THIS) {WebUtility.HtmlDecode(match.Groups[1].Value)}";
+ }
+
+ // Error count
+ if (string.IsNullOrEmpty(info.CommonDiscInfo.ErrorsCount))
+ {
+ match = Constants.ErrorCountRegex.Match(discData);
+ if (match.Success)
+ info.CommonDiscInfo.ErrorsCount = match.Groups[1].Value;
+ }
+
+ // Version
+ if (info.VersionAndEditions!.Version == null)
+ {
+ match = Constants.VersionRegex.Match(discData);
+ if (match.Success)
+ info.VersionAndEditions.Version = $"(VERIFY THIS) {WebUtility.HtmlDecode(match.Groups[1].Value)}";
+ }
+
+ // Dumpers
+ matches = Constants.DumpersRegex.Matches(discData);
+ if (matches.Count > 0)
+ {
+ // Start with any currently listed dumpers
+ var tempDumpers = new List();
+ if (info.DumpersAndStatus!.Dumpers != null && info.DumpersAndStatus.Dumpers.Length > 0)
+ {
+ foreach (string dumper in info.DumpersAndStatus.Dumpers)
+ tempDumpers.Add(dumper);
+ }
+
+ foreach (Match submatch in matches.Cast())
+ {
+ tempDumpers.Add(WebUtility.HtmlDecode(submatch.Groups[1].Value));
+ }
+
+ info.DumpersAndStatus.Dumpers = [.. tempDumpers];
+ }
+
+ // TODO: Unify handling of fields that can include site codes (Comments/Contents)
+
+ // Comments
+ if (includeAllData)
+ {
+ match = Constants.CommentsRegex.Match(discData);
+ if (match.Success)
+ {
+ // Process the old comments block
+ string oldComments = info.CommonDiscInfo.Comments
+ + (string.IsNullOrEmpty(info.CommonDiscInfo.Comments) ? string.Empty : "\n")
+ + WebUtility.HtmlDecode(match.Groups[1].Value)
+ .Replace("\r\n", "\n")
+ .Replace(" \n", "\n")
+ .Replace(" ", string.Empty)
+ .Replace("", string.Empty)
+ .Replace("[+]", string.Empty)
+ .ReplaceHtmlWithSiteCodes();
+ oldComments = Regex.Replace(oldComments, @"
", string.Empty, RegexOptions.Compiled);
+
+ // Create state variables
+ bool addToLast = false;
+ SiteCode? lastSiteCode = null;
+ string newComments = string.Empty;
+
+ // Process the comments block line-by-line
+ string[] commentsSeparated = oldComments.Split('\n');
+ for (int i = 0; i < commentsSeparated.Length; i++)
+ {
+ string commentLine = commentsSeparated[i].Trim();
+
+ // If we have an empty line, we want to treat this as intentional
+ if (string.IsNullOrWhiteSpace(commentLine))
+ {
+ addToLast = false;
+ lastSiteCode = null;
+ newComments += $"{commentLine}\n";
+ continue;
+ }
+
+ // Otherwise, we need to find what tag is in use
+ bool foundTag = false;
+ foreach (SiteCode? siteCode in Enum.GetValues(typeof(SiteCode)))
+ {
+ // If we have a null site code, just skip
+ if (siteCode == null)
+ continue;
+
+ // If the line doesn't contain this tag, just skip
+ var shortName = siteCode.ShortName();
+ if (shortName == null || !commentLine.Contains(shortName))
+ continue;
+
+ // Mark as having found a tag
+ foundTag = true;
+
+ // Cache the current site code
+ lastSiteCode = siteCode;
+
+ // A subset of tags can be multiline
+ addToLast = siteCode.IsMultiLine();
+
+ // Skip certain site codes because of data issues
+ switch (siteCode)
+ {
+ // Multiple
+ case SiteCode.InternalSerialName:
+ case SiteCode.Multisession:
+ case SiteCode.VolumeLabel:
+ continue;
+
+ // Audio CD
+ case SiteCode.RingNonZeroDataStart:
+ case SiteCode.UniversalHash:
+ continue;
+
+ // Microsoft Xbox and Xbox 360
+ case SiteCode.DMIHash:
+ case SiteCode.PFIHash:
+ case SiteCode.SSHash:
+ case SiteCode.SSVersion:
+ case SiteCode.XMID:
+ case SiteCode.XeMID:
+ continue;
+
+ // Microsoft Xbox One and Series X/S
+ case SiteCode.Filename:
+ continue;
+
+ // Nintendo Gamecube
+ case SiteCode.InternalName:
+ continue;
+ }
+
+ // If we don't already have this site code, add it to the dictionary
+ if (!info.CommonDiscInfo.CommentsSpecialFields!.ContainsKey(siteCode.Value))
+ info.CommonDiscInfo.CommentsSpecialFields[siteCode.Value] = $"(VERIFY THIS) {commentLine.Replace(shortName, string.Empty).Trim()}";
+
+ // Otherwise, append the value to the existing key
+ else
+ info.CommonDiscInfo.CommentsSpecialFields[siteCode.Value] += $", {commentLine.Replace(shortName, string.Empty).Trim()}";
+
+ break;
+ }
+
+ // If we didn't find a known tag, just add the line, just in case
+ if (!foundTag)
+ {
+ if (addToLast && lastSiteCode != null)
+ {
+ if (!string.IsNullOrWhiteSpace(info.CommonDiscInfo.CommentsSpecialFields![lastSiteCode.Value]))
+ info.CommonDiscInfo.CommentsSpecialFields[lastSiteCode.Value] += "\n";
+
+ info.CommonDiscInfo.CommentsSpecialFields[lastSiteCode.Value] += commentLine;
+ }
+ else
+ {
+ newComments += $"{commentLine}\n";
+ }
+ }
+ }
+
+ // Set the new comments field
+ info.CommonDiscInfo.Comments = newComments;
+ }
+ }
+
+ // Contents
+ if (includeAllData)
+ {
+ match = Constants.ContentsRegex.Match(discData);
+ if (match.Success)
+ {
+ // Process the old contents block
+ string oldContents = info.CommonDiscInfo.Contents
+ + (string.IsNullOrEmpty(info.CommonDiscInfo.Contents) ? string.Empty : "\n")
+ + WebUtility.HtmlDecode(match.Groups[1].Value)
+ .Replace("\r\n", "\n")
+ .Replace(" \n", "\n")
+ .Replace(" ", string.Empty)
+ .Replace("
", string.Empty, RegexOptions.Compiled);
+
+ // Create state variables
+ bool addToLast = false;
+ SiteCode? lastSiteCode = null;
+ string newContents = string.Empty;
+
+ // Process the contents block line-by-line
+ string[] contentsSeparated = oldContents.Split('\n');
+ for (int i = 0; i < contentsSeparated.Length; i++)
+ {
+ string contentLine = contentsSeparated[i].Trim();
+
+ // If we have an empty line, we want to treat this as intentional
+ if (string.IsNullOrWhiteSpace(contentLine))
+ {
+ addToLast = false;
+ lastSiteCode = null;
+ newContents += $"{contentLine}\n";
+ continue;
+ }
+
+ // Otherwise, we need to find what tag is in use
+ bool foundTag = false;
+ foreach (SiteCode? siteCode in Enum.GetValues(typeof(SiteCode)))
+ {
+ // If we have a null site code, just skip
+ if (siteCode == null)
+ continue;
+
+ // If the line doesn't contain this tag, just skip
+ var shortName = siteCode.ShortName();
+ if (shortName == null || !contentLine.Contains(shortName))
+ continue;
+
+ // Cache the current site code
+ lastSiteCode = siteCode;
+
+ // If we don't already have this site code, add it to the dictionary
+ if (!info.CommonDiscInfo.ContentsSpecialFields!.ContainsKey(siteCode.Value))
+ info.CommonDiscInfo.ContentsSpecialFields[siteCode.Value] = $"(VERIFY THIS) {contentLine.Replace(shortName, string.Empty).Trim()}";
+
+ // A subset of tags can be multiline
+ addToLast = siteCode.IsMultiLine();
+
+ // Mark as having found a tag
+ foundTag = true;
+ break;
+ }
+
+ // If we didn't find a known tag, just add the line, just in case
+ if (!foundTag)
+ {
+ if (addToLast && lastSiteCode != null)
+ {
+ if (!string.IsNullOrWhiteSpace(info.CommonDiscInfo.ContentsSpecialFields![lastSiteCode.Value]))
+ info.CommonDiscInfo.ContentsSpecialFields[lastSiteCode.Value] += "\n";
+
+ info.CommonDiscInfo.ContentsSpecialFields[lastSiteCode.Value] += contentLine;
+ }
+ else
+ {
+ newContents += $"{contentLine}\n";
+ }
+ }
+ }
+
+ // Set the new contents field
+ info.CommonDiscInfo.Contents = newContents;
+ }
+ }
+
+ // Added
+ match = Constants.AddedRegex.Match(discData);
+ if (match.Success)
+ {
+ if (DateTime.TryParse(match.Groups[1].Value, out DateTime added))
+ info.Added = added;
+ else
+ info.Added = null;
+ }
+
+ // Last Modified
+ match = Constants.LastModifiedRegex.Match(discData);
+ if (match.Success)
+ {
+ if (DateTime.TryParse(match.Groups[1].Value, out DateTime lastModified))
+ info.LastModified = lastModified;
+ else
+ info.LastModified = null;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Ensure all required sections in a submission info exist
+ ///
+ /// SubmissionInfo object to verify
+ public static SubmissionInfo EnsureAllSections(SubmissionInfo? info)
+ {
+ // If there's no info, create one
+ info ??= new SubmissionInfo();
+
+ // Ensure all sections
+ info.CommonDiscInfo ??= new CommonDiscInfoSection();
+ info.VersionAndEditions ??= new VersionAndEditionsSection();
+ info.EDC ??= new EDCSection();
+ info.ParentCloneRelationship ??= new ParentCloneRelationshipSection();
+ info.Extras ??= new ExtrasSection();
+ info.CopyProtection ??= new CopyProtectionSection();
+ info.DumpersAndStatus ??= new DumpersAndStatusSection();
+ info.TracksAndWriteOffsets ??= new TracksAndWriteOffsetsSection();
+ info.SizeAndChecksums ??= new SizeAndChecksumsSection();
+ info.DumpingInfo ??= new DumpingInfoSection();
+
+ // Ensure special dictionaries
+ info.CommonDiscInfo.CommentsSpecialFields ??= [];
+ info.CommonDiscInfo.ContentsSpecialFields ??= [];
+
+ return info;
+ }
+
+ ///
+ /// Inject information from a seed SubmissionInfo into the existing one
+ ///
+ /// Existing submission information
+ /// User-supplied submission information
+ public static void InjectSubmissionInformation(SubmissionInfo? info, SubmissionInfo? seed)
+ {
+ // If we have any invalid info
+ if (seed == null)
+ return;
+
+ // Ensure that required sections exist
+ info = EnsureAllSections(info);
+
+ // Otherwise, inject information as necessary
+ if (info.CommonDiscInfo != null && seed.CommonDiscInfo != null)
+ {
+ // Info that only overwrites if supplied
+ if (!string.IsNullOrWhiteSpace(seed.CommonDiscInfo.Title)) info.CommonDiscInfo.Title = seed.CommonDiscInfo.Title;
+ if (!string.IsNullOrWhiteSpace(seed.CommonDiscInfo.ForeignTitleNonLatin)) info.CommonDiscInfo.ForeignTitleNonLatin = seed.CommonDiscInfo.ForeignTitleNonLatin;
+ if (!string.IsNullOrWhiteSpace(seed.CommonDiscInfo.DiscNumberLetter)) info.CommonDiscInfo.DiscNumberLetter = seed.CommonDiscInfo.DiscNumberLetter;
+ if (!string.IsNullOrWhiteSpace(seed.CommonDiscInfo.DiscTitle)) info.CommonDiscInfo.DiscTitle = seed.CommonDiscInfo.DiscTitle;
+ if (seed.CommonDiscInfo.Category != null) info.CommonDiscInfo.Category = seed.CommonDiscInfo.Category;
+ if (seed.CommonDiscInfo.Region != null) info.CommonDiscInfo.Region = seed.CommonDiscInfo.Region;
+ if (seed.CommonDiscInfo.Languages != null) info.CommonDiscInfo.Languages = seed.CommonDiscInfo.Languages;
+ if (seed.CommonDiscInfo.LanguageSelection != null) info.CommonDiscInfo.LanguageSelection = seed.CommonDiscInfo.LanguageSelection;
+ if (!string.IsNullOrWhiteSpace(seed.CommonDiscInfo.Serial)) info.CommonDiscInfo.Serial = seed.CommonDiscInfo.Serial;
+ if (!string.IsNullOrWhiteSpace(seed.CommonDiscInfo.Barcode)) info.CommonDiscInfo.Barcode = seed.CommonDiscInfo.Barcode;
+ if (!string.IsNullOrWhiteSpace(seed.CommonDiscInfo.Comments)) info.CommonDiscInfo.Comments = seed.CommonDiscInfo.Comments;
+ if (seed.CommonDiscInfo.CommentsSpecialFields != null) info.CommonDiscInfo.CommentsSpecialFields = seed.CommonDiscInfo.CommentsSpecialFields;
+ if (!string.IsNullOrWhiteSpace(seed.CommonDiscInfo.Contents)) info.CommonDiscInfo.Contents = seed.CommonDiscInfo.Contents;
+ if (seed.CommonDiscInfo.ContentsSpecialFields != null) info.CommonDiscInfo.ContentsSpecialFields = seed.CommonDiscInfo.ContentsSpecialFields;
+
+ // Info that always overwrites
+ info.CommonDiscInfo.Layer0MasteringRing = seed.CommonDiscInfo.Layer0MasteringRing;
+ info.CommonDiscInfo.Layer0MasteringSID = seed.CommonDiscInfo.Layer0MasteringSID;
+ info.CommonDiscInfo.Layer0ToolstampMasteringCode = seed.CommonDiscInfo.Layer0ToolstampMasteringCode;
+ info.CommonDiscInfo.Layer0MouldSID = seed.CommonDiscInfo.Layer0MouldSID;
+ info.CommonDiscInfo.Layer0AdditionalMould = seed.CommonDiscInfo.Layer0AdditionalMould;
+
+ info.CommonDiscInfo.Layer1MasteringRing = seed.CommonDiscInfo.Layer1MasteringRing;
+ info.CommonDiscInfo.Layer1MasteringSID = seed.CommonDiscInfo.Layer1MasteringSID;
+ info.CommonDiscInfo.Layer1ToolstampMasteringCode = seed.CommonDiscInfo.Layer1ToolstampMasteringCode;
+ info.CommonDiscInfo.Layer1MouldSID = seed.CommonDiscInfo.Layer1MouldSID;
+ info.CommonDiscInfo.Layer1AdditionalMould = seed.CommonDiscInfo.Layer1AdditionalMould;
+
+ info.CommonDiscInfo.Layer2MasteringRing = seed.CommonDiscInfo.Layer2MasteringRing;
+ info.CommonDiscInfo.Layer2MasteringSID = seed.CommonDiscInfo.Layer2MasteringSID;
+ info.CommonDiscInfo.Layer2ToolstampMasteringCode = seed.CommonDiscInfo.Layer2ToolstampMasteringCode;
+
+ info.CommonDiscInfo.Layer3MasteringRing = seed.CommonDiscInfo.Layer3MasteringRing;
+ info.CommonDiscInfo.Layer3MasteringSID = seed.CommonDiscInfo.Layer3MasteringSID;
+ info.CommonDiscInfo.Layer3ToolstampMasteringCode = seed.CommonDiscInfo.Layer3ToolstampMasteringCode;
+ }
+
+ if (info.VersionAndEditions != null && seed.VersionAndEditions != null)
+ {
+ // Info that only overwrites if supplied
+ if (!string.IsNullOrWhiteSpace(seed.VersionAndEditions.Version)) info.VersionAndEditions.Version = seed.VersionAndEditions.Version;
+ if (!string.IsNullOrWhiteSpace(seed.VersionAndEditions.OtherEditions)) info.VersionAndEditions.OtherEditions = seed.VersionAndEditions.OtherEditions;
+ }
+
+ if (info.CopyProtection != null && seed.CopyProtection != null)
+ {
+ // Info that only overwrites if supplied
+ if (!string.IsNullOrWhiteSpace(seed.CopyProtection.Protection)) info.CopyProtection.Protection = seed.CopyProtection.Protection;
+ }
+ }
+
+ #endregion
+
+ #region Helpers
+
+ ///
+ /// Process a text block and replace with internal identifiers
+ ///
+ /// Text block to process
+ /// Processed text block, if possible
+ private static string ReplaceHtmlWithSiteCodes(this string text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ return text;
+
+ foreach (SiteCode? siteCode in Enum.GetValues(typeof(SiteCode)))
+ {
+ var longname = siteCode.LongName();
+ if (!string.IsNullOrEmpty(longname))
+ text = text.Replace(longname, siteCode.ShortName());
+ }
+
+ // For some outdated tags, we need to use alternate names
+ text = text.Replace("Demos:", ((SiteCode?)SiteCode.PlayableDemos).ShortName());
+ text = text.Replace("DMI:", ((SiteCode?)SiteCode.DMIHash).ShortName());
+ text = text.Replace("LucasArts ID:", ((SiteCode?)SiteCode.LucasArtsID).ShortName());
+ text = text.Replace("PFI:", ((SiteCode?)SiteCode.PFIHash).ShortName());
+ text = text.Replace("SS:", ((SiteCode?)SiteCode.SSHash).ShortName());
+ text = text.Replace("SSv1:", ((SiteCode?)SiteCode.SSHash).ShortName());
+ text = text.Replace("SSv1:", ((SiteCode?)SiteCode.SSHash).ShortName());
+ text = text.Replace("SSv2:", ((SiteCode?)SiteCode.SSHash).ShortName());
+ text = text.Replace("SSv2:", ((SiteCode?)SiteCode.SSHash).ShortName());
+ text = text.Replace("SS version:", ((SiteCode?)SiteCode.SSVersion).ShortName());
+ text = text.Replace("Universal Hash (SHA-1):", ((SiteCode?)SiteCode.UniversalHash).ShortName());
+ text = text.Replace("XeMID:", ((SiteCode?)SiteCode.XeMID).ShortName());
+ text = text.Replace("XMID:", ((SiteCode?)SiteCode.XMID).ShortName());
+
+ return text;
+ }
+
+ #endregion
+ }
+}
diff --git a/Data/Constants.cs b/Data/Constants.cs
index bad33b2..38487fb 100644
--- a/Data/Constants.cs
+++ b/Data/Constants.cs
@@ -10,17 +10,17 @@ namespace SabreTools.RedumpLib.Data
///
/// Regex matching the added field on a disc page
///
- public static Regex AddedRegex = new Regex(@"
Added
(.*?)
");
+ public static Regex AddedRegex = new(@"
Added
(.*?)
");
///
/// Regex matching the barcode field on a disc page
///
- public static Regex BarcodeRegex = new Regex(@"
Barcode
(.*?)
");
+ public static Regex BarcodeRegex = new(@"
Barcode
(.*?)
");
///
/// Regex matching the BCA field on a disc page
///
- public static Regex BcaRegex = new Regex(@"
BCA .*?/>
"
+ public static Regex BcaRegex = new(@"
BCA .*?/>
"
+ "
Row
Contents
ASCII
"
+ "
(?.*?)
(?.*?)
(?.*?)
"
+ "
(?.*?)
(?.*?)
(?.*?)
"
@@ -30,82 +30,82 @@ namespace SabreTools.RedumpLib.Data
///
/// Regex matching the category field on a disc page
///
- public static Regex CategoryRegex = new Regex(@"
Category
(.*?)
");
+ public static Regex CategoryRegex = new(@"
Category
(.*?)
");
///
/// Regex matching the comments field on a disc page
///
- public static Regex CommentsRegex = new Regex(@"
Comments
(.*?)
", RegexOptions.Singleline);
+ public static Regex CommentsRegex = new(@"
Comments
(.*?)
", RegexOptions.Singleline);
///
/// Regex matching the contents field on a disc page
///
- public static Regex ContentsRegex = new Regex(@"
Contents
(.*?)
", RegexOptions.Singleline);
+ public static Regex ContentsRegex = new(@"
");
///
/// Regex matching the languages field on a disc page
///
- public static Regex LanguagesRegex = new Regex(@"\s*");
+ public static Regex LanguagesRegex = new(@"\s*");
///
/// Regex matching the last modified field on a disc page
///
- public static Regex LastModifiedRegex = new Regex(@"
Last modified
(.*?)
");
+ public static Regex LastModifiedRegex = new(@"
Last modified
(.*?)
");
///
/// Regex matching the media field on a disc page
///
- public static Regex MediaRegex = new Regex(@"
");
+ public static Regex PartialMatchRegex = new(@"
partial match ids: (.*?)
");
///
/// Regex matching the PVD field on a disc page
///
- public static Regex PvdRegex = new Regex(@"
Primary Volume Descriptor (PVD)
"
+ public static Regex PvdRegex = new(@"
Primary Volume Descriptor (PVD)
"
+ @"
Record / Entry
Contents
Date
Time
GMT
"
+ @"
Creation
(?.*?)
(?.*?)
(?.*?)
(?.*?)
"
+ @"
Modification
(?.*?)
(?.*?)
(?.*?)
(?.*?)
"
@@ -115,57 +115,57 @@ namespace SabreTools.RedumpLib.Data
///
/// Regex matching the region field on a disc page
///
- public static Regex RegionRegex = new Regex(@"