Invoke-IntuneProfileManager.ps1
|
#Requires -Version 7.0 <#PSScriptInfo .VERSION 1.2.0 .GUID 041b1471-ad40-45b7-9fb0-81a12f91cd19 .AUTHOR Alex Durrant .COMPANYNAME modernworkspacehub.com .COPYRIGHT (c) 2026 Alex Durrant / modernworkspacehub.com. All rights reserved. .TAGS Intune Graph MicrosoftGraph DeviceManagement ConfigurationProfiles BulkRename Rename MEM Windows .LICENSEURI https://github.com/durrante/Intune-Profile-Bulk-Renamer-Tool/blob/main/LICENSE .PROJECTURI https://github.com/durrante/Intune-Profile-Bulk-Renamer-Tool .ICONURI .EXTERNALMODULEDEPENDENCIES .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES 1.2.0 - Added the settings-based compliance policy engine (deviceManagement/compliancePolicies, used for Linux and newer compliance policies) alongside the existing legacy compliance policies. Fixed Apply/Import for content types whose name contains parentheses (e.g. App Protection (iOS)). 1.1.0 - Expanded from configuration profiles to ~20 Intune content types (compliance policies, app protection & configuration, PowerShell/remediation/macOS scripts, assignment filters, Autopilot profiles, device categories, driver/feature/quality updates, and opt-in Entra groups). Added a Content Types picker to choose what each Pull fetches, expanded Graph scopes, and fixed content-type selection persistence. 1.0.0 - Initial release. Throughout, only display name and description are ever modified; nothing else about an item is changed. .PRIVATEDATA #> <# .SYNOPSIS Intune Profile Manager — bulk rename & re-describe Intune configuration profiles. .DESCRIPTION A self-contained Windows Forms (PowerShell 7+) desktop tool that connects to Microsoft Graph and bulk-edits the display name and description of Intune objects. Only the name and description are ever changed - nothing else about an item is touched. Covers ~20 content types (all via Graph beta, so every derived template is surfaced): - Settings Catalog and Device Configuration (every template: device restrictions, domain join, Wi-Fi, VPN, SCEP/PKCS/trusted certificates, health monitoring, kiosk, custom, ...) - Administrative Templates and Templates / security baselines (intents) - Compliance policies - App protection (iOS/Android) and app configuration (managed apps/devices) - PowerShell, remediation and macOS shell scripts - Assignment filters, Autopilot profiles, device categories - Driver / feature / quality update profiles and quality update policies - Entra ID groups (opt-in) Workflow: 1. Connect to Microsoft Graph (delegated sign-in; consents the scopes for the above). 2. Pick which content types to pull, then Pull them into an editable grid. 3. Edit New Name / New Description inline, use Find & Replace, or Export to CSV, edit in Excel, and Import back. 4. Apply - only items whose name or description changed are PATCHed. 5. Dry-run mode previews changes without calling Graph. JSON backup & restore included. Project, documentation and issues: https://github.com/durrante/Intune-Profile-Bulk-Renamer-Tool A modernworkspacehub.com tool, part of the same toolset as Win32Forge (https://github.com/durrante/Win32Forge). Provided "as is", without warranty of any kind - use at your own risk. Not affiliated with, endorsed by, or supported by Microsoft. Requires the Microsoft Graph PowerShell SDK authentication module: Install-Module Microsoft.Graph.Authentication -Scope CurrentUser .EXAMPLE pwsh .\Invoke-IntuneProfileManager.ps1 #> [CmdletBinding()] param() $ErrorActionPreference = 'Stop' # Belt-and-braces check in case the #Requires line is somehow bypassed if ($PSVersionTable.PSVersion.Major -lt 7) { Write-Error ("This tool requires PowerShell 7 (pwsh.exe).`n" + "You are running PowerShell $($PSVersionTable.PSVersion).`n`n" + "Start the tool with: pwsh `"$PSCommandPath`"") exit 1 } # ───────────────────────────────────────────────────────────────────────────── #region Assemblies & palette # ───────────────────────────────────────────────────────────────────────────── Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing [System.Windows.Forms.Application]::EnableVisualStyles() # Can only be set once per process — throws if a WinForms object already exists # (e.g. when re-running in the same PowerShell session). Safe to ignore in that case. try { [System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($false) } catch {} function C { param([string]$Hex) [System.Drawing.ColorTranslator]::FromHtml($Hex) } # Palette lifted directly from Win32Forge $Theme = @{ GradLeft = C '#0693E3' # header gradient start (blue) GradRight = C '#9B51E0' # header gradient end (purple) SubtleText = C '#D4C5F9' # header subtitle StatusBg = C '#F0F0F0' Border = C '#DDDDDD' ToolbarBg = C '#F8F8F8' ToolbarLine = C '#E0E0E0' FooterBg = C '#F5F5F5' FooterText = C '#666666' FooterFaint = C '#AAAAAA' Primary = C '#4A2B8F' # main purple PrimaryDark = C '#2D1B69' AccentBlue = C '#5BA3E8' DeepPurple = C '#3A2673' DotRed = C '#D32F2F' DotGreen = C '#2E7D32' GridHeaderBg = C '#F0EBF9' GridHeaderFg = C '#4A2B8F' GridAltRow = C '#FAFAFA' GridSelBg = C '#E8DEFF' GridSelFg = C '#2D1B69' GridLine = C '#DDDDDD' ChangedBg = C '#FFF4D6' # amber tint for pending-change cells ChangedFg = C '#8A5A00' LogOk = C '#2E7D32' LogWarn = C '#B7791F' LogFail = C '#C62828' LogInfo = C '#333333' White = [System.Drawing.Color]::White BtnText = C '#333333' } $FontUI = New-Object System.Drawing.Font('Segoe UI', 9) $FontUIBold = New-Object System.Drawing.Font('Segoe UI', 9, [System.Drawing.FontStyle]::Bold) $FontTitle = New-Object System.Drawing.Font('Segoe UI Light', 18, [System.Drawing.FontStyle]::Regular) $FontSub = New-Object System.Drawing.Font('Segoe UI', 8.5) $FontMono = New-Object System.Drawing.Font('Consolas', 9.5) # Graph endpoints — beta is used throughout because it surfaces every derived type. $GraphBeta = 'https://graph.microsoft.com/beta' $GraphV1 = 'https://graph.microsoft.com/v1.0' # Delegated scopes requested at sign-in. Together they cover every content type below. # If the signed-in admin hasn't consented to one, the affected types are simply skipped # on pull (logged as a warning) rather than failing the whole operation. $RequiredScopes = @( 'DeviceManagementConfiguration.ReadWrite.All' # config, compliance, admin templates, baselines, filters, update profiles 'DeviceManagementScripts.ReadWrite.All' # PowerShell / remediation / macOS shell scripts 'DeviceManagementApps.ReadWrite.All' # app protection & app configuration policies 'DeviceManagementServiceConfig.ReadWrite.All' # Autopilot profiles, enrollment configurations 'Group.ReadWrite.All' # Entra ID groups (opt-in content type) ) # Content type catalogue — every Intune object family the tool can rename / re-describe. # Keyed by the friendly name shown in the grid, CSV and Content Types picker. # Base/Collection : Graph endpoint # Select : $select clause (keeps payloads small) # NameProp : property holding the display name when reading # PatchNameProp : property name the new display name is sent as on PATCH # NeedsODataType : polymorphic collections need the derived @odata.type on PATCH # Subtype : show the specific template kind in brackets (Device Configuration only) # Default : pre-checked in the Content Types picker (Entra groups are opt-in) $ContentTypes = [ordered]@{ 'Settings Catalog' = @{ Base=$GraphBeta; Collection='deviceManagement/configurationPolicies'; Select='id,name,description'; NameProp='name'; PatchNameProp='name'; NeedsODataType=$false; Subtype=$false; Default=$true } 'Device Configuration' = @{ Base=$GraphBeta; Collection='deviceManagement/deviceConfigurations'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$true; Subtype=$true; Default=$true } 'Administrative Template' = @{ Base=$GraphBeta; Collection='deviceManagement/groupPolicyConfigurations'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$false; Subtype=$false; Default=$true } 'Template / Baseline' = @{ Base=$GraphBeta; Collection='deviceManagement/intents'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$false; Subtype=$false; Default=$true } 'Compliance Policy' = @{ Base=$GraphBeta; Collection='deviceManagement/deviceCompliancePolicies'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$true; Subtype=$false; Default=$true } 'Compliance Policy (Settings)'= @{ Base=$GraphBeta; Collection='deviceManagement/compliancePolicies'; Select='id,name,description'; NameProp='name'; PatchNameProp='name'; NeedsODataType=$false; Subtype=$false; Default=$true } 'PowerShell Script' = @{ Base=$GraphBeta; Collection='deviceManagement/deviceManagementScripts'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$false; Subtype=$false; Default=$true } 'Remediation Script' = @{ Base=$GraphBeta; Collection='deviceManagement/deviceHealthScripts'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$false; Subtype=$false; Default=$true } 'macOS Shell Script' = @{ Base=$GraphBeta; Collection='deviceManagement/deviceShellScripts'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$false; Subtype=$false; Default=$true } 'App Protection (iOS)' = @{ Base=$GraphBeta; Collection='deviceAppManagement/iosManagedAppProtections'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$false; Subtype=$false; Default=$true } 'App Protection (Android)' = @{ Base=$GraphBeta; Collection='deviceAppManagement/androidManagedAppProtections'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$false; Subtype=$false; Default=$true } 'App Config (managed apps)' = @{ Base=$GraphBeta; Collection='deviceAppManagement/targetedManagedAppConfigurations'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$false; Subtype=$false; Default=$true } 'App Config (managed devices)'= @{ Base=$GraphBeta; Collection='deviceAppManagement/mobileAppConfigurations'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$true; Subtype=$false; Default=$true } 'Assignment Filter' = @{ Base=$GraphBeta; Collection='deviceManagement/assignmentFilters'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$false; Subtype=$false; Default=$true } 'Autopilot Profile' = @{ Base=$GraphBeta; Collection='deviceManagement/windowsAutopilotDeploymentProfiles'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$true; Subtype=$false; Default=$true } 'Device Category' = @{ Base=$GraphBeta; Collection='deviceManagement/deviceCategories'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$false; Subtype=$false; Default=$true } 'Driver Update Profile' = @{ Base=$GraphBeta; Collection='deviceManagement/windowsDriverUpdateProfiles'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$false; Subtype=$false; Default=$true } 'Feature Update Profile' = @{ Base=$GraphBeta; Collection='deviceManagement/windowsFeatureUpdateProfiles'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$false; Subtype=$false; Default=$true } 'Quality Update Profile' = @{ Base=$GraphBeta; Collection='deviceManagement/windowsQualityUpdateProfiles'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$false; Subtype=$false; Default=$true } 'Quality Update Policy' = @{ Base=$GraphBeta; Collection='deviceManagement/windowsQualityUpdatePolicies'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$false; Subtype=$false; Default=$true } 'Entra Group' = @{ Base=$GraphV1; Collection='groups'; Select='id,displayName,description'; NameProp='displayName'; PatchNameProp='displayName'; NeedsODataType=$false; Subtype=$false; Default=$false } } # ── Paths (logs + backups live in the same repo as the script) ────────────── $script:ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } $script:LogDir = Join-Path $script:ScriptDir 'Logs' $script:BackupDir = Join-Path $script:ScriptDir 'Backups' foreach ($d in @($script:LogDir, $script:BackupDir)) { if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null } } $script:LogFile = Join-Path $script:LogDir "IntuneProfileManager_$(Get-Date -Format 'yyyyMMdd').log" # ── Shared state ──────────────────────────────────────────────────────────── $script:Connected = $false $script:Loading = $false # suppresses highlight churn during bulk grid load $script:OdataMap = @{} # ProfileId -> @odata.type (polymorphic types only) # Which content types the next Pull will fetch (chosen in the Content Types picker). # Defaults to every type flagged Default=$true (i.e. everything except Entra groups). # NOTE: global scope — the picker saves from inside a closure, and a $script: write # inside a closure lands in the closure's own scope, not the real script scope. $global:IPMSelectedContentTypes = [System.Collections.Generic.List[string]]::new() foreach ($k in $ContentTypes.Keys) { if ($ContentTypes[$k].Default) { $global:IPMSelectedContentTypes.Add($k) } } #endregion # ───────────────────────────────────────────────────────────────────────────── #region Button styling helpers (match Win32Forge tile / tool button look) # ───────────────────────────────────────────────────────────────────────────── # Darken (factor < 1) or lighten (factor > 1) a colour — used for solid hover states function Get-Shade { param([System.Drawing.Color]$Color, [double]$Factor) $r = [Math]::Max(0, [Math]::Min(255, [int]($Color.R * $Factor))) $g = [Math]::Max(0, [Math]::Min(255, [int]($Color.G * $Factor))) $b = [Math]::Max(0, [Math]::Min(255, [int]($Color.B * $Factor))) return [System.Drawing.Color]::FromArgb($r, $g, $b) } # Consistent sizing shared by every toolbar / status button so the row lines up cleanly. # -Compact gives a shorter, tighter button for the slim status bar. function Set-ButtonMetrics { param([System.Windows.Forms.Button]$Btn, [switch]$Compact) $Btn.FlatStyle = 'Flat' $Btn.Font = $FontUI $Btn.Cursor = 'Hand' $Btn.AutoSize = $true $Btn.AutoSizeMode = 'GrowAndShrink' $Btn.TextAlign = 'MiddleCenter' if ($Compact) { $Btn.Padding = New-Object System.Windows.Forms.Padding(14, 3, 14, 3) $Btn.MinimumSize = New-Object System.Drawing.Size(0, 26) $Btn.Margin = New-Object System.Windows.Forms.Padding(0, 0, 0, 0) } else { $Btn.Padding = New-Object System.Windows.Forms.Padding(15, 5, 15, 5) $Btn.MinimumSize = New-Object System.Drawing.Size(0, 30) $Btn.Margin = New-Object System.Windows.Forms.Padding(0, 12, 8, 0) } } function Set-PrimaryButton { param([System.Windows.Forms.Button]$Btn, [System.Drawing.Color]$Back, [switch]$Compact) Set-ButtonMetrics -Btn $Btn -Compact:$Compact $Btn.BackColor = $Back $Btn.ForeColor = $Theme.White $Btn.FlatAppearance.BorderSize = 0 $Btn.FlatAppearance.MouseOverBackColor = Get-Shade -Color $Back -Factor 0.88 $Btn.FlatAppearance.MouseDownBackColor = Get-Shade -Color $Back -Factor 0.75 } function Set-ToolButton { param([System.Windows.Forms.Button]$Btn, [switch]$Compact) Set-ButtonMetrics -Btn $Btn -Compact:$Compact $Btn.BackColor = $Theme.White $Btn.ForeColor = $Theme.BtnText $Btn.FlatAppearance.BorderColor = C '#CCCCCC' $Btn.FlatAppearance.BorderSize = 1 $Btn.FlatAppearance.MouseOverBackColor = C '#ECECEC' $Btn.FlatAppearance.MouseDownBackColor = C '#DDDDDD' } # Thin vertical divider between logical button groups in a FlowLayoutPanel function Add-FlowSeparator { param([System.Windows.Forms.FlowLayoutPanel]$Flow) $sep = New-Object System.Windows.Forms.Panel $sep.Size = New-Object System.Drawing.Size(1, 24) $sep.BackColor = C '#C4C4C4' $sep.Margin = New-Object System.Windows.Forms.Padding(6, 15, 13, 0) $Flow.Controls.Add($sep) return $sep } # Build the application icon at runtime from the brand gradient (no external .ico needed): # a rounded square in blue→purple with three white "rename" bars. Used for the title bar # and taskbar so the tool presents like a real app. function New-AppIcon { $size = 32 $bmp = New-Object System.Drawing.Bitmap($size, $size) $g = [System.Drawing.Graphics]::FromImage($bmp) $g.SmoothingMode = 'AntiAlias' $path = New-Object System.Drawing.Drawing2D.GraphicsPath $d = 14 # corner diameter $path.AddArc(0, 0, $d, $d, 180, 90) $path.AddArc($size - $d, 0, $d, $d, 270, 90) $path.AddArc($size - $d, $size - $d, $d, $d, 0, 90) $path.AddArc(0, $size - $d, $d, $d, 90, 90) $path.CloseFigure() $rect = New-Object System.Drawing.Rectangle(0, 0, $size, $size) $brush = New-Object System.Drawing.Drawing2D.LinearGradientBrush($rect, $Theme.GradLeft, $Theme.GradRight, 0.0) $g.FillPath($brush, $path) $white = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::White) foreach ($y in 8, 15, 22) { $g.FillRectangle($white, 7, $y, 18, 3) } $brush.Dispose(); $white.Dispose(); $path.Dispose(); $g.Dispose() $hicon = $bmp.GetHicon() return [System.Drawing.Icon]::FromHandle($hicon) } #endregion # ───────────────────────────────────────────────────────────────────────────── #region Form scaffold # ───────────────────────────────────────────────────────────────────────────── $form = New-Object System.Windows.Forms.Form $form.Text = 'Intune Profile Manager' $form.Size = New-Object System.Drawing.Size(1240, 760) $form.MinimumSize = New-Object System.Drawing.Size(1080, 560) $form.StartPosition = 'CenterScreen' $form.BackColor = $Theme.White $form.Font = $FontUI try { $form.Icon = New-AppIcon } catch {} # Root layout — 5 stacked rows $root = New-Object System.Windows.Forms.TableLayoutPanel $root.Dock = 'Fill' $root.ColumnCount = 1 $root.RowCount = 5 $root.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null $root.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 64))) | Out-Null # header $root.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 42))) | Out-Null # status $root.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 54))) | Out-Null # toolbar $root.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null # split $root.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 28))) | Out-Null # footer $form.Controls.Add($root) # ── HEADER (gradient, painted) ─────────────────────────────────────────────── $header = New-Object System.Windows.Forms.Panel $header.Dock = 'Fill' $header.Add_Paint({ param($s, $e) $rect = $s.ClientRectangle if ($rect.Width -le 0 -or $rect.Height -le 0) { return } $brush = New-Object System.Drawing.Drawing2D.LinearGradientBrush( $rect, $Theme.GradLeft, $Theme.GradRight, 0.0) $e.Graphics.FillRectangle($brush, $rect) $brush.Dispose() $e.Graphics.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::ClearTypeGridFit $titleBrush = New-Object System.Drawing.SolidBrush($Theme.White) $subBrush = New-Object System.Drawing.SolidBrush($Theme.SubtleText) $e.Graphics.DrawString('Intune Profile Manager', $FontTitle, $titleBrush, 18, 8) $e.Graphics.DrawString('Bulk rename & re-describe Intune configuration profiles • modernworkspacehub.com', $FontSub, $subBrush, 21, 40) $titleBrush.Dispose() $subBrush.Dispose() }) $root.Controls.Add($header, 0, 0) # ── STATUS BAR ─────────────────────────────────────────────────────────────── $statusBar = New-Object System.Windows.Forms.Panel $statusBar.Dock = 'Fill' $statusBar.BackColor = $Theme.StatusBg $statusBar.Padding = New-Object System.Windows.Forms.Padding(20, 0, 12, 0) $root.Controls.Add($statusBar, 0, 1) # Connection dot (painted circle) $connDot = New-Object System.Windows.Forms.Panel $connDot.Size = New-Object System.Drawing.Size(12, 12) $connDot.Location = New-Object System.Drawing.Point(2, 15) $script:DotColor = $Theme.DotRed $connDot.Add_Paint({ param($s, $e) $e.Graphics.SmoothingMode = 'AntiAlias' $b = New-Object System.Drawing.SolidBrush($script:DotColor) $e.Graphics.FillEllipse($b, 0, 0, 11, 11) $b.Dispose() }) $statusBar.Controls.Add($connDot) $lblStatus = New-Object System.Windows.Forms.Label $lblStatus.Text = 'Not connected' $lblStatus.Font = $FontUI $lblStatus.AutoSize = $true $lblStatus.Location = New-Object System.Drawing.Point(22, 13) $statusBar.Controls.Add($lblStatus) $btnConnect = New-Object System.Windows.Forms.Button $btnConnect.Text = 'Connect to Intune' $btnConnect.Anchor = 'Top,Right' Set-PrimaryButton -Btn $btnConnect -Back $Theme.Primary -Compact $statusBar.Controls.Add($btnConnect) # Right-align and vertically centre the Connect button within the status bar function Update-ConnectButtonLayout { $y = [int](($statusBar.ClientSize.Height - $btnConnect.Height) / 2) if ($y -lt 0) { $y = 0 } $btnConnect.Location = New-Object System.Drawing.Point(($statusBar.ClientSize.Width - $btnConnect.Width - 16), $y) } $statusBar.Add_Resize({ Update-ConnectButtonLayout }) Update-ConnectButtonLayout # ── TOOLBAR ────────────────────────────────────────────────────────────────── $toolbar = New-Object System.Windows.Forms.Panel $toolbar.Dock = 'Fill' $toolbar.BackColor = $Theme.ToolbarBg $toolbar.Add_Paint({ param($s, $e) $pen = New-Object System.Drawing.Pen($Theme.ToolbarLine, 1) $e.Graphics.DrawLine($pen, 0, ($s.ClientSize.Height - 1), $s.ClientSize.Width, ($s.ClientSize.Height - 1)) $pen.Dispose() }) $root.Controls.Add($toolbar, 0, 2) $flow = New-Object System.Windows.Forms.FlowLayoutPanel $flow.Dock = 'Fill' $flow.FlowDirection = 'LeftToRight' $flow.WrapContents = $false $flow.Padding = New-Object System.Windows.Forms.Padding(10, 0, 10, 0) $toolbar.Controls.Add($flow) $btnPull = New-Object System.Windows.Forms.Button; $btnPull.Text = 'Pull' $btnTypes = New-Object System.Windows.Forms.Button; $btnTypes.Text = 'Content Types ▾' $btnExport = New-Object System.Windows.Forms.Button; $btnExport.Text = 'Export' $btnImport = New-Object System.Windows.Forms.Button; $btnImport.Text = 'Import' $btnFind = New-Object System.Windows.Forms.Button; $btnFind.Text = 'Find/Replace' $btnBackup = New-Object System.Windows.Forms.Button; $btnBackup.Text = 'Backup' $btnRestore = New-Object System.Windows.Forms.Button; $btnRestore.Text = 'Restore' $btnApply = New-Object System.Windows.Forms.Button; $btnApply.Text = 'Apply' $btnClear = New-Object System.Windows.Forms.Button; $btnClear.Text = 'Clear' Set-PrimaryButton -Btn $btnPull -Back $Theme.AccentBlue Set-ToolButton -Btn $btnTypes Set-ToolButton -Btn $btnExport Set-ToolButton -Btn $btnImport Set-ToolButton -Btn $btnFind Set-ToolButton -Btn $btnBackup Set-ToolButton -Btn $btnRestore Set-PrimaryButton -Btn $btnApply -Back $Theme.Primary Set-ToolButton -Btn $btnClear $chkDryRun = New-Object System.Windows.Forms.CheckBox $chkDryRun.Text = 'Dry run' $chkDryRun.AutoSize = $true $chkDryRun.Font = $FontUI $chkDryRun.Margin = New-Object System.Windows.Forms.Padding(6, 17, 10, 0) # Tooltips carry the detail the short labels drop $tip = New-Object System.Windows.Forms.ToolTip $tip.SetToolTip($btnPull, 'Pull the selected content types from Intune') $tip.SetToolTip($btnTypes, 'Choose which Intune content types to pull') $tip.SetToolTip($btnExport, 'Export the grid to a CSV file') $tip.SetToolTip($btnImport, 'Import an edited CSV back into the grid') $tip.SetToolTip($btnFind, 'Find & replace across New Name / New Description') $tip.SetToolTip($btnBackup, 'Save a JSON snapshot of current names & descriptions') $tip.SetToolTip($btnRestore, 'Restore names & descriptions from a JSON backup') $tip.SetToolTip($btnApply, 'Write changed profiles to Intune') $tip.SetToolTip($btnClear, 'Remove all rows from the grid') $tip.SetToolTip($chkDryRun, 'Preview changes without writing anything to Intune') # Initial enablement — set authoritatively by Set-ActionButtonsEnabled $btnExport.Enabled = $false $btnFind.Enabled = $false $btnBackup.Enabled = $false $btnApply.Enabled = $false $btnRestore.Enabled = $false # Grouped: [Pull · Content Types] | [Export / Import / Find] | [Backup / Restore] | [Apply / Dry run] | [Clear] $flow.Controls.Add($btnPull) $flow.Controls.Add($btnTypes) Add-FlowSeparator -Flow $flow | Out-Null $flow.Controls.Add($btnExport) $flow.Controls.Add($btnImport) $flow.Controls.Add($btnFind) Add-FlowSeparator -Flow $flow | Out-Null $flow.Controls.Add($btnBackup) $flow.Controls.Add($btnRestore) Add-FlowSeparator -Flow $flow | Out-Null $flow.Controls.Add($btnApply) $flow.Controls.Add($chkDryRun) Add-FlowSeparator -Flow $flow | Out-Null $flow.Controls.Add($btnClear) # ── SPLIT: grid (top) + log (bottom) ───────────────────────────────────────── $split = New-Object System.Windows.Forms.SplitContainer $split.Dock = 'Fill' $split.Orientation = 'Horizontal' $split.SplitterWidth = 6 $split.BackColor = $Theme.Border $root.Controls.Add($split, 0, 3) # DataGridView $grid = New-Object System.Windows.Forms.DataGridView $grid.Dock = 'Fill' $grid.AllowUserToAddRows = $false $grid.AllowUserToDeleteRows = $false $grid.AllowUserToResizeRows = $false $grid.RowHeadersVisible = $false $grid.SelectionMode = 'FullRowSelect' $grid.EditMode = 'EditOnKeystrokeOrF2' $grid.AutoSizeColumnsMode = 'Fill' $grid.BorderStyle = 'None' $grid.BackgroundColor = $Theme.White $grid.GridColor = $Theme.GridLine $grid.EnableHeadersVisualStyles = $false $grid.ColumnHeadersHeightSizeMode = 'DisableResizing' $grid.ColumnHeadersHeight = 32 $grid.ColumnHeadersBorderStyle = 'Single' $grid.ColumnHeadersDefaultCellStyle.BackColor = $Theme.GridHeaderBg $grid.ColumnHeadersDefaultCellStyle.ForeColor = $Theme.GridHeaderFg $grid.ColumnHeadersDefaultCellStyle.Font = $FontUIBold $grid.ColumnHeadersDefaultCellStyle.Padding = New-Object System.Windows.Forms.Padding(6, 0, 0, 0) $grid.AlternatingRowsDefaultCellStyle.BackColor = $Theme.GridAltRow $grid.DefaultCellStyle.SelectionBackColor = $Theme.GridSelBg $grid.DefaultCellStyle.SelectionForeColor = $Theme.GridSelFg $grid.DefaultCellStyle.Padding = New-Object System.Windows.Forms.Padding(4, 0, 0, 0) $grid.RowTemplate.Height = 26 function Add-GridColumn { param([string]$Name, [string]$Header, [bool]$ReadOnly, [int]$Weight, [bool]$Visible = $true) $col = New-Object System.Windows.Forms.DataGridViewTextBoxColumn $col.Name = $Name $col.HeaderText = $Header $col.ReadOnly = $ReadOnly $col.FillWeight = $Weight $col.Visible = $Visible $col.SortMode = 'NotSortable' if ($ReadOnly) { $col.DefaultCellStyle.ForeColor = C '#555555' } $grid.Columns.Add($col) | Out-Null } Add-GridColumn -Name 'ProfileId' -Header 'ProfileId' -ReadOnly $true -Weight 1 -Visible $false Add-GridColumn -Name 'ProfileType' -Header 'Type' -ReadOnly $true -Weight 14 Add-GridColumn -Name 'CurrentName' -Header 'Current Name' -ReadOnly $true -Weight 22 Add-GridColumn -Name 'NewName' -Header 'New Name' -ReadOnly $false -Weight 22 Add-GridColumn -Name 'CurrentDescription' -Header 'Current Description' -ReadOnly $true -Weight 21 Add-GridColumn -Name 'NewDescription' -Header 'New Description' -ReadOnly $false -Weight 21 $split.Panel1.Controls.Add($grid) # Log area (Panel2): header strip + Consolas box $logHeader = New-Object System.Windows.Forms.Panel $logHeader.Dock = 'Top' $logHeader.Height = 26 $logHeader.BackColor = $Theme.White $lblLog = New-Object System.Windows.Forms.Label $lblLog.Text = 'Activity Log' $lblLog.Font = $FontUIBold $lblLog.AutoSize = $true $lblLog.Location = New-Object System.Drawing.Point(4, 5) $logHeader.Controls.Add($lblLog) $btnClearLog = New-Object System.Windows.Forms.Button $btnClearLog.Text = 'Clear' $btnClearLog.Height = 22 $btnClearLog.Anchor = 'Top,Right' Set-ToolButton -Btn $btnClearLog $btnClearLog.AutoSize = $false $btnClearLog.Width = 60 $logHeader.Controls.Add($btnClearLog) $logHeader.Add_Resize({ $btnClearLog.Location = New-Object System.Drawing.Point(($logHeader.ClientSize.Width - $btnClearLog.Width - 4), 2) }.GetNewClosure()) $logBox = New-Object System.Windows.Forms.RichTextBox $logBox.Dock = 'Fill' $logBox.ReadOnly = $true $logBox.BackColor = $Theme.White $logBox.BorderStyle = 'None' $logBox.Font = $FontMono $logBox.WordWrap = $true $split.Panel2.Controls.Add($logBox) $split.Panel2.Controls.Add($logHeader) $split.Panel2.Padding = New-Object System.Windows.Forms.Padding(2, 0, 2, 2) # ── FOOTER ─────────────────────────────────────────────────────────────────── $footer = New-Object System.Windows.Forms.Panel $footer.Dock = 'Fill' $footer.BackColor = $Theme.FooterBg $footer.Add_Paint({ param($s, $e) $pen = New-Object System.Drawing.Pen($Theme.Border, 1) $e.Graphics.DrawLine($pen, 0, 0, $s.ClientSize.Width, 0) $pen.Dispose() }) $root.Controls.Add($footer, 0, 4) $lblFooter = New-Object System.Windows.Forms.Label $lblFooter.Text = 'Ready' $lblFooter.Font = $FontSub $lblFooter.ForeColor = $Theme.FooterText $lblFooter.AutoSize = $true $lblFooter.Location = New-Object System.Drawing.Point(16, 7) $footer.Controls.Add($lblFooter) $lblFooterR = New-Object System.Windows.Forms.Label $lblFooterR.Text = 'Intune Profile Manager — modernworkspacehub.com — Provided without warranty' $lblFooterR.Font = New-Object System.Drawing.Font('Segoe UI', 8) $lblFooterR.ForeColor = $Theme.FooterFaint $lblFooterR.AutoSize = $true $lblFooterR.Anchor = 'Top,Right' $footer.Controls.Add($lblFooterR) $footer.Add_Resize({ $lblFooterR.Location = New-Object System.Drawing.Point(($footer.ClientSize.Width - $lblFooterR.Width - 16), 7) }.GetNewClosure()) $lblFooterR.Location = New-Object System.Drawing.Point(($footer.ClientSize.Width - $lblFooterR.Width - 16), 7) # Splitter distance must be set after the form has a size $form.Add_Shown({ try { $split.SplitterDistance = [int]($split.Height * 0.62) } catch {} $logBox.Focus() | Out-Null }) #endregion # ───────────────────────────────────────────────────────────────────────────── #region Helper functions # ───────────────────────────────────────────────────────────────────────────── function Write-Log { param([string]$Text, [ValidateSet('Info','OK','Warn','Fail')][string]$Level = 'Info') $prefix, $color = switch ($Level) { 'OK' { '[OK] ', $Theme.LogOk } 'Warn' { '[WARN] ', $Theme.LogWarn } 'Fail' { '[FAIL] ', $Theme.LogFail } default{ '[INFO] ', $Theme.LogInfo } } $line = "$(Get-Date -Format 'HH:mm:ss') $prefix $Text`n" $logBox.SelectionStart = $logBox.TextLength $logBox.SelectionLength = 0 $logBox.SelectionColor = $color $logBox.AppendText($line) $logBox.SelectionColor = $logBox.ForeColor $logBox.ScrollToCaret() # Persist to the rolling daily log file in the repo (best-effort — never break the UI) try { $fileLine = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $prefix $Text" Add-Content -Path $script:LogFile -Value $fileLine -Encoding UTF8 } catch {} [System.Windows.Forms.Application]::DoEvents() } function Set-Footer { param([string]$Text) $lblFooter.Text = $Text; [System.Windows.Forms.Application]::DoEvents() } function Set-ConnectedState { param([bool]$On, [string]$Account = '', [string]$Tenant = '') $script:Connected = $On if ($On) { $script:DotColor = $Theme.DotGreen $lblStatus.Text = "Connected as $Account • Tenant: $Tenant" $btnConnect.Text = 'Disconnect' Set-PrimaryButton -Btn $btnConnect -Back $Theme.PrimaryDark -Compact } else { $script:DotColor = $Theme.DotRed $lblStatus.Text = 'Not connected' $btnConnect.Text = 'Connect to Intune' Set-PrimaryButton -Btn $btnConnect -Back $Theme.Primary -Compact } Update-ConnectButtonLayout $connDot.Invalidate() } # Robust string compare treating $null and '' as equal function Test-Differs { param($A, $B) return (([string]$A) -ne ([string]$B)) } function Test-RowChanged { param($Row) if ($Row.IsNewRow) { return $false } return (Test-Differs $Row.Cells['CurrentName'].Value $Row.Cells['NewName'].Value) -or (Test-Differs $Row.Cells['CurrentDescription'].Value $Row.Cells['NewDescription'].Value) } # Re-colour the editable cells of one row to flag pending changes function Update-RowHighlight { param($Row) if ($Row.IsNewRow) { return } $nameChanged = Test-Differs $Row.Cells['CurrentName'].Value $Row.Cells['NewName'].Value $descChanged = Test-Differs $Row.Cells['CurrentDescription'].Value $Row.Cells['NewDescription'].Value $nameCell = $Row.Cells['NewName'] if ($nameChanged) { $nameCell.Style.BackColor = $Theme.ChangedBg; $nameCell.Style.ForeColor = $Theme.ChangedFg } else { $nameCell.Style.BackColor = [System.Drawing.Color]::Empty; $nameCell.Style.ForeColor = [System.Drawing.Color]::Empty } $descCell = $Row.Cells['NewDescription'] if ($descChanged) { $descCell.Style.BackColor = $Theme.ChangedBg; $descCell.Style.ForeColor = $Theme.ChangedFg } else { $descCell.Style.BackColor = [System.Drawing.Color]::Empty; $descCell.Style.ForeColor = [System.Drawing.Color]::Empty } } function Update-AllHighlights { foreach ($r in $grid.Rows) { Update-RowHighlight -Row $r } } function Get-ChangedRowCount { $n = 0 foreach ($r in $grid.Rows) { if (Test-RowChanged -Row $r) { $n++ } } return $n } function Set-ActionButtonsEnabled { $has = $grid.Rows.Count -gt 0 $btnExport.Enabled = $has $btnFind.Enabled = $has $btnBackup.Enabled = $has $btnApply.Enabled = $has -and $script:Connected $btnRestore.Enabled = $script:Connected } # Graph collection getter with automatic @odata.nextLink paging function Get-GraphCollection { param([Parameter(Mandatory)][string]$Uri) $items = [System.Collections.Generic.List[object]]::new() $next = $Uri do { $resp = Invoke-MgGraphRequest -Method GET -Uri $next -OutputType PSObject if ($resp.value) { $items.AddRange([object[]]$resp.value) } $next = $resp.'@odata.nextLink' } while ($next) return $items } # Map a deviceConfiguration @odata.type to a friendly template kind shown in brackets, # e.g. "#microsoft.graph.windows81VpnConfiguration" -> "VPN". function Get-TemplateSubtype { param([string]$ODataType) if (-not $ODataType) { return $null } $t = ($ODataType.TrimStart('#')) -replace '^microsoft\.graph\.', '' switch -Regex ($t.ToLower()) { 'domainjoin' { return 'Domain join' } 'scepcertificate' { return 'SCEP certificate' } 'pkcscertificate' { return 'PKCS certificate' } 'trustedrootcertificate' { return 'Trusted certificate' } 'vpnconfiguration' { return 'VPN' } 'wifi' { return 'Wi-Fi' } 'wirednetwork' { return 'Wired network' } 'healthmonitoring' { return 'Health monitoring' } 'endpointprotection' { return 'Endpoint protection' } 'identityprotection' { return 'Identity protection' } 'advancedthreatprotection' { return 'Defender for Endpoint' } 'devicefirmware' { return 'DFCI' } 'kiosk' { return 'Kiosk' } 'editionupgrade' { return 'Edition upgrade' } 'easemail|emailprofile' { return 'Email' } 'customconfiguration' { return 'Custom (OMA-URI)' } 'deliveryoptimization' { return 'Delivery optimization' } 'networkboundary' { return 'Network boundary' } 'secureassessment' { return 'Secure assessment' } 'sharedpc' { return 'Shared multi-user' } 'updateforbusiness' { return 'Windows Update ring' } 'teamgeneral' { return 'Surface Hub' } 'generalconfiguration|generaldeviceconfiguration' { return 'Device restrictions' } default { # Fall back to a tidied concrete type name (strip suffix, split camelCase) $name = $t -replace '(Configuration|Profile)$', '' $name = $name -creplace '([a-z0-9])([A-Z])', '$1 $2' if ($name) { return $name.Trim() } else { return $null } } } } # The grid/CSV ProfileType may carry a subtype suffix "Base (Subtype)" (e.g. Device # Configuration (VPN)); routing needs the base key. Some content-type keys legitimately # contain parentheses (e.g. "App Protection (iOS)"), so match an exact key first and only # strip a trailing " (...)" when the full value isn't itself a known content type. function Get-BaseProfileType { param([string]$Value) if (-not $Value) { return $Value } if ($ContentTypes.Contains($Value)) { return $Value } $i = $Value.IndexOf(' (') if ($i -ge 0) { return $Value.Substring(0, $i) } return $Value } function Add-ProfileRow { param([string]$Id, [string]$Type, [string]$CurName, [string]$CurDesc, [string]$NewName, [string]$NewDesc) $idx = $grid.Rows.Add() $row = $grid.Rows[$idx] $row.Cells['ProfileId'].Value = $Id $row.Cells['ProfileType'].Value = $Type $row.Cells['CurrentName'].Value = $CurName $row.Cells['NewName'].Value = $NewName $row.Cells['CurrentDescription'].Value = $CurDesc $row.Cells['NewDescription'].Value = $NewDesc } #endregion # ───────────────────────────────────────────────────────────────────────────── #region Connect / Disconnect # ───────────────────────────────────────────────────────────────────────────── $btnConnect.Add_Click({ if ($script:Connected) { try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} Set-ConnectedState -On $false Set-ActionButtonsEnabled Set-Footer 'Disconnected' Write-Log 'Signed out of Microsoft Graph.' 'Info' return } Set-Footer 'Connecting to Microsoft Graph...' try { Import-Module Microsoft.Graph.Authentication -ErrorAction Stop } catch { Write-Log 'Microsoft Graph SDK not found.' 'Fail' [System.Windows.Forms.MessageBox]::Show( "The Microsoft Graph PowerShell SDK is required but not installed.`n`n" + "Install it with:`n`n Install-Module Microsoft.Graph.Authentication -Scope CurrentUser`n`n" + "Then restart this tool.", 'Graph SDK Missing', 'OK', 'Warning') | Out-Null Set-Footer 'Ready' return } try { Write-Log "Opening sign-in — requesting $($RequiredScopes.Count) delegated scopes..." 'Info' Connect-MgGraph -Scopes $RequiredScopes -NoWelcome -ErrorAction Stop | Out-Null $ctx = Get-MgContext if (-not $ctx) { throw 'No Graph context returned after sign-in.' } # Note any requested scopes that weren't granted — those content types will be skipped on pull $missing = @($RequiredScopes | Where-Object { $ctx.Scopes -notcontains $_ }) if ($missing.Count -gt 0) { Write-Log "Not consented: $($missing -join ', '). Content types needing these will be skipped." 'Warn' } Set-ConnectedState -On $true -Account $ctx.Account -Tenant $ctx.TenantId Set-ActionButtonsEnabled Write-Log "Connected to tenant $($ctx.TenantId) as $($ctx.Account)." 'OK' Set-Footer 'Connected. Choose Content Types if needed, then click Pull.' } catch { Set-ConnectedState -On $false Write-Log "Connection failed: $($_.Exception.Message)" 'Fail' [System.Windows.Forms.MessageBox]::Show( "Could not connect to Microsoft Graph:`n`n$($_.Exception.Message)`n`n" + "Check your network, account permissions, and that you consented to the requested scope.", 'Connection Failed', 'OK', 'Error') | Out-Null Set-Footer 'Ready' } }) #endregion # ───────────────────────────────────────────────────────────────────────────── #region Pull profiles # ───────────────────────────────────────────────────────────────────────────── $btnPull.Add_Click({ if (-not $script:Connected) { [System.Windows.Forms.MessageBox]::Show('Please connect to Intune first.', 'Not Connected', 'OK', 'Warning') | Out-Null return } if ($grid.Rows.Count -gt 0) { $ans = [System.Windows.Forms.MessageBox]::Show( 'Pulling will replace the current grid contents. Any unsaved edits will be lost. Continue?', 'Replace Grid?', 'YesNo', 'Question') if ($ans -ne 'Yes') { return } } # Only the content types ticked in the Content Types picker $typesToPull = @($ContentTypes.Keys | Where-Object { $global:IPMSelectedContentTypes -contains $_ }) if ($typesToPull.Count -eq 0) { [System.Windows.Forms.MessageBox]::Show('No content types selected. Click "Content Types" and tick at least one.', 'Nothing Selected', 'OK', 'Warning') | Out-Null return } $btnPull.Enabled = $false Set-Footer 'Pulling selected content types...' $script:Loading = $true $grid.Rows.Clear() $script:OdataMap.Clear() $total = 0; $anyFailed = $false Write-Log "Pulling $($typesToPull.Count) content type$(if ($typesToPull.Count -ne 1){'s'}): $($typesToPull -join ', ')" 'Info' foreach ($type in $typesToPull) { $kind = $ContentTypes[$type] $count = 0 try { Write-Log "Fetching $type..." 'Info' $uri = "$($kind.Base)/$($kind.Collection)?`$select=$($kind.Select)&`$top=100" $items = Get-GraphCollection -Uri $uri foreach ($p in $items) { $nm = [string]$p.($kind.NameProp) $ds = [string]$p.description $label = $type # Polymorphic types carry a derived @odata.type — capture it for the PATCH, and # (for Device Configuration) show the specific template kind, e.g. "(VPN)". if ($kind.NeedsODataType -and $p.'@odata.type') { $script:OdataMap[$p.id] = $p.'@odata.type' if ($kind.Subtype) { $sub = Get-TemplateSubtype $p.'@odata.type' if ($sub) { $label = "$type ($sub)" } } } Add-ProfileRow -Id $p.id -Type $label -CurName $nm -CurDesc $ds -NewName $nm -NewDesc $ds $count++ } Write-Log "Loaded $count $type item$(if ($count -ne 1){'s'})." 'OK' $total += $count } catch { $anyFailed = $true $msg = $_.Exception.Message if ($msg -match '403|Forbidden') { Write-Log "Skipped $type — access denied (role/scope not consented for this type)." 'Warn' } else { Write-Log "Failed to load $type`: $msg" 'Fail' } } } $script:Loading = $false Update-AllHighlights Set-ActionButtonsEnabled $btnPull.Enabled = $true Write-Log "Pull complete — $total item$(if ($total -ne 1){'s'}) total across $($typesToPull.Count) content type$(if ($typesToPull.Count -ne 1){'s'})." $(if ($anyFailed) { 'Warn' } else { 'OK' }) Set-Footer "$total items loaded. Edit New Name / New Description, or Export to CSV." }) #endregion # ───────────────────────────────────────────────────────────────────────────── #region Export CSV # ───────────────────────────────────────────────────────────────────────────── $btnExport.Add_Click({ if ($grid.Rows.Count -eq 0) { [System.Windows.Forms.MessageBox]::Show('Nothing to export — pull profiles first.', 'Empty Grid', 'OK', 'Information') | Out-Null return } $dlg = New-Object System.Windows.Forms.SaveFileDialog $dlg.Filter = 'CSV files (*.csv)|*.csv' $dlg.FileName = "IntuneProfiles_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" $dlg.Title = 'Export profiles to CSV' if ($dlg.ShowDialog() -ne 'OK') { return } try { $rows = foreach ($r in $grid.Rows) { if ($r.IsNewRow) { continue } [PSCustomObject]@{ ProfileId = [string]$r.Cells['ProfileId'].Value ProfileType = [string]$r.Cells['ProfileType'].Value CurrentName = [string]$r.Cells['CurrentName'].Value NewName = [string]$r.Cells['NewName'].Value CurrentDescription = [string]$r.Cells['CurrentDescription'].Value NewDescription = [string]$r.Cells['NewDescription'].Value } } $rows | Export-Csv -Path $dlg.FileName -NoTypeInformation -Encoding UTF8 Write-Log "Exported $(@($rows).Count) profiles to: $($dlg.FileName)" 'OK' Set-Footer "Exported to $($dlg.FileName)" # Open the CSV in the default handler (usually Excel) Start-Process -FilePath $dlg.FileName } catch { Write-Log "Export failed: $($_.Exception.Message)" 'Fail' [System.Windows.Forms.MessageBox]::Show("Could not export CSV:`n`n$($_.Exception.Message)", 'Export Failed', 'OK', 'Error') | Out-Null } }) #endregion # ───────────────────────────────────────────────────────────────────────────── #region Import CSV # ───────────────────────────────────────────────────────────────────────────── $btnImport.Add_Click({ $dlg = New-Object System.Windows.Forms.OpenFileDialog $dlg.Filter = 'CSV files (*.csv)|*.csv' $dlg.Title = 'Import edited profile CSV' if ($dlg.ShowDialog() -ne 'OK') { return } try { $data = Import-Csv -Path $dlg.FileName } catch { Write-Log "Import failed: $($_.Exception.Message)" 'Fail' [System.Windows.Forms.MessageBox]::Show("Could not read CSV:`n`n$($_.Exception.Message)", 'Import Failed', 'OK', 'Error') | Out-Null return } if (-not $data -or @($data).Count -eq 0) { [System.Windows.Forms.MessageBox]::Show('The CSV file is empty.', 'Empty CSV', 'OK', 'Warning') | Out-Null return } # Validate required columns $required = @('ProfileId','ProfileType','CurrentName','NewName','CurrentDescription','NewDescription') $present = @($data[0].PSObject.Properties.Name) $missing = $required | Where-Object { $_ -notin $present } if ($missing.Count -gt 0) { Write-Log "Import rejected — missing columns: $($missing -join ', ')" 'Fail' [System.Windows.Forms.MessageBox]::Show( "The CSV is missing required column(s):`n`n $($missing -join "`n ")`n`n" + "Expected columns:`n $($required -join ', ')", 'Invalid CSV', 'OK', 'Error') | Out-Null return } $script:Loading = $true $grid.Rows.Clear() $script:OdataMap.Clear() $loaded = 0; $skipped = 0; $lineNo = 1 foreach ($row in $data) { $lineNo++ $id = [string]$row.ProfileId $type = [string]$row.ProfileType if ([string]::IsNullOrWhiteSpace($id) -or [string]::IsNullOrWhiteSpace($type)) { Write-Log "Skipped CSV line $lineNo — blank ProfileId or ProfileType." 'Warn' $skipped++; continue } if (-not $ContentTypes.Contains((Get-BaseProfileType $type))) { Write-Log "Skipped CSV line $lineNo — unknown ProfileType '$type'." 'Warn' $skipped++; continue } Add-ProfileRow -Id $id -Type $type ` -CurName $row.CurrentName -CurDesc $row.CurrentDescription ` -NewName $row.NewName -NewDesc $row.NewDescription $loaded++ } $script:Loading = $false Update-AllHighlights Set-ActionButtonsEnabled $changed = Get-ChangedRowCount Write-Log "Imported $loaded row$(if ($loaded -ne 1){'s'}) ($changed with changes, $skipped skipped) from $(Split-Path $dlg.FileName -Leaf)." 'OK' Set-Footer "Imported $loaded profiles — $changed pending change$(if ($changed -ne 1){'s'})." if ($skipped -gt 0) { [System.Windows.Forms.MessageBox]::Show( "$loaded row(s) imported.`n$skipped row(s) skipped (see Activity Log for details).", 'Import Complete', 'OK', 'Warning') | Out-Null } }) #endregion # ───────────────────────────────────────────────────────────────────────────── #region Apply changes # ───────────────────────────────────────────────────────────────────────────── # Resolve a polymorphic item's derived @odata.type (required on PATCH for some types). # Prefers the value captured during pull; otherwise GETs the single item from its endpoint. function Get-ContentODataType { param([string]$Base, [string]$Collection, [string]$Id) if ($script:OdataMap.ContainsKey($Id) -and $script:OdataMap[$Id]) { return $script:OdataMap[$Id] } $item = Invoke-MgGraphRequest -Method GET -Uri "$Base/$Collection/$Id" -OutputType PSObject $odt = $item.'@odata.type' if ($odt) { $script:OdataMap[$Id] = $odt } return $odt } # Single PATCH path used by both Apply and Restore — routes by content type via $ContentTypes. # Only ever touches the display name and description; nothing else about the item is sent. function Invoke-ProfilePatch { param([string]$Type, [string]$Id, [string]$Name, [string]$Desc) $kind = $ContentTypes[(Get-BaseProfileType $Type)] if (-not $kind) { throw "Unknown content type '$Type'." } $body = @{ description = $Desc } $body[$kind.PatchNameProp] = $Name if ($kind.NeedsODataType) { $odt = Get-ContentODataType -Base $kind.Base -Collection $kind.Collection -Id $Id if (-not $odt) { throw 'Could not determine @odata.type for this item.' } $body['@odata.type'] = $odt } Invoke-MgGraphRequest -Method PATCH -Uri "$($kind.Base)/$($kind.Collection)/$Id" -Body $body | Out-Null } # Snapshot the CURRENT (live) name + description of every grid row to a timestamped JSON # in .\Backups so changes can be reverted via Restore JSON. Returns the file path (or $null if empty). function New-ProfileBackup { param([switch]$Auto) $snapshot = foreach ($r in $grid.Rows) { if ($r.IsNewRow) { continue } [PSCustomObject]@{ ProfileId = [string]$r.Cells['ProfileId'].Value ProfileType = [string]$r.Cells['ProfileType'].Value Name = [string]$r.Cells['CurrentName'].Value Description = [string]$r.Cells['CurrentDescription'].Value } } $snapshot = @($snapshot) if ($snapshot.Count -eq 0) { return $null } $tenant = '' try { $tenant = (Get-MgContext).TenantId } catch {} $tag = if ($Auto) { 'auto-preapply' } else { 'manual' } $path = Join-Path $script:BackupDir "ProfileBackup_$(Get-Date -Format 'yyyyMMdd_HHmmss')_$tag.json" [PSCustomObject]@{ CreatedUtc = (Get-Date).ToUniversalTime().ToString('s') + 'Z' Tenant = $tenant ProfileCount = $snapshot.Count Profiles = $snapshot } | ConvertTo-Json -Depth 6 | Set-Content -Path $path -Encoding UTF8 return $path } $btnApply.Add_Click({ if (-not $script:Connected) { [System.Windows.Forms.MessageBox]::Show('Please connect to Intune first.', 'Not Connected', 'OK', 'Warning') | Out-Null return } # Commit any in-progress cell edit before reading values try { $grid.EndEdit() | Out-Null } catch {} $changedRows = @($grid.Rows | Where-Object { -not $_.IsNewRow -and (Test-RowChanged -Row $_) }) if ($changedRows.Count -eq 0) { [System.Windows.Forms.MessageBox]::Show('No changes detected — every New value matches its Current value.', 'Nothing to Apply', 'OK', 'Information') | Out-Null return } $dry = $chkDryRun.Checked $verb = if ($dry) { 'Preview' } else { 'Apply' } $ans = [System.Windows.Forms.MessageBox]::Show( "$($changedRows.Count) profile(s) have changes.`n`n" + $(if ($dry) { "DRY RUN: nothing will actually be modified in Intune." } else { "These changes will be written to Intune via Microsoft Graph.`nOnly display name and description are modified." }) + "`n`nProceed?", "$verb Changes", 'YesNo', $(if ($dry) { 'Information' } else { 'Warning' })) if ($ans -ne 'Yes') { return } $btnApply.Enabled = $false; $btnPull.Enabled = $false $ok = 0; $fail = 0; $i = 0 # Automatic safety backup of current values before any real write, so changes can be reverted if (-not $dry) { try { $autoBackup = New-ProfileBackup -Auto if ($autoBackup) { Write-Log "Safety backup written: $autoBackup" 'Info' } } catch { Write-Log "Could not write safety backup: $($_.Exception.Message)" 'Warn' } } Write-Log "─── ${verb}: $($changedRows.Count) profile(s)$(if ($dry){' [DRY RUN]'}) ───" 'Info' foreach ($row in $changedRows) { $i++ $id = [string]$row.Cells['ProfileId'].Value $type = [string]$row.Cells['ProfileType'].Value $newName = [string]$row.Cells['NewName'].Value $newDesc = [string]$row.Cells['NewDescription'].Value $curName = [string]$row.Cells['CurrentName'].Value Set-Footer "$verb $i of $($changedRows.Count): $newName" # Guard: don't let a profile be blanked out if ([string]::IsNullOrWhiteSpace($newName)) { Write-Log "[$i/$($changedRows.Count)] Skipped '$curName' — New Name is blank." 'Warn' $fail++; continue } try { if ($dry) { $bits = @() if (Test-Differs $row.Cells['CurrentName'].Value $newName) { $bits += "name → '$newName'" } if (Test-Differs $row.Cells['CurrentDescription'].Value $newDesc) { $bits += 'description changed' } Write-Log "[$i/$($changedRows.Count)] [DRY RUN] $type '$curName' : $($bits -join ', ')" 'Info' $ok++ continue } Invoke-ProfilePatch -Type $type -Id $id -Name $newName -Desc $newDesc # Success — the New values are now the Current values $row.Cells['CurrentName'].Value = $newName $row.Cells['CurrentDescription'].Value = $newDesc Update-RowHighlight -Row $row Write-Log "[$i/$($changedRows.Count)] Updated $type '$newName'." 'OK' $ok++ } catch { $msg = $_.Exception.Message $hint = '' if ($msg -match '403|Forbidden') { $hint = ' (permission denied — check DeviceManagementConfiguration.ReadWrite.All consent)' } elseif ($msg -match '404|NotFound') { $hint = ' (profile no longer exists — try Pull Profiles again)' } Write-Log "[$i/$($changedRows.Count)] FAILED '$curName': $msg$hint" 'Fail' $fail++ } } $summary = "$verb complete — $ok succeeded, $fail failed." Write-Log $summary $(if ($fail -gt 0) { 'Warn' } else { 'OK' }) Set-Footer $summary $btnApply.Enabled = $true; $btnPull.Enabled = $true Set-ActionButtonsEnabled [System.Windows.Forms.MessageBox]::Show( $summary + $(if ($dry) { "`n`n(Dry run — nothing was actually changed.)" } else { '' }), "$verb Results", 'OK', $(if ($fail -gt 0) { 'Warning' } else { 'Information' })) | Out-Null }) #endregion # ───────────────────────────────────────────────────────────────────────────── #region Backup / Restore (JSON) # ───────────────────────────────────────────────────────────────────────────── $btnBackup.Add_Click({ if ($grid.Rows.Count -eq 0) { [System.Windows.Forms.MessageBox]::Show('Nothing to back up — pull profiles first.', 'Empty Grid', 'OK', 'Information') | Out-Null return } try { $path = New-ProfileBackup if ($path) { Write-Log "Backup written: $path" 'OK' Set-Footer "Backup saved to $path" [System.Windows.Forms.MessageBox]::Show( "Saved a JSON snapshot of all current profile names and descriptions to:`n`n$path`n`n" + 'Use "Restore JSON" to revert to these values later.', 'Backup Complete', 'OK', 'Information') | Out-Null } } catch { Write-Log "Backup failed: $($_.Exception.Message)" 'Fail' [System.Windows.Forms.MessageBox]::Show("Could not write backup:`n`n$($_.Exception.Message)", 'Backup Failed', 'OK', 'Error') | Out-Null } }) $btnRestore.Add_Click({ if (-not $script:Connected) { [System.Windows.Forms.MessageBox]::Show('Please connect to Intune first.', 'Not Connected', 'OK', 'Warning') | Out-Null return } $dlg = New-Object System.Windows.Forms.OpenFileDialog $dlg.Filter = 'JSON backups (*.json)|*.json' $dlg.Title = 'Restore profiles from a JSON backup' if (Test-Path $script:BackupDir) { $dlg.InitialDirectory = $script:BackupDir } if ($dlg.ShowDialog() -ne 'OK') { return } try { $data = Get-Content -Path $dlg.FileName -Raw | ConvertFrom-Json } catch { Write-Log "Restore failed — could not read backup: $($_.Exception.Message)" 'Fail' [System.Windows.Forms.MessageBox]::Show("Could not read backup JSON:`n`n$($_.Exception.Message)", 'Restore Failed', 'OK', 'Error') | Out-Null return } # Accept either the wrapped format ({ Profiles: [...] }) or a bare array $profiles = if ($data.PSObject.Properties.Name -contains 'Profiles') { @($data.Profiles) } else { @($data) } $profiles = @($profiles | Where-Object { $_.ProfileId -and $_.ProfileType }) if ($profiles.Count -eq 0) { [System.Windows.Forms.MessageBox]::Show('No valid profiles found in that backup file.', 'Nothing to Restore', 'OK', 'Warning') | Out-Null return } $dry = $chkDryRun.Checked $verb = if ($dry) { 'Preview restore of' } else { 'Restore' } $ans = [System.Windows.Forms.MessageBox]::Show( "$verb $($profiles.Count) profile(s) from:`n$(Split-Path $dlg.FileName -Leaf)`n`n" + $(if ($dry) { 'DRY RUN: nothing will actually be changed.' } else { 'Each profile''s name and description will be overwritten in Intune with the backed-up values.' }) + "`n`nProceed?", 'Confirm Restore', 'YesNo', $(if ($dry) { 'Information' } else { 'Warning' })) if ($ans -ne 'Yes') { return } $btnRestore.Enabled = $false; $btnApply.Enabled = $false; $btnPull.Enabled = $false $ok = 0; $fail = 0; $i = 0 Write-Log "─── ${verb} $($profiles.Count) profile(s) from backup$(if ($dry){' [DRY RUN]'}) ───" 'Info' foreach ($p in $profiles) { $i++ $name = [string]$p.Name $desc = [string]$p.Description Set-Footer "$verb $i of $($profiles.Count): $name" if ([string]::IsNullOrWhiteSpace($name)) { Write-Log "[$i/$($profiles.Count)] Skipped $($p.ProfileType) $($p.ProfileId) — backed-up name is blank." 'Warn' $fail++; continue } try { if ($dry) { Write-Log "[$i/$($profiles.Count)] [DRY RUN] Would restore $($p.ProfileType) '$name'." 'Info' } else { Invoke-ProfilePatch -Type ([string]$p.ProfileType) -Id ([string]$p.ProfileId) -Name $name -Desc $desc Write-Log "[$i/$($profiles.Count)] Restored $($p.ProfileType) '$name'." 'OK' } $ok++ } catch { Write-Log "[$i/$($profiles.Count)] FAILED to restore '$name': $($_.Exception.Message)" 'Fail' $fail++ } } $summary = "Restore complete — $ok succeeded, $fail failed." Write-Log $summary $(if ($fail -gt 0) { 'Warn' } else { 'OK' }) Set-Footer $summary Set-ActionButtonsEnabled $btnPull.Enabled = $true [System.Windows.Forms.MessageBox]::Show( $summary + $(if ($dry) { "`n`n(Dry run — nothing was actually changed.)" } else { "`n`nTip: click Pull Profiles to refresh the grid with the restored values." }), 'Restore Results', 'OK', $(if ($fail -gt 0) { 'Warning' } else { 'Information' })) | Out-Null }) #endregion # ───────────────────────────────────────────────────────────────────────────── #region Grid edit / misc events # ───────────────────────────────────────────────────────────────────────────── $grid.Add_CellEndEdit({ param($s, $e) if ($script:Loading) { return } Update-RowHighlight -Row $grid.Rows[$e.RowIndex] }) $btnClear.Add_Click({ if ($grid.Rows.Count -eq 0) { return } $ans = [System.Windows.Forms.MessageBox]::Show('Clear all rows from the grid?', 'Clear Grid', 'YesNo', 'Question') if ($ans -eq 'Yes') { $grid.Rows.Clear() $script:OdataMap.Clear() Set-ActionButtonsEnabled Write-Log 'Grid cleared.' 'Info' Set-Footer 'Grid cleared.' } }) $btnClearLog.Add_Click({ $logBox.Clear() }) $btnFind.Add_Click({ Show-FindReplace }) $btnTypes.Add_Click({ Show-ContentTypePicker $btnTypes }) $form.Add_FormClosing({ if ($script:Connected) { try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} } }) #endregion # ───────────────────────────────────────────────────────────────────────────── #region Find & Replace dialog # ───────────────────────────────────────────────────────────────────────────── # Edits the New Name / New Description columns in place. Supports literal or regex # replace, case sensitivity, name/description targeting, all-vs-selected scope, and # a one-click "strip trailing version" preset (e.g. removing " v3.9" / "-2.8"). function Show-FindReplace { if ($grid.Rows.Count -eq 0) { [System.Windows.Forms.MessageBox]::Show('Pull or import profiles first.', 'Nothing to Edit', 'OK', 'Information') | Out-Null return } try { $grid.EndEdit() | Out-Null } catch {} $dlg = New-Object System.Windows.Forms.Form $dlg.Text = 'Find & Replace' $dlg.ClientSize = New-Object System.Drawing.Size(484, 416) $dlg.StartPosition = 'CenterParent' $dlg.FormBorderStyle = 'FixedDialog' $dlg.MaximizeBox = $false $dlg.MinimizeBox = $false $dlg.Font = $FontUI $dlg.BackColor = $Theme.White try { $dlg.Icon = $form.Icon } catch {} # Layout via TableLayoutPanel + docking (same approach as the main window) so the whole # dialog scales uniformly and never clips its controls on high-DPI displays. $rootT = New-Object System.Windows.Forms.TableLayoutPanel $rootT.Dock = 'Fill'; $rootT.ColumnCount = 1; $rootT.RowCount = 3 $rootT.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null $rootT.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 46))) | Out-Null $rootT.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null $rootT.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 58))) | Out-Null $dlg.Controls.Add($rootT) # Gradient header $hd = New-Object System.Windows.Forms.Panel $hd.Dock = 'Fill' $hd.Add_Paint({ param($s, $e) $r = $s.ClientRectangle if ($r.Width -le 0) { return } $b = New-Object System.Drawing.Drawing2D.LinearGradientBrush($r, $Theme.GradLeft, $Theme.GradRight, 0.0) $e.Graphics.FillRectangle($b, $r); $b.Dispose() $tb = New-Object System.Drawing.SolidBrush($Theme.White) $e.Graphics.DrawString('Find & Replace', (New-Object System.Drawing.Font('Segoe UI Light', 15)), $tb, 14, 9) $tb.Dispose() }) $rootT.Controls.Add($hd, 0, 0) # Content — one column, auto-height rows; text boxes Dock=Fill to stretch full width $ct = New-Object System.Windows.Forms.TableLayoutPanel $ct.Dock = 'Fill'; $ct.ColumnCount = 1 $ct.Padding = New-Object System.Windows.Forms.Padding(16, 8, 16, 4) $ct.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 100))) | Out-Null $rootT.Controls.Add($ct, 0, 1) function Add-CtRow { param($Control) $ct.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::AutoSize))) | Out-Null $ct.Controls.Add($Control, 0, ($ct.RowStyles.Count - 1)) $ct.RowCount = $ct.RowStyles.Count } function New-Lbl { param($Text, [bool]$Bold = $false) $l = New-Object System.Windows.Forms.Label $l.Text = $Text; $l.AutoSize = $true $l.Margin = New-Object System.Windows.Forms.Padding(0, 6, 0, 1) $l.Font = if ($Bold) { $FontUIBold } else { $FontUI } return $l } function New-RowFlow { $f = New-Object System.Windows.Forms.FlowLayoutPanel $f.AutoSize = $true; $f.AutoSizeMode = 'GrowAndShrink'; $f.WrapContents = $false $f.Dock = 'Fill'; $f.Margin = New-Object System.Windows.Forms.Padding(0, 4, 0, 0) return $f } Add-CtRow (New-Lbl 'Find:') $txtFind = New-Object System.Windows.Forms.TextBox $txtFind.Dock = 'Fill'; $txtFind.Margin = New-Object System.Windows.Forms.Padding(0, 0, 0, 2) Add-CtRow $txtFind Add-CtRow (New-Lbl 'Replace with: (leave blank to delete the matched text)') $txtRepl = New-Object System.Windows.Forms.TextBox $txtRepl.Dock = 'Fill'; $txtRepl.Margin = New-Object System.Windows.Forms.Padding(0, 0, 0, 2) Add-CtRow $txtRepl $optsFlow = New-RowFlow $chkRegex = New-Object System.Windows.Forms.CheckBox $chkRegex.Text = 'Use regular expression'; $chkRegex.AutoSize = $true $chkRegex.Margin = New-Object System.Windows.Forms.Padding(0, 3, 28, 3) $chkCase = New-Object System.Windows.Forms.CheckBox $chkCase.Text = 'Case sensitive'; $chkCase.AutoSize = $true $chkCase.Margin = New-Object System.Windows.Forms.Padding(0, 3, 0, 3) $optsFlow.Controls.AddRange(@($chkRegex, $chkCase)) Add-CtRow $optsFlow $applyFlow = New-RowFlow $lblApply = New-Lbl 'Apply to:' $true; $lblApply.Margin = New-Object System.Windows.Forms.Padding(0, 4, 12, 0) $chkName = New-Object System.Windows.Forms.CheckBox $chkName.Text = 'New Name'; $chkName.AutoSize = $true; $chkName.Checked = $true $chkName.Margin = New-Object System.Windows.Forms.Padding(0, 2, 18, 0) $chkDesc = New-Object System.Windows.Forms.CheckBox $chkDesc.Text = 'New Description'; $chkDesc.AutoSize = $true $chkDesc.Margin = New-Object System.Windows.Forms.Padding(0, 2, 0, 0) $applyFlow.Controls.AddRange(@($lblApply, $chkName, $chkDesc)) Add-CtRow $applyFlow $scopeFlow = New-RowFlow $lblScope = New-Lbl 'Scope:' $true; $lblScope.Margin = New-Object System.Windows.Forms.Padding(0, 4, 23, 0) $rdoAll = New-Object System.Windows.Forms.RadioButton $rdoAll.Text = 'All rows'; $rdoAll.AutoSize = $true; $rdoAll.Checked = $true $rdoAll.Margin = New-Object System.Windows.Forms.Padding(0, 2, 18, 0) $rdoSel = New-Object System.Windows.Forms.RadioButton $rdoSel.Text = 'Selected rows only'; $rdoSel.AutoSize = $true $rdoSel.Margin = New-Object System.Windows.Forms.Padding(0, 2, 0, 0) $scopeFlow.Controls.AddRange(@($lblScope, $rdoAll, $rdoSel)) Add-CtRow $scopeFlow $btnPreset = New-Object System.Windows.Forms.Button $btnPreset.Text = 'Strip trailing version (e.g. v3.9, -2.8)' Set-ToolButton -Btn $btnPreset $btnPreset.Margin = New-Object System.Windows.Forms.Padding(0, 10, 0, 2) Add-CtRow $btnPreset $btnPreset.Add_Click({ $chkRegex.Checked = $true $txtFind.Text = '[\s_\-]*[vV]?\d+(\.\d+)+\s*$' $txtRepl.Text = '' $chkName.Checked = $true }) $lblStatus = New-Object System.Windows.Forms.Label $lblStatus.AutoSize = $true $lblStatus.Margin = New-Object System.Windows.Forms.Padding(0, 8, 0, 0) $lblStatus.ForeColor = C '#555555' Add-CtRow $lblStatus # Footer — action buttons docked to the right so they are always visible $foot = New-Object System.Windows.Forms.Panel $foot.Dock = 'Fill' $footFlow = New-Object System.Windows.Forms.FlowLayoutPanel $footFlow.Dock = 'Right'; $footFlow.FlowDirection = 'LeftToRight'; $footFlow.WrapContents = $false $footFlow.AutoSize = $true; $footFlow.AutoSizeMode = 'GrowAndShrink' $footFlow.Padding = New-Object System.Windows.Forms.Padding(0, 14, 12, 0) $foot.Controls.Add($footFlow) $rootT.Controls.Add($foot, 0, 2) $btnPreview = New-Object System.Windows.Forms.Button; $btnPreview.Text = 'Preview' $btnDoRepl = New-Object System.Windows.Forms.Button; $btnDoRepl.Text = 'Replace' $btnClose = New-Object System.Windows.Forms.Button; $btnClose.Text = 'Close' Set-ToolButton -Btn $btnPreview Set-PrimaryButton -Btn $btnDoRepl -Back $Theme.Primary Set-ToolButton -Btn $btnClose foreach ($b in @($btnPreview, $btnDoRepl, $btnClose)) { $b.MinimumSize = New-Object System.Drawing.Size(80, 30) $b.Margin = New-Object System.Windows.Forms.Padding(0, 0, 7, 0) } $footFlow.Controls.AddRange(@($btnPreview, $btnDoRepl, $btnClose)) # Core replace routine — $PreviewOnly counts without modifying the grid $run = { param([bool]$PreviewOnly) $find = $txtFind.Text if ([string]::IsNullOrEmpty($find)) { $lblStatus.ForeColor = $Theme.LogFail; $lblStatus.Text = 'Enter something to find.'; return } if (-not $chkName.Checked -and -not $chkDesc.Checked) { $lblStatus.ForeColor = $Theme.LogFail; $lblStatus.Text = 'Choose at least one target (Name / Description).'; return } $cols = @() if ($chkName.Checked) { $cols += 'NewName' } if ($chkDesc.Checked) { $cols += 'NewDescription' } $opts = if ($chkCase.Checked) { [System.Text.RegularExpressions.RegexOptions]::None } else { [System.Text.RegularExpressions.RegexOptions]::IgnoreCase } $pattern = if ($chkRegex.Checked) { $find } else { [System.Text.RegularExpressions.Regex]::Escape($find) } try { $re = [System.Text.RegularExpressions.Regex]::new($pattern, $opts) } catch { $lblStatus.ForeColor = $Theme.LogFail; $lblStatus.Text = "Invalid regex: $($_.Exception.Message)"; return } $replText = $txtRepl.Text # Literal mode: route replacement through an evaluator so $ and \ aren't treated as substitutions $evaluator = [System.Text.RegularExpressions.MatchEvaluator] { param($m) $replText }.GetNewClosure() $rows = if ($rdoSel.Checked) { @($grid.SelectedRows) } else { @($grid.Rows) } if ($rdoSel.Checked -and $rows.Count -eq 0) { $lblStatus.ForeColor = $Theme.LogFail; $lblStatus.Text = 'No rows are selected.'; return } $cells = 0; $rowsHit = @{} foreach ($r in $rows) { if ($r.IsNewRow) { continue } foreach ($col in $cols) { $old = [string]$r.Cells[$col].Value $new = if ($chkRegex.Checked) { $re.Replace($old, $replText) } else { $re.Replace($old, $evaluator) } if ($new -ne $old) { $cells++; $rowsHit[$r.Index] = $true if (-not $PreviewOnly) { $r.Cells[$col].Value = $new } } } } if ($PreviewOnly) { $lblStatus.ForeColor = C '#555555' $lblStatus.Text = "Preview: $cells cell(s) in $($rowsHit.Count) row(s) would change." } else { Update-AllHighlights $lblStatus.ForeColor = $Theme.LogOk $lblStatus.Text = "Replaced in $cells cell(s) across $($rowsHit.Count) row(s)." if ($cells -gt 0) { Write-Log "Find & Replace: '$find' -> '$replText' changed $cells cell(s) in $($rowsHit.Count) row(s)$(if ($chkRegex.Checked) {' [regex]'})." 'OK' } } }.GetNewClosure() $btnPreview.Add_Click({ & $run $true }) $btnDoRepl.Add_Click({ & $run $false }) $btnClose.Add_Click({ $dlg.Close() }) $dlg.AcceptButton = $btnDoRepl $dlg.CancelButton = $btnClose [void]$dlg.ShowDialog($form) $dlg.Dispose() } #endregion # ───────────────────────────────────────────────────────────────────────────── #region Content Types picker # ───────────────────────────────────────────────────────────────────────────── # A dropdown-style popup (checklist + Select all) under the Content Types button. # The ticked set drives what the next Pull fetches. Closes when it loses focus. function Show-ContentTypePicker { param($AnchorButton) $pop = New-Object System.Windows.Forms.Form $pop.FormBorderStyle = 'None' $pop.StartPosition = 'Manual' $pop.ShowInTaskbar = $false $pop.KeyPreview = $true $pop.BackColor = C '#B9B9B9' # shows as a 1px border $pop.Padding = New-Object System.Windows.Forms.Padding(1) $pop.Size = New-Object System.Drawing.Size(268, 474) $inner = New-Object System.Windows.Forms.Panel $inner.Dock = 'Fill'; $inner.BackColor = $Theme.White $inner.Padding = New-Object System.Windows.Forms.Padding(12, 10, 12, 10) $pop.Controls.Add($inner) $clb = New-Object System.Windows.Forms.CheckedListBox $clb.Dock = 'Fill'; $clb.Font = $FontUI; $clb.CheckOnClick = $true $clb.BorderStyle = 'None'; $clb.IntegralHeight = $false foreach ($k in $ContentTypes.Keys) { [void]$clb.Items.Add($k) } $sep = New-Object System.Windows.Forms.Panel $sep.Dock = 'Top'; $sep.Height = 8; $sep.BackColor = $Theme.White $chkAll = New-Object System.Windows.Forms.CheckBox $chkAll.Text = 'Select all'; $chkAll.Dock = 'Top'; $chkAll.Height = 26; $chkAll.Font = $FontUI $hdr = New-Object System.Windows.Forms.Label $hdr.Text = 'Content Types'; $hdr.Dock = 'Top'; $hdr.Height = 24; $hdr.Font = $FontUIBold # Fill added first (sits behind), then Top items in bottom-to-top add order $inner.Controls.Add($clb) $inner.Controls.Add($sep) $inner.Controls.Add($chkAll) $inner.Controls.Add($hdr) for ($i = 0; $i -lt $clb.Items.Count; $i++) { $clb.SetItemChecked($i, ($global:IPMSelectedContentTypes -contains ([string]$clb.Items[$i]))) } $chkAll.Checked = ($clb.CheckedItems.Count -eq $clb.Items.Count) $chkAll.Add_Click({ $state = $chkAll.Checked for ($i = 0; $i -lt $clb.Items.Count; $i++) { $clb.SetItemChecked($i, $state) } }.GetNewClosure()) $pop.Add_KeyDown({ param($s, $e) if ($e.KeyCode -eq 'Escape') { $pop.Close() } }.GetNewClosure()) $pop.Add_Deactivate({ $pop.Close() }.GetNewClosure()) $pop.Add_FormClosed({ $sel = [System.Collections.Generic.List[string]]::new() foreach ($it in $clb.CheckedItems) { $sel.Add([string]$it) } $global:IPMSelectedContentTypes = $sel Set-Footer "$($sel.Count) of $($ContentTypes.Count) content types selected for the next Pull." }.GetNewClosure()) # Position under the button, clamped to the screen working area $pt = $AnchorButton.PointToScreen([System.Drawing.Point]::new(0, $AnchorButton.Height + 2)) $wa = [System.Windows.Forms.Screen]::FromControl($AnchorButton).WorkingArea $x = [Math]::Min([int]$pt.X, $wa.Right - $pop.Width - 4) $y = [int]$pt.Y if ($y + $pop.Height -gt $wa.Bottom) { $y = $wa.Bottom - $pop.Height - 4 } $pop.Location = New-Object System.Drawing.Point($x, $y) $pop.Show($form) $pop.Activate() } #endregion # ───────────────────────────────────────────────────────────────────────────── #region Launch # ───────────────────────────────────────────────────────────────────────────── Write-Log 'Intune Profile Manager started.' 'Info' Write-Log 'Connect, choose Content Types if needed, then Pull.' 'Info' [void]$form.ShowDialog() #endregion |