diff --git a/.gitignore b/.gitignore index 707d5c0..e26e613 100644 --- a/.gitignore +++ b/.gitignore @@ -287,4 +287,9 @@ __pycache__/ *.odx.cs *.xsd.cs -NuGet/ \ No newline at end of file +NuGet/ + +.claude/ +.nuget-apikey +# The private XrmToolBox instance xtb.ps1 builds, including its connection and its token cache. +.xtb/ \ No newline at end of file diff --git a/DocTemplateManagerControl.cs b/DocTemplateManagerControl.cs index 1c1c35e..386cf87 100644 --- a/DocTemplateManagerControl.cs +++ b/DocTemplateManagerControl.cs @@ -27,6 +27,9 @@ public partial class DocTemplateManagerControl : PluginControlBase, IStatusBarMe private EditTemplateControl _editControl = null; private UploadMultipleSummary _uploadSummary = null; + // Where the last download went, so the picker reopens there instead of at This PC. + private string _lastDownloadFolder = null; + public DocTemplateManagerControl() { InitializeComponent(); @@ -292,16 +295,12 @@ private void PerformDeleteTemplates() private void PerformDownloadTemplates() { // choose a folder for the save... - var folderDlg = new FolderBrowserDialog() - { - Description = "Select a destination folder for your download", - RootFolder = Environment.SpecialFolder.MyComputer, - ShowNewFolderButton = true - }; + var saveFolder = FolderPicker.Pick(this, "Select a destination folder for your download", _lastDownloadFolder); - if ((folderDlg.ShowDialog() == DialogResult.OK) && (folderDlg.SelectedPath != null)) + if (saveFolder != null) { - ExecuteMethod(DownloadDocumentTemplates, folderDlg.SelectedPath); + _lastDownloadFolder = saveFolder; + ExecuteMethod(DownloadDocumentTemplates, saveFolder); } } diff --git a/Futurez.Xrm.Tools.DocTemplateManager.csproj b/Futurez.Xrm.Tools.DocTemplateManager.csproj index 0a0bfe1..93b21bd 100644 --- a/Futurez.Xrm.Tools.DocTemplateManager.csproj +++ b/Futurez.Xrm.Tools.DocTemplateManager.csproj @@ -9,7 +9,7 @@ Properties Futurez.Xrm.Tools Futurez.Xrm.Tools.DocTemplateManager - v4.7 + v4.8 512 SAK SAK @@ -233,6 +233,7 @@ + diff --git a/Helper/FolderPicker.cs b/Helper/FolderPicker.cs new file mode 100644 index 0000000..26ff0fb --- /dev/null +++ b/Helper/FolderPicker.cs @@ -0,0 +1,170 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace Futurez.Xrm.Tools +{ + /// + /// Folder picker built on the Vista-era IFileOpenDialog, i.e. the same Explorer window + /// users see everywhere else, with a path bar, search and the usual navigation pane. + /// WinForms' on .NET Framework is still the old + /// SHBrowseForFolder tree, so it is only kept here as a fallback. + /// + internal static class FolderPicker + { + /// Returns the picked folder, or null when the user cancelled. + public static string Pick(IWin32Window owner, string title, string initialFolder) + { + try + { + return PickVista(owner, title, initialFolder); + } + catch (Exception) + { + // Anything odd about the shell (older OS, blocked COM) falls back to the old dialog. + return PickLegacy(title, initialFolder); + } + } + + private static string PickVista(IWin32Window owner, string title, string initialFolder) + { + var dialog = (IFileOpenDialog)new FileOpenDialog(); + try + { + dialog.SetOptions(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST); + dialog.SetTitle(title); + + var start = ExistingFolder(initialFolder); + if (start != null) + { + object item; + if (SHCreateItemFromParsingName(start, IntPtr.Zero, typeof(IShellItem).GUID, out item) == 0) + { + dialog.SetFolder((IShellItem)item); + } + } + + var hwnd = owner != null ? owner.Handle : IntPtr.Zero; + if (dialog.Show(hwnd) != 0) + { + return null; // cancelled + } + + IShellItem result; + dialog.GetResult(out result); + string path; + result.GetDisplayName(SIGDN_FILESYSPATH, out path); + Marshal.ReleaseComObject(result); + return path; + } + finally + { + Marshal.ReleaseComObject(dialog); + } + } + + private static string PickLegacy(string title, string initialFolder) + { + using (var dialog = new FolderBrowserDialog { Description = title, ShowNewFolderButton = true }) + { + var start = ExistingFolder(initialFolder); + if (start != null) + { + dialog.SelectedPath = start; + } + + return dialog.ShowDialog() == DialogResult.OK ? dialog.SelectedPath : null; + } + } + + private static string ExistingFolder(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + try + { + var full = Path.GetFullPath(path.Trim()); + return Directory.Exists(full) ? full : null; + } + catch (Exception) + { + return null; + } + } + + private const uint FOS_PICKFOLDERS = 0x00000020; + private const uint FOS_FORCEFILESYSTEM = 0x00000040; + private const uint FOS_PATHMUSTEXIST = 0x00000800; + private const uint SIGDN_FILESYSPATH = 0x80058000; + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = true)] + private static extern int SHCreateItemFromParsingName( + [MarshalAs(UnmanagedType.LPWStr)] string path, + IntPtr bindingContext, + [MarshalAs(UnmanagedType.LPStruct)] Guid interfaceId, + [MarshalAs(UnmanagedType.Interface)] out object item); + + [ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")] + private class FileOpenDialog + { + } + + /// + /// IModalWindow + IFileDialog + IFileOpenDialog flattened into one declaration: the + /// slots have to appear in vtable order, and the ones this tool never calls are + /// declared with blind arguments just to keep the layout right. + /// + [ComImport, Guid("d57c7288-d4ad-4768-be02-9d969532d960"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IFileOpenDialog + { + // IModalWindow + [PreserveSig] + int Show(IntPtr parent); + + // IFileDialog + void SetFileTypes(uint fileTypes, IntPtr filterSpec); + void SetFileTypeIndex(uint fileType); + void GetFileTypeIndex(out uint fileType); + void Advise(IntPtr events, out uint cookie); + void Unadvise(uint cookie); + void SetOptions(uint options); + void GetOptions(out uint options); + void SetDefaultFolder(IShellItem folder); + void SetFolder(IShellItem folder); + void GetFolder(out IShellItem folder); + void GetCurrentSelection(out IShellItem item); + void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string name); + void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string name); + void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string title); + void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string text); + void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string label); + void GetResult(out IShellItem item); + void AddPlace(IShellItem place, int order); + void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string extension); + void Close([MarshalAs(UnmanagedType.Error)] int result); + void SetClientGuid(ref Guid client); + void ClearClientData(); + void SetFilter(IntPtr filter); + + // IFileOpenDialog + void GetResults(out IntPtr items); + void GetSelectedItems(out IntPtr items); + } + + [ComImport, Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellItem + { + void BindToHandler(IntPtr bindingContext, ref Guid handler, ref Guid interfaceId, out IntPtr result); + void GetParent(out IShellItem parent); + void GetDisplayName(uint name, [MarshalAs(UnmanagedType.LPWStr)] out string displayName); + void GetAttributes(uint mask, out uint attributes); + void Compare(IShellItem other, uint hint, out int order); + } + } +} diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..3d6aa8b --- /dev/null +++ b/build.ps1 @@ -0,0 +1,46 @@ +# Builds the tool and drops it into the XrmToolBox you use for real work. +# +# Unlike the sibling tools this is the non-SDK, packages.config project inherited from +# upstream, so two things differ. Packages are restored by nuget.exe, because dotnet restore +# only understands PackageReference. And the build runs on the full MSBuild that ships with +# Visual Studio rather than on dotnet build, because the .resx files hold images and the SDK +# build refuses those (MSB3823/MSB3822) unless the project takes on a System.Resources. +# Extensions dependency that upstream does not have. + +$ErrorActionPreference = "Stop" + +$solution = Join-Path $PSScriptRoot "Futurez.Xrm.Tools.DocTemplateManager.sln" + +if (-not (Test-Path (Join-Path $PSScriptRoot "packages"))) { + $nuget = Get-Command nuget -ErrorAction SilentlyContinue + if (-not $nuget) { + throw "packages\ is missing and nuget.exe is not on PATH. Get it from https://dist.nuget.org/win-x86-commandline/latest/nuget.exe, then re-run." + } + & $nuget.Source restore $solution + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} + +$vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" +$msbuild = $null +if (Test-Path $vswhere) { + $msbuild = & $vswhere -latest -prerelease -products * -requires Microsoft.Component.MSBuild ` + -find "MSBuild\**\Bin\amd64\MSBuild.exe" | Select-Object -First 1 +} +if (-not $msbuild) { + $msbuild = (Get-Command msbuild -ErrorAction SilentlyContinue).Source +} +if (-not $msbuild) { + throw "MSBuild was not found. Install Visual Studio or the Build Tools with the MSBuild component." +} + +& $msbuild $solution /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /v:minimal /nologo +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +$pluginDir = "C:\Users\kk\Downloads\XrmToolbox\Plugins" +if (-not (Test-Path $pluginDir)) { New-Item -ItemType Directory -Path $pluginDir | Out-Null } + +# No target framework folder in the path: a non-SDK project writes straight into bin\Debug. +$source = Join-Path $PSScriptRoot "bin\Debug\Futurez.Xrm.Tools.DocTemplateManager.dll" +Copy-Item $source -Destination $pluginDir -Force + +Write-Host "Built and deployed Futurez.Xrm.Tools.DocTemplateManager.dll to $pluginDir" diff --git a/docs/shots/dialog-after.png b/docs/shots/dialog-after.png new file mode 100644 index 0000000..818036f Binary files /dev/null and b/docs/shots/dialog-after.png differ diff --git a/docs/shots/dialog-before.png b/docs/shots/dialog-before.png new file mode 100644 index 0000000..8f89274 Binary files /dev/null and b/docs/shots/dialog-before.png differ diff --git a/docs/shots/grid-seeded.png b/docs/shots/grid-seeded.png new file mode 100644 index 0000000..51833d0 Binary files /dev/null and b/docs/shots/grid-seeded.png differ diff --git a/test_doc.docx b/test_doc.docx new file mode 100644 index 0000000..0890752 Binary files /dev/null and b/test_doc.docx differ diff --git a/tests/dialog-shots.ps1 b/tests/dialog-shots.ps1 new file mode 100644 index 0000000..9c9279e --- /dev/null +++ b/tests/dialog-shots.ps1 @@ -0,0 +1,149 @@ +# Photographs the two folder dialogs side by side: the one Download used to open, and the one +# it opens now. +# +# .\tests\dialog-shots.ps1 -Out docs\shots +# +# Driving the dialog through XrmToolBox is unreliable -- its automation tree is rebuilt while +# the tool's tab opens, so elements go stale between being found and being used -- and the +# dialog does not depend on any of it. So each one is opened directly instead: +# +# before the five lines PerformDownloadTemplates used to run, FolderBrowserDialog with +# RootFolder = MyComputer, copied from the parent of the fix commit +# after FolderPicker.Pick out of the built assembly, i.e. the shipping code itself +# +# Runs on Windows PowerShell 5.1 for the net48 assembly, opens each dialog on an STA thread, +# photographs it with PrintWindow and closes it, so nothing is ever downloaded. + +param( + [Parameter(Mandatory)][string]$Out +) + +$ErrorActionPreference = "Stop" + +if ($PSVersionTable.PSEdition -eq "Core") { + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File $MyInvocation.MyCommand.Path -Out $Out + exit $LASTEXITCODE +} + +Add-Type -AssemblyName System.Windows.Forms, System.Drawing + +Add-Type @" +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +public static class Shot { + public delegate bool EnumProc(IntPtr h, IntPtr p); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr p); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r); + [DllImport("user32.dll")] public static extern int GetClassName(IntPtr h, System.Text.StringBuilder s, int n); + [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr dc, uint flags); + [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h, uint m, IntPtr w, IntPtr l); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); + [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h); + [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; } + + public static List Dialogs(uint pid) { + var found = new List(); + EnumWindows((h, p) => { + uint w; GetWindowThreadProcessId(h, out w); + if (w != pid || !IsWindowVisible(h)) return true; + var sb = new System.Text.StringBuilder(256); + GetClassName(h, sb, sb.Capacity); + if (sb.ToString() == "#32770") found.Add(h); + return true; + }, IntPtr.Zero); + return found; + } +} +"@ + +$procId = [uint32](Get-Process -Id $PID).Id + +New-Item -ItemType Directory -Force -Path $Out | Out-Null +$Out = (Resolve-Path $Out).Path + +function Save-Dialog { + # PrintFlags picks how the window is asked to draw itself. 2 is PW_RENDERFULLCONTENT, + # needed by the modern shell dialog, which composes itself and comes out blank without it. + # The old SHBrowseForFolder dialog is the opposite case: its tree is a child window, which + # only appears under flag 0, where children are drawn through WM_PRINT. + param([scriptblock]$Show, [string]$Path, [string]$Label, $Arg, [uint32]$PrintFlags = 2) + + Write-Host "$Label ..." + $known = [Shot]::Dialogs($procId) + + $rs = [runspacefactory]::CreateRunspace() + $rs.ApartmentState = "STA" # the shell dialogs require a single threaded apartment + $rs.Open() + $ps = [powershell]::Create() + $ps.Runspace = $rs + # Passed as an argument rather than captured: $using: is only understood by Invoke-Command + # and jobs, not by a scriptblock handed to a runspace like this. + $null = $ps.AddScript($Show) + if ($null -ne $Arg) { $null = $ps.AddArgument($Arg) } + $async = $ps.BeginInvoke() + + $dialog = [IntPtr]::Zero + $deadline = (Get-Date).AddSeconds(25) + do { + Start-Sleep -Milliseconds 400 + foreach ($h in [Shot]::Dialogs($procId)) { + if ($known -notcontains $h) { $dialog = $h; break } + } + } while ($dialog -eq [IntPtr]::Zero -and (Get-Date) -lt $deadline) + + if ($dialog -eq [IntPtr]::Zero) { + $err = $ps.Streams.Error | ForEach-Object { $_.ToString() } + $ps.Dispose(); $rs.Dispose() + throw "$Label never opened a dialog. $($err -join '; ')" + } + + Start-Sleep -Milliseconds 2000 # let it finish painting its contents + + $r = New-Object Shot+RECT + [Shot]::GetWindowRect($dialog, [ref]$r) | Out-Null + $w = $r.Right - $r.Left; $h2 = $r.Bottom - $r.Top + $bmp = New-Object System.Drawing.Bitmap $w, $h2 + $g = [System.Drawing.Graphics]::FromImage($bmp) + $dc = $g.GetHdc() + [Shot]::PrintWindow($dialog, $dc, $PrintFlags) | Out-Null + $g.ReleaseHdc($dc) + $g.Dispose() + $bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) + $bmp.Dispose() + Write-Host " saved $(Split-Path -Leaf $Path) ($w x $h2)" + + [Shot]::SendMessage($dialog, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null # WM_CLOSE + Start-Sleep -Milliseconds 600 + $ps.Dispose(); $rs.Dispose() +} + +$binDir = Join-Path (Split-Path -Parent $PSScriptRoot) "bin\Debug" +$dll = Join-Path $binDir "Futurez.Xrm.Tools.DocTemplateManager.dll" +if (-not (Test-Path $dll)) { throw "Build first: $dll is missing." } + +# --- before: exactly what PerformDownloadTemplates used to run ------------------------------- + +Save-Dialog -Label "before (FolderBrowserDialog)" -Path (Join-Path $Out "dialog-before.png") -PrintFlags 0 -Show { + Add-Type -AssemblyName System.Windows.Forms + $dlg = New-Object System.Windows.Forms.FolderBrowserDialog + $dlg.Description = "Select a destination folder for your download" + $dlg.RootFolder = [System.Environment+SpecialFolder]::MyComputer + $dlg.ShowNewFolderButton = $true + $null = $dlg.ShowDialog() +} + +# --- after: FolderPicker out of the built assembly ------------------------------------------- + +Save-Dialog -Label "after (FolderPicker/IFileOpenDialog)" -Path (Join-Path $Out "dialog-after.png") -Arg $dll -Show { + param([string]$dll) + $asm = [System.Reflection.Assembly]::LoadFrom($dll) + # FolderPicker is internal, so it is reached by name rather than through a using. + $t = $asm.GetType("Futurez.Xrm.Tools.FolderPicker", $true) + $m = $t.GetMethod("Pick", [System.Reflection.BindingFlags]"Public,NonPublic,Static") + $null = $m.Invoke($null, @($null, "Select a destination folder for your download", $null)) +} + +Write-Host "Done. Shots in $Out" diff --git a/tests/seed.ps1 b/tests/seed.ps1 new file mode 100644 index 0000000..6f5e167 --- /dev/null +++ b/tests/seed.ps1 @@ -0,0 +1,295 @@ +# Creates mock document templates in the test organization, so the tool has something to +# list, select and download. Without them the grid is empty and the download path cannot be +# exercised at all. +# +# .\tests\seed.ps1 # create the mock templates +# .\tests\seed.ps1 -List # just show what is there +# .\tests\seed.ps1 -Remove # delete the ones this script created +# +# Runs on Windows PowerShell 5.1, not pwsh: CrmServiceClient is a net462 assembly and its +# dependencies do not load on .NET 8. The assemblies come from the tool's own build output, +# which is the one place they are all gathered together, so build.ps1 has to have run. +# +# Every template is a copy of test_doc.docx under a different name. That file is a genuine +# Word template for cr543_opportunity, and Dataverse validates the customXml against the +# entity it names, so cloning a real one is the only way to get content it will accept +# without hand-building the mapping. + +param( + [string]$Environment, + [switch]$List, + [switch]$Dump, + [switch]$Broken, + [switch]$Remove +) + +$ErrorActionPreference = "Stop" + +if ($PSVersionTable.PSEdition -eq "Core") { + $self = $MyInvocation.MyCommand.Path + $argv = @() + if ($Environment) { $argv += @("-Environment", $Environment) } + if ($List) { $argv += "-List" } + if ($Dump) { $argv += "-Dump" } + if ($Broken) { $argv += "-Broken" } + if ($Remove) { $argv += "-Remove" } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File $self @argv + exit $LASTEXITCODE +} + +$root = Split-Path -Parent $PSScriptRoot +$binDir = Join-Path $root "bin\Debug" +if (-not (Test-Path (Join-Path $binDir "Microsoft.Xrm.Tooling.Connector.dll"))) { + throw "bin\Debug is missing the SDK assemblies. Run build.ps1 first." +} + +# The SDK spreads itself over a dozen assemblies that find each other by name at runtime, +# so point the loader at the build output rather than loading each one in the right order. +$onResolve = { + param($s, $e) + $name = (New-Object System.Reflection.AssemblyName($e.Name)).Name + $path = Join-Path $binDir "$name.dll" + if (Test-Path $path) { return [System.Reflection.Assembly]::LoadFrom($path) } + return $null +} +[System.AppDomain]::CurrentDomain.add_AssemblyResolve($onResolve) +Add-Type -Path (Join-Path $binDir "Microsoft.Xrm.Tooling.Connector.dll") + +if (-not $Environment) { + $who = pac org who --json | ConvertFrom-Json + if (-not $who.OrgUrl) { throw "pac has no active organization. Run 'pac auth create' or pass -Environment." } + $Environment = $who.OrgUrl +} +$Environment = $Environment.TrimEnd('/') + +# The same public client id and reply url XrmToolBox signs in with, so this reuses the token +# already cached for that sign in instead of asking for another one. +$conn = "AuthType=OAuth;Url=$Environment;" + + "AppId=51f81489-12ee-4a9e-aaae-a2591f45987d;" + + "RedirectUri=app://58145B91-0C36-4500-8554-080854F2AC97;" + + "LoginPrompt=Auto;RequireNewInstance=False" + +Write-Host "Connecting to $Environment ..." +$svc = New-Object Microsoft.Xrm.Tooling.Connector.CrmServiceClient($conn) +if (-not $svc.IsReady) { throw "Connect failed: $($svc.LastCrmError)" } +Write-Host "Connected as $($svc.OAuthUserId) to $($svc.ConnectedOrgFriendlyName)." + +Add-Type -AssemblyName System.Runtime.Serialization + +# The record is built in C# rather than in PowerShell. Assigning through Entity's indexer +# from PowerShell stores a PSObject wrapper around the value, which survives all the way to +# the WCF serializer and fails there with a deserialization error that names +# System.Management.Automation instead of the attribute -- and casting at the call site does +# not prevent it, because the wrapping happens at the indexer's object parameter. +Add-Type -ReferencedAssemblies @( + (Join-Path $binDir "Microsoft.Xrm.Sdk.dll"), + (Join-Path $binDir "Microsoft.Xrm.Tooling.Connector.dll"), + "System.Runtime.Serialization.dll" +) -TypeDefinition @" +using System; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Tooling.Connector; + +public static class TemplateSeed +{ + public static Guid Create(CrmServiceClient svc, string name, int documentType, string entityLogicalName, string content) + { + var e = new Entity("documenttemplate"); + e["name"] = name; + e["documenttype"] = new OptionSetValue(documentType); + e["associatedentitytypecode"] = entityLogicalName; + e["content"] = content; + return svc.Create(e); + } +} +"@ + +function Get-Templates { + $q = New-Object Microsoft.Xrm.Sdk.Query.QueryExpression("documenttemplate") + $q.ColumnSet = New-Object Microsoft.Xrm.Sdk.Query.ColumnSet(@("name", "documenttype", "associatedentitytypecode", "status")) + $q.AddOrder("name", [Microsoft.Xrm.Sdk.Query.OrderType]::Ascending) + return $svc.RetrieveMultiple($q).Entities +} + +function Show-Templates { + $rows = Get-Templates + if (-not $rows -or $rows.Count -eq 0) { + Write-Host "No document templates in this organization." + return + } + Write-Host "$($rows.Count) document template(s):" + foreach ($r in $rows) { + $type = if ($r.Contains("documenttype")) { $r["documenttype"].Value } else { "?" } + $ent = if ($r.Contains("associatedentitytypecode")) { $r["associatedentitytypecode"] } else { "?" } + "{0,-34} type={1} entity={2}" -f $r["name"], $type, $ent | Write-Host + } +} + +if ($List) { Show-Templates; exit 0 } + +# The tool reads associatedentitytypecode twice, as a raw string and as a formatted value, +# and throws if either is missing. -Dump shows what the server actually returns for it. +if ($Dump) { + $q = New-Object Microsoft.Xrm.Sdk.Query.QueryExpression("documenttemplate") + $q.ColumnSet = New-Object Microsoft.Xrm.Sdk.Query.ColumnSet($true) + $rows = $svc.RetrieveMultiple($q).Entities + if ($rows.Count -eq 0) { Write-Host "No templates to dump."; exit 0 } + $r = $rows[0] + Write-Host "Dumping '$($r["name"])'" + foreach ($k in @("associatedentitytypecode", "documenttype", "status")) { + if ($r.Contains($k)) { + $v = $r[$k] + Write-Host (" {0,-26} raw='{1}' type={2}" -f $k, $v, $v.GetType().FullName) + } else { + Write-Host (" {0,-26} ATTRIBUTE ABSENT" -f $k) + } + if ($r.FormattedValues.ContainsKey($k)) { + Write-Host (" {0,-26} formatted='{1}'" -f "", $r.FormattedValues[$k]) + } else { + Write-Host (" {0,-26} NO FORMATTED VALUE" -f "") + } + } + Write-Host " formatted keys present: $($r.FormattedValues.Keys -join ', ')" + exit 0 +} + +# The names read like the out of the box Dataverse templates, so a screenshot of the grid +# looks like a real organization rather than "test 1, test 2, test 3". Each is paired with +# the table it claims to be for, which is what fills the Associated Entity column. +# +# More candidates than are needed, because associatedentitytypecode is validated against the +# tables the organization actually has, and a bare Dataverse environment has no Sales or +# Marketing tables at all: campaign, invoice, quote and opportunity are all absent from one. +$candidates = @( + @{ Name = "Account Summary"; Entity = "account" }, + @{ Name = "Contact Details"; Entity = "contact" }, + @{ Name = "Case Resolution Report"; Entity = "incident" }, + @{ Name = "Opportunity Review"; Entity = "opportunity" }, + @{ Name = "Quote Summary"; Entity = "quote" }, + @{ Name = "Task Report"; Entity = "task" }, + @{ Name = "Team Roster"; Entity = "team" }, + @{ Name = "User Directory"; Entity = "systemuser" }, + @{ Name = "Appointment Schedule"; Entity = "appointment" }, + @{ Name = "Email Digest"; Entity = "email" } +) +$WANTED = 6 + +function Get-EntityTypeCode { + param([string]$LogicalName) + try { + $req = New-Object Microsoft.Xrm.Sdk.Messages.RetrieveEntityRequest + $req.LogicalName = $LogicalName + $req.EntityFilters = [Microsoft.Xrm.Sdk.Metadata.EntityFilters]::Entity + $md = ([Microsoft.Xrm.Sdk.Messages.RetrieveEntityResponse]$svc.Execute($req)).EntityMetadata + return [int]$md.ObjectTypeCode + } catch { + return $null + } +} + +# Setting associatedentitytypecode is not enough on its own: the server takes the associated +# table from the template content's customXml and writes the attribute from that, so a +# template whose content names a table this organization does not have lands as "none" with +# no formatted value at all -- which is exactly what the tool then crashes on. +# +# test_doc.docx is a template for cr543_opportunity, object type code 10654. Rebinding it +# means rewriting that pair in the two customXml parts that declare it: item1.xml carries +# the schema in its namespace and root element, itemProps1.xml repeats it as a schemaRef. +function New-ReboundContent { + param([string]$SourceDocx, [string]$LogicalName, [int]$TypeCode) + + $tmp = Join-Path ([IO.Path]::GetTempPath()) ("tpl-" + [guid]::NewGuid().ToString("N") + ".docx") + Copy-Item -LiteralPath $SourceDocx -Destination $tmp + + Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::Open($tmp, [System.IO.Compression.ZipArchiveMode]::Update) + try { + foreach ($part in @("customXml/item1.xml", "customXml/itemProps1.xml")) { + $entry = $zip.Entries | Where-Object { $_.FullName -eq $part } + if (-not $entry) { continue } + + $sr = New-Object System.IO.StreamReader($entry.Open()) + $text = $sr.ReadToEnd(); $sr.Close() + + $text = $text.Replace("document-template/cr543_opportunity/10654/", "document-template/$LogicalName/$TypeCode/") + $text = $text.Replace("", "") + + $s = $entry.Open() + $s.SetLength(0) + $sw = New-Object System.IO.StreamWriter($s) + $sw.Write($text); $sw.Flush(); $sw.Close() + } + } finally { + $zip.Dispose() + } + + $bytes = [IO.File]::ReadAllBytes($tmp) + Remove-Item -LiteralPath $tmp -Force + return [Convert]::ToBase64String($bytes) +} + +$names = $candidates | ForEach-Object { $_.Name } + +if ($Remove) { + $existing = Get-Templates | Where-Object { $names -contains $_["name"] } + foreach ($r in $existing) { + $svc.Delete("documenttemplate", $r.Id) + Write-Host "Deleted $($r["name"])" + } + if (-not $existing) { Write-Host "Nothing to delete." } + exit 0 +} + +$docx = Join-Path $root "test_doc.docx" +if (-not (Test-Path $docx)) { throw "test_doc.docx not found at $docx." } + +# The record the crash is about: content left bound to cr543_opportunity, a table this +# organization does not have. The server cannot resolve it, so the attribute lands as 'none' +# with no formatted value beside it, which is the state the tool used to die on. Uploaded +# unrebound, i.e. exactly what happens when a template is moved between organizations. +if ($Broken) { + $name = "Unresolvable Table (repro)" + if (@(Get-Templates | ForEach-Object { $_["name"] }) -contains $name) { + Write-Host "'$name' is already there." + } else { + $raw = [Convert]::ToBase64String([IO.File]::ReadAllBytes($docx)) + $id = [TemplateSeed]::Create($svc, $name, 2, "account", $raw) + Write-Host "Created '$name' ($id)" + } + Write-Host "" + & $MyInvocation.MyCommand.Path -Dump + exit 0 +} + +$WORD = 2 + +# associatedentitytypecode reads as an integer but is a string attribute holding the table's +# logical name. Putting a number in it creates a record that Dataverse accepts and the tool +# then crashes on, because it reads the attribute as a string. +Write-Host "Checking which tables this organization has ..." +$templates = @() +foreach ($c in $candidates) { + if ($templates.Count -ge $WANTED) { break } + $code = Get-EntityTypeCode $c.Entity + if ($null -ne $code) { + $templates += @{ Name = $c.Name; Entity = $c.Entity; TypeCode = $code } + } else { + Write-Host " no '$($c.Entity)' table here, skipping $($c.Name)" + } +} +if ($templates.Count -eq 0) { throw "None of the candidate tables exist in this organization." } + +$existingNames = @(Get-Templates | ForEach-Object { $_["name"] }) +foreach ($t in $templates) { + if ($existingNames -contains $t.Name) { + Write-Host "Skipped $($t.Name) (already there)." + continue + } + $content = New-ReboundContent -SourceDocx $docx -LogicalName $t.Entity -TypeCode $t.TypeCode + $id = [TemplateSeed]::Create($svc, $t.Name, $WORD, $t.Entity, $content) + if (-not $id) { throw "Create failed for '$($t.Name)': $($svc.LastCrmError)" } + Write-Host "Created $($t.Name) -> $($t.Entity) ($($t.TypeCode)) ($id)" +} + +Write-Host "" +Show-Templates diff --git a/tests/ui.ps1 b/tests/ui.ps1 new file mode 100644 index 0000000..716e264 --- /dev/null +++ b/tests/ui.ps1 @@ -0,0 +1,293 @@ +# Drives the tool in a running sandbox and photographs its grid, so what the seeded templates +# look like in the tool can be shown rather than described. The folder dialog is a separate +# script, dialog-shots.ps1. +# +# .\tests\ui.ps1 -Out docs\shots -Tag after +# +# Runs on Windows PowerShell 5.1: UIAutomationClient is a .NET Framework assembly with no +# .NET 8 equivalent. Start the instance with xtb.ps1 first and let it finish connecting. +# +# Windows are captured with PrintWindow rather than by grabbing screen pixels, so the shots +# are of this tool even when something else is on top of it, and driving it does not have to +# steal the foreground from whatever you are doing. +# +# Be warned that it does not always get there. XrmToolBox rebuilds its automation tree while +# the tool's tab opens and connects, and a run that starts during the wrong moment of that can +# fail to see a toolbar it is looking straight at. Re-run it against a freshly started, fully +# connected instance and it goes through. Nothing it does is destructive, so re-running is free. + +param( + [Parameter(Mandatory)][string]$Out, + [Parameter(Mandatory)][string]$Tag, + [string]$InstanceRoot +) + +$ErrorActionPreference = "Stop" + +if ($PSVersionTable.PSEdition -eq "Core") { + $argv = @("-Out", $Out, "-Tag", $Tag) + if ($InstanceRoot) { $argv += @("-InstanceRoot", $InstanceRoot) } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File $MyInvocation.MyCommand.Path @argv + exit $LASTEXITCODE +} + +Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes, System.Windows.Forms, System.Drawing + +Add-Type @" +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +public static class Nat { + public delegate bool EnumProc(IntPtr h, IntPtr p); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr p); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r); + [DllImport("user32.dll")] public static extern int GetClassName(IntPtr h, System.Text.StringBuilder s, int n); + [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr dc, uint flags); + [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; } + + public static List TopLevel(uint pid) { + var found = new List(); + EnumWindows((h, p) => { + uint w; GetWindowThreadProcessId(h, out w); + if (w == pid && IsWindowVisible(h)) found.Add(h); + return true; + }, IntPtr.Zero); + return found; + } + public static string ClassOf(IntPtr h) { + var sb = new System.Text.StringBuilder(256); + GetClassName(h, sb, sb.Capacity); + return sb.ToString(); + } +} +"@ + +function Save-Window { + param([IntPtr]$Handle, [string]$Path) + $r = New-Object Nat+RECT + [Nat]::GetWindowRect($Handle, [ref]$r) | Out-Null + $w = $r.Right - $r.Left; $h = $r.Bottom - $r.Top + if ($w -le 0 -or $h -le 0) { throw "Window $Handle has no size." } + $bmp = New-Object System.Drawing.Bitmap $w, $h + $g = [System.Drawing.Graphics]::FromImage($bmp) + $dc = $g.GetHdc() + # 2 is PW_RENDERFULLCONTENT, which is what makes it work for windows that draw + # themselves through DirectComposition, the shell dialog among them. + [Nat]::PrintWindow($Handle, $dc, 2) | Out-Null + $g.ReleaseHdc($dc); $g.Dispose() + $bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) + $bmp.Dispose() + Write-Host " saved $(Split-Path -Leaf $Path) ($w x $h)" +} + +if (-not $InstanceRoot) { $InstanceRoot = Join-Path (Split-Path -Parent $PSScriptRoot) ".xtb" } +$InstanceRoot = [IO.Path]::GetFullPath($InstanceRoot) + +$proc = Get-CimInstance Win32_Process -Filter "Name='XrmToolBox.exe'" | + Where-Object { $_.CommandLine -and $_.CommandLine.Contains("/overridepath:$InstanceRoot") } +if (-not $proc) { throw "No XrmToolBox running for $InstanceRoot. Start it with xtb.ps1." } +$procId = [uint32]$proc.ProcessId +Write-Host "Driving pid $procId ($InstanceRoot)" + +New-Item -ItemType Directory -Force -Path $Out | Out-Null +$Out = (Resolve-Path $Out).Path + +# The largest visible top-level window of the process, rather than Process.MainWindowHandle, +# which drifts onto tooltips and other transient windows and then reports a main window a few +# pixels wide with nothing inside it. Launched moments ago the process also exists before any +# window does, so this waits. +function Get-MainWindow { + param([uint32]$OwnerPid) + $best = [IntPtr]::Zero + $bestArea = 0 + foreach ($h in [Nat]::TopLevel($OwnerPid)) { + $r = New-Object Nat+RECT + if (-not [Nat]::GetWindowRect($h, [ref]$r)) { continue } + $area = ($r.Right - $r.Left) * ($r.Bottom - $r.Top) + if ($area -gt $bestArea) { $bestArea = $area; $best = $h } + } + # A tooltip or a splash is small; a real main window is not. + if ($bestArea -lt 200000) { return [IntPtr]::Zero } + return $best +} + +$main = [IntPtr]::Zero +$deadline = (Get-Date).AddSeconds(60) +do { + Start-Sleep -Milliseconds 500 + $main = Get-MainWindow -OwnerPid $procId +} while ($main -eq [IntPtr]::Zero -and (Get-Date) -lt $deadline) +if ($main -eq [IntPtr]::Zero) { throw "XrmToolBox never showed a window big enough to be its main one." } + +$root = [System.Windows.Automation.AutomationElement]::FromHandle($main) + +function Find-Element { + param($Parent, [string]$Name, [string]$Type, [int]$TimeoutMs = 20000) + $cond = if ($Type) { + New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $Name)), + (New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, $Type))) + } else { + New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $Name) + } + $deadline = (Get-Date).AddMilliseconds($TimeoutMs) + do { + $el = $Parent.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $cond) + if ($el) { return $el } + Start-Sleep -Milliseconds 400 + } while ((Get-Date) -lt $deadline) + throw "Could not find '$Name'." +} + +# Invoke fails intermittently with COM errors ("Could not open the process token") when the +# target is mid-repaint, so it is retried rather than treated as fatal on the first try. +function Invoke-Element { + param($El, [int]$Attempts = 6) + for ($i = 1; $i -le $Attempts; $i++) { + try { + $El.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + return + } catch { + if ($i -eq $Attempts) { throw } + Start-Sleep -Milliseconds 700 + } + } +} + +# Same as Find-Element, but re-resolves the main window every time round. Between launch and +# a usable toolbar XrmToolBox shows a splash, then connects, then opens the tool's tab, and +# the window that is largest -- and the automation tree hanging off it -- changes underneath +# a root captured once at the start. +function Find-InMain { + param([string]$Name, [int]$TimeoutMs = 150000) + $cond = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, $Name) + $deadline = (Get-Date).AddMilliseconds($TimeoutMs) + do { + $h = Get-MainWindow -OwnerPid $procId + if ($h -ne [IntPtr]::Zero) { + $script:main = $h + $script:root = [System.Windows.Automation.AutomationElement]::FromHandle($h) + # More than one element can carry the name -- the button, its tooltip, a label -- + # and only the button can be invoked, so take the first match that actually can. + $all = $script:root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond) + foreach ($i in 0..([Math]::Max($all.Count - 1, 0))) { + if ($i -ge $all.Count) { break } + $cand = $all.Item($i) + $pat = $null + if ($cand.TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$pat)) { + return $cand + } + } + } + # A modal dialog -- a connection failure, an update prompt -- leaves the main window + # present but unreachable, and waiting the full timeout hides why. Say what it says. + foreach ($h in [Nat]::TopLevel($procId)) { + if ([Nat]::ClassOf($h) -eq "#32770") { + $dlg = [System.Windows.Automation.AutomationElement]::FromHandle($h) + $texts = $dlg.FindAll([System.Windows.Automation.TreeScope]::Descendants, + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Text))) + $msg = @() + foreach ($i in 0..([Math]::Max($texts.Count - 1, 0))) { + if ($i -lt $texts.Count) { $msg += $texts.Item($i).Current.Name } + } + # Empty ones are transient shells that appear and go during startup; only a + # dialog with something written on it is actually in the way. + $text = ($msg | Where-Object { $_ }) -join ' ' + if ($text) { throw "A dialog is blocking the tool: $text" } + } + } + Start-Sleep -Milliseconds 700 + } while ((Get-Date) -lt $deadline) + throw "Could not find '$Name' within $([int]($TimeoutMs/1000))s." +} + +$listCondG = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::List) +$itemCondG = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::ListItem) + +# Descendants rather than Children: the grid groups its rows by template type, so they hang +# off a "Microsoft Word" group instead of off the list itself. +function Get-Rows { + $h = Get-MainWindow -OwnerPid $procId + if ($h -eq [IntPtr]::Zero) { return $null } + $r = [System.Windows.Automation.AutomationElement]::FromHandle($h) + $l = $r.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $listCondG) + if (-not $l) { return $null } + return $l.FindAll([System.Windows.Automation.TreeScope]::Descendants, $itemCondG) +} + +# --- load the templates ------------------------------------------------------------------- + +Write-Host "Waiting for the tool's toolbar ..." +$loadBtn = Find-InMain -Name "Load Templates" + +# The tool loads by itself when its tab opens, so this only presses the button when that has +# not already happened. Pressing it on a filled grid raises a "Clear the current list of +# Document Templates and reload from the server?" prompt, which then blocks everything after. +$rows = Get-Rows +if ($rows -and $rows.Count -gt 0) { + Write-Host "Grid already holds $($rows.Count) row(s); not reloading" +} else { + Write-Host "Load Templates" + # Re-found on every attempt, not just re-invoked: the toolbar is rebuilt as the tab + # finishes opening, and an element located before that goes stale, reporting + # "Unsupported Pattern" at the moment it is used rather than when it was found. + for ($i = 1; $i -le 8; $i++) { + try { + $btn = Find-InMain -Name "Load Templates" -TimeoutMs 15000 + $btn.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + break + } catch { + if ($i -eq 8) { throw } + Start-Sleep -Milliseconds 900 + } + } +} + +# The retrieve runs on a worker thread behind a "Retrieving the list of Document Templates" +# panel, so wait for rows rather than for a fixed number of seconds. +$listCond = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::List) +$itemCond = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::ListItem) + +$items = $null +$deadline = (Get-Date).AddSeconds(90) +do { + Start-Sleep -Milliseconds 800 + $found = Get-Rows + if ($found -and $found.Count -gt 0) { $items = $found } +} while (-not $items -and (Get-Date) -lt $deadline) + +if (-not $items) { throw "The grid never filled. Run tests\seed.ps1, and check the tool finished loading." } +Write-Host "$($items.Count) template(s) in the grid" +Start-Sleep -Milliseconds 500 +Save-Window -Handle $main -Path (Join-Path $Out "$Tag-1-templates.png") + +# --- select them -------------------------------------------------------------------------- + + +foreach ($i in 0..($items.Count - 1)) { + $si = $items.Item($i).GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + if ($i -eq 0) { $si.Select() } else { $si.AddToSelection() } +} +Start-Sleep -Milliseconds 800 +Save-Window -Handle $main -Path (Join-Path $Out "$Tag-2-selected.png") + + +# The download dialog itself is photographed by dialog-shots.ps1, not from here. Driving +# it through XrmToolBox proved unreliable: the automation tree is rebuilt while the tab +# opens, so a toolbar element found a moment earlier reports "Unsupported Pattern" when it +# is finally used, and the dialog does not depend on the tool being up anyway. + +Write-Host "Done. Shots in $Out" diff --git a/xtb.ps1 b/xtb.ps1 new file mode 100644 index 0000000..a43a2b6 --- /dev/null +++ b/xtb.ps1 @@ -0,0 +1,50 @@ +# Builds the tool and launches a private XrmToolBox instance that contains nothing but it. +# +# The instance lives in .xtb and is created from scratch, so it cannot disturb the +# XrmToolBox you use for real work: its own Plugins folder, its own settings, its own +# connection list. Delete the folder to undo everything this script did. +# +# .\xtb.ps1 # build, wire up, launch +# .\xtb.ps1 -Reset # throw the instance away and rebuild it +# .\xtb.ps1 -NoLaunch # set it up without starting XrmToolBox +# +# The connection points at the active organization of the current pac auth profile. Pass +# -Environment to aim somewhere else. +# +# test_doc.docx is left on the clipboard, because the tool takes a local .docx to upload and +# that is the one thing you always have to go and find. +# +# Unlike the sibling tools this is a non-SDK, packages.config project inherited from +# upstream, which dotnet build cannot compile at all: the .resx files hold images and the SDK +# build refuses those. So build.ps1 does the build on the full MSBuild, and the sandbox is +# asked not to build again (-NoBuild) but to take the dll build.ps1 produced, which lands in +# bin\Debug with no target framework folder in the path. +# +# Everything that is XrmToolBox rather than Document Template Manager lives in the XtbSandbox +# module (github.com/comentality/xrmtoolbox-sandbox), shared with the other tools. + +param( + [string]$Environment, + [string]$XrmToolBoxPath, + [switch]$Reset, + [switch]$NoLaunch +) + +$ErrorActionPreference = "Stop" + +if (-not (Get-Module -ListAvailable XtbSandbox)) { + throw "XtbSandbox is not installed. Run: Install-Module XtbSandbox -Scope CurrentUser`n(see https://github.com/comentality/xrmtoolbox-sandbox)" +} +Import-Module XtbSandbox + +& (Join-Path $PSScriptRoot "build.ps1") +if ($LASTEXITCODE -ne 0) { throw "Build failed with exit code $LASTEXITCODE." } + +Start-XtbSandbox @PSBoundParameters ` + -InstanceRoot (Join-Path $PSScriptRoot ".xtb") ` + -ProjectPath (Join-Path $PSScriptRoot "Futurez.Xrm.Tools.DocTemplateManager.csproj") ` + -NoBuild ` + -DllPath (Join-Path $PSScriptRoot "bin\Debug\Futurez.Xrm.Tools.DocTemplateManager.dll") ` + -ToolName "Document Template Manager" ` + -ConnectionName "DocTemplateManager E2E" ` + -Clipboard (Join-Path $PSScriptRoot "test_doc.docx")