From a5ddae1560250eeab4b2c85c5db97da069b7474a Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Tue, 26 May 2026 19:10:49 +0200 Subject: [PATCH 1/2] feat: auto-generate ImportConfig.xml and order solutions by csproj --- src/Dataverse/PDPackage/README.md | 10 + ...d.Dataverse.PdPackage.ImportConfig.targets | 34 ++- .../Tasks/ExtractPdPackageDataFolderName.cs | 63 +++++ .../Tasks/Tasks/PostProcessImportConfig.cs | 217 ++++++++++++++++++ ...ALXIS.DevKit.Build.Dataverse.Tasks.targets | 1 + 5 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 src/Dataverse/Tasks/Tasks/ExtractPdPackageDataFolderName.cs diff --git a/src/Dataverse/PDPackage/README.md b/src/Dataverse/PDPackage/README.md index b53be1c..9f27527 100644 --- a/src/Dataverse/PDPackage/README.md +++ b/src/Dataverse/PDPackage/README.md @@ -36,6 +36,16 @@ All `ProjectReference` items default to `ReferenceOutputAssembly=false` via `Ite `_DetectPdProjectReferenceTypes` probes all `ProjectReference` items for `GetProjectType`. Solution-type references have `ReferenceOutputAssembly` set to `false` so their DLLs are not included in the package output. +### ImportConfig auto-generation + +`ImportConfig.xml` is not required to exist in the project. If `ImportConfig.xml` is missing, a skeleton (`` with empty ``, ``, ``) is generated in `$(IntermediateOutputPath)/ImportConfig.xml` before MS validation runs, and `@(PdImportConfig)` is rewired to point at it. `` is read from the `GetImportPackageDataFolderName` property of any `ImportExtension`-derived class found in the project's top-level `.cs` files (default `PkgAssets`). + +When `AutoGeneratePdImportConfig` is `true` (default) and the source `ImportConfig.xml` contains no `` entries, the `` section is generated automatically from the project's solution references. Both `` items pointing at Solution projects and `` items pointing at `pp-solution` NuGet packages are picked up, and each one produces a `` entry with `requiredimportmode="async"`. + +The generated entries appear in the **same order as the references are declared in the `.csproj` file**, walking `` and `` items in document order (interleaved). A `PackageReference` is matched by its package id; a `ProjectReference` is matched by the csproj file name (without extension), which by convention equals the solution unique name. Any `` element that can't be matched against a csproj reference is moved to the end while keeping its relative position. + +If you ship a hand-written `PkgAssets/ImportConfig.xml` with explicit `` entries, auto-generation is skipped entirely and your file is used as-is. + ### ILRepack `DataverseILRepack` (runs after `Build`) merges all non-Microsoft DLLs (excluding reference assemblies and `Newtonsoft.Json`) into the main output assembly using ILRepack.exe. Can be disabled with `DataversePackageRunILRepack=false` or `SkipPackageILRepack=true`. diff --git a/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.PdPackage.ImportConfig.targets b/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.PdPackage.ImportConfig.targets index 91ac450..0c36c5b 100644 --- a/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.PdPackage.ImportConfig.targets +++ b/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.PdPackage.ImportConfig.targets @@ -3,6 +3,37 @@ <_TalxisSourceImportConfig>$([System.IO.Path]::Combine('$(MSBuildProjectDirectory)','PkgAssets','ImportConfig.xml')) + + + + <_TalxisCurrentPdImportConfig>@(PdImportConfig->'%(FullPath)') + <_TalxisSkipImportConfigSkeleton Condition="'$(_TalxisCurrentPdImportConfig)' != '' and Exists('$(_TalxisCurrentPdImportConfig)')">true + + + + + + + + <_TalxisGeneratedImportConfigDir>$([System.IO.Path]::GetFullPath($([System.IO.Path]::Combine('$(MSBuildProjectDirectory)','$(IntermediateOutputPath)','$(_TalxisPdDataFolderName)')))) + <_TalxisGeneratedImportConfigPath>$([System.IO.Path]::Combine('$(_TalxisGeneratedImportConfigDir)','ImportConfig.xml')) + + + + + + + + + + @@ -34,6 +65,7 @@ + CmtDataFileName="$(_TalxisCmtDataFileForImportConfig)" + CsprojPath="$(MSBuildProjectFullPath)" /> diff --git a/src/Dataverse/Tasks/Tasks/ExtractPdPackageDataFolderName.cs b/src/Dataverse/Tasks/Tasks/ExtractPdPackageDataFolderName.cs new file mode 100644 index 0000000..7e114e1 --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/ExtractPdPackageDataFolderName.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +public class ExtractPdPackageDataFolderName : Task +{ + [Required] + public string ProjectDirectory { get; set; } = ""; + + public string DefaultFolderName { get; set; } = "PkgAssets"; + + [Output] + public string FolderName { get; private set; } = ""; + + private static readonly Regex PropertyRegex = new Regex( + @"GetImportPackageDataFolderName\s*(?:=>\s*""([^""]+)""|\{[^}]*?return\s*""([^""]+)"")", + RegexOptions.Singleline | RegexOptions.Compiled); + + public override bool Execute() + { + FolderName = string.IsNullOrWhiteSpace(DefaultFolderName) ? "PkgAssets" : DefaultFolderName; + + try + { + var dir = (ProjectDirectory ?? "").Trim(); + if (string.IsNullOrWhiteSpace(dir) || !Directory.Exists(dir)) + { + Log.LogMessage(MessageImportance.Low, + $"ProjectDirectory is empty or missing — using default folder name '{FolderName}'."); + return true; + } + + foreach (var file in Directory.EnumerateFiles(dir, "*.cs", SearchOption.TopDirectoryOnly)) + { + string content; + try { content = File.ReadAllText(file); } + catch { continue; } + + var match = PropertyRegex.Match(content); + if (!match.Success) continue; + + var value = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value; + if (string.IsNullOrWhiteSpace(value)) continue; + + FolderName = value; + Log.LogMessage(MessageImportance.Normal, + $"Detected GetImportPackageDataFolderName='{FolderName}' from '{file}'."); + return true; + } + + Log.LogMessage(MessageImportance.Low, + $"GetImportPackageDataFolderName not found in any .cs file under '{dir}'. Using default '{FolderName}'."); + return true; + } + catch (Exception ex) + { + Log.LogWarningFromException(ex); + return true; + } + } +} diff --git a/src/Dataverse/Tasks/Tasks/PostProcessImportConfig.cs b/src/Dataverse/Tasks/Tasks/PostProcessImportConfig.cs index df0e85b..8065d2e 100644 --- a/src/Dataverse/Tasks/Tasks/PostProcessImportConfig.cs +++ b/src/Dataverse/Tasks/Tasks/PostProcessImportConfig.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Xml; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; @@ -13,6 +15,10 @@ public class PostProcessImportConfig : Task public string CmtDataFileName { get; set; } = ""; + public string CsprojPath { get; set; } = ""; + + public bool CreateSkeletonIfMissing { get; set; } + [Output] public string UpdatedImportConfig { get; private set; } = ""; @@ -28,12 +34,26 @@ public override bool Execute() } configPath = Path.GetFullPath(configPath); + if (!File.Exists(configPath)) { + if (CreateSkeletonIfMissing) + { + WriteSkeleton(configPath); + UpdatedImportConfig = configPath; + return !Log.HasLoggedErrors; + } + Log.LogError($"ImportConfig file not found: {configPath}"); return false; } + if (CreateSkeletonIfMissing) + { + UpdatedImportConfig = configPath; + return true; + } + var doc = new XmlDocument { PreserveWhitespace = true }; doc.Load(configPath); @@ -45,6 +65,7 @@ public override bool Execute() } AnnotateSolutionFiles(doc, root); + ReorderSolutionFiles(root); SetCmtDataImportFile(root); doc.Save(configPath); @@ -58,6 +79,40 @@ public override bool Execute() } } + private void WriteSkeleton(string configPath) + { + var dir = Path.GetDirectoryName(configPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var doc = new XmlDocument(); + var decl = doc.CreateXmlDeclaration("1.0", "utf-8", null); + doc.AppendChild(decl); + + var root = doc.CreateElement("configdatastorage"); + root.SetAttribute("installsampledata", "false"); + root.SetAttribute("waitforsampledatatoinstall", "true"); + doc.AppendChild(root); + + root.AppendChild(doc.CreateElement("solutions")); + root.AppendChild(doc.CreateElement("filestoimport")); + root.AppendChild(doc.CreateElement("filesmapstoimport")); + + var settings = new System.Xml.XmlWriterSettings + { + Indent = true, + IndentChars = " ", + Encoding = new System.Text.UTF8Encoding(false), + }; + using (var writer = System.Xml.XmlWriter.Create(configPath, settings)) + { + doc.Save(writer); + } + + Log.LogMessage(MessageImportance.High, + $"Generated skeleton ImportConfig.xml at: {configPath}"); + } + private void AnnotateSolutionFiles(XmlDocument doc, XmlElement root) { var solutionNodes = root.SelectNodes("//configsolutionfile"); @@ -114,6 +169,168 @@ private string LookupSolutionZipFilename(string uniqueName) return uniqueName + ".zip"; } + private void ReorderSolutionFiles(XmlElement root) + { + var csproj = (CsprojPath ?? "").Trim(); + if (string.IsNullOrWhiteSpace(csproj) || !File.Exists(csproj)) + { + Log.LogMessage(MessageImportance.Low, + "CsprojPath not provided or missing — skipping configsolutionfile reorder."); + return; + } + + var orderedNames = ReadReferenceOrderFromCsproj(csproj); + if (orderedNames.Count == 0) + return; + + var container = root.SelectSingleNode("solutions") as XmlElement; + if (container == null) + return; + + var bundles = new List(); + XmlNode pendingWhitespace = null; + XmlNode trailingWhitespace = null; + + foreach (XmlNode child in container.ChildNodes) + { + if (child is XmlElement el && + string.Equals(el.LocalName, "configsolutionfile", StringComparison.OrdinalIgnoreCase)) + { + bundles.Add(new Bundle(pendingWhitespace, el)); + pendingWhitespace = null; + } + else if (IsWhitespaceNode(child)) + { + pendingWhitespace = child; + } + } + trailingWhitespace = pendingWhitespace; + + if (bundles.Count == 0) + return; + + var ordered = bundles + .Select((b, idx) => new + { + Bundle = b, + Rank = RankConfigSolutionFile(b.Element, orderedNames), + OriginalIndex = idx + }) + .OrderBy(x => x.Rank) + .ThenBy(x => x.OriginalIndex) + .Select(x => x.Bundle) + .ToList(); + + bool alreadyOrdered = true; + for (int i = 0; i < bundles.Count; i++) + { + if (!ReferenceEquals(bundles[i].Element, ordered[i].Element)) + { + alreadyOrdered = false; + break; + } + } + if (alreadyOrdered) + return; + + foreach (var bundle in bundles) + { + if (bundle.Whitespace != null && bundle.Whitespace.ParentNode == container) + container.RemoveChild(bundle.Whitespace); + if (bundle.Element.ParentNode == container) + container.RemoveChild(bundle.Element); + } + + foreach (var bundle in ordered) + { + if (bundle.Whitespace != null) + container.InsertBefore(bundle.Whitespace, trailingWhitespace); + container.InsertBefore(bundle.Element, trailingWhitespace); + } + + Log.LogMessage(MessageImportance.High, + "Reordered configsolutionfile elements to match the order of " + + "PackageReference / ProjectReference items in the csproj."); + } + + private static bool IsWhitespaceNode(XmlNode node) + { + if (node == null) return false; + return node.NodeType == XmlNodeType.Whitespace + || node.NodeType == XmlNodeType.SignificantWhitespace + || (node.NodeType == XmlNodeType.Text && string.IsNullOrWhiteSpace(node.Value)); + } + + private sealed class Bundle + { + public XmlNode Whitespace { get; } + public XmlElement Element { get; } + public Bundle(XmlNode whitespace, XmlElement element) + { + Whitespace = whitespace; + Element = element; + } + } + + private static int RankConfigSolutionFile(XmlElement node, List orderedNames) + { + var unique = ExtractUniqueName(node); + if (string.IsNullOrWhiteSpace(unique)) + return int.MaxValue; + + for (int i = 0; i < orderedNames.Count; i++) + { + if (string.Equals(orderedNames[i], unique, StringComparison.OrdinalIgnoreCase)) + return i; + } + return int.MaxValue; + } + + private static string ExtractUniqueName(XmlElement configSolutionFile) + { + var unique = configSolutionFile.GetAttribute("solutionpackageuniquename"); + if (!string.IsNullOrWhiteSpace(unique)) + return unique; + + var filename = configSolutionFile.GetAttribute("solutionpackagefilename"); + if (!string.IsNullOrWhiteSpace(filename)) + return Path.GetFileNameWithoutExtension(filename); + + return ""; + } + + private static List ReadReferenceOrderFromCsproj(string csprojPath) + { + var result = new List(); + var doc = new XmlDocument(); + doc.Load(csprojPath); + + var itemGroups = doc.GetElementsByTagName("ItemGroup"); + foreach (XmlNode ig in itemGroups) + { + foreach (XmlNode child in ig.ChildNodes) + { + if (!(child is XmlElement el)) + continue; + + var include = el.GetAttribute("Include"); + if (string.IsNullOrWhiteSpace(include)) + continue; + + if (string.Equals(el.LocalName, "PackageReference", StringComparison.OrdinalIgnoreCase)) + { + result.Add(include); + } + else if (string.Equals(el.LocalName, "ProjectReference", StringComparison.OrdinalIgnoreCase)) + { + result.Add(Path.GetFileNameWithoutExtension(include)); + } + } + } + + return result; + } + private void SetCmtDataImportFile(XmlElement root) { var cmtFileName = (CmtDataFileName ?? "").Trim(); diff --git a/src/Dataverse/Tasks/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Tasks.targets b/src/Dataverse/Tasks/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Tasks.targets index 05d3dad..ef29f4e 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Tasks.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Tasks.targets @@ -34,6 +34,7 @@ + From 61ed4b0352edc16bb0cf3b3798acb8548aceab13 Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Tue, 16 Jun 2026 14:05:34 +0200 Subject: [PATCH 2/2] conflict resoolved --- ...ALXIS.DevKit.Build.Dataverse.Tasks.targets | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/src/Dataverse/Tasks/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Tasks.targets b/src/Dataverse/Tasks/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Tasks.targets index ef29f4e..78f410e 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Tasks.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Tasks.targets @@ -15,34 +15,34 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +