diff --git a/.gitignore b/.gitignore index 8a30d25..dd320be 100644 --- a/.gitignore +++ b/.gitignore @@ -396,3 +396,6 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml + + + diff --git a/README.md b/README.md index fe13ac4..a3e1019 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,19 @@ Generates version numbers based on Git commit history, applying these versions a #### Build Number From Dependencies Project dependency folders are analyzed for Git changes to be reflected in generated version numbers. See [here](/docs/Versioning.md) for more details. +## Packages + +| Package | Description | +|---------|-------------| +| [TALXIS.DevKit.Build.Sdk](src/Dataverse/Sdk/README.md) | MSBuild SDK that auto-resolves the correct package based on `ProjectType`. Entry point for new projects. | +| [TALXIS.DevKit.Build.Dataverse.Tasks](src/Dataverse/Tasks/README.md) | Core MSBuild tasks shared by all packages: Git versioning, schema validation, solution packaging, CMT data merging. | +| [TALXIS.DevKit.Build.Dataverse.Solution](src/Dataverse/Solution/README.md) | Orchestrates the full Dataverse solution build: component discovery, XML patching, PAC solution packager, NuGet packing. | +| [TALXIS.DevKit.Build.Dataverse.Plugin](src/Dataverse/Plugin/README.md) | MSBuild integration for Dataverse plugin assemblies with auto-versioning and metadata exposure for Solution projects. | +| [TALXIS.DevKit.Build.Dataverse.Pcf](src/Dataverse/Pcf/README.md) | MSBuild integration for PCF controls. Wraps `Microsoft.PowerApps.MSBuild.Pcf` with Git-based versioning. | +| [TALXIS.DevKit.Build.Dataverse.WorkflowActivity](src/Dataverse/WorkflowActivity/README.md) | MSBuild integration for custom workflow activity assemblies with auto-versioning and Solution project integration. | +| [TALXIS.DevKit.Build.Dataverse.ScriptLibrary](src/Dataverse/ScriptLibrary/README.md) | Builds TypeScript/JS web resource projects (`npm install` + `npm run build`) and integrates them into Solution builds. | +| [TALXIS.DevKit.Build.Dataverse.PdPackage](src/Dataverse/PDPackage/README.md) | Package Deployer integration with ILRepack assembly merging and CMT metadata merge/zip support. | + ## Getting Started > [!TIP] > You can find demo steps for creating a new solution using PAC CLI and this package [here](https://tntg.cz/repo-init-demo). diff --git a/TALXIS.DevKit.Build.slnx b/TALXIS.DevKit.Build.slnx index 9c4b1bf..cbd11a5 100644 --- a/TALXIS.DevKit.Build.slnx +++ b/TALXIS.DevKit.Build.slnx @@ -6,4 +6,7 @@ + + + diff --git a/src/Dataverse/PDPackage/README.md b/src/Dataverse/PDPackage/README.md new file mode 100644 index 0000000..10c26c9 --- /dev/null +++ b/src/Dataverse/PDPackage/README.md @@ -0,0 +1,99 @@ +# TALXIS.DevKit.Build.Dataverse.PdPackage + +MSBuild integration for Power Platform Package Deployer (PD) packages. Wraps `Microsoft.PowerApps.MSBuild.PDPackage`, adds ILRepack-based assembly merging for the deployment package DLL, and provides Configuration Migration Tool (CMT) package discovery, metadata merging, and zipping. + +## Installation + +```xml + +``` + +Or use the SDK approach: + +```xml + + + PdPackage + + +``` + +## How It Works + +### Microsoft PDPackage import + +Props and targets from `Microsoft.PowerApps.MSBuild.PDPackage` are imported automatically. The version is controlled by `PdPackageMsBuildVersion`. + +### Project reference filtering + +`_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. + +### 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`. + +### CMT package discovery + +`TalxisDiscoverCmtPackages` scans for folders containing `[Content_Types].xml` with sibling `data.xml` and `data_schema.xml`. Supports include/exclude filtering via `IncludedCmtPackages`/`ExcludedCmtPackages`. + +### CMT package zipping + +`TalxisZipCmtPackages` (runs after `Build`) zips each discovered CMT package directory into `CmtPackageOutputDir`. + +### CMT metadata merging + +`TalxisPrepareCmtPackageMetadata` merges `data.xml` and `data_schema.xml` from all CMT packages into a single combined package, generates `[Content_Types].xml`, zips it, and appends a reference to `ImportConfig.xml`. + +### Publishing and NuGet packing + +`dotnet publish` is the primary build command. It publishes the project, generates the `.pdpkg.zip` via `GeneratePdPackage`, and then automatically runs `Pack` to produce a `.nupkg` containing the `.pdpkg.zip` (controlled by `GeneratePackageOnPublish`). + +## MSBuild Properties + +### PDPackage + +| Property | Default | Description | +|----------|---------|-------------| +| `PdPackageMsBuildVersion` | `1.50.1` | Version of `Microsoft.PowerApps.MSBuild.PDPackage` imported by the package. | +| `GeneratePdPackageOnBuild` | `true` | Runs `GeneratePdPackage` after publish. | +| `GeneratePackageOnPublish` | `true` | Triggers NuGet pack after `dotnet publish` to produce a `.nupkg` containing the `.pdpkg.zip`. | + +### ILRepack + +| Property | Default | Description | +|----------|---------|-------------| +| `DataversePackageRunILRepack` | `true` | Runs ILRepack after build. | +| `SkipPackageILRepack` | _(none)_ | Set to `true` to skip ILRepack. | +| `ILRepackVersion` | `2.0.18` | ILRepack NuGet package version. | +| `ILRepackExe` | `$(NuGetPackageRoot)ilrepack\$(ILRepackVersion)\tools\ILRepack.exe` | Path to ILRepack.exe. | +| `ReferencedAssembliesDir` | `$(TargetDir)` | Directory scanned for assemblies to merge. | +| `DataversePackageILRepackKeyFile` | _(none)_ | Strong-name key file passed to ILRepack `/keyfile`. | + +### CMT packages + +| Property | Default | Description | +|----------|---------|-------------| +| `CmtPackageSearchRoot` | Project directory | Root folder scanned for CMT packages. | +| `CmtPackageOutputDir` | `$(TargetDir)\CmtPackages` | Output folder for zipped CMT packages. | +| `IncludedCmtPackages` | _(none)_ | Semicolon-separated package names to include (case-insensitive). | +| `ExcludedCmtPackages` | _(none)_ | Semicolon-separated package names to exclude (case-insensitive). | + +### CMT metadata merge + +| Property | Default | Description | +|----------|---------|-------------| +| `CmtPackageName` | _(none)_ | Name injected into merged metadata. | +| `CmtMetadataOutputDir` | `$(IntermediateOutputPath)\CmtMetadata\$(CmtMetadataZipName)` | Temp folder for merged metadata. | +| `CmtMetadataZipName` | `$(CmtPackageName)` or `MainCmtPackage` | Name of the merged metadata zip. | +| `CmtMetadataLcid` | _(none)_ | LCID used when appending metadata to ImportConfig. | +| `CmtMetadataUserMapFileName` | _(none)_ | Optional user map file name used in ImportConfig. | +| `CmtImportConfigPath` | _(none)_ | Path to ImportConfig.xml used for metadata injection. | +| `AutoGeneratePdImportConfig` | _(none)_ | When `true`, uses the generated ImportConfig instead of copying a project file. | +| `PdAssetsTargetFolder` | _(none)_ | Target folder under publish assets for the merged metadata zip. | + +## Related Packages + +- **Depends on**: `Microsoft.PowerApps.MSBuild.PDPackage`, `ilrepack` +- **Typically references**: `TALXIS.DevKit.Build.Dataverse.Solution` projects + + diff --git a/src/Dataverse/PDPackage/TALXIS.DevKit.Build.Dataverse.PdPackage.csproj b/src/Dataverse/PDPackage/TALXIS.DevKit.Build.Dataverse.PdPackage.csproj new file mode 100644 index 0000000..938c901 --- /dev/null +++ b/src/Dataverse/PDPackage/TALXIS.DevKit.Build.Dataverse.PdPackage.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + true + 0.0.0.1 + 2.0.18 + TALXIS.DevKit.Build.Dataverse.PdPackage.nuspec + Version=$(Version);ILRepackVersion=$(ILRepackVersion) + + + \ No newline at end of file diff --git a/src/Dataverse/PDPackage/TALXIS.DevKit.Build.Dataverse.PdPackage.nuspec b/src/Dataverse/PDPackage/TALXIS.DevKit.Build.Dataverse.PdPackage.nuspec new file mode 100644 index 0000000..b47a5aa --- /dev/null +++ b/src/Dataverse/PDPackage/TALXIS.DevKit.Build.Dataverse.PdPackage.nuspec @@ -0,0 +1,29 @@ + + + + TALXIS.DevKit.Build.Dataverse.PdPackage + $Version$ + TALXIS + true + false + MIT + https://licenses.nuget.org/MIT + README.md + https://github.com/TALXIS/tools-devkit-build + Dataverse MSBuild PDPackage + https://github.com/TALXIS/tools-devkit-build/releases + 2025 NETWORG + + + + + + + + + + + + + diff --git a/src/Dataverse/PDPackage/msbuild/build/TALXIS.DevKit.Build.Dataverse.PdPackage.props b/src/Dataverse/PDPackage/msbuild/build/TALXIS.DevKit.Build.Dataverse.PdPackage.props new file mode 100644 index 0000000..166aaad --- /dev/null +++ b/src/Dataverse/PDPackage/msbuild/build/TALXIS.DevKit.Build.Dataverse.PdPackage.props @@ -0,0 +1,14 @@ + + + + + 1.50.1 + + false + <_PdPackageMsBuildProps Condition="'$(_PdPackageMsBuildProps)'==''"> + $(NuGetPackageRoot)microsoft.powerapps.msbuild.pdpackage\$(PdPackageMsBuildVersion)\build\Microsoft.PowerApps.MSBuild.PDPackage.props + + + + + diff --git a/src/Dataverse/PDPackage/msbuild/build/TALXIS.DevKit.Build.Dataverse.PdPackage.targets b/src/Dataverse/PDPackage/msbuild/build/TALXIS.DevKit.Build.Dataverse.PdPackage.targets new file mode 100644 index 0000000..09bf47b --- /dev/null +++ b/src/Dataverse/PDPackage/msbuild/build/TALXIS.DevKit.Build.Dataverse.PdPackage.targets @@ -0,0 +1,21 @@ + + + + 1.50.1 + <_PdPackageMsBuildTargets Condition="'$(_PdPackageMsBuildTargets)'==''"> + $(NuGetPackageRoot)microsoft.powerapps.msbuild.pdpackage\$(PdPackageMsBuildVersion)\build\Microsoft.PowerApps.MSBuild.PDPackage.targets + + + + + + + + + + + diff --git a/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.CmtPackage.Main.targets b/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.CmtPackage.Main.targets new file mode 100644 index 0000000..2a35911 --- /dev/null +++ b/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.CmtPackage.Main.targets @@ -0,0 +1,132 @@ + + + <_CmtMetadataImportConfigDir Condition="'$(_CmtMetadataImportConfigDir)'=='' and '$(_GeneratedPdImportConfig)'!=''">$([System.IO.Path]::GetDirectoryName('$(_GeneratedPdImportConfig)')) + <_CmtMetadataImportConfigDir Condition="'$(_CmtMetadataImportConfigDir)'==''">$([System.IO.Path]::GetFullPath('$(IntermediateOutputPath)')) + + + $([System.IO.Path]::Combine('$([System.IO.Path]::GetFullPath('$(IntermediateOutputPath)'))','CmtMetadata','$(CmtMetadataZipName)')) + + $([System.String]::Copy('$([System.Text.RegularExpressions.Regex]::Replace('$(CmtMetadataOutputDir)','[\\/]+$', ''))').Trim()) + + <_CmtMetadataDataXml>$([System.IO.Path]::Combine('$(CmtMetadataOutputDir)','data.xml')) + <_CmtMetadataDataSchemaXml>$([System.IO.Path]::Combine('$(CmtMetadataOutputDir)','data_schema.xml')) + <_CmtMetadataContentTypes>$([System.IO.Path]::Combine('$(CmtMetadataOutputDir)','[Content_Types].xml')) + $(CmtPackageName) + MainCmtPackage + $([System.IO.Path]::Combine('$(CmtPackageOutputDir)','$(CmtMetadataZipName).zip')) + $([System.IO.Path]::GetFileName('$(CmtMetadataZipFile)')) + + + + + <_CmtMetadataContentTypesContent>]]> + + + + + + <_CmtPdImportConfig>@(PdImportConfig->'%(FullPath)') + $(_GeneratedPdImportConfig) + $([System.IO.Path]::GetFullPath('$(_CmtPdImportConfig)')) + $([System.IO.Path]::Combine('$(MSBuildProjectDirectory)','PkgAssets','ImportConfig.xml')) + <_CmtWorkingImportConfig Condition="'$(_CmtWorkingImportConfig)'==''">$([System.IO.Path]::Combine('$(_CmtMetadataImportConfigDir)','ImportConfig.cmt.g.xml')) + <_HasCmtPackages>@(_CmtPackageDirs) + + + + + + + + + + + + + + + $(_CmtWorkingImportConfig) + + + + <_CmtPackageMetadata Include="@(_CmtPackageDirs)"> + $([System.IO.Path]::Combine('%(_CmtPackageDirs.Identity)','data.xml')) + $([System.IO.Path]::Combine('%(_CmtPackageDirs.Identity)','data_schema.xml')) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_TalxisCmtMetadataPrepared>true + + + + + + + + + $(PdAssetsTargetFolder)\$(CmtMetadataZipFileName) + PreserveNewest + + + + diff --git a/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.CmtPackage.targets b/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.CmtPackage.targets new file mode 100644 index 0000000..c7b14b3 --- /dev/null +++ b/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.CmtPackage.targets @@ -0,0 +1,103 @@ + + + + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)')) + + + + $([System.IO.Path]::Combine('$([System.IO.Path]::GetFullPath('$(TargetDir)'))','CmtPackages')) + + + $([System.IO.Path]::Combine('$([System.IO.Path]::GetFullPath('$(OutputPath)'))','CmtPackages')) + + + $([System.String]::Copy('$([System.Text.RegularExpressions.Regex]::Replace('$(CmtPackageOutputDir)', + '[\\/]*$', ''))').Trim()) + + + + + + + <_IncludedCmtPackagesNormalized Condition="'$(IncludedCmtPackages)'!=''">$(IncludedCmtPackages.ToLowerInvariant().Replace(' ', '')) + <_ExcludedCmtPackagesNormalized Condition="'$(ExcludedCmtPackages)'!=''">$(ExcludedCmtPackages.ToLowerInvariant().Replace(' ', '')) + + + + <_CmtPackageCandidates Include="@(None->'%(FullPath)')" + Condition="'%(Filename)%(Extension)'=='[Content_Types].xml'"> + $([System.IO.Path]::GetDirectoryName('%(Identity)')) + + $([System.IO.Path]::Combine('$([System.IO.Path]::GetDirectoryName('%(Identity)'))','data_schema.xml')) + + $([System.IO.Path]::Combine('$([System.IO.Path]::GetDirectoryName('%(Identity)'))','data.xml')) + + <_CmtPackageCandidates Include="@(Content->'%(FullPath)')" + Condition="'%(Filename)%(Extension)'=='[Content_Types].xml'"> + $([System.IO.Path]::GetDirectoryName('%(Identity)')) + + $([System.IO.Path]::Combine('$([System.IO.Path]::GetDirectoryName('%(Identity)'))','data_schema.xml')) + + $([System.IO.Path]::Combine('$([System.IO.Path]::GetDirectoryName('%(Identity)'))','data.xml')) + + + + + <_CmtPackageDirsRaw Include="@(_CmtPackageCandidates)" + Condition="Exists('%(_CmtPackageCandidates.DataSchemaPath)') and + Exists('%(_CmtPackageCandidates.DataPath)')"> + %(PackageDir) + + + + + <_CmtPackageDirsWithMeta Include="@(_CmtPackageDirsRaw->'%(PackageDir)')" Distinct="true"> + %(_CmtPackageDirsRaw.PackageDir) + $([System.IO.Path]::GetFileName('%(_CmtPackageDirsRaw.PackageDir)')) + $([System.String]::Copy('$([System.IO.Path]::GetFileName('%(_CmtPackageDirsRaw.PackageDir)'))').ToLowerInvariant()) + + + + + <_CmtPackageDirs Include="@(_CmtPackageDirsWithMeta)"> + %(PackageName) + %(PackageNameLower) + + + + + <_CmtPackageDirs Remove="@(_CmtPackageDirs)" Condition="!$([System.String]::Copy(';$(_IncludedCmtPackagesNormalized);').Contains(';%(PackageNameLower);'))" /> + + + + <_CmtPackageDirs Remove="@(_CmtPackageDirs)" Condition="$([System.String]::Copy(';$(_ExcludedCmtPackagesNormalized);').Contains(';%(PackageNameLower);'))" /> + + + + + + + + + + + + <_CmtPackageZips Include="@(_CmtPackageDirs)"> + $([System.IO.Path]::Combine('$(CmtPackageOutputDir)','%(Filename).zip')) + + + + + + + + diff --git a/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.PdPackage.ILRepack.targets b/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.PdPackage.ILRepack.targets new file mode 100644 index 0000000..d63fe14 --- /dev/null +++ b/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.PdPackage.ILRepack.targets @@ -0,0 +1,40 @@ + + + 2.0.18 + $(NuGetPackageRoot)ilrepack\$(ILRepackVersion)\tools\ILRepack.exe + true + $(TargetDir) + $([System.Text.RegularExpressions.Regex]::Replace('$(ReferencedAssembliesDir)', '[\\/]+$', '')) + + + + + $(TargetPath) + <_KeyFileSwitch Condition="Exists('$(DataversePackageILRepackKeyFile)')">/keyfile:"$(DataversePackageILRepackKeyFile)" + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.PdPackage.Pack.targets b/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.PdPackage.Pack.targets new file mode 100644 index 0000000..d40d23b --- /dev/null +++ b/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.PdPackage.Pack.targets @@ -0,0 +1,35 @@ + + + + + + true + false + true + true + pp-pdpackage + true + + true + false + false + + + + + + + + + + + + + diff --git a/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.PdPackage.targets b/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.PdPackage.targets new file mode 100644 index 0000000..b2c353b --- /dev/null +++ b/src/Dataverse/PDPackage/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.PdPackage.targets @@ -0,0 +1,54 @@ + + + + + true + + + + + + + + + + false + + + + + + + + + + + + + + + + + + + + diff --git a/src/Dataverse/Pcf/README.md b/src/Dataverse/Pcf/README.md index e8af5e6..f38f146 100644 --- a/src/Dataverse/Pcf/README.md +++ b/src/Dataverse/Pcf/README.md @@ -1,3 +1,45 @@ # TALXIS.DevKit.Build.Dataverse.Pcf -See [here](https://github.com/TALXIS/tools-devkit-build) for more information. \ No newline at end of file +MSBuild integration for Power Apps Component Framework (PCF) projects. Wraps `Microsoft.PowerApps.MSBuild.Pcf` and adds automatic Git-based version number generation that is applied to the PCF control before build. + +## Installation + +```xml + +``` + +Or use the SDK approach: + +```xml + + + Pcf + + +``` + +## How It Works + +The package imports `Microsoft.PowerApps.MSBuild.Pcf` targets as a NuGet dependency and layers versioning on top. + +The `TalxisBeforeBuild` target runs before `BeforeBuild` and executes two steps in sequence: + +1. **GenerateVersionNumber** (from `Tasks`) -- reads the `Version` property, inspects the current Git branch against `ApplyToBranches` rules, and produces a full four-part version number. +2. **ApplyPluginVersionNumber** -- writes the generated version to `AssemblyVersion`, `FileVersion`, `Version`, and `PackageVersion`. + +The Microsoft PCF targets version is controlled by `MicrosoftPowerAppsTargetsVersion` from `Directory.Build.props`. + +## MSBuild Properties + +| Property | Default | Description | +|----------|---------|-------------| +| `Version` | _(required)_ | Base version (`Major.Minor`); used by Git versioning to produce the full version. | +| `ApplyToBranches` | _(none)_ | Semicolon-separated branch rules (e.g. `master;hotfix;develop:1;pr:3;feature/*:2`). | +| `LocalBranchBuildVersionNumber` | `0.0.0.1` | Fallback version when the current branch does not match `ApplyToBranches`. | + +## Related Packages + +- **Depends on**: `TALXIS.DevKit.Build.Dataverse.Tasks`, `Microsoft.PowerApps.MSBuild.Pcf` +- **Consumed by**: `TALXIS.DevKit.Build.Dataverse.Solution` projects via `ProjectReference` + + diff --git a/src/Dataverse/Plugin/README.md b/src/Dataverse/Plugin/README.md index bae364d..d153e96 100644 --- a/src/Dataverse/Plugin/README.md +++ b/src/Dataverse/Plugin/README.md @@ -1,3 +1,53 @@ # TALXIS.DevKit.Build.Dataverse.Plugin -See [here](https://github.com/TALXIS/tools-devkit-build) for more information. \ No newline at end of file +MSBuild integration for Dataverse plugin assembly projects. Configures Visual Studio project type GUIDs for CRM plugin development, brings in `Microsoft.CrmSdk.CoreAssemblies` and `Microsoft.PowerApps.MSBuild.Plugin`, applies automatic Git-based versioning, and exposes metadata targets that allow Solution projects to discover and integrate plugin assemblies during build. + +## Installation + +```xml + +``` + +Or use the SDK approach: + +```xml + + + Plugin + + +``` + +## How It Works + +The package sets `ProjectType` to `Plugin` and configures `ProjectTypeGuids` for CRM plugin recognition in Visual Studio. + +### Build-time targets + +- **TalxisBeforeBuild** (runs before `BeforeBuild`) -- executes `GenerateVersionNumber` followed by `ApplyPluginVersionNumber` to set `AssemblyVersion`, `FileVersion`, `Version`, and `PackageVersion` from Git. + +### Integration targets + +These targets are called by `TALXIS.DevKit.Build.Dataverse.Solution` when it discovers this project via `ProjectReference`: + +- **GetProjectType** -- returns `Plugin` so the Solution build knows how to handle this reference. +- **GetPluginAssemblyInfo** -- returns `PluginRootPath`, `PluginAssemblyId`, `TargetFramework`, `PublishFolderName`, and `AssemblyName` for automatic plugin assembly metadata generation in the solution. + +## MSBuild Properties + +| Property | Default | Description | +|----------|---------|-------------| +| `ProjectType` | `Plugin` | Marks the project as a plugin for reference discovery. | +| `Version` | _(required)_ | Base version; major/minor are used for Git versioning. | +| `ApplyToBranches` | _(none)_ | Semicolon-separated branch rules (e.g. `master;hotfix;develop:1;pr:3;feature/*:2`). | +| `LocalBranchBuildVersionNumber` | `0.0.0.1` | Fallback version when Git versioning is not applied. | +| `PluginTargetFramework` | `$(TargetFramework)` or `net462` | Target framework used to locate the compiled plugin DLL. | +| `PluginPublishFolderName` | `publish` | Publish folder name under `bin\\\`. | +| `PluginAssemblyId` | _(auto-generated)_ | Explicit GUID for the plugin assembly metadata; a new GUID is generated if empty. | + +## Related Packages + +- **Depends on**: `TALXIS.DevKit.Build.Dataverse.Tasks`, `Microsoft.PowerApps.MSBuild.Plugin`, `Microsoft.CrmSdk.CoreAssemblies` +- **Consumed by**: `TALXIS.DevKit.Build.Dataverse.Solution` projects via `ProjectReference` + + diff --git a/src/Dataverse/Plugin/TALXIS.DevKit.Build.Dataverse.Plugin.nuspec b/src/Dataverse/Plugin/TALXIS.DevKit.Build.Dataverse.Plugin.nuspec index 9086d49..1ce91af 100644 --- a/src/Dataverse/Plugin/TALXIS.DevKit.Build.Dataverse.Plugin.nuspec +++ b/src/Dataverse/Plugin/TALXIS.DevKit.Build.Dataverse.Plugin.nuspec @@ -15,6 +15,8 @@ + + diff --git a/src/Dataverse/Plugin/msbuild/build/TALXIS.DevKit.Build.Dataverse.Plugin.props b/src/Dataverse/Plugin/msbuild/build/TALXIS.DevKit.Build.Dataverse.Plugin.props index 29482aa..a10194c 100644 --- a/src/Dataverse/Plugin/msbuild/build/TALXIS.DevKit.Build.Dataverse.Plugin.props +++ b/src/Dataverse/Plugin/msbuild/build/TALXIS.DevKit.Build.Dataverse.Plugin.props @@ -1,5 +1,4 @@ - \ No newline at end of file diff --git a/src/Dataverse/Plugin/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Plugin.props b/src/Dataverse/Plugin/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Plugin.props index a10194c..6f64f4c 100644 --- a/src/Dataverse/Plugin/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Plugin.props +++ b/src/Dataverse/Plugin/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Plugin.props @@ -1,4 +1,17 @@ - \ No newline at end of file + + Plugin + $(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\PowerApps + {4C25E9B5-9FA6-436c-8E19-B395D2A65FAF};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + + + + + + + + + + diff --git a/src/Dataverse/Plugin/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Plugin.targets b/src/Dataverse/Plugin/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Plugin.targets index fcdee6d..74e7ce9 100644 --- a/src/Dataverse/Plugin/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Plugin.targets +++ b/src/Dataverse/Plugin/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Plugin.targets @@ -1,9 +1,45 @@ + + - \ No newline at end of file + + + + <_ProjectType Include="$(MSBuildProjectFullPath)"> + $(ProjectType) + + + + + + + <_PluginTargetFramework Condition="'$(TargetFramework)'!=''">$(TargetFramework) + <_PluginTargetFramework Condition="'$(_PluginTargetFramework)'=='' and '$(PluginTargetFramework)'!=''">$(PluginTargetFramework) + <_PluginTargetFramework Condition="'$(_PluginTargetFramework)'==''">net462 + + <_PluginPublishFolderName Condition="'$(PluginPublishFolderName)'!=''">$(PluginPublishFolderName) + <_PluginPublishFolderName Condition="'$(_PluginPublishFolderName)'==''">publish + + <_PluginAssemblyName Condition="'$(AssemblyName)'!=''">$(AssemblyName) + <_PluginAssemblyName Condition="'$(_PluginAssemblyName)'==''">$(MSBuildProjectName) + + + + <_PluginAssemblyInfo Include="$(MSBuildProjectFullPath)"> + $(MSBuildProjectDirectory) + $(PluginAssemblyId) + $(_PluginTargetFramework) + $(_PluginPublishFolderName) + $(_PluginAssemblyName) + + + + diff --git a/src/Dataverse/ScriptLibrary/README.md b/src/Dataverse/ScriptLibrary/README.md new file mode 100644 index 0000000..e6a3d7a --- /dev/null +++ b/src/Dataverse/ScriptLibrary/README.md @@ -0,0 +1,63 @@ +# TALXIS.DevKit.Build.Dataverse.ScriptLibrary + +MSBuild integration for Dataverse web resource (JavaScript/TypeScript) projects. Automatically runs `npm install` and `npm run build` when a TypeScript project is detected, copies the compiled JS output to the build output directory, and exposes metadata targets that allow Solution projects to discover and integrate script libraries as web resources. + +## Installation + +```xml + +``` + +Or use the SDK approach: + +```xml + + + ScriptLibrary + + +``` + +## Prerequisites + +When `RunNodeBuild` is `true` (auto-detected from the presence of `package.json` in `TypeScriptDir`): + +- **Node.js** must be available on `PATH` +- **npm** must be available on `PATH` + +The build will fail with a descriptive error if either is missing. + +## How It Works + +The package sets `ProjectType` to `ScriptLibrary` and disables `GenerateAssemblyInfo` by default since this is not a traditional .NET assembly project. + +### Build-time targets + +1. **CheckScriptLibraryPrereqs** -- validates that `TypeScriptDir` exists, `package.json` is present, and `node`/`npm` are on `PATH`. +2. **BuildTypeScript** (runs before `Build`) -- executes `npm install` followed by `npm run build` in `TypeScriptDir`. +3. **CopyScriptLibraryMainToOutput** (runs after `Build`) -- copies the main JS file from `TypeScriptDir\build\` to the output directory. + +### Integration targets + +Called by `TALXIS.DevKit.Build.Dataverse.Solution` via `ProjectReference`: + +- **GetProjectType** -- returns `ScriptLibrary`. +- **GetScriptLibraryOutputs** -- exposes the compiled JS file path for the solution to copy into `WebResources/`. + +## MSBuild Properties + +| Property | Default | Description | +|----------|---------|-------------| +| `ProjectType` | `ScriptLibrary` | Marks the project for reference discovery by Solution projects. | +| `RunNodeBuild` | Auto-detected | Set to `true` to run `npm install` and `npm run build`. Defaults to `true` if `package.json` exists in `TypeScriptDir`. | +| `TypeScriptDir` | `$(MSBuildProjectDirectory)\TS` | Folder containing the TypeScript project (`package.json`, sources). | +| `ScriptLibraryMainFile` | _(none)_ | Main script file path used by consuming targets. | +| `LangVersion` | `latest` | C# language version for the project. | +| `GenerateAssemblyInfo` | `false` | Disables auto-generated assembly info. | + +## Related Packages + +- **Depends on**: `TALXIS.DevKit.Build.Dataverse.Tasks` +- **Consumed by**: `TALXIS.DevKit.Build.Dataverse.Solution` projects via `ProjectReference` + + diff --git a/src/Dataverse/ScriptLibrary/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.csproj b/src/Dataverse/ScriptLibrary/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.csproj new file mode 100644 index 0000000..6a1c63d --- /dev/null +++ b/src/Dataverse/ScriptLibrary/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + true + 0.0.0.1 + TALXIS.DevKit.Build.Dataverse.ScriptLibrary.nuspec + + Version=$(Version) + + + + \ No newline at end of file diff --git a/src/Dataverse/ScriptLibrary/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.nuspec b/src/Dataverse/ScriptLibrary/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.nuspec new file mode 100644 index 0000000..4317d12 --- /dev/null +++ b/src/Dataverse/ScriptLibrary/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.nuspec @@ -0,0 +1,27 @@ + + + + TALXIS.DevKit.Build.Dataverse.ScriptLibrary + $Version$ + TALXIS + true + false + MIT + https://licenses.nuget.org/MIT + README.md + https://github.com/TALXIS/tools-devkit-build + Dataverse MSBuild ScriptLibrary + https://github.com/TALXIS/tools-devkit-build/releases + 2025 NETWORG + + + + + + + + + + + \ No newline at end of file diff --git a/src/Dataverse/ScriptLibrary/msbuild/build/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.props b/src/Dataverse/ScriptLibrary/msbuild/build/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.props new file mode 100644 index 0000000..07cd5de --- /dev/null +++ b/src/Dataverse/ScriptLibrary/msbuild/build/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.props @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/Dataverse/ScriptLibrary/msbuild/build/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.targets b/src/Dataverse/ScriptLibrary/msbuild/build/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.targets new file mode 100644 index 0000000..8a0424d --- /dev/null +++ b/src/Dataverse/ScriptLibrary/msbuild/build/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.targets @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Dataverse/ScriptLibrary/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.props b/src/Dataverse/ScriptLibrary/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.props new file mode 100644 index 0000000..68fa309 --- /dev/null +++ b/src/Dataverse/ScriptLibrary/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.props @@ -0,0 +1,9 @@ + + + ScriptLibrary + main + $(MSBuildProjectDirectory)\TS\build\$(ScriptLibraryName).js + latest + false + + diff --git a/src/Dataverse/ScriptLibrary/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.targets b/src/Dataverse/ScriptLibrary/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.targets new file mode 100644 index 0000000..221c292 --- /dev/null +++ b/src/Dataverse/ScriptLibrary/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.ScriptLibrary.targets @@ -0,0 +1,61 @@ + + + + + $(MSBuildProjectDirectory)\TS + + true + false + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + <_ProjectType Include="$(MSBuildProjectFullPath)"> + $(ProjectType) + + + + + + + <_ScriptLibraryOutputs Include="$(TargetDir)$(ScriptLibraryName).js" /> + + + + + + <_ScriptLibraryMainFile Include="$(TypeScriptDir)\**\$(ScriptLibraryName).js" /> + + + + + + diff --git a/src/Dataverse/Sdk/README.md b/src/Dataverse/Sdk/README.md new file mode 100644 index 0000000..7b05feb --- /dev/null +++ b/src/Dataverse/Sdk/README.md @@ -0,0 +1,46 @@ +# TALXIS.DevKit.Build.Sdk + +An MSBuild SDK package that simplifies project setup by automatically resolving and referencing the correct `TALXIS.DevKit.Build.Dataverse.*` package based on the `ProjectType` property. Instead of manually adding `PackageReference` entries, projects declare this SDK and set `ProjectType` to have everything wired automatically. + +## Installation + +This is an MSBuild SDK, used differently from a regular NuGet package. + +```xml + + + Solution + + +``` + +## How It Works + +- **Sdk.props** imports `Microsoft.NET.Sdk` props and defines default values for `TALXISDevKitDataversePackageBase` and `TALXISDevKitDataversePackageVersion`. +- **Sdk.targets** imports `Microsoft.NET.Sdk` targets, then constructs `TALXISDevKitDataversePackageName` from `$(TALXISDevKitDataversePackageBase).$(ProjectType)` when `ProjectType` is set. It adds a `PackageReference` for the resolved package with `PrivateAssets="All"`. + +### Supported ProjectType values + +`Solution`, `Plugin`, `Pcf`, `ScriptLibrary`, `PdPackage`, `WorkflowActivity` + +The `TALXISDevKitDataversePackageName` property can be set explicitly to override the auto-resolution for advanced scenarios. + +## MSBuild Properties + +| Property | Default | Description | +|----------|---------|-------------| +| `ProjectType` | _(none)_ | Selects the package to reference (e.g. `Solution`, `Plugin`, `Pcf`). | +| `TALXISDevKitDataversePackageBase` | `TALXIS.DevKit.Build.Dataverse` | Base package name combined with `ProjectType`. | +| `TALXISDevKitDataversePackageVersion` | `0.0.0.1` | Version used in the auto-generated package reference. | +| `TALXISDevKitDataversePackageName` | `$(Base).$(ProjectType)` | Explicit package name; overrides the base + ProjectType combination. | + +## Related Packages + +This is the entry point to the TALXIS.DevKit.Build ecosystem. Based on `ProjectType`, it references one of: + +- `TALXIS.DevKit.Build.Dataverse.Solution` +- `TALXIS.DevKit.Build.Dataverse.Plugin` +- `TALXIS.DevKit.Build.Dataverse.Pcf` +- `TALXIS.DevKit.Build.Dataverse.ScriptLibrary` +- `TALXIS.DevKit.Build.Dataverse.PdPackage` +- `TALXIS.DevKit.Build.Dataverse.WorkflowActivity` diff --git a/src/Dataverse/Sdk/Sdk/Sdk.props b/src/Dataverse/Sdk/Sdk/Sdk.props new file mode 100644 index 0000000..6ac8f96 --- /dev/null +++ b/src/Dataverse/Sdk/Sdk/Sdk.props @@ -0,0 +1,9 @@ + + + + + + TALXIS.DevKit.Build.Dataverse + 0.0.0.1 + + diff --git a/src/Dataverse/Sdk/Sdk/Sdk.targets b/src/Dataverse/Sdk/Sdk/Sdk.targets new file mode 100644 index 0000000..8abf675 --- /dev/null +++ b/src/Dataverse/Sdk/Sdk/Sdk.targets @@ -0,0 +1,15 @@ + + + + + $(TALXISDevKitDataversePackageBase).$(ProjectType) + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/src/Dataverse/Sdk/TALXIS.DevKit.Build.Sdk.csproj b/src/Dataverse/Sdk/TALXIS.DevKit.Build.Sdk.csproj new file mode 100644 index 0000000..229c117 --- /dev/null +++ b/src/Dataverse/Sdk/TALXIS.DevKit.Build.Sdk.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + true + 0.0.0.1 + TALXIS.DevKit.Build.Sdk.nuspec + + Version=$(Version) + + + + diff --git a/src/Dataverse/Sdk/TALXIS.DevKit.Build.Sdk.nuspec b/src/Dataverse/Sdk/TALXIS.DevKit.Build.Sdk.nuspec new file mode 100644 index 0000000..e39f9e7 --- /dev/null +++ b/src/Dataverse/Sdk/TALXIS.DevKit.Build.Sdk.nuspec @@ -0,0 +1,26 @@ + + + + TALXIS.DevKit.Build.Sdk + $Version$ + TALXIS + true + false + MIT + https://licenses.nuget.org/MIT + README.md + https://github.com/TALXIS/tools-devkit-build + Dataverse MSBuild SDK + https://github.com/TALXIS/tools-devkit-build/releases + 2025 NETWORG + + + + + + + + + + diff --git a/src/Dataverse/Solution/README.md b/src/Dataverse/Solution/README.md index 87a3314..2bbb582 100644 --- a/src/Dataverse/Solution/README.md +++ b/src/Dataverse/Solution/README.md @@ -1,3 +1,97 @@ # TALXIS.DevKit.Build.Dataverse.Solution -See [here](https://github.com/TALXIS/tools-devkit-build) for more information. \ No newline at end of file +MSBuild integration for building complete Dataverse solutions. Orchestrates the entire solution build pipeline: discovers and builds referenced Plugin, WorkflowActivity, ScriptLibrary, and PCF projects; patches solution XML with version, publisher, and managed state; runs the PAC solution packager to produce a `.zip` file; and supports `dotnet pack` to generate a NuGet package containing the solution zip. + +## Installation + +```xml + +``` + +Or use the SDK approach: + +```xml + + + Solution + + +``` + +## How It Works + +The package sets `ProjectType` to `Solution` and imports `Microsoft.PowerApps.MSBuild.Solution` targets. The build pipeline executes in the following order: + +### 1. Component discovery + +`ProbePluginLibraries`, `ProbeScriptLibraries`, and `ProbeWorkflowActivityLibraries` call `GetProjectType` on all `ProjectReference` items to classify them by component type. + +### 2. Component builds + +`BuildPluginLibraries`, `BuildScriptLibraries`, and `BuildWorkflowActivityLibraries` compile each referenced component project before `CopyCdsSolutionContent`. + +### 3. Component metadata generation + +- **Plugin assemblies** -- `EnsurePluginAssemblyDataXml` generates `.data.xml` files under `PluginAssemblies/`. +- **Workflow activities** -- `EnsureWorkflowActivityAssemblyDataXml` generates `.data.xml` for workflow activity assemblies. +- **Script libraries** -- `CopyScriptLibrariesToWebResources` resolves web resource names with the publisher prefix, generates `.data.xml`, and registers root components in `Solution.xml`. + +### 4. Solution XML patching + +`PatchSolutionXml` writes `Version`, `Managed`, `PublisherName`, and `PublisherPrefix` into `Solution.xml`. + +### 5. PAC override and versioning + +`ProcessCdsProjectReferencesOutputs` replaces the Microsoft default to filter ScriptLibrary and WorkflowActivity references from PAC processing. Then `GenerateVersionNumber` and `ApplyVersionNumber` patch the version across all solution metadata. + +### 6. Solution packaging + +`PackDataverseSolution` invokes the PAC solution packager to produce the output `.zip`. + +### 7. NuGet packing + +`dotnet pack` produces a `.nupkg` with the solution `.zip` under `content/solution/`. + +## MSBuild Properties + +### General + +| Property | Default | Description | +|----------|---------|-------------| +| `ProjectType` | `Solution` | Marks the project as a solution for reference discovery. | +| `Version` | _(required)_ | Base version; used for Git versioning and applied to solution.xml and related metadata. | +| `ApplyToBranches` | _(none)_ | Semicolon-separated branch rules (e.g. `master;hotfix;develop:1;pr:3;feature/*:2`). | +| `LocalBranchBuildVersionNumber` | `0.0.0.1` | Fallback version when Git versioning is not applied. | + +### Solution metadata + +| Property | Default | Description | +|----------|---------|-------------| +| `Managed` | _(none)_ | Value written to the `` element in solution.xml. | +| `PublisherName` | _(none)_ | Value written to the publisher name fields in solution.xml. | +| `PublisherPrefix` | _(none)_ | Value written to solution.xml and used as the web resource name prefix. | + +### Paths + +| Property | Default | Description | +|----------|---------|-------------| +| `SolutionRootPath` | `.` | Relative path to the solution source root. | +| `SolutionPackagerWorkingDirectory` | `$(IntermediateOutputPath)` | Working folder for solution packager operations. | +| `SolutionPackagerMetadataWorkingDirectory` | `$(SolutionPackagerWorkingDirectory)Metadata` | Metadata folder used for version updates. | +| `SolutionPackagerLocalizationWorkingDirectory` | _(none)_ | Optional localization working folder (cleaned by `CleanupWorkingDirectory`). | +| `SolutionPackageLogFilePath` | `$(IntermediateOutputPath)SolutionPackager.log` | SolutionPackager log path. | +| `SolutionPackageZipFilePath` | `$(OutputPath)$(MSBuildProjectName).zip` | Output zip path for pack tasks. | + +### Web resources and PCF + +| Property | Default | Description | +|----------|---------|-------------| +| `WebResourcesDir` | `$(MSBuildProjectDirectory)\$(SolutionRootPath)\WebResources\` | Destination folder for script library web resources. | +| `PcfForceUpdate` | _(none)_ | Forwarded to PAC `ProcessCdsProjectReferencesOutputs` to force PCF updates. | + +## Related Packages + +- **Depends on**: `TALXIS.DevKit.Build.Dataverse.Tasks`, `Microsoft.PowerApps.MSBuild.Solution` +- **Discovers and builds**: `Plugin`, `WorkflowActivity`, `ScriptLibrary`, and `Pcf` projects via `ProjectReference` + + diff --git a/src/Dataverse/Solution/msbuild/build/TALXIS.DevKit.Build.Dataverse.Solution.Data.targets b/src/Dataverse/Solution/msbuild/build/TALXIS.DevKit.Build.Dataverse.Solution.Data.targets new file mode 100644 index 0000000..763fffb --- /dev/null +++ b/src/Dataverse/Solution/msbuild/build/TALXIS.DevKit.Build.Dataverse.Solution.Data.targets @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/Dataverse/Solution/msbuild/build/TALXIS.DevKit.Build.Dataverse.Solution.OverridePAC.targets b/src/Dataverse/Solution/msbuild/build/TALXIS.DevKit.Build.Dataverse.Solution.OverridePAC.targets new file mode 100644 index 0000000..658e6ba --- /dev/null +++ b/src/Dataverse/Solution/msbuild/build/TALXIS.DevKit.Build.Dataverse.Solution.OverridePAC.targets @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/Dataverse/Solution/msbuild/build/TALXIS.DevKit.Build.Dataverse.Solution.ScriptLibraries.targets b/src/Dataverse/Solution/msbuild/build/TALXIS.DevKit.Build.Dataverse.Solution.ScriptLibraries.targets new file mode 100644 index 0000000..658e6ba --- /dev/null +++ b/src/Dataverse/Solution/msbuild/build/TALXIS.DevKit.Build.Dataverse.Solution.ScriptLibraries.targets @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/Dataverse/Solution/msbuild/build/TALXIS.DevKit.Build.Dataverse.Solution.props b/src/Dataverse/Solution/msbuild/build/TALXIS.DevKit.Build.Dataverse.Solution.props index 29482aa..4a623cf 100644 --- a/src/Dataverse/Solution/msbuild/build/TALXIS.DevKit.Build.Dataverse.Solution.props +++ b/src/Dataverse/Solution/msbuild/build/TALXIS.DevKit.Build.Dataverse.Solution.props @@ -1,5 +1,8 @@ - + + Solution + true + \ No newline at end of file diff --git a/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.Data.targets b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.Data.targets new file mode 100644 index 0000000..04348fa --- /dev/null +++ b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.Data.targets @@ -0,0 +1,26 @@ + + + + + + + + + + + + diff --git a/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.OverridePAC.targets b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.OverridePAC.targets new file mode 100644 index 0000000..51eff2c --- /dev/null +++ b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.OverridePAC.targets @@ -0,0 +1,82 @@ + + + + + + + + + + + + <_PluginProjects Remove="@(_PluginProjects)" /> + <_PluginProjects Include="@(_ProjectTypeFromReferences)" + Condition="'%(ProjectType)'=='Plugin'" /> + <_WorkflowActivityProjects Remove="@(_WorkflowActivityProjects)" /> + <_WorkflowActivityProjects Include="@(_ProjectTypeFromReferences)" + Condition="'%(ProjectType)'=='WorkflowActivity'" /> + + + + <_ScriptLibraryProjectsList>;@(_ScriptLibraryProjects->'%(Identity)'); + <_WorkflowActivityProjectsList>;@(_WorkflowActivityProjects->'%(Identity)'); + + + + <_CdsRefs Include="@(ProjectReference)" /> + + + + <_CdsRefs Remove="@(_CdsRefs)" + Condition="$([System.String]::Copy('$(_ScriptLibraryProjectsList)').Contains(';%(FullPath);'))" /> + <_CdsRefs Remove="@(_CdsRefs)" + Condition="$([System.String]::Copy('$(_WorkflowActivityProjectsList)').Contains(';%(FullPath);'))" /> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.Pack.consumer.props b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.Pack.consumer.props new file mode 100644 index 0000000..4d0025c --- /dev/null +++ b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.Pack.consumer.props @@ -0,0 +1,9 @@ + + + + + Solution + PdSolution + + + diff --git a/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.Pack.targets b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.Pack.targets new file mode 100644 index 0000000..a4e3bd3 --- /dev/null +++ b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.Pack.targets @@ -0,0 +1,38 @@ + + + + + + true + false + true + true + pp-solution + true + + true + false + false + + + + + + + + + + + + + + diff --git a/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.Plugin.targets b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.Plugin.targets new file mode 100644 index 0000000..89e7f1a --- /dev/null +++ b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.Plugin.targets @@ -0,0 +1,89 @@ + + + + + + BuildPluginLibraries;$(BuildDependsOn) + + + + + + + + + + <_PluginLibraryProjects Remove="@(_PluginLibraryProjects)" /> + <_PluginLibraryProjects Include="@(_ProjectTypeFromReferences)" + Condition="'%(ProjectType)'=='Plugin'" /> + + + + + + + + + + + + + + + <_PluginAssemblyInfo Remove="@(_PluginAssemblyInfo)" + Condition="'%(_PluginAssemblyInfo.PluginRootPath)'=='' or '%(_PluginAssemblyInfo.AssemblyName)'==''" /> + <_PluginAssemblyInfo Update="@(_PluginAssemblyInfo)" + Condition="'%(_PluginAssemblyInfo.PluginAssemblyId)'==''"> + $([System.Guid]::NewGuid().ToString('D')) + + <_PluginAssemblyInfo Update="@(_PluginAssemblyInfo)"> + $([System.IO.Path]::Combine('%(_PluginAssemblyInfo.PluginRootPath)','bin','$(Configuration)','%(_PluginAssemblyInfo.TargetFramework)','%(_PluginAssemblyInfo.PublishFolderName)','%(_PluginAssemblyInfo.AssemblyName).dll')) + + + + + + + + + + + + + + + diff --git a/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.ScriptLibraries.targets b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.ScriptLibraries.targets new file mode 100644 index 0000000..b670f05 --- /dev/null +++ b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.ScriptLibraries.targets @@ -0,0 +1,118 @@ + + + + + + + + + + + + <_ScriptLibraryProjects Remove="@(_ScriptLibraryProjects)" /> + <_ScriptLibraryProjects Include="@(_ProjectTypeFromReferences)" + Condition="'%(ProjectType)'=='ScriptLibrary'" /> + + + + + + + + + + + + + + + + + $(MSBuildProjectDirectory)\$(SolutionRootPath)\WebResources\ + + + + + + + + + + + + + <_ScriptFilesToCopy Include="@(_ResolvedScriptFiles)"> + $(WebResourcesDir)%(ResolvedName) + %(ResolvedName) + %(DisplayName) + $(WebResourcesDir)%(ResolvedName).data.xml + + + + + <_ScriptFilesMissingDataXml Include="@(_ScriptFilesToCopy)" + Condition="!Exists('%(DataXmlFile)')"> + + + + + + + + + + + + + + <_MetadataWebResourcesDir>$(SolutionPackagerMetadataWorkingDirectory)\WebResources\ + + + + + + + + + + diff --git a/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.WorkflowActivity.targets b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.WorkflowActivity.targets new file mode 100644 index 0000000..34cb2c3 --- /dev/null +++ b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.WorkflowActivity.targets @@ -0,0 +1,107 @@ + + + + + + BuildWorkflowActivityLibraries;$(BuildDependsOn) + + + + + + + + + + <_WorkflowActivityLibraryProjects Remove="@(_WorkflowActivityLibraryProjects)" /> + <_WorkflowActivityLibraryProjects Include="@(_ProjectTypeFromReferences)" + Condition="'%(ProjectType)'=='WorkflowActivity'" /> + + + + + + + + + + + + + + + <_WorkflowActivityAssemblyInfo Remove="@(_WorkflowActivityAssemblyInfo)" + Condition="'%(_WorkflowActivityAssemblyInfo.WorkflowActivityRootPath)'=='' or '%(_WorkflowActivityAssemblyInfo.AssemblyName)'==''" /> + <_WorkflowActivityAssemblyInfo Update="@(_WorkflowActivityAssemblyInfo)" + Condition="'%(_WorkflowActivityAssemblyInfo.WorkflowActivityAssemblyId)'==''"> + $([System.Guid]::NewGuid().ToString('D')) + + <_WorkflowActivityAssemblyInfo Update="@(_WorkflowActivityAssemblyInfo)"> + $([System.IO.Path]::Combine('%(_WorkflowActivityAssemblyInfo.WorkflowActivityRootPath)','bin','$(Configuration)','%(_WorkflowActivityAssemblyInfo.TargetFramework)','%(_WorkflowActivityAssemblyInfo.PublishFolderName)','%(_WorkflowActivityAssemblyInfo.AssemblyName).dll')) + + + + + + + + + + + + <_WorkflowActivityDllSource>$([System.IO.Path]::Combine('%(_WorkflowActivityAssemblyInfo.WorkflowActivityRootPath)','bin','$(Configuration)','%(_WorkflowActivityAssemblyInfo.TargetFramework)','%(_WorkflowActivityAssemblyInfo.AssemblyName).dll')) + + + + + + + + + + + + + + diff --git a/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.props b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.props index a10194c..f6dcf8a 100644 --- a/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.props +++ b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.props @@ -1,4 +1,7 @@ - - \ No newline at end of file + + Solution + true + + diff --git a/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.targets b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.targets index 72ecfe8..f65a907 100644 --- a/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.targets +++ b/src/Dataverse/Solution/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.Solution.targets @@ -1,15 +1,66 @@ - - - - - - - - - - - - - \ No newline at end of file + + + Solution + + + + + %(Filename) + %(ManifestResourceName) + + + + + + <_ProjectType Include="$(MSBuildProjectFullPath)"> + $(ProjectType) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Dataverse/Tasks/README.md b/src/Dataverse/Tasks/README.md index d88af65..5fbf0b4 100644 --- a/src/Dataverse/Tasks/README.md +++ b/src/Dataverse/Tasks/README.md @@ -1,3 +1,76 @@ -# TALXIS.DevKit.Build.Dataverse.Tasks +# TALXIS.DevKit.Build.Dataverse.Tasks + +Core MSBuild tasks and targets library shared by all `TALXIS.DevKit.Build.Dataverse.*` packages. Provides custom C# MSBuild tasks for Git-based version generation, solution XML patching, solution packaging via PAC CLI, schema validation, CMT data merging, and web resource management. Most users do not reference this package directly -- it is pulled in as a dependency of the higher-level packages (`Plugin`, `Solution`, `Pcf`, etc.). + +## Installation + +```xml + +``` + +Typically this package is referenced transitively through one of the component packages. + +## How It Works + +The package ships compiled task assemblies for `net472` and `net6.0`. At build time, the correct assembly is selected based on `MSBuildRuntimeType` (Core vs Full Framework). + +### Registered MSBuild tasks + +| Category | Tasks | +|----------|-------| +| Versioning | `GenerateGitVersion`, `ApplyVersionNumber`, `ApplyPcfVersionNumber`, `ApplyPluginVersionNumberInSolution` | +| Solution packaging | `InvokeSolutionPackager`, `PatchSolutionXml`, `EnsureCustomizationsNode` | +| Component metadata | `EnsurePluginAssemblyDataXml`, `EnsureWorkflowActivityAssemblyDataXml`, `EnsureWebResourceDataXml`, `AddRootComponentToSolution` | +| Validation | `ValidateSolutionComponentSchema`, `ValidateXmlFiles`, `ValidateJsonFiles` | +| CMT data | `MergeCmtDataXml`, `MergeCmtDataSchemaXml`, `AppendCmtDataFileToImportConfig` | +| Utilities | `RetrieveProjectReferences`, `ResolveWebResourceName` | + +### Key targets + +- **GenerateVersionNumber** -- requires the `Version` property. Runs `GenerateGitVersion` using the major/minor from `Version`, the current Git branch, and `ApplyToBranches` rules to produce a full four-part version number. +- **ApplyVersionNumber** -- patches the generated version into solution metadata folders (`SolutionXml`, `PluginAssemblies`, `Workflows`, `Controls`, `SdkMessageProcessingSteps`). +- **ApplyPcfVersionNumber** -- updates the version in `ControlManifest.xml` for PCF controls. +- **PackDataverseSolution** -- invokes the PAC solution packager to produce a `.zip` from the working directory. +- **ValidateSolutionComponentSchema** -- validates solution XML files against bundled XSD schemas and JSON files against JSON schemas. +- **InitializeSolutionPackagerWorkingDirectory** -- copies solution source files into the intermediate working directory for packaging. +- **CleanupWorkingDirectory** -- removes temporary localization and working directories after build. + +## MSBuild Properties + +### Versioning + +| Property | Default | Description | +|----------|---------|-------------| +| `Version` | _(required)_ | Base version (`Major.Minor`); used by `GenerateGitVersion` to produce the full version. | +| `ApplyToBranches` | _(none)_ | Semicolon-separated branch rules (e.g. `master;hotfix;develop:1;pr:3;feature/*:2`). | +| `LocalBranchBuildVersionNumber` | `0.0.20000.0` | Fallback version used when the current branch does not match `ApplyToBranches`. | + +### Solution packager paths + +| Property | Default | Description | +|----------|---------|-------------| +| `SolutionRootPath` | `.` | Relative path to the solution source root. | +| `SolutionPackagerWorkingDirectory` | `$(IntermediateOutputPath)` | Working folder for pack/unpack operations. | +| `SolutionPackagerMetadataWorkingDirectory` | `$(SolutionPackagerWorkingDirectory)Metadata` | Metadata folder used by version update targets. | +| `SolutionPackagerLocalizationWorkingDirectory` | _(none)_ | Optional localization working folder (cleaned by `CleanupWorkingDirectory`). | +| `SolutionPackageLogFilePath` | `$(IntermediateOutputPath)SolutionPackager.log` | SolutionPackager log file path. | +| `SolutionPackageZipFilePath` | `$(OutputPath)$(MSBuildProjectName).zip` | Output path for the packed solution `.zip`. | + +### PCF versioning + +| Property | Default | Description | +|----------|---------|-------------| +| `PcfOutputPath` | _(none)_ | Output directory containing `ControlManifest.xml` (used by `ApplyPcfVersionNumber`). | + +## Related Packages + +This is the foundational package in the ecosystem. The following packages depend on it: + +- `TALXIS.DevKit.Build.Dataverse.Pcf` +- `TALXIS.DevKit.Build.Dataverse.Plugin` +- `TALXIS.DevKit.Build.Dataverse.WorkflowActivity` +- `TALXIS.DevKit.Build.Dataverse.ScriptLibrary` +- `TALXIS.DevKit.Build.Dataverse.Solution` +- `TALXIS.DevKit.Build.Dataverse.PdPackage` + -See [here](https://github.com/TALXIS/tools-devkit-build) for more information. \ No newline at end of file diff --git a/src/Dataverse/Tasks/TALXIS.DevKit.Build.Dataverse.Tasks.csproj b/src/Dataverse/Tasks/TALXIS.DevKit.Build.Dataverse.Tasks.csproj index 2e0561b..18903f6 100644 --- a/src/Dataverse/Tasks/TALXIS.DevKit.Build.Dataverse.Tasks.csproj +++ b/src/Dataverse/Tasks/TALXIS.DevKit.Build.Dataverse.Tasks.csproj @@ -16,12 +16,11 @@ NU1604 portable true + latest false true true - - bin/$(Configuration) @@ -53,9 +52,19 @@ - - - - \ No newline at end of file + + + + $(TargetsForTfmSpecificContentInPackage);_AddTasksDllToPackage + + + + + + tasks/$(TargetFramework) + + + + diff --git a/src/Dataverse/Tasks/Tasks/AddRootComponentToSolution.cs b/src/Dataverse/Tasks/Tasks/AddRootComponentToSolution.cs new file mode 100644 index 0000000..094b1e5 --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/AddRootComponentToSolution.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; +using System.Xml; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +public sealed class AddRootComponentToSolution : Task +{ + [Required] + public string SolutionPath { get; set; } = ""; + + [Required] + public string Type { get; set; } = ""; + + public string Id { get; set; } = ""; + + public string SchemaName { get; set; } = ""; + + public string Behavior { get; set; } = "0"; + + public override bool Execute() + { + try + { + ValidateInputs(); + + var fullPath = Path.GetFullPath(SolutionPath); + var doc = new XmlDocument(); + doc.Load(fullPath); + + var rootComponents = doc.SelectSingleNode("//RootComponents") as XmlElement; + if (rootComponents == null) + { + if (doc.DocumentElement == null) + throw new InvalidOperationException("Solution.xml is missing a document element."); + + rootComponents = doc.CreateElement("RootComponents"); + doc.DocumentElement.AppendChild(rootComponents); + } + + if (!ExistsAlready(rootComponents)) + { + var rc = doc.CreateElement("RootComponent"); + rc.SetAttribute("type", Type.Trim()); + + if (!string.IsNullOrWhiteSpace(Id)) + rc.SetAttribute("id", Normalize(Id)); + + if (!string.IsNullOrWhiteSpace(SchemaName)) + rc.SetAttribute("schemaName", SchemaName.Trim()); + + if (!string.IsNullOrWhiteSpace(Behavior)) + rc.SetAttribute("behavior", Behavior.Trim()); + + rootComponents.AppendChild(rc); + Log.LogMessage(MessageImportance.High, $"RootComponent added to {fullPath}"); + } + else + { + Log.LogMessage(MessageImportance.Low, "RootComponent already present, no changes written."); + } + + doc.Save(fullPath); + return true; + } + catch (Exception ex) + { + Log.LogErrorFromException(ex, true, true, null); + return false; + } + } + + private void ValidateInputs() + { + if (string.IsNullOrWhiteSpace(SolutionPath)) + throw new ArgumentException("SolutionPath is required."); + + if (!File.Exists(SolutionPath)) + throw new FileNotFoundException("Solution.xml not found", SolutionPath); + + if (string.IsNullOrWhiteSpace(Type)) + throw new ArgumentException("Type is required."); + + if (string.IsNullOrWhiteSpace(Id) && string.IsNullOrWhiteSpace(SchemaName)) + throw new ArgumentException("Either Id or SchemaName must be provided."); + } + + private bool ExistsAlready(XmlElement rootComponents) + { + foreach (XmlNode node in rootComponents.ChildNodes) + { + if (node is not XmlElement el) + continue; + + if (!string.Equals(el.Name, "RootComponent", StringComparison.Ordinal)) + continue; + + var typeAttr = el.GetAttribute("type"); + if (!string.Equals(typeAttr, Type.Trim(), StringComparison.Ordinal)) + continue; + + var idAttr = el.GetAttribute("id"); + var schemaAttr = el.GetAttribute("schemaName"); + + bool idMatches = !string.IsNullOrWhiteSpace(Id) && + string.Equals(Normalize(idAttr), Normalize(Id), StringComparison.OrdinalIgnoreCase); + + bool schemaMatches = !string.IsNullOrWhiteSpace(SchemaName) && + string.Equals(schemaAttr?.Trim(), SchemaName.Trim(), StringComparison.Ordinal); + + if ((idMatches && !string.IsNullOrWhiteSpace(Id)) || + (schemaMatches && !string.IsNullOrWhiteSpace(SchemaName))) + { + return true; + } + } + + return false; + } + + private static string Normalize(string guidLike) + { + if (string.IsNullOrWhiteSpace(guidLike)) + return ""; + + return guidLike.Trim().Trim('{', '}'); + } +} diff --git a/src/Dataverse/Tasks/Tasks/AppendCmtDataFileToImportConfig.cs b/src/Dataverse/Tasks/Tasks/AppendCmtDataFileToImportConfig.cs new file mode 100644 index 0000000..5c3bb46 --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/AppendCmtDataFileToImportConfig.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Xml; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +public class AppendCmtDataFileToImportConfig : Task +{ + [Required] + public string ImportConfigPath { get; set; } = ""; + + [Required] + public string FileName { get; set; } = ""; + + public string Lcid { get; set; } = ""; + + public string UserMapFileName { get; set; } = ""; + + [Output] + public string UpdatedImportConfig { get; private set; } = ""; + + public override bool Execute() + { + try + { + var importConfig = NormalizePath(ImportConfigPath); + var fileName = (FileName ?? "").Trim(); + var lcid = (Lcid ?? "").Trim(); + var userMap = (UserMapFileName ?? "").Trim(); + + if (string.IsNullOrWhiteSpace(importConfig)) + { + Log.LogError("ImportConfigPath is empty."); + return false; + } + + if (string.IsNullOrWhiteSpace(fileName)) + { + Log.LogError("FileName is empty."); + return false; + } + + if (!File.Exists(importConfig)) + { + Log.LogError($"ImportConfig file not found: {importConfig}"); + return false; + } + + var doc = new XmlDocument + { + PreserveWhitespace = true + }; + doc.Load(importConfig); + + var root = doc.DocumentElement; + if (root == null) + { + Log.LogError("ImportConfig has no root element."); + return false; + } + + var cmtNode = root.SelectSingleNode("cmtdatafiles") as XmlElement; + if (cmtNode == null) + { + cmtNode = doc.CreateElement("cmtdatafiles"); + root.AppendChild(cmtNode); + } + + var existing = cmtNode.SelectSingleNode($"cmtdatafile[@filename='{fileName}']") as XmlElement; + if (existing == null) + { + var item = doc.CreateElement("cmtdatafile"); + item.SetAttribute("filename", fileName); + if (!string.IsNullOrWhiteSpace(lcid)) + item.SetAttribute("lcid", lcid); + item.SetAttribute("usermapfilename", userMap); + cmtNode.AppendChild(item); + Log.LogMessage(MessageImportance.Low, $"Added cmtdatafile '{fileName}' to ImportConfig."); + } + else + { + if (!string.IsNullOrWhiteSpace(lcid)) + existing.SetAttribute("lcid", lcid); + if (!string.IsNullOrWhiteSpace(userMap) || existing.GetAttribute("usermapfilename") == "") + existing.SetAttribute("usermapfilename", userMap); + } + + doc.Save(importConfig); + UpdatedImportConfig = importConfig; + return !Log.HasLoggedErrors; + } + catch (Exception ex) + { + Log.LogErrorFromException(ex, true, true, null); + return false; + } + } + + private static string NormalizePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return ""; + + return Path.GetFullPath(path.Trim()); + } +} diff --git a/src/Dataverse/Tasks/Tasks/ApplyVersionNumber.cs b/src/Dataverse/Tasks/Tasks/ApplyVersionNumber.cs index 9a35694..40736ca 100644 --- a/src/Dataverse/Tasks/Tasks/ApplyVersionNumber.cs +++ b/src/Dataverse/Tasks/Tasks/ApplyVersionNumber.cs @@ -19,6 +19,7 @@ public class ApplyVersionNumber : Task [Required] public string WorkingDirectoryPath { get; set; } public ITaskItem PluginAssembliesFolder { get; set; } + public ITaskItem[] PluginAssembliesFolders { get; set; } public ITaskItem SdkMessageProcessingStepsFolder { get; set; } public ITaskItem WorkflowsFolder { get; set; } public ITaskItem ControlsFolder { get; set; } @@ -28,15 +29,23 @@ public class ApplyVersionNumber : Task public override bool Execute() { UpdateVersionInSolutionXmlFile(SolutionXml.ItemSpec, Version); - if (PluginAssembliesFolder != null && Directory.Exists(PluginAssembliesFolder.ItemSpec)) + var pluginAssemblyFolders = GetPluginAssemblyFolders().ToList(); + foreach (var pluginAssembliesFolder in pluginAssemblyFolders) { - var pluginAssemblies = Directory.EnumerateFiles(PluginAssembliesFolder.ItemSpec, "*.dll.data.xml", SearchOption.AllDirectories); + var pluginAssemblies = Directory.EnumerateFiles(pluginAssembliesFolder, "*.dll.data.xml", SearchOption.AllDirectories); foreach (var pluginAssemblyXmlPath in pluginAssemblies) { var pluginAssemblyDocument = XDocument.Load(pluginAssemblyXmlPath); var fullNameAttributeValue = pluginAssemblyDocument.Root.Attribute("FullName")?.Value; var assemblyName = fullNameAttributeValue?.Split(',')[0].Trim(); - var assembly = Assembly.LoadFrom(pluginAssemblyXmlPath.Replace(".data.xml", "")); + var assemblyPath = ResolveAssemblyPath(pluginAssemblyXmlPath, pluginAssemblyFolders); + if (assemblyPath == null) + { + Log.LogMessage(MessageImportance.High, $" > Skipping plugin assembly: file not found for {pluginAssemblyXmlPath}"); + continue; + } + + var assembly = Assembly.LoadFrom(assemblyPath); _assemblies.Add(assembly); Log.LogMessage(MessageImportance.High, $" > Discovered {assembly.FullName} at {pluginAssemblyXmlPath}"); @@ -161,4 +170,49 @@ private string ExtractVersionFromFQDN(string fullName) var match = Regex.Match(fullName, @"Version=([\d.]*),"); return match.Success ? match.Groups[1].Value : null; } -} + + private string ResolveAssemblyPath(string pluginAssemblyXmlPath, IEnumerable searchRoots) + { + var directPath = pluginAssemblyXmlPath.Replace(".data.xml", ""); + if (File.Exists(directPath)) + { + return directPath; + } + + var assemblyFileName = Path.GetFileName(directPath); + foreach (var root in searchRoots) + { + var match = Directory.EnumerateFiles(root, assemblyFileName, SearchOption.AllDirectories).FirstOrDefault(); + if (match != null) + { + return match; + } + } + + return null; + } + + private IEnumerable GetPluginAssemblyFolders() + { + var folders = new List(); + + if (PluginAssembliesFolders != null) + { + folders.AddRange(PluginAssembliesFolders.Select(x => x?.ItemSpec).Where(x => !string.IsNullOrWhiteSpace(x))); + } + + if (PluginAssembliesFolder != null && !string.IsNullOrWhiteSpace(PluginAssembliesFolder.ItemSpec)) + { + folders.Add(PluginAssembliesFolder.ItemSpec); + } + + var distinctFolders = folders.Where(Directory.Exists).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + foreach (var missing in folders.Except(distinctFolders, StringComparer.OrdinalIgnoreCase)) + { + Log.LogMessage(MessageImportance.Low, $"Skipping non-existent plugin assemblies folder: {missing}"); + } + + return distinctFolders; + } +} \ No newline at end of file diff --git a/src/Dataverse/Tasks/Tasks/EnsureCustomizationsNode.cs b/src/Dataverse/Tasks/Tasks/EnsureCustomizationsNode.cs new file mode 100644 index 0000000..f755885 --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/EnsureCustomizationsNode.cs @@ -0,0 +1,94 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +public class EnsureCustomizationsNode : Task +{ + [Required] + public string CustomizationsXmlFile { get; set; } + + [Required] + public string NodeName { get; set; } + + public override bool Execute() + { + try + { + if (string.IsNullOrWhiteSpace(CustomizationsXmlFile) || !File.Exists(CustomizationsXmlFile)) + { + Log.LogError($"Customizations.xml not found: {CustomizationsXmlFile}"); + + return false; + } + + var nodeName = (NodeName ?? "").Trim(); + + if (string.IsNullOrWhiteSpace(nodeName)) + { + Log.LogError("NodeName is empty."); + + return false; + } + + try + { + XmlConvert.VerifyNCName(nodeName); + } + catch (Exception ex) + { + Log.LogError($"NodeName is not a valid XML name: {nodeName}. {ex.Message}"); + + return false; + } + + var document = XDocument.Load(CustomizationsXmlFile); + var root = document.Root; + + if (root == null) + { + Log.LogError($"Customizations.xml has no document element: {CustomizationsXmlFile}"); + + return false; + } + + var elementName = root.Name.Namespace + nodeName; + + if (root.Elements(elementName).Any()) + { + Log.LogMessage(MessageImportance.Low, $"Customizations.xml already contains node '{nodeName}'."); + + return true; + } + + root.Add(new XElement(elementName)); + + var settings = new XmlWriterSettings + { + Encoding = new UTF8Encoding(false), + Indent = true, + NewLineChars = Environment.NewLine, + NewLineHandling = NewLineHandling.Replace + }; + + using (var writer = XmlWriter.Create(CustomizationsXmlFile, settings)) + { + document.Save(writer); + } + + Log.LogMessage(MessageImportance.High, $"Added node '{nodeName}' to Customizations.xml: {CustomizationsXmlFile}"); + + return true; + } + catch (Exception ex) + { + Log.LogErrorFromException(ex, true); + + return false; + } + } +} diff --git a/src/Dataverse/Tasks/Tasks/EnsurePluginAssemblyDataXml.cs b/src/Dataverse/Tasks/Tasks/EnsurePluginAssemblyDataXml.cs new file mode 100644 index 0000000..0a5dab0 --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/EnsurePluginAssemblyDataXml.cs @@ -0,0 +1,831 @@ +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Xml; +using System.Xml.Linq; +using System.Collections.Generic; +using System.Threading; +#if NET6_0_OR_GREATER +using System.Runtime.Loader; +#endif + +public sealed class EnsurePluginAssemblyDataXml : Task +{ + [Required] + public string PluginRootPath { get; set; } = ""; + + [Required] + public string PluginAssemblyId { get; set; } = ""; + + public string RepositoryRoot { get; set; } = ""; + + public string Configuration { get; set; } = "Debug"; + public string TargetFramework { get; set; } = "net462"; + public string PublishFolderName { get; set; } = "publish"; + public string PluginDllPath { get; set; } = ""; + + public override bool Execute() + { + try + { + ValidatePluginRootPath(); + string repoRoot = GetRepositoryRoot(); + + string csprojPath = FindProjectFile(PluginRootPath); + string csprojFileName = Path.GetFileNameWithoutExtension(csprojPath); + Tuple meta = ReadProjectMetadata(csprojPath, csprojFileName); + string assemblyName = meta.Item1; + + string existingId = FindPluginAssemblyId(repoRoot, assemblyName); + string effectiveId = !string.IsNullOrWhiteSpace(existingId) ? existingId : PluginAssemblyId; + if (string.IsNullOrWhiteSpace(effectiveId)) + effectiveId = Guid.NewGuid().ToString("D"); + + string normalizedGuid = NormalizeGuid(effectiveId); + PluginAssemblyId = normalizedGuid; + + PluginProjectInfo info = BuildProjectInfo(repoRoot, normalizedGuid); + + GeneratePluginAssemblyData(info, normalizedGuid); + + Log.LogMessage(MessageImportance.High, "PluginAssembly data xml generated: " + info.XmlPath); + return true; + } + catch (Exception ex) + { + Log.LogErrorFromException(ex, true, true, null); + return false; + } + } + + private void ValidatePluginRootPath() + { + if (string.IsNullOrWhiteSpace(PluginRootPath)) + throw new ArgumentException("PluginRootPath is empty"); + + if (!Directory.Exists(PluginRootPath)) + throw new DirectoryNotFoundException("PluginRootPath not found: " + PluginRootPath); + } + + private string GetRepositoryRoot() + { + return !string.IsNullOrWhiteSpace(RepositoryRoot) + ? RepositoryRoot + : Directory.GetCurrentDirectory(); + } + + private PluginProjectInfo BuildProjectInfo(string repoRoot, string normalizedGuid) + { + string csprojPath = FindProjectFile(PluginRootPath); + string projectDirectory = GetProjectDirectory(csprojPath); + string csprojFileName = Path.GetFileNameWithoutExtension(csprojPath); + + Tuple meta = ReadProjectMetadata(csprojPath, csprojFileName); + string assemblyName = meta.Item1; + string fileVersion = meta.Item2; + + string dllPath = ResolvePluginDllPath(assemblyName); + string xmlPath = BuildPluginDataXmlPath(repoRoot, assemblyName, normalizedGuid); + + return new PluginProjectInfo + { + RepositoryRoot = repoRoot, + ProjectDirectory = projectDirectory, + CsprojFileName = csprojFileName, + AssemblyName = assemblyName, + FileVersion = fileVersion, + XmlPath = xmlPath, + DllPath = dllPath + }; + } + + private void GeneratePluginAssemblyData(PluginProjectInfo info, string normalizedGuid) + { + if (!File.Exists(info.DllPath)) + throw new FileNotFoundException("Build not found", info.DllPath); + + string tempDllPath = CopyDllToTempFolder(info); + + HashSet probeDirs = BuildProbeDirectories(tempDllPath, info.ProjectDirectory); + ResolveEventHandler handler = CreateAssemblyResolveHandler(probeDirs); + + AppDomain.CurrentDomain.AssemblyResolve += handler; + + try + { + TryAddSdkAssemblyProbe(probeDirs); + + Assembly pluginAssembly = LoadPluginAssembly(tempDllPath, info.AssemblyName, probeDirs); + string publicKeyToken = GetPublicKeyToken(pluginAssembly); + + List classList = GetPluginClassNames(pluginAssembly); + if (!classList.Any()) + throw new Exception("Plugins not found"); + + string xmlDir = EnsureDirectoryForFile(info.XmlPath); + + XmlDocument pluginDoc = CreatePluginAssemblyDocument( + info.AssemblyName, + info.FileVersion, + publicKeyToken, + normalizedGuid, + classList, + info.CsprojFileName, + info.XmlPath, + info.RepositoryRoot + ); + + pluginDoc.Save(info.XmlPath); + + UpsertRootComponentIntoSolutionXml( + info.RepositoryRoot, + normalizedGuid, + info.AssemblyName, + info.FileVersion, + publicKeyToken + ); + } + finally + { + AppDomain.CurrentDomain.AssemblyResolve -= handler; + } + } + + private static string FindProjectFile(string pluginRootPath) + { + string csprojPath = Directory.GetFiles(pluginRootPath, "*.csproj").FirstOrDefault(); + if (csprojPath == null) + throw new Exception("csproj not found"); + + return csprojPath; + } + + private static string GetProjectDirectory(string csprojPath) + { + string projectDirectory = Path.GetDirectoryName(csprojPath); + if (string.IsNullOrEmpty(projectDirectory)) + throw new Exception("ProjectDirectory not resolved"); + + return projectDirectory; + } + + private static string FindExistingDataXml(string repoRoot, string assemblyName) + { + string pluginAssembliesRoot = Path.Combine(repoRoot, "PluginAssemblies"); + + if (!Directory.Exists(pluginAssembliesRoot)) + return null; + + string dataXmlFileName = assemblyName + ".dll.data.xml"; + + var files = Directory.GetFiles(pluginAssembliesRoot, dataXmlFileName, SearchOption.AllDirectories); + + return files.FirstOrDefault(); + } + + private static string BuildPluginDataXmlPath(string repoRoot, string assemblyName, string normalizedGuid) + { + string existingXmlPath = FindExistingDataXml(repoRoot, assemblyName); + + if (existingXmlPath != null) + { + return existingXmlPath; + } + + return Path.Combine( + repoRoot, + "PluginAssemblies", + assemblyName + ".dll.data.xml" + ); + } + + private string FindPluginAssemblyId(string repoRoot, string assemblyName) + { + string existingXmlPath = FindExistingDataXml(repoRoot, assemblyName); + + if (existingXmlPath == null) + return ""; + + var doc = XDocument.Load(existingXmlPath); + var root = doc.Root; + if (root == null) + return ""; + + var idAttr = root.Attribute("PluginAssemblyId"); + return idAttr == null ? "" : idAttr.Value; + } + + private string BuildPluginDllPath(string assemblyName) + { + return Path.Combine( + PluginRootPath, + "bin", + Configuration, + TargetFramework, + PublishFolderName, + assemblyName + ".dll" + ); + } + + private string ResolvePluginDllPath(string assemblyName) + { + if (!string.IsNullOrWhiteSpace(PluginDllPath)) + { + var candidate = PluginDllPath; + if (!Path.IsPathRooted(candidate)) + candidate = Path.Combine(PluginRootPath, candidate); + + return Path.GetFullPath(candidate); + } + + return BuildPluginDllPath(assemblyName); + } + + private HashSet BuildProbeDirectories(string dllPath, string projectDirectory) + { + string dllDir = Path.GetDirectoryName(dllPath); + if (string.IsNullOrEmpty(dllDir)) + throw new Exception("dll directory not resolved"); + + var probeDirs = new HashSet(StringComparer.OrdinalIgnoreCase); + probeDirs.Add(dllDir); + probeDirs.Add(Path.Combine(PluginRootPath, "bin", Configuration, TargetFramework)); + probeDirs.Add(projectDirectory); + + return probeDirs; + } + + private static ResolveEventHandler CreateAssemblyResolveHandler(HashSet probeDirs) + { + return (sender, args) => + { + string name = null; + try + { + var an = new AssemblyName(args.Name); + name = an.Name; + } + catch { /* ignore */ } + + if (string.IsNullOrWhiteSpace(name)) + return null; + + foreach (var dir in probeDirs) + { + var candidate = Path.Combine(dir, name + ".dll"); + if (File.Exists(candidate)) + { + try + { + // Load from byte array to avoid file locking + var bytes = File.ReadAllBytes(candidate); + return Assembly.Load(bytes); + } + catch { /* ignore */ } + } + } + return null; + }; + } + + private void TryAddSdkAssemblyProbe(HashSet probeDirs) + { + string sdkPath = Path.Combine(PluginRootPath, "bin", Configuration, TargetFramework, "Microsoft.Xrm.Sdk.dll"); + + if (!File.Exists(sdkPath)) + return; + + TryLoadAssemblyNoThrow(sdkPath); + + string sdkDir = Path.GetDirectoryName(sdkPath); + + if (!string.IsNullOrEmpty(sdkDir)) + probeDirs.Add(sdkDir); + } + + private static string GetPublicKeyToken(Assembly pluginAssembly) + { + byte[] token = pluginAssembly.GetName().GetPublicKeyToken(); + + if (token == null || token.Length == 0) + throw new Exception("Build not signed"); + + return BitConverter.ToString(token).Replace("-", "").ToLowerInvariant(); + } + + private static List GetPluginClassNames(Assembly pluginAssembly) + { + return GetPluginTypesSafe(pluginAssembly) + .Where(t => t.IsClass && t.IsPublic) + .Where(t => ImplementsInterfaceByName(t, "Microsoft.Xrm.Sdk.IPlugin")) + .Select(t => t.FullName) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .ToList(); + } + + private static string EnsureDirectoryForFile(string filePath) + { + string dir = Path.GetDirectoryName(filePath); + if (string.IsNullOrEmpty(dir)) + throw new Exception("xml directory not resolved"); + + Directory.CreateDirectory(dir); + + return dir; + } + + private static XmlDocument CreatePluginAssemblyDocument( + string assemblyName, + string fileVersion, + string publicKeyToken, + string normalizedGuid, + IEnumerable classList, + string csprojFileName, + string existingXmlPath, + string repoRoot) + { + string pluginBaseName = string.IsNullOrEmpty(csprojFileName) ? "" : csprojFileName + ".PluginBase"; + + if (File.Exists(existingXmlPath)) + { + return UpdateExistingPluginAssemblyDocument( + existingXmlPath, classList, pluginBaseName, + assemblyName, fileVersion, publicKeyToken); + } + + return CreateNewPluginAssemblyDocument( + assemblyName, fileVersion, publicKeyToken, normalizedGuid, + classList, pluginBaseName, existingXmlPath, repoRoot); + } + + private static XmlDocument UpdateExistingPluginAssemblyDocument( + string existingXmlPath, + IEnumerable classList, + string pluginBaseName, + string assemblyName, + string fileVersion, + string publicKeyToken) + { + var pluginDoc = new XmlDocument(); + pluginDoc.Load(existingXmlPath); + + var pluginTypesNode = pluginDoc.SelectSingleNode("//PluginAssembly/PluginTypes") as XmlElement; + if (pluginTypesNode == null) + { + var root = pluginDoc.DocumentElement; + if (root == null) + throw new Exception("Existing XML has no document element"); + + pluginTypesNode = pluginDoc.CreateElement("PluginTypes"); + root.AppendChild(pluginTypesNode); + } + + var existingClassNames = new HashSet(StringComparer.Ordinal); + foreach (XmlNode node in pluginTypesNode.ChildNodes) + { + var el = node as XmlElement; + if (el == null || !string.Equals(el.Name, "PluginType", StringComparison.Ordinal)) + continue; + + string className = GetPluginTypeClassName(el); + if (!string.IsNullOrWhiteSpace(className)) + existingClassNames.Add(className); + } + + var seen = new HashSet(StringComparer.Ordinal); + foreach (var className in classList) + { + if (className == pluginBaseName) + continue; + + if (!seen.Add(className)) + continue; + + if (existingClassNames.Contains(className)) + continue; + + XmlElement pluginType = CreatePluginTypeElement(pluginDoc); + pluginType.SetAttribute("PluginTypeId", Guid.NewGuid().ToString("D")); + pluginType.SetAttribute("Name", className); + pluginType.SetAttribute( + "AssemblyQualifiedName", + BuildAssemblyQualifiedTypeName(className, assemblyName, fileVersion, publicKeyToken) + ); + + var friendlyName = pluginDoc.CreateElement("FriendlyName"); + friendlyName.InnerText = Guid.NewGuid().ToString("D"); + pluginType.AppendChild(friendlyName); + + pluginTypesNode.AppendChild(pluginType); + } + + return pluginDoc; + } + + private static XmlDocument CreateNewPluginAssemblyDocument( + string assemblyName, + string fileVersion, + string publicKeyToken, + string normalizedGuid, + IEnumerable classList, + string pluginBaseName, + string xmlPath, + string repoRoot) + { + var pluginDoc = new XmlDocument(); + var xmlDecl = pluginDoc.CreateXmlDeclaration("1.0", "utf-8", null); + pluginDoc.AppendChild(xmlDecl); + + XmlElement root = pluginDoc.CreateElement("PluginAssembly"); + root.SetAttribute("FullName", BuildAssemblyFullName(assemblyName, fileVersion, publicKeyToken)); + root.SetAttribute("PluginAssemblyId", normalizedGuid); + root.SetAttribute("CustomizationLevel", "1"); + root.SetAttribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"); + pluginDoc.AppendChild(root); + + XmlElement isolationMode = pluginDoc.CreateElement("IsolationMode"); + isolationMode.InnerText = "2"; + root.AppendChild(isolationMode); + + XmlElement sourceType = pluginDoc.CreateElement("SourceType"); + sourceType.InnerText = "0"; + root.AppendChild(sourceType); + + XmlElement fileName = pluginDoc.CreateElement("FileName"); + fileName.InnerText = BuildRelativeDllPath(xmlPath, repoRoot, assemblyName); + root.AppendChild(fileName); + + XmlElement pluginTypes = pluginDoc.CreateElement("PluginTypes"); + root.AppendChild(pluginTypes); + + var seen = new HashSet(StringComparer.Ordinal); + foreach (var className in classList) + { + if (className == pluginBaseName) + continue; + + if (!seen.Add(className)) + continue; + + XmlElement pluginType = CreatePluginTypeElement(pluginDoc); + pluginType.SetAttribute("PluginTypeId", Guid.NewGuid().ToString("D")); + pluginType.SetAttribute("Name", className); + pluginType.SetAttribute( + "AssemblyQualifiedName", + BuildAssemblyQualifiedTypeName(className, assemblyName, fileVersion, publicKeyToken) + ); + + var friendlyName = pluginDoc.CreateElement("FriendlyName"); + friendlyName.InnerText = Guid.NewGuid().ToString("D"); + pluginType.AppendChild(friendlyName); + + pluginTypes.AppendChild(pluginType); + } + + return pluginDoc; + } + + private static void UpsertRootComponentIntoSolutionXml( + string repoRoot, + string normalizedGuid, + string assemblyName, + string fileVersion, + string publicKeyToken) + { + var solutionPath = Path.Combine(repoRoot, "Other", "Solution.xml"); + if (!File.Exists(solutionPath)) + throw new FileNotFoundException("Solution.xml not found", solutionPath); + + var doc = new XmlDocument(); + + doc.Load(solutionPath); + + XmlElement rootComponents = doc.SelectSingleNode("//RootComponents") as XmlElement; + if (rootComponents == null) + { + if (doc.DocumentElement == null) + throw new Exception("Solution.xml has no document element"); + + rootComponents = doc.CreateElement("RootComponents"); + doc.DocumentElement.AppendChild(rootComponents); + } + + var desiredIdBraced = "{" + normalizedGuid + "}"; + + XmlElement existing = null; + foreach (XmlNode n in rootComponents.ChildNodes) + { + var el = n as XmlElement; + if (el == null) continue; + if (!string.Equals(el.Name, "RootComponent", StringComparison.Ordinal)) continue; + + var typeAttr = el.GetAttribute("type"); + if (!string.Equals(typeAttr, "91", StringComparison.Ordinal)) continue; + + var idAttr = el.GetAttribute("id"); + if (IsSameGuidBraced(idAttr, desiredIdBraced)) + { + existing = el; + break; + } + } + + if (existing != null) + { + // Assembly already registered in Solution.xml — do not modify + return; + } + + XmlElement rc = doc.CreateElement("RootComponent"); + rc.SetAttribute("type", "91"); + rc.SetAttribute("id", desiredIdBraced); + rc.SetAttribute("schemaName", BuildAssemblyFullName(assemblyName, fileVersion, publicKeyToken)); + rc.SetAttribute("behavior", "0"); + rootComponents.AppendChild(rc); + + doc.Save(solutionPath); + } + + private static bool IsSameGuidBraced(string a, string b) + { + string na = NormalizeGuidBraces(a); + string nb = NormalizeGuidBraces(b); + + return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeGuidBraces(string s) + { + if (string.IsNullOrWhiteSpace(s)) return ""; + + return s.Trim().Trim('{', '}'); + } + + + private static string BuildRelativeDllPath(string xmlPath, string repoRoot, string assemblyName) + { + string xmlDir = Path.GetDirectoryName(xmlPath); + if (string.IsNullOrEmpty(xmlDir)) + return "/PluginAssemblies/" + assemblyName + ".dll"; + + string pluginAssembliesRoot = Path.Combine(repoRoot, "PluginAssemblies"); + string relativePath; + + if (xmlDir.Equals(pluginAssembliesRoot, StringComparison.OrdinalIgnoreCase)) + { + relativePath = "/PluginAssemblies/" + assemblyName + ".dll"; + } + else + { + string subFolder = xmlDir.Substring(pluginAssembliesRoot.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + relativePath = "/PluginAssemblies/" + subFolder.Replace(Path.DirectorySeparatorChar, '/') + "/" + assemblyName + ".dll"; + } + + return relativePath; + } + + private static string BuildAssemblyFullName(string assemblyName, string fileVersion, string publicKeyToken) + { + return assemblyName + ", Version=" + fileVersion + ", Culture=neutral, PublicKeyToken=" + publicKeyToken; + } + + private static string BuildAssemblyQualifiedTypeName(string className, string assemblyName, string fileVersion, string publicKeyToken) + { + return className + ", " + BuildAssemblyFullName(assemblyName, fileVersion, publicKeyToken); + } + + private static void TryLoadAssemblyNoThrow(string path) + { + try + { + // Load from byte array to avoid file locking + var bytes = File.ReadAllBytes(path); + Assembly.Load(bytes); + } + catch { /* ignore */ } + } + + private Assembly LoadPluginAssembly(string dllPath, string assemblyName, HashSet probeDirs) + { + var alreadyLoaded = FindLoadedAssembly(assemblyName); + if (alreadyLoaded != null) + return alreadyLoaded; + +#if NET6_0_OR_GREATER + try + { + var alc = new AssemblyLoadContext("PluginAssembly-" + Guid.NewGuid().ToString("N"), isCollectible: true); + alc.Resolving += (context, name) => + { + foreach (var dir in probeDirs) + { + var candidate = Path.Combine(dir, name.Name + ".dll"); + if (File.Exists(candidate)) + return context.LoadFromAssemblyPath(candidate); + } + return null; + }; + + var bytes = File.ReadAllBytes(dllPath); + var asm = alc.LoadFromStream(new MemoryStream(bytes)); + return asm; + } + catch (FileLoadException) + { + var loaded = FindLoadedAssembly(assemblyName); + if (loaded != null) + return loaded; + throw; + } +#else + try + { + // Load from byte array to avoid file locking + var bytes = File.ReadAllBytes(dllPath); + return Assembly.Load(bytes); + } + catch (FileLoadException) + { + var loaded = FindLoadedAssembly(assemblyName); + if (loaded != null) + return loaded; + throw; + } +#endif + } + + private static Assembly FindLoadedAssembly(string assemblyName) + { + return AppDomain.CurrentDomain + .GetAssemblies() + .FirstOrDefault(a => + { + var name = a.GetName(); + return name != null && string.Equals(name.Name, assemblyName, StringComparison.OrdinalIgnoreCase); + }); + } + + private static string NormalizeGuid(string guidText) + { + if (string.IsNullOrWhiteSpace(guidText)) + throw new ArgumentException("PluginAssemblyId is empty"); + + var trimmed = guidText.Trim().Trim('{', '}'); + + Guid g; + if (!Guid.TryParse(trimmed, out g)) + throw new ArgumentException("PluginAssemblyId is not a valid GUID: " + guidText); + + return g.ToString("D"); + } + + private static Tuple ReadProjectMetadata(string csprojPath, string fallbackAssemblyName) + { + var xdoc = XDocument.Load(csprojPath); + + string assemblyName = xdoc.Descendants() + .FirstOrDefault(e => e.Name.LocalName == "AssemblyName") + ?.Value; + + string fileVersion = xdoc.Descendants() + .FirstOrDefault(e => e.Name.LocalName == "FileVersion") + ?.Value; + + assemblyName = (assemblyName ?? "").Trim(); + fileVersion = (fileVersion ?? "").Trim(); + + if (string.IsNullOrWhiteSpace(assemblyName)) + assemblyName = fallbackAssemblyName; + + if (string.IsNullOrWhiteSpace(fileVersion)) + fileVersion = "1.0.0.0"; + + return Tuple.Create(assemblyName, fileVersion); + } + + private static IEnumerable GetPluginTypesSafe(Assembly asm) + { + try + { + return asm.GetTypes(); + } + catch (ReflectionTypeLoadException rtle) + { + return rtle.Types.Where(t => t != null).Cast(); + } + } + + private static bool ImplementsInterfaceByName(Type t, string interfaceFullName) + { + try + { + return t.GetInterfaces().Any(i => string.Equals(i.FullName, interfaceFullName, StringComparison.Ordinal)); + } + catch + { + return false; + } + } + + private static Dictionary LoadExistingPluginTypeMap(string xmlPath, XmlDocument targetDoc) + { + var result = new Dictionary(StringComparer.Ordinal); + + if (!File.Exists(xmlPath)) + return result; + + var existingDoc = new XmlDocument(); + existingDoc.Load(xmlPath); + + var pluginTypesNode = existingDoc.SelectSingleNode("//PluginAssembly/PluginTypes") as XmlElement; + if (pluginTypesNode == null) + return result; + + foreach (var node in pluginTypesNode.ChildNodes) + { + var el = node as XmlElement; + if (el == null) + continue; + + if (!string.Equals(el.Name, "PluginType", StringComparison.Ordinal)) + continue; + + string className = GetPluginTypeClassName(el); + if (string.IsNullOrWhiteSpace(className)) + continue; + + if (result.ContainsKey(className)) + continue; + + var imported = (XmlElement)targetDoc.ImportNode(el, true); + result[className] = imported; + } + + return result; + } + + private static string GetPluginTypeClassName(XmlElement pluginTypeElement) + { + string aqn = pluginTypeElement.GetAttribute("AssemblyQualifiedName"); + if (!string.IsNullOrWhiteSpace(aqn)) + { + int commaIndex = aqn.IndexOf(','); + if (commaIndex < 0) + return aqn.Trim(); + return aqn.Substring(0, commaIndex).Trim(); + } + + string nameAttr = pluginTypeElement.GetAttribute("Name"); + if (!string.IsNullOrWhiteSpace(nameAttr)) + return nameAttr.Trim(); + + return ""; + } + + private static XmlElement CreatePluginTypeElement(XmlDocument doc) + { + return doc.CreateElement("PluginType"); + } + + private string CopyDllToTempFolder(PluginProjectInfo info) + { + string tempDir = Path.Combine( + info.RepositoryRoot, + "obj", + Configuration, + TargetFramework, + "Temp" + ); + + Directory.CreateDirectory(tempDir); + + string tempDllPath = Path.Combine(tempDir, info.AssemblyName + ".dll"); + File.Copy(info.DllPath, tempDllPath, true); + + return tempDllPath; + } + + private static void CopyPluginAssembly(PluginProjectInfo info) + { + string destDir = Path.GetDirectoryName(info.XmlPath); + if (string.IsNullOrEmpty(destDir)) + throw new Exception("PluginAssembly data directory not resolved"); + + string destPath = Path.Combine(destDir, info.AssemblyName + ".dll"); + File.Copy(info.DllPath, destPath, true); + } + + private sealed class PluginProjectInfo + { + public string RepositoryRoot { get; set; } = ""; + public string ProjectDirectory { get; set; } = ""; + public string CsprojFileName { get; set; } = ""; + public string AssemblyName { get; set; } = ""; + public string FileVersion { get; set; } = ""; + public string XmlPath { get; set; } = ""; + public string DllPath { get; set; } = ""; + } +} diff --git a/src/Dataverse/Tasks/Tasks/EnsureWebResourceDataXml.cs b/src/Dataverse/Tasks/Tasks/EnsureWebResourceDataXml.cs new file mode 100644 index 0000000..c3c87af --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/EnsureWebResourceDataXml.cs @@ -0,0 +1,81 @@ +using System; +using System.IO; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +public class EnsureWebResourceDataXml : Task +{ + [Required] + public string DataXmlFile { get; set; } + + [Required] + public string WebResourceName { get; set; } + + [Required] + public string DisplayName { get; set; } + public string WebResourceType { get; set; } = "3"; + public string IntroducedVersion { get; set; } = "1.0.0.0"; + + public override bool Execute() + { + try + { + if (File.Exists(DataXmlFile)) + { + return true; + } + + var directory = Path.GetDirectoryName(DataXmlFile); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + var guid = Guid.NewGuid(); + var guidLower = guid.ToString(); + var guidUpper = guid.ToString().ToUpperInvariant(); + + var doc = new XDocument( + new XDeclaration("1.0", "utf-8", null), + new XElement("WebResource", + new XAttribute(XNamespace.Xmlns + "xsi", "http://www.w3.org/2001/XMLSchema-instance"), + new XElement("WebResourceId", $"{{{guidLower}}}"), + new XElement("Name", WebResourceName), + new XElement("DisplayName", DisplayName), + new XElement("WebResourceType", string.IsNullOrWhiteSpace(WebResourceType) ? "3" : WebResourceType), + new XElement("IntroducedVersion", string.IsNullOrWhiteSpace(IntroducedVersion) ? "1.0.0.0" : IntroducedVersion), + new XElement("IsEnabledForMobileClient", "0"), + new XElement("IsAvailableForMobileOffline", "0"), + new XElement("IsCustomizable", "1"), + new XElement("CanBeDeleted", "1"), + new XElement("IsHidden", "0"), + new XElement("FileName", $"/WebResources/{WebResourceName}{guidUpper}") + ) + ); + + var settings = new XmlWriterSettings + { + Encoding = new UTF8Encoding(false), + Indent = true, + NewLineChars = Environment.NewLine, + NewLineHandling = NewLineHandling.Replace + }; + + using (var writer = XmlWriter.Create(DataXmlFile, settings)) + { + doc.Save(writer); + } + + Log.LogMessage(MessageImportance.High, $"Generated webresource data.xml: {DataXmlFile}"); + return true; + } + catch (Exception ex) + { + Log.LogErrorFromException(ex, true); + return false; + } + } +} diff --git a/src/Dataverse/Tasks/Tasks/EnsureWorkflowActivityAssemblyDataXml.cs b/src/Dataverse/Tasks/Tasks/EnsureWorkflowActivityAssemblyDataXml.cs new file mode 100644 index 0000000..d26b398 --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/EnsureWorkflowActivityAssemblyDataXml.cs @@ -0,0 +1,975 @@ +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Xml; +using System.Xml.Linq; +using System.Collections.Generic; +using System.Threading; +#if NET6_0_OR_GREATER +using System.Runtime.Loader; +#endif + +public sealed class EnsureWorkflowActivityAssemblyDataXml : Task +{ + [Required] + public string WorkflowActivityRootPath { get; set; } = ""; + + [Required] + public string WorkflowActivityAssemblyId { get; set; } = ""; + + public string RepositoryRoot { get; set; } = ""; + + public string Configuration { get; set; } = "Debug"; + public string TargetFramework { get; set; } = "net462"; + public string PublishFolderName { get; set; } = "publish"; + public string WorkflowActivityDllPath { get; set; } = ""; + public string DefaultWorkflowActivityGroupName { get; set; } = ""; + + public override bool Execute() + { + try + { + ValidateRootPath(); + string repoRoot = GetRepositoryRoot(); + + string csprojPath = FindProjectFile(WorkflowActivityRootPath); + string csprojFileName = Path.GetFileNameWithoutExtension(csprojPath); + Tuple meta = ReadProjectMetadata(csprojPath, csprojFileName); + string assemblyName = meta.Item1; + + string existingId = FindWorkflowActivityAssemblyId(repoRoot, assemblyName); + string effectiveId = !string.IsNullOrWhiteSpace(existingId) ? existingId : WorkflowActivityAssemblyId; + if (string.IsNullOrWhiteSpace(effectiveId)) + effectiveId = Guid.NewGuid().ToString("D"); + + string normalizedGuid = NormalizeGuid(effectiveId); + WorkflowActivityAssemblyId = normalizedGuid; + + WorkflowActivityProjectInfo info = BuildProjectInfo(repoRoot, normalizedGuid); + + GenerateWorkflowActivityAssemblyData(info, normalizedGuid); + + Log.LogMessage(MessageImportance.High, "WorkflowActivityAssembly data xml generated: " + info.XmlPath); + return true; + } + catch (Exception ex) + { + Log.LogErrorFromException(ex, true, true, null); + return false; + } + } + + private void ValidateRootPath() + { + if (string.IsNullOrWhiteSpace(WorkflowActivityRootPath)) + throw new ArgumentException("WorkflowActivityRootPath is empty"); + + if (!Directory.Exists(WorkflowActivityRootPath)) + throw new DirectoryNotFoundException("WorkflowActivityRootPath not found: " + WorkflowActivityRootPath); + } + + private string GetRepositoryRoot() + { + return !string.IsNullOrWhiteSpace(RepositoryRoot) + ? RepositoryRoot + : Directory.GetCurrentDirectory(); + } + + private WorkflowActivityProjectInfo BuildProjectInfo(string repoRoot, string normalizedGuid) + { + string csprojPath = FindProjectFile(WorkflowActivityRootPath); + string projectDirectory = GetProjectDirectory(csprojPath); + string csprojFileName = Path.GetFileNameWithoutExtension(csprojPath); + + Tuple meta = ReadProjectMetadata(csprojPath, csprojFileName); + string assemblyName = meta.Item1; + string fileVersion = meta.Item2; + + string dllPath = ResolveWorkflowActivityDllPath(assemblyName); + string xmlPath = BuildWorkflowActivityDataXmlPath(repoRoot, assemblyName, normalizedGuid); + + return new WorkflowActivityProjectInfo + { + RepositoryRoot = repoRoot, + ProjectDirectory = projectDirectory, + CsprojFileName = csprojFileName, + AssemblyName = assemblyName, + FileVersion = fileVersion, + XmlPath = xmlPath, + DllPath = dllPath + }; + } + + private void GenerateWorkflowActivityAssemblyData(WorkflowActivityProjectInfo info, string normalizedGuid) + { + if (!File.Exists(info.DllPath)) + throw new FileNotFoundException("Build not found", info.DllPath); + + string tempDllPath = CopyDllToTempFolder(info); + + HashSet probeDirs = BuildProbeDirectories(tempDllPath, info.ProjectDirectory); + ResolveEventHandler handler = CreateAssemblyResolveHandler(probeDirs); + + AppDomain.CurrentDomain.AssemblyResolve += handler; + + try + { + TryAddSdkAssemblyProbe(probeDirs); + + Assembly workflowActivityAssembly = LoadWorkflowActivityAssembly(tempDllPath, info.AssemblyName, probeDirs); + string publicKeyToken = GetPublicKeyToken(workflowActivityAssembly); + + List classList = GetWorkflowActivityClassInfos(workflowActivityAssembly, info.FileVersion); + if (!classList.Any()) + throw new Exception("WorkflowActivities not found in assembly " + info.AssemblyName); + + string xmlDir = EnsureDirectoryForFile(info.XmlPath); + + XmlDocument workflowActivityDoc = CreateWorkflowActivityAssemblyDocument( + info.AssemblyName, + info.FileVersion, + publicKeyToken, + normalizedGuid, + classList, + info.CsprojFileName, + info.XmlPath, + info.RepositoryRoot + ); + + workflowActivityDoc.Save(info.XmlPath); + + UpsertRootComponentIntoSolutionXml( + info.RepositoryRoot, + normalizedGuid, + info.AssemblyName, + info.FileVersion, + publicKeyToken + ); + } + finally + { + AppDomain.CurrentDomain.AssemblyResolve -= handler; + } + } + + private static string FindProjectFile(string rootPath) + { + string csprojPath = Directory.GetFiles(rootPath, "*.csproj").FirstOrDefault(); + if (csprojPath == null) + throw new Exception("csproj not found"); + + return csprojPath; + } + + private static string GetProjectDirectory(string csprojPath) + { + string projectDirectory = Path.GetDirectoryName(csprojPath); + if (string.IsNullOrEmpty(projectDirectory)) + throw new Exception("ProjectDirectory not resolved"); + + return projectDirectory; + } + + private static string FindExistingDataXml(string repoRoot, string assemblyName) + { + string pluginAssembliesRoot = Path.Combine(repoRoot, "PluginAssemblies"); + + if (!Directory.Exists(pluginAssembliesRoot)) + return null; + + string dataXmlFileName = assemblyName + ".dll.data.xml"; + + var files = Directory.GetFiles(pluginAssembliesRoot, dataXmlFileName, SearchOption.AllDirectories); + + return files.FirstOrDefault(); + } + + private static string BuildWorkflowActivityDataXmlPath(string repoRoot, string assemblyName, string normalizedGuid) + { + string existingXmlPath = FindExistingDataXml(repoRoot, assemblyName); + + if (existingXmlPath != null) + { + return existingXmlPath; + } + + return Path.Combine( + repoRoot, + "PluginAssemblies", + assemblyName + ".dll.data.xml" + ); + } + + private string FindWorkflowActivityAssemblyId(string repoRoot, string assemblyName) + { + string existingXmlPath = FindExistingDataXml(repoRoot, assemblyName); + + if (existingXmlPath == null) + return ""; + + var doc = XDocument.Load(existingXmlPath); + var root = doc.Root; + if (root == null) + return ""; + + var idAttr = root.Attribute("PluginAssemblyId"); + return idAttr == null ? "" : idAttr.Value; + } + + private string BuildWorkflowActivityDllPath(string assemblyName) + { + return Path.Combine( + WorkflowActivityRootPath, + "bin", + Configuration, + TargetFramework, + PublishFolderName, + assemblyName + ".dll" + ); + } + + private string ResolveWorkflowActivityDllPath(string assemblyName) + { + if (!string.IsNullOrWhiteSpace(WorkflowActivityDllPath)) + { + var candidate = WorkflowActivityDllPath; + if (!Path.IsPathRooted(candidate)) + candidate = Path.Combine(WorkflowActivityRootPath, candidate); + + return Path.GetFullPath(candidate); + } + + return BuildWorkflowActivityDllPath(assemblyName); + } + + private HashSet BuildProbeDirectories(string dllPath, string projectDirectory) + { + string dllDir = Path.GetDirectoryName(dllPath); + if (string.IsNullOrEmpty(dllDir)) + throw new Exception("dll directory not resolved"); + + var probeDirs = new HashSet(StringComparer.OrdinalIgnoreCase); + probeDirs.Add(dllDir); + probeDirs.Add(Path.Combine(WorkflowActivityRootPath, "bin", Configuration, TargetFramework)); + probeDirs.Add(projectDirectory); + + return probeDirs; + } + + private static ResolveEventHandler CreateAssemblyResolveHandler(HashSet probeDirs) + { + return (sender, args) => + { + string name = null; + try + { + var an = new AssemblyName(args.Name); + name = an.Name; + } + catch { /* ignore */ } + + if (string.IsNullOrWhiteSpace(name)) + return null; + + foreach (var dir in probeDirs) + { + var candidate = Path.Combine(dir, name + ".dll"); + if (File.Exists(candidate)) + { + try + { + var bytes = File.ReadAllBytes(candidate); + return Assembly.Load(bytes); + } + catch { /* ignore */ } + } + } + return null; + }; + } + + private void TryAddSdkAssemblyProbe(HashSet probeDirs) + { + string sdkPath = Path.Combine(WorkflowActivityRootPath, "bin", Configuration, TargetFramework, "Microsoft.Xrm.Sdk.dll"); + + if (!File.Exists(sdkPath)) + return; + + TryLoadAssemblyNoThrow(sdkPath); + + string sdkDir = Path.GetDirectoryName(sdkPath); + + if (!string.IsNullOrEmpty(sdkDir)) + probeDirs.Add(sdkDir); + + // Add .NET Framework reference assemblies for System.Activities + TryAddFrameworkAssemblyProbe(probeDirs); + } + + private void TryAddFrameworkAssemblyProbe(HashSet probeDirs) + { + // Try to find System.Activities in standard .NET Framework locations + // Order matters - try GAC first, then reference assemblies + string[] possiblePaths = new[] + { + @"C:\Windows\Microsoft.NET\Framework64\v4.0.30319", + @"C:\Windows\Microsoft.NET\Framework\v4.0.30319", + @"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.6.2", + @"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8", + @"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.7.2", + }; + + foreach (var path in possiblePaths) + { + if (Directory.Exists(path)) + { + probeDirs.Add(path); + // Force load System.Activities before loading the workflow assembly + string systemActivitiesPath = Path.Combine(path, "System.Activities.dll"); + if (File.Exists(systemActivitiesPath)) + { + ForceLoadAssembly(systemActivitiesPath); + break; // Only load from first found location + } + } + } + } + + private void ForceLoadAssembly(string path) + { + try + { + // First check if already loaded + var name = AssemblyName.GetAssemblyName(path); + var existing = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => string.Equals(a.GetName().Name, name.Name, StringComparison.OrdinalIgnoreCase)); + + if (existing != null) + return; + + // Load from GAC/framework path - this ensures proper binding + Assembly.LoadFrom(path); + } + catch (Exception ex) + { + Log.LogMessage(MessageImportance.Low, "Failed to load " + path + ": " + ex.Message); + } + } + + private static string GetPublicKeyToken(Assembly assembly) + { + byte[] token = assembly.GetName().GetPublicKeyToken(); + + if (token == null || token.Length == 0) + throw new Exception("Build not signed"); + + return BitConverter.ToString(token).Replace("-", "").ToLowerInvariant(); + } + + private List GetWorkflowActivityClassInfos(Assembly assembly, string fileVersion) + { + var result = new List(); + + foreach (var type in GetTypesSafe(assembly)) + { + if (!type.IsClass || !type.IsPublic || type.IsAbstract) + continue; + + if (!InheritsFromByName(type, "System.Activities.CodeActivity")) + continue; + + string fullName = type.FullName; + if (string.IsNullOrWhiteSpace(fullName)) + continue; + + string groupName = GetWorkflowActivityGroupName(type, fileVersion); + string displayName = GetWorkflowActivityDisplayName(type); + + result.Add(new WorkflowActivityTypeInfo + { + FullName = fullName, + GroupName = groupName, + DisplayName = displayName + }); + } + + return result; + } + + private string GetWorkflowActivityGroupName(Type type, string fileVersion) + { + // Try to get from CrmPluginRegistrationAttribute (Group parameter) + foreach (var attr in type.GetCustomAttributesData()) + { + if (attr.AttributeType.Name == "CrmPluginRegistrationAttribute") + { + // Look for Group named argument + foreach (var namedArg in attr.NamedArguments) + { + if (namedArg.MemberName == "Group" && namedArg.TypedValue.Value is string groupValue) + { + if (!string.IsNullOrWhiteSpace(groupValue)) + return groupValue + " (" + fileVersion + ")"; + } + } + } + } + + // Fallback to DefaultWorkflowActivityGroupName or assembly name + string baseName = !string.IsNullOrWhiteSpace(DefaultWorkflowActivityGroupName) + ? DefaultWorkflowActivityGroupName + : type.Assembly.GetName().Name; + + return baseName + " (" + fileVersion + ")"; + } + + private static string GetWorkflowActivityDisplayName(Type type) + { + // Try to get from CrmPluginRegistrationAttribute (Name parameter) + foreach (var attr in type.GetCustomAttributesData()) + { + if (attr.AttributeType.Name == "CrmPluginRegistrationAttribute") + { + // First constructor argument is usually the Name + if (attr.ConstructorArguments.Count > 0) + { + var nameValue = attr.ConstructorArguments[0].Value as string; + if (!string.IsNullOrWhiteSpace(nameValue)) + return nameValue; + } + } + } + + // Fallback to class name + return type.Name; + } + + private static bool InheritsFromByName(Type t, string baseClassName) + { + try + { + Type current = t.BaseType; + while (current != null) + { + // Check by FullName + if (string.Equals(current.FullName, baseClassName, StringComparison.Ordinal)) + return true; + + // Also check by Name only (in case namespace differs) + string simpleClassName = baseClassName; + int lastDot = baseClassName.LastIndexOf('.'); + if (lastDot >= 0) + simpleClassName = baseClassName.Substring(lastDot + 1); + + if (string.Equals(current.Name, simpleClassName, StringComparison.Ordinal)) + return true; + + current = current.BaseType; + } + } + catch + { + // If we can't inspect the type hierarchy, return false + } + return false; + } + + private static string EnsureDirectoryForFile(string filePath) + { + string dir = Path.GetDirectoryName(filePath); + if (string.IsNullOrEmpty(dir)) + throw new Exception("xml directory not resolved"); + + Directory.CreateDirectory(dir); + + return dir; + } + + private static XmlDocument CreateWorkflowActivityAssemblyDocument( + string assemblyName, + string fileVersion, + string publicKeyToken, + string normalizedGuid, + IEnumerable classList, + string csprojFileName, + string existingXmlPath, + string repoRoot) + { + if (File.Exists(existingXmlPath)) + { + return UpdateExistingWorkflowActivityDocument( + existingXmlPath, classList, + assemblyName, fileVersion, publicKeyToken); + } + + return CreateNewWorkflowActivityDocument( + assemblyName, fileVersion, publicKeyToken, normalizedGuid, + classList, existingXmlPath, repoRoot); + } + + private static XmlDocument UpdateExistingWorkflowActivityDocument( + string existingXmlPath, + IEnumerable classList, + string assemblyName, + string fileVersion, + string publicKeyToken) + { + var doc = new XmlDocument(); + doc.Load(existingXmlPath); + + var pluginTypesNode = doc.SelectSingleNode("//PluginAssembly/PluginTypes") as XmlElement; + if (pluginTypesNode == null) + { + var root = doc.DocumentElement; + if (root == null) + throw new Exception("Existing XML has no document element"); + + pluginTypesNode = doc.CreateElement("PluginTypes"); + root.AppendChild(pluginTypesNode); + } + + var existingClassNames = new HashSet(StringComparer.Ordinal); + foreach (XmlNode node in pluginTypesNode.ChildNodes) + { + var el = node as XmlElement; + if (el == null || !string.Equals(el.Name, "PluginType", StringComparison.Ordinal)) + continue; + + string className = GetPluginTypeClassName(el); + if (!string.IsNullOrWhiteSpace(className)) + existingClassNames.Add(className); + } + + var seen = new HashSet(StringComparer.Ordinal); + foreach (var classInfo in classList) + { + string className = classInfo.FullName; + + if (!seen.Add(className)) + continue; + + if (existingClassNames.Contains(className)) + continue; + + XmlElement pluginType = CreatePluginTypeElement(doc); + pluginType.SetAttribute("PluginTypeId", Guid.NewGuid().ToString("D")); + pluginType.SetAttribute("Name", classInfo.DisplayName); + pluginType.SetAttribute( + "AssemblyQualifiedName", + BuildAssemblyQualifiedTypeName(className, assemblyName, fileVersion, publicKeyToken) + ); + + var friendlyName = doc.CreateElement("FriendlyName"); + friendlyName.InnerText = Guid.NewGuid().ToString("D"); + pluginType.AppendChild(friendlyName); + + var workflowGroupName = doc.CreateElement("WorkflowActivityGroupName"); + workflowGroupName.InnerText = classInfo.GroupName; + pluginType.AppendChild(workflowGroupName); + + pluginTypesNode.AppendChild(pluginType); + } + + return doc; + } + + private static XmlDocument CreateNewWorkflowActivityDocument( + string assemblyName, + string fileVersion, + string publicKeyToken, + string normalizedGuid, + IEnumerable classList, + string xmlPath, + string repoRoot) + { + var doc = new XmlDocument(); + var xmlDecl = doc.CreateXmlDeclaration("1.0", "utf-8", null); + doc.AppendChild(xmlDecl); + + XmlElement root = doc.CreateElement("PluginAssembly"); + root.SetAttribute("FullName", BuildAssemblyFullName(assemblyName, fileVersion, publicKeyToken)); + root.SetAttribute("PluginAssemblyId", normalizedGuid); + root.SetAttribute("CustomizationLevel", "1"); + root.SetAttribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"); + doc.AppendChild(root); + + XmlElement isolationMode = doc.CreateElement("IsolationMode"); + isolationMode.InnerText = "2"; + root.AppendChild(isolationMode); + + XmlElement sourceType = doc.CreateElement("SourceType"); + sourceType.InnerText = "0"; + root.AppendChild(sourceType); + + XmlElement introducedVersion = doc.CreateElement("IntroducedVersion"); + introducedVersion.InnerText = "1.0"; + root.AppendChild(introducedVersion); + + XmlElement fileName = doc.CreateElement("FileName"); + fileName.InnerText = BuildRelativeDllPath(xmlPath, repoRoot, assemblyName); + root.AppendChild(fileName); + + XmlElement pluginTypes = doc.CreateElement("PluginTypes"); + root.AppendChild(pluginTypes); + + var seen = new HashSet(StringComparer.Ordinal); + foreach (var classInfo in classList) + { + string className = classInfo.FullName; + + if (!seen.Add(className)) + continue; + + XmlElement pluginType = CreatePluginTypeElement(doc); + pluginType.SetAttribute("PluginTypeId", Guid.NewGuid().ToString("D")); + pluginType.SetAttribute("Name", classInfo.DisplayName); + pluginType.SetAttribute( + "AssemblyQualifiedName", + BuildAssemblyQualifiedTypeName(className, assemblyName, fileVersion, publicKeyToken) + ); + + var friendlyName = doc.CreateElement("FriendlyName"); + friendlyName.InnerText = Guid.NewGuid().ToString("D"); + pluginType.AppendChild(friendlyName); + + var workflowGroupName = doc.CreateElement("WorkflowActivityGroupName"); + workflowGroupName.InnerText = classInfo.GroupName; + pluginType.AppendChild(workflowGroupName); + + pluginTypes.AppendChild(pluginType); + } + + return doc; + } + + private static void UpsertRootComponentIntoSolutionXml( + string repoRoot, + string normalizedGuid, + string assemblyName, + string fileVersion, + string publicKeyToken) + { + var solutionPath = Path.Combine(repoRoot, "Other", "Solution.xml"); + if (!File.Exists(solutionPath)) + throw new FileNotFoundException("Solution.xml not found", solutionPath); + + var doc = new XmlDocument(); + + doc.Load(solutionPath); + + XmlElement rootComponents = doc.SelectSingleNode("//RootComponents") as XmlElement; + if (rootComponents == null) + { + if (doc.DocumentElement == null) + throw new Exception("Solution.xml has no document element"); + + rootComponents = doc.CreateElement("RootComponents"); + doc.DocumentElement.AppendChild(rootComponents); + } + + var desiredIdBraced = "{" + normalizedGuid + "}"; + + XmlElement existing = null; + foreach (XmlNode n in rootComponents.ChildNodes) + { + var el = n as XmlElement; + if (el == null) continue; + if (!string.Equals(el.Name, "RootComponent", StringComparison.Ordinal)) continue; + + var typeAttr = el.GetAttribute("type"); + if (!string.Equals(typeAttr, "91", StringComparison.Ordinal)) continue; + + var idAttr = el.GetAttribute("id"); + if (IsSameGuidBraced(idAttr, desiredIdBraced)) + { + existing = el; + break; + } + } + + if (existing != null) + { + // Assembly already registered in Solution.xml — do not modify + return; + } + + XmlElement rc = doc.CreateElement("RootComponent"); + rc.SetAttribute("type", "91"); + rc.SetAttribute("id", desiredIdBraced); + rc.SetAttribute("schemaName", BuildAssemblyFullName(assemblyName, fileVersion, publicKeyToken)); + rc.SetAttribute("behavior", "0"); + rootComponents.AppendChild(rc); + + doc.Save(solutionPath); + } + + private static bool IsSameGuidBraced(string a, string b) + { + string na = NormalizeGuidBraces(a); + string nb = NormalizeGuidBraces(b); + + return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeGuidBraces(string s) + { + if (string.IsNullOrWhiteSpace(s)) return ""; + + return s.Trim().Trim('{', '}'); + } + + private static string BuildRelativeDllPath(string xmlPath, string repoRoot, string assemblyName) + { + string xmlDir = Path.GetDirectoryName(xmlPath); + if (string.IsNullOrEmpty(xmlDir)) + return "/PluginAssemblies/" + assemblyName + ".dll"; + + string pluginAssembliesRoot = Path.Combine(repoRoot, "PluginAssemblies"); + string relativePath; + + if (xmlDir.Equals(pluginAssembliesRoot, StringComparison.OrdinalIgnoreCase)) + { + relativePath = "/PluginAssemblies/" + assemblyName + ".dll"; + } + else + { + string subFolder = xmlDir.Substring(pluginAssembliesRoot.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + relativePath = "/PluginAssemblies/" + subFolder.Replace(Path.DirectorySeparatorChar, '/') + "/" + assemblyName + ".dll"; + } + + return relativePath; + } + + private static string BuildAssemblyFullName(string assemblyName, string fileVersion, string publicKeyToken) + { + return assemblyName + ", Version=" + fileVersion + ", Culture=neutral, PublicKeyToken=" + publicKeyToken; + } + + private static string BuildAssemblyQualifiedTypeName(string className, string assemblyName, string fileVersion, string publicKeyToken) + { + return className + ", " + BuildAssemblyFullName(assemblyName, fileVersion, publicKeyToken); + } + + private static void TryLoadAssemblyNoThrow(string path) + { + try + { + var bytes = File.ReadAllBytes(path); + Assembly.Load(bytes); + } + catch { /* ignore */ } + } + + private Assembly LoadWorkflowActivityAssembly(string dllPath, string assemblyName, HashSet probeDirs) + { + // Always load fresh copy - don't reuse cached assemblies that may have been loaded + // without proper dependencies (causes intermittent ReflectionTypeLoadException) + +#if NET6_0_OR_GREATER + try + { + var alc = new AssemblyLoadContext("WorkflowActivityAssembly-" + Guid.NewGuid().ToString("N"), isCollectible: true); + alc.Resolving += (context, name) => + { + foreach (var dir in probeDirs) + { + var candidate = Path.Combine(dir, name.Name + ".dll"); + if (File.Exists(candidate)) + return context.LoadFromAssemblyPath(candidate); + } + return null; + }; + + var bytes = File.ReadAllBytes(dllPath); + var asm = alc.LoadFromStream(new MemoryStream(bytes)); + return asm; + } + catch (FileLoadException) + { + // Fallback to already loaded if fresh load fails + var loaded = FindLoadedAssembly(assemblyName); + if (loaded != null) + return loaded; + throw; + } +#else + try + { + // For .NET Framework, use Assembly.LoadFile to load into a separate context + // This avoids reusing cached assemblies that may be incomplete + return Assembly.LoadFile(dllPath); + } + catch (FileLoadException) + { + // Fallback to byte array loading + try + { + var bytes = File.ReadAllBytes(dllPath); + return Assembly.Load(bytes); + } + catch + { + var loaded = FindLoadedAssembly(assemblyName); + if (loaded != null) + return loaded; + throw; + } + } +#endif + } + + private static Assembly FindLoadedAssembly(string assemblyName) + { + return AppDomain.CurrentDomain + .GetAssemblies() + .FirstOrDefault(a => + { + var name = a.GetName(); + return name != null && string.Equals(name.Name, assemblyName, StringComparison.OrdinalIgnoreCase); + }); + } + + private static string NormalizeGuid(string guidText) + { + if (string.IsNullOrWhiteSpace(guidText)) + throw new ArgumentException("WorkflowActivityAssemblyId is empty"); + + var trimmed = guidText.Trim().Trim('{', '}'); + + Guid g; + if (!Guid.TryParse(trimmed, out g)) + throw new ArgumentException("WorkflowActivityAssemblyId is not a valid GUID: " + guidText); + + return g.ToString("D"); + } + + private static Tuple ReadProjectMetadata(string csprojPath, string fallbackAssemblyName) + { + var xdoc = XDocument.Load(csprojPath); + + string assemblyName = xdoc.Descendants() + .FirstOrDefault(e => e.Name.LocalName == "AssemblyName") + ?.Value; + + string fileVersion = xdoc.Descendants() + .FirstOrDefault(e => e.Name.LocalName == "FileVersion") + ?.Value; + + assemblyName = (assemblyName ?? "").Trim(); + fileVersion = (fileVersion ?? "").Trim(); + + if (string.IsNullOrWhiteSpace(assemblyName)) + assemblyName = fallbackAssemblyName; + + if (string.IsNullOrWhiteSpace(fileVersion)) + fileVersion = "1.0.0.0"; + + return Tuple.Create(assemblyName, fileVersion); + } + + private static IEnumerable GetTypesSafe(Assembly asm) + { + try + { + return asm.GetTypes(); + } + catch (ReflectionTypeLoadException rtle) + { + return rtle.Types.Where(t => t != null).Cast(); + } + } + + private static Dictionary LoadExistingPluginTypeMap(string xmlPath, XmlDocument targetDoc) + { + var result = new Dictionary(StringComparer.Ordinal); + + if (!File.Exists(xmlPath)) + return result; + + var existingDoc = new XmlDocument(); + existingDoc.Load(xmlPath); + + var pluginTypesNode = existingDoc.SelectSingleNode("//PluginAssembly/PluginTypes") as XmlElement; + if (pluginTypesNode == null) + return result; + + foreach (var node in pluginTypesNode.ChildNodes) + { + var el = node as XmlElement; + if (el == null) + continue; + + if (!string.Equals(el.Name, "PluginType", StringComparison.Ordinal)) + continue; + + string className = GetPluginTypeClassName(el); + if (string.IsNullOrWhiteSpace(className)) + continue; + + if (result.ContainsKey(className)) + continue; + + var imported = (XmlElement)targetDoc.ImportNode(el, true); + result[className] = imported; + } + + return result; + } + + private static string GetPluginTypeClassName(XmlElement pluginTypeElement) + { + string aqn = pluginTypeElement.GetAttribute("AssemblyQualifiedName"); + if (string.IsNullOrWhiteSpace(aqn)) + return ""; + + int commaIndex = aqn.IndexOf(','); + if (commaIndex < 0) + return aqn.Trim(); + + return aqn.Substring(0, commaIndex).Trim(); + } + + private static XmlElement CreatePluginTypeElement(XmlDocument doc) + { + return doc.CreateElement("PluginType"); + } + + private string CopyDllToTempFolder(WorkflowActivityProjectInfo info) + { + string tempDir = Path.Combine( + info.RepositoryRoot, + "obj", + Configuration, + TargetFramework, + "Temp" + ); + + Directory.CreateDirectory(tempDir); + + string tempDllPath = Path.Combine(tempDir, info.AssemblyName + ".dll"); + File.Copy(info.DllPath, tempDllPath, true); + + return tempDllPath; + } + + private sealed class WorkflowActivityProjectInfo + { + public string RepositoryRoot { get; set; } = ""; + public string ProjectDirectory { get; set; } = ""; + public string CsprojFileName { get; set; } = ""; + public string AssemblyName { get; set; } = ""; + public string FileVersion { get; set; } = ""; + public string XmlPath { get; set; } = ""; + public string DllPath { get; set; } = ""; + } + + private sealed class WorkflowActivityTypeInfo + { + public string FullName { get; set; } = ""; + public string GroupName { get; set; } = ""; + public string DisplayName { get; set; } = ""; + } +} diff --git a/src/Dataverse/Tasks/Tasks/GenerateGitVersion.cs b/src/Dataverse/Tasks/Tasks/GenerateGitVersion.cs index fe79fe0..eee0929 100644 --- a/src/Dataverse/Tasks/Tasks/GenerateGitVersion.cs +++ b/src/Dataverse/Tasks/Tasks/GenerateGitVersion.cs @@ -39,77 +39,94 @@ public override bool Execute() LocalBranchBuildVersionNumber = "0.0.0.1"; } - // Prepare for running git commands - var gitInfo = CreateGitProcessInfo(ProjectPath); - - var currentBranch = GetCurrentBranch(gitInfo); - _branches = ApplyToBranches.Split(';').Select(BranchVersioning.Parse); - if (_branches == null || !_branches.Any()) + // Ensure repository is connected to Git before running commands + if (!TryFindGitRoot(ProjectPath, out var gitRoot)) { - Log.LogWarning($"No valid branches found in ApplyToBranches '{ApplyToBranches}'."); + Log.LogMessage(MessageImportance.High, "Git repository not found; skipping automatic Git versioning."); VersionOutput = LocalBranchBuildVersionNumber; return true; } - var branch = _branches.FirstOrDefault(b => - string.Equals(b.BranchName, currentBranch, StringComparison.OrdinalIgnoreCase) || - // Basic wildcard support, e.g. feature/* - (b.BranchName.EndsWith("*") && currentBranch.StartsWith(b.BranchName.TrimEnd('*'), StringComparison.OrdinalIgnoreCase)) - ); - if (branch == null) - { - Log.LogWarning($"The current branch '{currentBranch}' is not enabled for automatic Git versioning."); - VersionOutput = LocalBranchBuildVersionNumber; - return true; - } - else - { - Log.LogMessage($"The current branch '{currentBranch}' is enabled for automatic Git versioning."); - var projects = new List + // Prepare for running git commands + var gitInfo = CreateGitProcessInfo(gitRoot); + + try + { + var currentBranch = GetCurrentBranch(gitInfo); + _branches = ApplyToBranches.Split(';').Select(BranchVersioning.Parse); + if (_branches == null || !_branches.Any()) + { + Log.LogWarning($"No valid branches found in ApplyToBranches '{ApplyToBranches}'."); + VersionOutput = LocalBranchBuildVersionNumber; + return true; + } + var branch = _branches.FirstOrDefault(b => + string.Equals(b.BranchName, currentBranch, StringComparison.OrdinalIgnoreCase) || + // Basic wildcard support, e.g. feature/* + (b.BranchName.EndsWith("*") && currentBranch.StartsWith(b.BranchName.TrimEnd('*'), StringComparison.OrdinalIgnoreCase)) + ); + if (branch == null) { - ProjectPath - }; - RetrieveAllProjectReferences(ProjectPath, projects); - Log.LogMessage(MessageImportance.High, $"Got number of projects: {projects.Count}"); + Log.LogWarning($"The current branch '{currentBranch}' is not enabled for automatic Git versioning."); + VersionOutput = LocalBranchBuildVersionNumber; + return true; + } + else + { + Log.LogMessage($"The current branch '{currentBranch}' is enabled for automatic Git versioning."); + + var projects = new List + { + ProjectPath + }; + RetrieveAllProjectReferences(ProjectPath, projects); + Log.LogMessage(MessageImportance.High, $"Got number of projects: {projects.Count}"); - var totalComitCount = 0; - var latestCommitDate = new DateTime(1900, 1, 1); + var totalComitCount = 0; + var latestCommitDate = new DateTime(1900, 1, 1); - foreach (var project in projects) - { - Log.LogMessage(MessageImportance.High, $"Project: {project}"); - var (commitCount, lastCommitDate) = GetNumberOfCommits(project); - Log.LogMessage(MessageImportance.High, $"{project}: Last commit: {lastCommitDate:yyyy-MM-dd}, count: {commitCount}"); - if (latestCommitDate < lastCommitDate) + foreach (var project in projects) { - totalComitCount = commitCount; - latestCommitDate = lastCommitDate; + Log.LogMessage(MessageImportance.High, $"Project: {project}"); + var (commitCount, lastCommitDate) = GetNumberOfCommits(project); + Log.LogMessage(MessageImportance.High, $"{project}: Last commit: {lastCommitDate:yyyy-MM-dd}, count: {commitCount}"); + if (latestCommitDate < lastCommitDate) + { + totalComitCount = commitCount; + latestCommitDate = lastCommitDate; + } + else if (latestCommitDate == lastCommitDate) + { + totalComitCount += commitCount; + } } - else if (latestCommitDate == lastCommitDate) + Log.LogMessage(MessageImportance.High, $"Commit count for the month: {totalComitCount}"); + if (totalComitCount > 999) { - totalComitCount += commitCount; + throw new Exception($"Too many commits ({totalComitCount} > 999), cannot generate version number. Please reach out to the author."); } - } - Log.LogMessage(MessageImportance.High, $"Commit count for the month: {totalComitCount}"); - if (totalComitCount > 999) - { - throw new Exception($"Too many commits ({totalComitCount} > 999), cannot generate version number. Please reach out to the author."); - } - // Convert the latest commit date to build number - // DateTime lastCommitDateTime = DateTime.ParseExact(latestCommitDate, "yyyy-MM-dd HH:mm:ss K", CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal); - var build = ushort.Parse(latestCommitDate.ToString("yyMM")); - if (branch.Prefix.HasValue) - { - build = ushort.Parse($"{branch.Prefix}{latestCommitDate:yyMM}"); - } + // Convert the latest commit date to build number + // DateTime lastCommitDateTime = DateTime.ParseExact(latestCommitDate, "yyyy-MM-dd HH:mm:ss K", CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal); + var build = ushort.Parse(latestCommitDate.ToString("yyMM")); + if (branch.Prefix.HasValue) + { + build = ushort.Parse($"{branch.Prefix}{latestCommitDate:yyMM}"); + } - // Get the revision number as the last commit day and commit count for the month (reduce risk of deploying lower version after refactoring) - var revision = ushort.Parse($"{latestCommitDate:dd}{totalComitCount:000}"); + // Get the revision number as the last commit day and commit count for the month (reduce risk of deploying lower version after refactoring) + var revision = ushort.Parse($"{latestCommitDate:dd}{totalComitCount:000}"); - // Combine the version parts into final version number - VersionOutput = $"{VersionMajor}.{VersionMinor}.{build}.{revision}"; + // Combine the version parts into final version number + VersionOutput = $"{VersionMajor}.{VersionMinor}.{build}.{revision}"; + return true; + } + } + catch (Exception ex) + { + Log.LogMessage(MessageImportance.High, $"Git versioning skipped: {ex.Message}"); + VersionOutput = LocalBranchBuildVersionNumber; return true; } } @@ -214,6 +231,21 @@ private string ExecuteGitCommand(ProcessStartInfo gitInfo, string command) } } } + private bool TryFindGitRoot(string path, out string gitRoot) + { + var directory = new DirectoryInfo(path); + while (directory != null) + { + if (Directory.Exists(Path.Combine(directory.FullName, ".git"))) + { + gitRoot = directory.FullName; + return true; + } + directory = directory.Parent; + } + gitRoot = null; + return false; + } private void RetrieveAllProjectReferences(string projectPath, List projects) { var projectFile = ""; diff --git a/src/Dataverse/Tasks/Tasks/MergeCmtDataSchemaXml.cs b/src/Dataverse/Tasks/Tasks/MergeCmtDataSchemaXml.cs new file mode 100644 index 0000000..746674f --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/MergeCmtDataSchemaXml.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +public class MergeCmtDataSchemaXml : Task +{ + [Required] + public ITaskItem[] DataSchemaFiles { get; set; } = Array.Empty(); + + public string CmtPackageName { get; set; } = ""; + + public string ProjectDirectory { get; set; } = ""; + + public string OutputDirectory { get; set; } = ""; + + [Output] + public string OutputDataSchemaXml { get; private set; } = ""; + + public override bool Execute() + { + try + { + var files = NormalizeFiles(DataSchemaFiles); + if (files.Count == 0) + { + Log.LogError("No data_schema.xml files were provided."); + return false; + } + + var missing = files.Where(f => !File.Exists(f)).ToList(); + if (missing.Any()) + { + foreach (var path in missing) + { + Log.LogError($"data_schema.xml not found: {path}"); + } + return false; + } + + var packageName = GetPackageName(); + var baseDir = ResolveOutputDirectory(packageName); + Directory.CreateDirectory(baseDir); + + OutputDataSchemaXml = Path.Combine(baseDir, "data_schema.xml"); + + MergeFiles(files, OutputDataSchemaXml); + + Log.LogMessage(MessageImportance.High, + $"Merged {files.Count} data_schema.xml file(s) into {OutputDataSchemaXml}"); + + return !Log.HasLoggedErrors; + } + catch (Exception ex) + { + Log.LogErrorFromException(ex, true, true, null); + return false; + } + } + + private List NormalizeFiles(ITaskItem[] items) + { + return (items ?? Array.Empty()) + .Select(i => i?.ItemSpec) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(Path.GetFullPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private string GetPackageName() + { + var name = string.IsNullOrWhiteSpace(CmtPackageName) + ? "MainCmtPackage" + : CmtPackageName.Trim(); + + var invalid = Path.GetInvalidFileNameChars(); + var sanitized = new string(name.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray()).Trim(); + + return string.IsNullOrWhiteSpace(sanitized) ? "MainCmtPackage" : sanitized; + } + + private string ResolveOutputDirectory(string packageName) + { + if (!string.IsNullOrWhiteSpace(OutputDirectory)) + return Path.GetFullPath(OutputDirectory); + + var root = string.IsNullOrWhiteSpace(ProjectDirectory) + ? Directory.GetCurrentDirectory() + : ProjectDirectory; + + return Path.GetFullPath(Path.Combine(root, "obj", "metadata", packageName)); + } + + private void MergeFiles(IReadOnlyCollection files, string outputPath) + { + XDocument outputDoc = null; + XElement outputRoot = null; + var entities = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var file in files) + { + var doc = XDocument.Load(file, LoadOptions.PreserveWhitespace | LoadOptions.SetBaseUri | LoadOptions.SetLineInfo); + var root = doc.Root ?? throw new InvalidDataException($"Root element is missing in {file}"); + + if (outputDoc == null) + { + outputDoc = CreateOutputDocument(root); + outputRoot = outputDoc.Root ?? throw new InvalidDataException("Failed to initialize merged document root."); + } + else + { + AddMissingAttributes(outputRoot, root); + } + + foreach (var entity in root.Elements("entity")) + { + var entityName = entity.Attribute("name")?.Value?.Trim(); + if (string.IsNullOrWhiteSpace(entityName)) + { + Log.LogWarning($"Entity without a name skipped in {file}."); + continue; + } + + if (!entities.TryGetValue(entityName, out var targetEntity)) + { + var cloned = new XElement(entity); + entities[entityName] = cloned; + outputRoot.Add(cloned); + } + else + { + MergeEntity(targetEntity, entity); + } + } + } + + if (outputDoc == null || outputRoot == null) + throw new InvalidOperationException("No entities were merged."); + + WriteDocument(outputDoc, outputPath); + } + + private static XDocument CreateOutputDocument(XElement templateRoot) + { + var outputRoot = new XElement(templateRoot.Name); + foreach (var attr in templateRoot.Attributes()) + { + outputRoot.Add(attr); + } + + var doc = new XDocument(new XDeclaration("1.0", "utf-8", null), outputRoot); + return doc; + } + + private void AddMissingAttributes(XElement targetRoot, XElement sourceRoot) + { + foreach (var attr in sourceRoot.Attributes()) + { + if (attr.IsNamespaceDeclaration) + { + var existing = targetRoot.Attributes() + .FirstOrDefault(a => a.IsNamespaceDeclaration && a.Name == attr.Name); + if (existing == null) + targetRoot.Add(attr); + } + else if (targetRoot.Attribute(attr.Name) == null) + { + targetRoot.SetAttributeValue(attr.Name, attr.Value); + } + } + } + + private void MergeEntity(XElement targetEntity, XElement sourceEntity) + { + MergeEntityAttributes(targetEntity, sourceEntity); + MergeChildElements(targetEntity, sourceEntity, "fields", "field", "name"); + MergeChildElements(targetEntity, sourceEntity, "relationships", "relationship", "name"); + } + + private void MergeEntityAttributes(XElement targetEntity, XElement sourceEntity) + { + foreach (var attr in sourceEntity.Attributes()) + { + if (attr.IsNamespaceDeclaration) + { + var existing = targetEntity.Attributes() + .FirstOrDefault(a => a.IsNamespaceDeclaration && a.Name == attr.Name); + if (existing == null) + targetEntity.Add(attr); + } + else if (targetEntity.Attribute(attr.Name) == null) + { + targetEntity.SetAttributeValue(attr.Name, attr.Value); + } + } + } + + private void MergeChildElements( + XElement targetEntity, + XElement sourceEntity, + string containerName, + string itemName, + string keyAttribute) + { + var sourceContainer = sourceEntity.Element(containerName); + if (sourceContainer == null) + return; + + var targetContainer = targetEntity.Element(containerName); + if (targetContainer == null) + { + targetContainer = new XElement(containerName); + targetEntity.Add(targetContainer); + } + + var existing = targetContainer.Elements(itemName) + .Select(e => new { Element = e, Key = e.Attribute(keyAttribute)?.Value?.Trim() }) + .Where(e => !string.IsNullOrWhiteSpace(e.Key)) + .ToDictionary(e => e.Key, e => e.Element, StringComparer.OrdinalIgnoreCase); + + foreach (var item in sourceContainer.Elements(itemName)) + { + var key = item.Attribute(keyAttribute)?.Value?.Trim(); + if (!string.IsNullOrWhiteSpace(key) && existing.ContainsKey(key)) + continue; + + var cloned = new XElement(item); + targetContainer.Add(cloned); + + if (!string.IsNullOrWhiteSpace(key)) + existing[key] = cloned; + } + } + + private static void WriteDocument(XDocument doc, string outputPath) + { + var settings = new XmlWriterSettings + { + Encoding = new UTF8Encoding(false), + Indent = true, + NewLineChars = Environment.NewLine, + NewLineHandling = NewLineHandling.Replace + }; + + using (var writer = XmlWriter.Create(outputPath, settings)) + { + doc.Save(writer); + } + } +} diff --git a/src/Dataverse/Tasks/Tasks/MergeCmtDataXml.cs b/src/Dataverse/Tasks/Tasks/MergeCmtDataXml.cs new file mode 100644 index 0000000..835cd41 --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/MergeCmtDataXml.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +public class MergeCmtDataXml : Task +{ + [Required] + public ITaskItem[] DataXmlFiles { get; set; } = Array.Empty(); + + public string CmtPackageName { get; set; } = ""; + + public string ProjectDirectory { get; set; } = ""; + + public string OutputDirectory { get; set; } = ""; + + [Output] + public string OutputDataXml { get; private set; } = ""; + + public override bool Execute() + { + try + { + var files = NormalizeFiles(DataXmlFiles); + if (files.Count == 0) + { + Log.LogError("No data.xml files were provided."); + return false; + } + + var missing = files.Where(f => !File.Exists(f)).ToList(); + if (missing.Any()) + { + foreach (var path in missing) + { + Log.LogError($"data.xml not found: {path}"); + } + return false; + } + + var packageName = GetPackageName(); + var baseDir = ResolveOutputDirectory(packageName); + Directory.CreateDirectory(baseDir); + + OutputDataXml = Path.Combine(baseDir, "data.xml"); + + MergeFiles(files, OutputDataXml); + + Log.LogMessage(MessageImportance.High, + $"Merged {files.Count} data.xml file(s) into {OutputDataXml}"); + + return !Log.HasLoggedErrors; + } + catch (Exception ex) + { + Log.LogErrorFromException(ex, true, true, null); + return false; + } + } + + private List NormalizeFiles(ITaskItem[] items) + { + return (items ?? Array.Empty()) + .Select(i => i?.ItemSpec) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(Path.GetFullPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private string GetPackageName() + { + var name = string.IsNullOrWhiteSpace(CmtPackageName) + ? "MainCmtPackage" + : CmtPackageName.Trim(); + + var invalid = Path.GetInvalidFileNameChars(); + var sanitized = new string(name.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray()).Trim(); + + return string.IsNullOrWhiteSpace(sanitized) ? "MainCmtPackage" : sanitized; + } + + private string ResolveOutputDirectory(string packageName) + { + if (!string.IsNullOrWhiteSpace(OutputDirectory)) + return Path.GetFullPath(OutputDirectory); + + var root = string.IsNullOrWhiteSpace(ProjectDirectory) + ? Directory.GetCurrentDirectory() + : ProjectDirectory; + + return Path.GetFullPath(Path.Combine(root, "obj", "metadata", packageName)); + } + + private void MergeFiles(IReadOnlyCollection files, string outputPath) + { + XDocument outputDoc = null; + XElement outputRoot = null; + var entities = new Dictionary(StringComparer.OrdinalIgnoreCase); + var recordKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var file in files) + { + var doc = XDocument.Load(file, LoadOptions.PreserveWhitespace | LoadOptions.SetBaseUri | LoadOptions.SetLineInfo); + var root = doc.Root ?? throw new InvalidDataException($"Root element is missing in {file}"); + + if (outputDoc == null) + { + outputDoc = CreateOutputDocument(root); + outputRoot = outputDoc.Root ?? throw new InvalidDataException("Failed to initialize merged document root."); + } + + foreach (var entity in root.Elements()) + { + if (entity.NodeType != XmlNodeType.Element) + continue; + + var entityName = entity.Attribute("name")?.Value?.Trim(); + var effectiveEntityName = string.IsNullOrWhiteSpace(entityName) + ? Guid.NewGuid().ToString("N") + : entityName; + + if (!entities.TryGetValue(effectiveEntityName, out var targetEntity)) + { + var cloned = new XElement(entity); + entities[effectiveEntityName] = cloned; + outputRoot.Add(cloned); + RegisterRecordKeys(cloned, effectiveEntityName, recordKeys); + } + else + { + MergeEntityRecords(targetEntity, entity, effectiveEntityName, recordKeys); + } + } + } + + if (outputDoc == null || outputRoot == null) + throw new InvalidOperationException("No entities were merged."); + + outputRoot.SetAttributeValue("timestamp", DateTime.UtcNow.ToString("o")); + + WriteDocument(outputDoc, outputPath); + } + + private static XDocument CreateOutputDocument(XElement templateRoot) + { + var outputRoot = new XElement(templateRoot.Name); + foreach (var attr in templateRoot.Attributes()) + { + if (attr.IsNamespaceDeclaration) + { + outputRoot.Add(attr); + } + else + { + outputRoot.SetAttributeValue(attr.Name, attr.Value); + } + } + + var doc = new XDocument(new XDeclaration("1.0", "utf-8", null), outputRoot); + return doc; + } + + private void MergeEntityRecords( + XElement targetEntity, + XElement sourceEntity, + string entityName, + HashSet recordKeys) + { + var targetRecords = EnsureRecordsContainer(targetEntity); + var sourceRecords = sourceEntity.Element("records"); + if (sourceRecords == null) + return; + + foreach (var record in sourceRecords.Elements("record")) + { + var recordId = record.Attribute("id")?.Value?.Trim(); + var recordKey = string.IsNullOrWhiteSpace(recordId) ? null : BuildRecordKey(entityName, recordId); + + if (recordKey != null && recordKeys.Contains(recordKey)) + continue; + + var cloned = new XElement(record); + targetRecords.Add(cloned); + + if (recordKey != null) + recordKeys.Add(recordKey); + } + } + + private void RegisterRecordKeys( + XElement entity, + string entityName, + HashSet recordKeys) + { + var records = entity.Element("records"); + if (records == null) + return; + + foreach (var record in records.Elements("record")) + { + var recordId = record.Attribute("id")?.Value?.Trim(); + if (string.IsNullOrWhiteSpace(recordId)) + continue; + + recordKeys.Add(BuildRecordKey(entityName, recordId)); + } + } + + private static XElement EnsureRecordsContainer(XElement entity) + { + var records = entity.Element("records"); + if (records == null) + { + records = new XElement("records"); + entity.Add(records); + } + return records; + } + + private static string BuildRecordKey(string entityName, string recordId) + { + return entityName + "|" + recordId; + } + + private static void WriteDocument(XDocument doc, string outputPath) + { + var settings = new XmlWriterSettings + { + Encoding = new UTF8Encoding(false), + Indent = true, + NewLineChars = Environment.NewLine, + NewLineHandling = NewLineHandling.Replace + }; + + using (var writer = XmlWriter.Create(outputPath, settings)) + { + doc.Save(writer); + } + } +} diff --git a/src/Dataverse/Tasks/Tasks/PatchSolutionXml.cs b/src/Dataverse/Tasks/Tasks/PatchSolutionXml.cs new file mode 100644 index 0000000..6446d12 --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/PatchSolutionXml.cs @@ -0,0 +1,220 @@ +using System; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +#nullable enable + +public sealed class PatchSolutionXml : Task +{ + [Required] public string ProjectDir { get; set; } = ""; + + public string? Version { get; set; } + public string? Managed { get; set; } + public string? PublisherName { get; set; } + public string? PublisherPrefix { get; set; } + + public bool FailOnManyMatches { get; set; } = true; + public int MaxMatches { get; set; } = 5; + + public override bool Execute() + { + var solutionXmlPath = FindSolutionXml(ProjectDir); + if (solutionXmlPath == null) + { + Log.LogMessage(MessageImportance.Low, $"solution.xml not found under '{ProjectDir}'. Skip."); + return true; + } + + var originalText = File.ReadAllText(solutionXmlPath); + var encoding = DetectEncoding(originalText) ?? Encoding.UTF8; + + var doc = new XmlDocument { PreserveWhitespace = true }; + try + { + doc.LoadXml(originalText); + } + catch (Exception ex) + { + Log.LogError($"Failed to load XML '{solutionXmlPath}': {ex.Message}"); + return false; + } + + bool changed = false; + + if (!string.IsNullOrWhiteSpace(Version)) + changed |= PatchInnerText(doc, "//*[local-name()='Version']", Version!.Trim()); + + if (!string.IsNullOrWhiteSpace(Managed)) + changed |= PatchInnerText(doc, "//*[local-name()='Managed']", Managed!.Trim()); + + if (!string.IsNullOrWhiteSpace(PublisherName)) + { + var name = PublisherName!.Trim(); + + changed |= PatchInnerText(doc, + "//*[local-name()='Publisher']/*[local-name()='UniqueName']", + name); + + changed |= PatchAttribute(doc, + "//*[local-name()='Publisher']//*[local-name()='LocalizedName']/@description", + name); + + changed |= PatchAttribute(doc, + "//*[local-name()='Publisher']//*[local-name()='Description']/@description", + name); + } + + if (!string.IsNullOrWhiteSpace(PublisherPrefix)) + { + var prefix = PublisherPrefix!.Trim().ToLowerInvariant(); + + changed |= PatchInnerText(doc, + "//*[local-name()='Publisher']/*[local-name()='CustomizationPrefix']", + prefix); + } + + if (!changed) + return !Log.HasLoggedErrors; + + var settings = new XmlWriterSettings + { + Indent = false, + NewLineHandling = NewLineHandling.None, + OmitXmlDeclaration = false, + Encoding = encoding + }; + + try + { + using var fs = new FileStream(solutionXmlPath, FileMode.Create, FileAccess.Write, FileShare.Read); + using var xw = XmlWriter.Create(fs, settings); + doc.Save(xw); + } + catch (Exception ex) + { + Log.LogError($"Failed to save XML '{solutionXmlPath}': {ex.Message}"); + return false; + } + + Log.LogMessage(MessageImportance.High, $"Patched solution.xml: {solutionXmlPath}"); + return !Log.HasLoggedErrors; + } + + private bool PatchInnerText(XmlDocument doc, string xpath, string value) + { + var nodes = doc.SelectNodes(xpath); + if (nodes == null || nodes.Count == 0) + return false; + + EnforceSafety(xpath, nodes.Count); + + bool changed = false; + foreach (XmlNode n in nodes) + { + if (n.InnerText != value) + { + n.InnerText = value; + changed = true; + } + } + return changed; + } + + private bool PatchAttribute(XmlDocument doc, string xpath, string value) + { + var nodes = doc.SelectNodes(xpath); + if (nodes == null || nodes.Count == 0) + return false; + + EnforceSafety(xpath, nodes.Count); + + bool changed = false; + foreach (XmlNode n in nodes) + { + if (n is XmlAttribute a && a.Value != value) + { + a.Value = value; + changed = true; + } + } + return changed; + } + + private void EnforceSafety(string xpath, int matches) + { + if (matches <= MaxMatches) return; + + var msg = $"Too many matches ({matches}) for XPath: {xpath}. MaxMatches={MaxMatches}. Fix mapping."; + if (FailOnManyMatches) Log.LogError(msg); + else Log.LogWarning(msg); + } + + private static Encoding? DetectEncoding(string xmlText) + { + var m = Regex.Match(xmlText, + @"<\?xml\s+version\s*=\s*[""'][^""']+[""']\s+encoding\s*=\s*[""'](?[^""']+)[""']", + RegexOptions.IgnoreCase); + if (!m.Success) return null; + + try { return Encoding.GetEncoding(m.Groups["e"].Value); } + catch { return null; } + } + + private string? FindSolutionXml(string projectDir) + { + var candidates = new[] + { + Path.Combine(projectDir, "solution.xml"), + Path.Combine(projectDir, "Solution", "solution.xml"), + Path.Combine(projectDir, "Solution", "Other", "solution.xml"), + Path.Combine(projectDir, "Other", "solution.xml"), + }; + + foreach (var c in candidates) + if (File.Exists(c)) return c; + + return FindByScan(projectDir, "solution.xml", maxDepth: 4); + } + + private static string? FindByScan(string root, string fileName, int maxDepth) + { + bool SkipDir(string d) + { + var name = Path.GetFileName(d); + return name.Equals("bin", StringComparison.OrdinalIgnoreCase) + || name.Equals("obj", StringComparison.OrdinalIgnoreCase) + || name.Equals(".git", StringComparison.OrdinalIgnoreCase) + || name.Equals("node_modules", StringComparison.OrdinalIgnoreCase); + } + + string? Scan(string dir, int depth) + { + if (depth > maxDepth) return null; + + try + { + foreach (var f in Directory.EnumerateFiles(dir, fileName, SearchOption.TopDirectoryOnly)) + return f; + + foreach (var sub in Directory.EnumerateDirectories(dir)) + { + if (SkipDir(sub)) continue; + var found = Scan(sub, depth + 1); + if (found != null) return found; + } + } + catch + { + // ignore access errors + } + + return null; + } + + return Scan(root, 0); + } +} diff --git a/src/Dataverse/Tasks/Tasks/ResolveWebResourceName.cs b/src/Dataverse/Tasks/Tasks/ResolveWebResourceName.cs new file mode 100644 index 0000000..dfc1782 --- /dev/null +++ b/src/Dataverse/Tasks/Tasks/ResolveWebResourceName.cs @@ -0,0 +1,73 @@ +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using System; +using System.Collections.Generic; + +public class ResolveWebResourceName : Task +{ + [Required] + public ITaskItem[] Files { get; set; } = Array.Empty(); + + [Required] + public string PublisherPrefix { get; set; } = ""; + + [Output] + public ITaskItem[] ResolvedFiles { get; set; } = Array.Empty(); + + public override bool Execute() + { + try + { + string prefix = PublisherPrefix.Trim().ToLowerInvariant(); + var results = new List(); + + foreach (var file in Files) + { + string filePath = file.ItemSpec; + string fileName = System.IO.Path.GetFileName(filePath); + + string resolvedName; + string displayName; + + int underscoreIndex = fileName.IndexOf('_'); + + if (underscoreIndex > 0) + { + string existingPrefix = fileName.Substring(0, underscoreIndex).ToLowerInvariant(); + + if (existingPrefix.Equals(prefix, StringComparison.OrdinalIgnoreCase)) + { + resolvedName = fileName; + displayName = fileName.Substring(underscoreIndex + 1); + } + else + { + resolvedName = prefix + "_" + fileName; + displayName = fileName; + } + } + else + { + resolvedName = prefix + "_" + fileName; + displayName = fileName; + } + + var resultItem = new TaskItem(filePath); + resultItem.SetMetadata("ResolvedName", resolvedName); + resultItem.SetMetadata("DisplayName", displayName); + results.Add(resultItem); + + Log.LogMessage(MessageImportance.Normal, + $"ResolveWebResourceName: {fileName} -> {resolvedName} (DisplayName: {displayName})"); + } + + ResolvedFiles = results.ToArray(); + return true; + } + catch (Exception ex) + { + Log.LogErrorFromException(ex, true); + return false; + } + } +} 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 5d062d3..b4df509 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 @@ -23,4 +23,14 @@ - \ No newline at end of file + + + + + + + + + + + diff --git a/src/Dataverse/Tasks/msbuild/tasks/Targets/GenerateVersionNumber.targets b/src/Dataverse/Tasks/msbuild/tasks/Targets/GenerateVersionNumber.targets index 53e9e59..a53a7f1 100644 --- a/src/Dataverse/Tasks/msbuild/tasks/Targets/GenerateVersionNumber.targets +++ b/src/Dataverse/Tasks/msbuild/tasks/Targets/GenerateVersionNumber.targets @@ -1,4 +1,7 @@ + + 0.0.20000.0 + diff --git a/src/Dataverse/WorkflowActivity/README.md b/src/Dataverse/WorkflowActivity/README.md new file mode 100644 index 0000000..d364cbf --- /dev/null +++ b/src/Dataverse/WorkflowActivity/README.md @@ -0,0 +1,53 @@ +# TALXIS.DevKit.Build.Dataverse.WorkflowActivity + +MSBuild integration for Dynamics 365 custom workflow activity assembly projects. Mirrors the Plugin package pattern: configures Visual Studio project type GUIDs, applies automatic Git-based versioning, and exposes metadata targets that allow Solution projects to discover and integrate workflow activity assemblies during build. + +## Installation + +```xml + +``` + +Or use the SDK approach: + +```xml + + + WorkflowActivity + + +``` + +## How It Works + +The package sets `ProjectType` to `WorkflowActivity` and configures `ProjectTypeGuids` for workflow activity recognition in Visual Studio. + +### Build-time targets + +- **TalxisBeforeBuild** (runs before `BeforeBuild`) -- executes `GenerateVersionNumber` followed by `ApplyPluginVersionNumber` to set `AssemblyVersion`, `FileVersion`, `Version`, and `PackageVersion` from Git. + +### Integration targets + +These targets are called by `TALXIS.DevKit.Build.Dataverse.Solution` when it discovers this project via `ProjectReference`: + +- **GetProjectType** -- returns `WorkflowActivity` so the Solution build knows how to handle this reference. +- **GetWorkflowActivityAssemblyInfo** -- returns `WorkflowActivityRootPath`, `WorkflowActivityAssemblyId`, `TargetFramework`, `PublishFolderName`, and `AssemblyName` for automatic workflow activity assembly metadata generation in the solution. + +## MSBuild Properties + +| Property | Default | Description | +|----------|---------|-------------| +| `ProjectType` | `WorkflowActivity` | Marks the project as a workflow activity for reference discovery. | +| `Version` | _(required)_ | Base version; major/minor are used for Git versioning. | +| `ApplyToBranches` | _(none)_ | Semicolon-separated branch rules (e.g. `master;hotfix;develop:1;pr:3;feature/*:2`). | +| `LocalBranchBuildVersionNumber` | `0.0.0.1` | Fallback version when Git versioning is not applied. | +| `WorkflowActivityTargetFramework` | `$(TargetFramework)` or `net462` | Target framework used to locate the compiled workflow activity DLL. | +| `WorkflowActivityPublishFolderName` | `publish` | Publish folder name under `bin\\\`. | +| `WorkflowActivityAssemblyId` | _(auto-generated)_ | Explicit GUID for the workflow activity assembly metadata; a new GUID is generated if empty. | + +## Related Packages + +- **Depends on**: `TALXIS.DevKit.Build.Dataverse.Tasks`, `Microsoft.PowerApps.MSBuild.Plugin`, `Microsoft.CrmSdk.CoreAssemblies` +- **Consumed by**: `TALXIS.DevKit.Build.Dataverse.Solution` projects via `ProjectReference` + + diff --git a/src/Dataverse/WorkflowActivity/TALXIS.DevKit.Build.Dataverse.WorkflowActivity.csproj b/src/Dataverse/WorkflowActivity/TALXIS.DevKit.Build.Dataverse.WorkflowActivity.csproj new file mode 100644 index 0000000..27eece5 --- /dev/null +++ b/src/Dataverse/WorkflowActivity/TALXIS.DevKit.Build.Dataverse.WorkflowActivity.csproj @@ -0,0 +1,25 @@ + + + TALXIS.DevKit.Build.Dataverse.WorkflowActivity + + + net472;net6.0 + + true + true + 0.0.0.1 + true + true + NU1604 + + $(MSBuildProjectName).nuspec + $(OutputPath) + Version=$(Version);MicrosoftPowerAppsTargetsVersion=$(MicrosoftPowerAppsTargetsVersion) + + + + + + + + \ No newline at end of file diff --git a/src/Dataverse/WorkflowActivity/TALXIS.DevKit.Build.Dataverse.WorkflowActivity.nuspec b/src/Dataverse/WorkflowActivity/TALXIS.DevKit.Build.Dataverse.WorkflowActivity.nuspec new file mode 100644 index 0000000..8fe1df1 --- /dev/null +++ b/src/Dataverse/WorkflowActivity/TALXIS.DevKit.Build.Dataverse.WorkflowActivity.nuspec @@ -0,0 +1,28 @@ + + + + TALXIS.DevKit.Build.Dataverse.WorkflowActivity + $Version$ + TALXIS + true + MIT + https://licenses.nuget.org/MIT + README.md + https://github.com/TALXIS/tools-devkit-build + Dataverse MSBuild WorkflowActivity + https://github.com/TALXIS/tools-devkit-build/releases + 2025 NETWORG + + + + + + + + + + + + + + diff --git a/src/Dataverse/WorkflowActivity/msbuild/build/TALXIS.DevKit.Build.Dataverse.WorkflowActivity.props b/src/Dataverse/WorkflowActivity/msbuild/build/TALXIS.DevKit.Build.Dataverse.WorkflowActivity.props new file mode 100644 index 0000000..8a0424d --- /dev/null +++ b/src/Dataverse/WorkflowActivity/msbuild/build/TALXIS.DevKit.Build.Dataverse.WorkflowActivity.props @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Dataverse/WorkflowActivity/msbuild/build/TALXIS.DevKit.Build.Dataverse.WorkflowActivity.targets b/src/Dataverse/WorkflowActivity/msbuild/build/TALXIS.DevKit.Build.Dataverse.WorkflowActivity.targets new file mode 100644 index 0000000..29482aa --- /dev/null +++ b/src/Dataverse/WorkflowActivity/msbuild/build/TALXIS.DevKit.Build.Dataverse.WorkflowActivity.targets @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/Dataverse/WorkflowActivity/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.WorkflowActivity.props b/src/Dataverse/WorkflowActivity/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.WorkflowActivity.props new file mode 100644 index 0000000..7cce676 --- /dev/null +++ b/src/Dataverse/WorkflowActivity/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.WorkflowActivity.props @@ -0,0 +1,14 @@ + + + + + WorkflowActivity + $(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\PowerApps + {2AA76AF3-4D9E-4AF0-B243-EB9BCDFB143B};{32f31d43-81cc-4c15-9de6-3fc5453562b6};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + + + + + + + diff --git a/src/Dataverse/WorkflowActivity/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.WorkflowActivity.targets b/src/Dataverse/WorkflowActivity/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.WorkflowActivity.targets new file mode 100644 index 0000000..54aa101 --- /dev/null +++ b/src/Dataverse/WorkflowActivity/msbuild/tasks/TALXIS.DevKit.Build.Dataverse.WorkflowActivity.targets @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + <_ProjectType Include="$(MSBuildProjectFullPath)"> + $(ProjectType) + + + + + + + <_WorkflowActivityTargetFramework Condition="'$(TargetFramework)'!=''">$(TargetFramework) + <_WorkflowActivityTargetFramework Condition="'$(_WorkflowActivityTargetFramework)'=='' and '$(WorkflowActivityTargetFramework)'!=''">$(WorkflowActivityTargetFramework) + <_WorkflowActivityTargetFramework Condition="'$(_WorkflowActivityTargetFramework)'==''">net462 + + <_WorkflowActivityPublishFolderName Condition="'$(WorkflowActivityPublishFolderName)'!=''">$(WorkflowActivityPublishFolderName) + <_WorkflowActivityPublishFolderName Condition="'$(_WorkflowActivityPublishFolderName)'==''">publish + + <_WorkflowActivityAssemblyName Condition="'$(AssemblyName)'!=''">$(AssemblyName) + <_WorkflowActivityAssemblyName Condition="'$(_WorkflowActivityAssemblyName)'==''">$(MSBuildProjectName) + + + + <_WorkflowActivityAssemblyInfo Include="$(MSBuildProjectFullPath)"> + $(MSBuildProjectDirectory) + $(WorkflowActivityAssemblyId) + $(_WorkflowActivityTargetFramework) + $(_WorkflowActivityPublishFolderName) + $(_WorkflowActivityAssemblyName) + + + +