using System; using System.Collections.Generic; using System.IO; using System.Linq; #if NET40_OR_GREATER || NETCOREAPP using System.Threading.Tasks; #endif using System.Xml.Serialization; using Newtonsoft.Json; using SabreTools.Core; using SabreTools.Core.Tools; using SabreTools.DatFiles.Formats; using SabreTools.DatItems; using SabreTools.DatItems.Formats; using SabreTools.Filter; using SabreTools.Hashing; using SabreTools.Logging; namespace SabreTools.DatFiles { /// /// Represents a format-agnostic DAT /// [JsonObject("datfile"), XmlRoot("datfile")] public abstract partial class DatFile { #region Fields /// /// Header values /// [JsonProperty("header"), XmlElement("header")] public DatHeader Header { get; set; } = new DatHeader(); /// /// DatItems and related statistics /// [JsonProperty("items"), XmlElement("items")] public ItemDictionary Items { get; set; } = []; #endregion #region Logging /// /// Logging object /// [JsonIgnore, XmlIgnore] protected Logger logger; #endregion #region Constructors /// /// Create a new DatFile from an existing one /// /// DatFile to get the values from public DatFile(DatFile? datFile) { logger = new Logger(this); if (datFile != null) { Header = datFile.Header; Items = datFile.Items; } } /// /// Create a specific type of DatFile to be used based on a format and a base DAT /// /// Format of the DAT to be created /// DatFile containing the information to use in specific operations /// For relevant types, assume the usage of quotes /// DatFile of the specific internal type that corresponds to the inputs public static DatFile Create(DatFormat? datFormat = null, DatFile? baseDat = null, bool quotes = true) { return datFormat switch { DatFormat.ArchiveDotOrg => new ArchiveDotOrg(baseDat), DatFormat.AttractMode => new AttractMode(baseDat), DatFormat.ClrMamePro => new ClrMamePro(baseDat, quotes), DatFormat.CSV => new SeparatedValue(baseDat, ','), DatFormat.DOSCenter => new DosCenter(baseDat), DatFormat.EverdriveSMDB => new EverdriveSMDB(baseDat), DatFormat.Listrom => new Listrom(baseDat), DatFormat.Listxml => new Listxml(baseDat), DatFormat.Logiqx => new Logiqx(baseDat, false), DatFormat.LogiqxDeprecated => new Logiqx(baseDat, true), DatFormat.MissFile => new Missfile(baseDat), DatFormat.OfflineList => new OfflineList(baseDat), DatFormat.OpenMSX => new OpenMSX(baseDat), DatFormat.RedumpMD5 => new Hashfile(baseDat, HashType.MD5), DatFormat.RedumpSFV => new Hashfile(baseDat, HashType.CRC32), DatFormat.RedumpSHA1 => new Hashfile(baseDat, HashType.SHA1), DatFormat.RedumpSHA256 => new Hashfile(baseDat, HashType.SHA256), DatFormat.RedumpSHA384 => new Hashfile(baseDat, HashType.SHA384), DatFormat.RedumpSHA512 => new Hashfile(baseDat, HashType.SHA512), DatFormat.RedumpSpamSum => new Hashfile(baseDat, HashType.SpamSum), DatFormat.RomCenter => new RomCenter(baseDat), DatFormat.SabreJSON => new SabreJSON(baseDat), DatFormat.SabreXML => new SabreXML(baseDat), DatFormat.SoftwareList => new Formats.SoftwareList(baseDat), DatFormat.SSV => new SeparatedValue(baseDat, ';'), DatFormat.TSV => new SeparatedValue(baseDat, '\t'), // We use new-style Logiqx as a backup for generic DatFile _ => new Logiqx(baseDat, false), }; } /// /// Create a new DatFile from an existing DatHeader /// /// DatHeader to get the values from public static DatFile Create(DatHeader datHeader) { DatFile datFile = Create(datHeader.DatFormat); datFile.Header = (DatHeader)datHeader.Clone(); return datFile; } /// /// Fill the header values based on existing Header and path /// /// Path used for creating a name, if necessary /// True if the date should be omitted from name and description, false otherwise public void FillHeaderFromPath(string path, bool bare) { // If the description is defined but not the name, set the name from the description if (string.IsNullOrEmpty(Header.GetFieldValue(Models.Metadata.Header.NameKey)) && !string.IsNullOrEmpty(Header.GetFieldValue(Models.Metadata.Header.DescriptionKey))) { Header.SetFieldValue(Models.Metadata.Header.NameKey, Header.GetFieldValue(Models.Metadata.Header.DescriptionKey)); } // If the name is defined but not the description, set the description from the name else if (!string.IsNullOrEmpty(Header.GetFieldValue(Models.Metadata.Header.NameKey)) && string.IsNullOrEmpty(Header.GetFieldValue(Models.Metadata.Header.DescriptionKey))) { Header.SetFieldValue(Models.Metadata.Header.DescriptionKey, Header.GetFieldValue(Models.Metadata.Header.NameKey) + (bare ? string.Empty : $" ({Header.GetFieldValue(Models.Metadata.Header.DateKey)})")); } // If neither the name or description are defined, set them from the automatic values else if (string.IsNullOrEmpty(Header.GetFieldValue(Models.Metadata.Header.NameKey)) && string.IsNullOrEmpty(Header.GetFieldValue(Models.Metadata.Header.DescriptionKey))) { string[] splitpath = path.TrimEnd(Path.DirectorySeparatorChar).Split(Path.DirectorySeparatorChar); Header.SetFieldValue(Models.Metadata.Header.NameKey, splitpath.Last()); Header.SetFieldValue(Models.Metadata.Header.DescriptionKey, Header.GetFieldValue(Models.Metadata.Header.NameKey) + (bare ? string.Empty : $" ({Header.GetFieldValue(Models.Metadata.Header.DateKey)})")); } } #endregion #region Filtering /// /// Execute all filters in a filter runner on the items in the dictionary /// /// Preconfigured filter runner to use public void ExecuteFilters(FilterRunner filterRunner) { List keys = [.. Items.Keys]; #if NET452_OR_GREATER || NETCOREAPP Parallel.ForEach(keys, Globals.ParallelOptions, key => #elif NET40_OR_GREATER Parallel.ForEach(keys, key => #else foreach (var key in keys) #endif { ConcurrentList? items = Items[key]; if (items == null) #if NET40_OR_GREATER || NETCOREAPP return; #else continue; #endif // Filter all items in the current key var newItems = new ConcurrentList(); foreach (var item in items) { if (item.PassesFilter(filterRunner)) newItems.Add(item); } // Set the value in the key to the new set Items[key] = newItems; #if NET40_OR_GREATER || NETCOREAPP }); #else } #endif } #endregion #region Parsing /// /// Parse DatFile and return all found games and roms within /// /// Name of the file to be parsed /// Index ID for the DAT /// True if full pathnames are to be kept, false otherwise (default) /// True to only add item statistics while parsing, false otherwise /// True if the error that is thrown should be thrown back to the caller, false otherwise public abstract void ParseFile(string filename, int indexId, bool keep, bool statsOnly = false, bool throwOnError = false); /// /// Add a rom to the Dat after checking /// /// Item data to check against /// True to only add item statistics while parsing, false otherwise /// The key for the item protected string ParseAddHelper(DatItem item, bool statsOnly) { string key; // If we have a Disk, Media, or Rom, clean the hash data if (item is Disk disk) { // If the file has aboslutely no hashes, skip and log if (disk.GetFieldValue(Models.Metadata.Disk.StatusKey) != ItemStatus.Nodump && string.IsNullOrEmpty(disk.GetFieldValue(Models.Metadata.Disk.MD5Key)) && string.IsNullOrEmpty(disk.GetFieldValue(Models.Metadata.Disk.SHA1Key))) { logger.Verbose($"Incomplete entry for '{disk.GetName()}' will be output as nodump"); disk.SetFieldValue(Models.Metadata.Disk.StatusKey, ItemStatus.Nodump); } item = disk; } if (item is Media media) { // If the file has aboslutely no hashes, skip and log if (string.IsNullOrEmpty(media.GetFieldValue(Models.Metadata.Media.MD5Key)) && string.IsNullOrEmpty(media.GetFieldValue(Models.Metadata.Media.SHA1Key)) && string.IsNullOrEmpty(media.GetFieldValue(Models.Metadata.Media.SHA256Key)) && string.IsNullOrEmpty(media.GetFieldValue(Models.Metadata.Media.SpamSumKey))) { logger.Verbose($"Incomplete entry for '{media.GetName()}' will be output as nodump"); } item = media; } else if (item is Rom rom) { // If we have the case where there is SHA-1 and nothing else, we don't fill in any other part of the data if (rom.GetFieldValue(Models.Metadata.Rom.SizeKey) == null && !rom.HasHashes()) { // No-op, just catch it so it doesn't go further logger.Verbose($"{Header.FileName}: Entry with only SHA-1 found - '{rom.GetName()}'"); } // If we have a rom and it's missing size AND the hashes match a 0-byte file, fill in the rest of the info else if ((rom.GetFieldValue(Models.Metadata.Rom.SizeKey) == 0 || rom.GetFieldValue(Models.Metadata.Rom.SizeKey) == null) && (string.IsNullOrEmpty(rom.GetFieldValue(Models.Metadata.Rom.CRCKey)) || rom.HasZeroHash())) { // TODO: All instances of Hash.DeepHashes should be made into 0x0 eventually rom.SetFieldValue(Models.Metadata.Rom.SizeKey, Constants.SizeZero); rom.SetFieldValue(Models.Metadata.Rom.CRCKey, Constants.CRCZero); rom.SetFieldValue(Models.Metadata.Rom.MD5Key, Constants.MD5Zero); rom.SetFieldValue(Models.Metadata.Rom.SHA1Key, Constants.SHA1Zero); rom.SetFieldValue(Models.Metadata.Rom.SHA256Key, null); // Constants.SHA256Zero; rom.SetFieldValue(Models.Metadata.Rom.SHA384Key, null); // Constants.SHA384Zero; rom.SetFieldValue(Models.Metadata.Rom.SHA512Key, null); // Constants.SHA512Zero; rom.SetFieldValue(Models.Metadata.Rom.SpamSumKey, null); // Constants.SpamSumZero; } // If the file has no size and it's not the above case, skip and log else if (rom.GetFieldValue(Models.Metadata.Rom.StatusKey) != ItemStatus.Nodump && (rom.GetFieldValue(Models.Metadata.Rom.SizeKey) == 0 || rom.GetFieldValue(Models.Metadata.Rom.SizeKey) == null)) { logger.Verbose($"{Header.FileName}: Incomplete entry for '{rom.GetName()}' will be output as nodump"); rom.SetFieldValue(Models.Metadata.Rom.StatusKey, ItemStatus.Nodump); } // If the file has a size but aboslutely no hashes, skip and log else if (rom.GetFieldValue(Models.Metadata.Rom.StatusKey) != ItemStatus.Nodump && rom.GetFieldValue(Models.Metadata.Rom.SizeKey) != null && rom.GetFieldValue(Models.Metadata.Rom.SizeKey) > 0 && !rom.HasHashes()) { logger.Verbose($"{Header.FileName}: Incomplete entry for '{rom.GetName()}' will be output as nodump"); rom.SetFieldValue(Models.Metadata.Rom.StatusKey, ItemStatus.Nodump); } item = rom; } // Get the key and add the file key = item.GetKey(ItemKey.Machine); // If only adding statistics, we add an empty key for games and then just item stats if (statsOnly) { Items.EnsureKey(key); Items.AddItemStatistics(item); } else { Items.Add(key, item); } return key; } /// /// Get a sanitized Date from an input string /// /// String to get value from /// Date as a string, if possible protected static string? CleanDate(string? input) { // Null in, null out if (string.IsNullOrEmpty(input)) return null; string date = string.Empty; if (input != null) { if (DateTime.TryParse(input, out DateTime dateTime)) date = dateTime.ToString(); else date = input; } return date; } /// /// Clean a hash string from a Listrom DAT /// /// Hash string to sanitize /// Cleaned string protected static string CleanListromHashData(string hash) { if (hash.StartsWith("CRC")) return hash.Substring(4, 8).ToLowerInvariant(); else if (hash.StartsWith("SHA1")) return hash.Substring(5, 40).ToLowerInvariant(); return hash; } #endregion #region Writing /// /// Create and open an output file for writing direct from a dictionary /// /// Name of the file to write to /// True if blank roms should be skipped on output, false otherwise (default) /// True if the error that is thrown should be thrown back to the caller, false otherwise /// True if the DAT was written correctly, false otherwise public abstract bool WriteToFile(string outfile, bool ignoreblanks = false, bool throwOnError = false); /// /// Create a prefix or postfix from inputs /// /// DatItem to create a prefix/postfix for /// True for prefix, false for postfix /// Sanitized string representing the postfix or prefix protected string CreatePrefixPostfix(DatItem item, bool prefix) { // Initialize strings string fix, game = item.GetFieldValue(DatItem.MachineKey)!.GetFieldValue(Models.Metadata.Machine.NameKey) ?? string.Empty, name = item.GetName() ?? item.GetFieldValue(Models.Metadata.DatItem.TypeKey).ToString(), crc = string.Empty, md5 = string.Empty, sha1 = string.Empty, sha256 = string.Empty, sha384 = string.Empty, sha512 = string.Empty, size = string.Empty, spamsum = string.Empty; // If we have a prefix if (prefix) fix = Header.GetFieldValue(DatHeader.PrefixKey) + (Header.GetFieldValue(DatHeader.QuotesKey) ? "\"" : string.Empty); // If we have a postfix else fix = (Header.GetFieldValue(DatHeader.QuotesKey) ? "\"" : string.Empty) + Header.GetFieldValue(DatHeader.PostfixKey); // Ensure we have the proper values for replacement if (item is Disk disk) { md5 = disk.GetFieldValue(Models.Metadata.Disk.MD5Key) ?? string.Empty; sha1 = disk.GetFieldValue(Models.Metadata.Disk.SHA1Key) ?? string.Empty; } else if (item is Media media) { md5 = media.GetFieldValue(Models.Metadata.Media.MD5Key) ?? string.Empty; sha1 = media.GetFieldValue(Models.Metadata.Media.SHA1Key) ?? string.Empty; sha256 = media.GetFieldValue(Models.Metadata.Media.SHA256Key) ?? string.Empty; spamsum = media.GetFieldValue(Models.Metadata.Media.SpamSumKey) ?? string.Empty; } else if (item is Rom rom) { crc = rom.GetFieldValue(Models.Metadata.Rom.CRCKey) ?? string.Empty; md5 = rom.GetFieldValue(Models.Metadata.Rom.MD5Key) ?? string.Empty; sha1 = rom.GetFieldValue(Models.Metadata.Rom.SHA1Key) ?? string.Empty; sha256 = rom.GetFieldValue(Models.Metadata.Rom.SHA256Key) ?? string.Empty; sha384 = rom.GetFieldValue(Models.Metadata.Rom.SHA384Key) ?? string.Empty; sha512 = rom.GetFieldValue(Models.Metadata.Rom.SHA512Key) ?? string.Empty; size = rom.GetFieldValue(Models.Metadata.Rom.SizeKey)?.ToString() ?? string.Empty; spamsum = rom.GetFieldValue(Models.Metadata.Rom.SpamSumKey) ?? string.Empty; } // Now do bulk replacement where possible fix = fix .Replace("%game%", game) .Replace("%machine%", game) .Replace("%name%", name) .Replace("%manufacturer%", item.GetFieldValue(DatItem.MachineKey)!.GetFieldValue(Models.Metadata.Machine.ManufacturerKey) ?? string.Empty) .Replace("%publisher%", item.GetFieldValue(DatItem.MachineKey)!.GetFieldValue(Models.Metadata.Machine.PublisherKey) ?? string.Empty) .Replace("%category%", item.GetFieldValue(DatItem.MachineKey)!.GetFieldValue(Models.Metadata.Machine.CategoryKey) ?? string.Empty) .Replace("%crc%", crc) .Replace("%md5%", md5) .Replace("%sha1%", sha1) .Replace("%sha256%", sha256) .Replace("%sha384%", sha384) .Replace("%sha512%", sha512) .Replace("%size%", size) .Replace("%spamsum%", spamsum); return fix; } /// /// Process an item and correctly set the item name /// /// DatItem to update /// True if the Quotes flag should be ignored, false otherwise /// True if the UseRomName should be always on (default), false otherwise protected void ProcessItemName(DatItem item, bool forceRemoveQuotes, bool forceRomName = true) { // Backup relevant values and set new ones accordingly bool quotesBackup = Header.GetFieldValue(DatHeader.QuotesKey); bool useRomNameBackup = Header.GetFieldValue(DatHeader.UseRomNameKey); if (forceRemoveQuotes) Header.SetFieldValue(DatHeader.QuotesKey, false); if (forceRomName) Header.SetFieldValue(DatHeader.UseRomNameKey, true); // Get the name to update string? name = (Header.GetFieldValue(DatHeader.UseRomNameKey) ? item.GetName() : item.GetFieldValue(DatItem.MachineKey)!.GetFieldValue(Models.Metadata.Machine.NameKey)) ?? string.Empty; // Create the proper Prefix and Postfix string pre = CreatePrefixPostfix(item, true); string post = CreatePrefixPostfix(item, false); // If we're in Depot mode, take care of that instead if (Header.OutputDepot?.IsActive == true) { if (item is Disk disk) { // We can only write out if there's a SHA-1 if (!string.IsNullOrEmpty(disk.GetFieldValue(Models.Metadata.Disk.SHA1Key))) { name = Utilities.GetDepotPath(disk.GetFieldValue(Models.Metadata.Disk.SHA1Key), Header.OutputDepot.Depth)?.Replace('\\', '/'); item.SetName($"{pre}{name}{post}"); } } else if (item is Media media) { // We can only write out if there's a SHA-1 if (!string.IsNullOrEmpty(media.GetFieldValue(Models.Metadata.Media.SHA1Key))) { name = Utilities.GetDepotPath(media.GetFieldValue(Models.Metadata.Media.SHA1Key), Header.OutputDepot.Depth)?.Replace('\\', '/'); item.SetName($"{pre}{name}{post}"); } } else if (item is Rom rom) { // We can only write out if there's a SHA-1 if (!string.IsNullOrEmpty(rom.GetFieldValue(Models.Metadata.Rom.SHA1Key))) { name = Utilities.GetDepotPath(rom.GetFieldValue(Models.Metadata.Rom.SHA1Key), Header.OutputDepot.Depth)?.Replace('\\', '/'); item.SetName($"{pre}{name}{post}"); } } return; } if (!string.IsNullOrEmpty(Header.GetFieldValue(DatHeader.ReplaceExtensionKey)) || Header.GetFieldValue(DatHeader.RemoveExtensionKey)) { if (Header.GetFieldValue(DatHeader.RemoveExtensionKey)) Header.SetFieldValue(DatHeader.ReplaceExtensionKey, string.Empty); string? dir = Path.GetDirectoryName(name); if (dir != null) { dir = dir.TrimStart(Path.DirectorySeparatorChar); name = Path.Combine(dir, Path.GetFileNameWithoutExtension(name) + Header.GetFieldValue(DatHeader.ReplaceExtensionKey)); } } if (!string.IsNullOrEmpty(Header.GetFieldValue(DatHeader.AddExtensionKey))) name += Header.GetFieldValue(DatHeader.AddExtensionKey); if (Header.GetFieldValue(DatHeader.UseRomNameKey) && Header.GetFieldValue(DatHeader.GameNameKey)) name = Path.Combine(item.GetFieldValue(DatItem.MachineKey)!.GetFieldValue(Models.Metadata.Machine.NameKey) ?? string.Empty, name); // Now assign back the formatted name name = $"{pre}{name}{post}"; if (Header.GetFieldValue(DatHeader.UseRomNameKey)) item.SetName(name); else if (item.GetFieldValue(DatItem.MachineKey) != null) item.GetFieldValue(DatItem.MachineKey)!.SetFieldValue(Models.Metadata.Machine.NameKey, name); // Restore all relevant values if (forceRemoveQuotes) Header.SetFieldValue(DatHeader.QuotesKey, quotesBackup); if (forceRomName) Header.SetFieldValue(DatHeader.UseRomNameKey, useRomNameBackup); } /// /// Process any DatItems that are "null", usually created from directory population /// /// DatItem to check for "null" status /// Cleaned DatItem protected DatItem ProcessNullifiedItem(DatItem datItem) { // If we don't have a Rom, we can ignore it if (datItem is not Rom rom) return datItem; // If the Rom has "null" characteristics, ensure all fields if (rom.GetFieldValue(Models.Metadata.Rom.SizeKey) == null && rom.GetFieldValue(Models.Metadata.Rom.CRCKey) == "null") { logger.Verbose($"Empty folder found: {datItem.GetFieldValue(DatItem.MachineKey)!.GetFieldValue(Models.Metadata.Machine.NameKey)}"); rom.SetName(rom.GetName() == "null" ? "-" : rom.GetName()); rom.SetFieldValue(Models.Metadata.Rom.SizeKey, Constants.SizeZero); rom.SetFieldValue(Models.Metadata.Rom.CRCKey, rom.GetFieldValue(Models.Metadata.Rom.CRCKey) == "null" ? Constants.CRCZero : null); rom.SetFieldValue(Models.Metadata.Rom.MD5Key, rom.GetFieldValue(Models.Metadata.Rom.MD5Key) == "null" ? Constants.MD5Zero : null); rom.SetFieldValue(Models.Metadata.Rom.SHA1Key, rom.GetFieldValue(Models.Metadata.Rom.SHA1Key) == "null" ? Constants.SHA1Zero : null); rom.SetFieldValue(Models.Metadata.Rom.SHA256Key, rom.GetFieldValue(Models.Metadata.Rom.SHA256Key) == "null" ? Constants.SHA256Zero : null); rom.SetFieldValue(Models.Metadata.Rom.SHA384Key, rom.GetFieldValue(Models.Metadata.Rom.SHA384Key) == "null" ? Constants.SHA384Zero : null); rom.SetFieldValue(Models.Metadata.Rom.SHA512Key, rom.GetFieldValue(Models.Metadata.Rom.SHA512Key) == "null" ? Constants.SHA512Zero : null); rom.SetFieldValue(Models.Metadata.Rom.SpamSumKey, rom.GetFieldValue(Models.Metadata.Rom.SpamSumKey) == "null" ? Constants.SpamSumZero : null); } return rom; } /// /// Get supported types for write /// /// List of supported types for writing protected virtual ItemType[] GetSupportedTypes() { return Enum.GetValues(typeof(ItemType)) as ItemType[] ?? []; } /// /// Return list of required fields missing from a DatItem /// /// List of missing required fields, null or empty if none were found protected virtual List? GetMissingRequiredFields(DatItem datItem) => null; /// /// Get if a machine contains any writable items /// /// DatItems to check /// True if the machine contains at least one writable item, false otherwise /// Empty machines are kept with this protected bool ContainsWritable(ConcurrentList datItems) { // Empty machines are considered writable if (datItems == null || datItems.Count == 0) return true; foreach (DatItem datItem in datItems) { if (GetSupportedTypes().Contains(datItem.GetFieldValue(Models.Metadata.DatItem.TypeKey))) return true; } return false; } /// /// Get if an item should be ignored on write /// /// DatItem to check /// True if blank roms should be skipped on output, false otherwise /// True if the item should be skipped on write, false otherwise protected bool ShouldIgnore(DatItem? datItem, bool ignoreBlanks) { // If this is invoked with a null DatItem, we ignore if (datItem == null) { logger?.Verbose($"Item was skipped because it was null"); return true; } // If the item is supposed to be removed, we ignore if (datItem.GetFieldValue(DatItem.RemoveKey)) { string itemString = JsonConvert.SerializeObject(datItem, Formatting.None); logger?.Verbose($"Item '{itemString}' was skipped because it was marked for removal"); return true; } // If we have the Blank dat item, we ignore if (datItem is Blank) { string itemString = JsonConvert.SerializeObject(datItem, Formatting.None); logger?.Verbose($"Item '{itemString}' was skipped because it was of type 'Blank'"); return true; } // If we're ignoring blanks and we have a Rom if (ignoreBlanks && datItem is Rom rom) { // If we have a 0-size or blank rom, then we ignore if (rom.GetFieldValue(Models.Metadata.Rom.SizeKey) == 0 || rom.GetFieldValue(Models.Metadata.Rom.SizeKey) == null) { string itemString = JsonConvert.SerializeObject(datItem, Formatting.None); logger?.Verbose($"Item '{itemString}' was skipped because it had an invalid size"); return true; } } // If we have an item type not in the list of supported values if (!GetSupportedTypes().Contains(datItem.GetFieldValue(Models.Metadata.DatItem.TypeKey))) { string itemString = JsonConvert.SerializeObject(datItem, Formatting.None); logger?.Verbose($"Item '{itemString}' was skipped because it was not supported in {Header?.DatFormat}"); return true; } // If we have an item with missing required fields List? missingFields = GetMissingRequiredFields(datItem); if (missingFields != null && missingFields.Count != 0) { string itemString = JsonConvert.SerializeObject(datItem, Formatting.None); #if NET20 || NET35 logger?.Verbose($"Item '{itemString}' was skipped because it was missing required fields for {Header?.DatFormat}: {string.Join(", ", [.. missingFields])}"); #else logger?.Verbose($"Item '{itemString}' was skipped because it was missing required fields for {Header?.DatFormat}: {string.Join(", ", missingFields)}"); #endif return true; } return false; } #endregion } }