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("", "$LogicalName>")
+
+ $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")