785 lines
26 KiB
PHP
785 lines
26 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Intune;
|
|
|
|
use App\Models\Policy;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Str;
|
|
|
|
class PolicyNormalizer
|
|
{
|
|
private const SETTINGS_CATALOG_MAX_ROWS = 1000;
|
|
|
|
private const SETTINGS_CATALOG_MAX_DEPTH = 8;
|
|
|
|
/**
|
|
* Normalize raw Intune snapshots into display-friendly blocks and warnings.
|
|
*/
|
|
public function __construct(
|
|
private readonly SnapshotValidator $validator,
|
|
private readonly SettingsCatalogDefinitionResolver $definitionResolver,
|
|
) {}
|
|
|
|
/**
|
|
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>, context?: string, record_id?: string}
|
|
*/
|
|
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
|
|
{
|
|
$snapshot = $snapshot ?? [];
|
|
$resultWarnings = [];
|
|
$status = 'success';
|
|
$settingsTable = null;
|
|
|
|
$validation = $this->validator->validate($snapshot);
|
|
$resultWarnings = array_merge($resultWarnings, $validation['warnings']);
|
|
|
|
$odataWarning = Policy::odataTypeWarning($snapshot, $policyType, $platform);
|
|
|
|
if ($odataWarning) {
|
|
$resultWarnings[] = $odataWarning;
|
|
}
|
|
|
|
if ($snapshot === []) {
|
|
return [
|
|
'status' => 'warning',
|
|
'settings' => [],
|
|
'warnings' => array_values(array_unique(array_merge($resultWarnings, ['No snapshot available']))),
|
|
];
|
|
}
|
|
|
|
$settings = [];
|
|
|
|
if (isset($snapshot['omaSettings']) && is_array($snapshot['omaSettings'])) {
|
|
$settings[] = $this->normalizeOmaSettings($snapshot['omaSettings']);
|
|
}
|
|
|
|
if (isset($snapshot['settings']) && is_array($snapshot['settings'])) {
|
|
if ($policyType === 'settingsCatalogPolicy') {
|
|
$normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settings']);
|
|
$settingsTable = $normalized['table'];
|
|
$resultWarnings = array_merge($resultWarnings, $normalized['warnings']);
|
|
} else {
|
|
$settings[] = $this->normalizeSettingsCatalog($snapshot['settings']);
|
|
}
|
|
} elseif (isset($snapshot['settingsDelta']) && is_array($snapshot['settingsDelta'])) {
|
|
if ($policyType === 'settingsCatalogPolicy') {
|
|
$normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settingsDelta'], 'Settings delta');
|
|
$settingsTable = $normalized['table'];
|
|
$resultWarnings = array_merge($resultWarnings, $normalized['warnings']);
|
|
} else {
|
|
$settings[] = $this->normalizeSettingsCatalog($snapshot['settingsDelta'], 'Settings delta');
|
|
}
|
|
} elseif ($policyType === 'settingsCatalogPolicy') {
|
|
$resultWarnings[] = 'Settings not hydrated for this Settings Catalog policy.';
|
|
}
|
|
|
|
$settings[] = $this->normalizeStandard($snapshot);
|
|
|
|
if (! empty($resultWarnings)) {
|
|
$status = 'warning';
|
|
}
|
|
|
|
$result = [
|
|
'status' => $status,
|
|
'settings' => array_values(array_filter($settings)),
|
|
'warnings' => array_values(array_unique($resultWarnings)),
|
|
];
|
|
|
|
if (is_array($settingsTable) && ! empty($settingsTable['rows'] ?? [])) {
|
|
$result['settings_table'] = $settingsTable;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Flatten normalized settings into key/value pairs for diffing.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
|
|
{
|
|
$normalized = $this->normalize($snapshot ?? [], $policyType, $platform);
|
|
$map = [];
|
|
|
|
if (isset($normalized['settings_table']['rows']) && is_array($normalized['settings_table']['rows'])) {
|
|
foreach ($normalized['settings_table']['rows'] as $row) {
|
|
if (! is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$key = $row['path'] ?? $row['definition'] ?? 'entry';
|
|
$map[$key] = $row['value'] ?? null;
|
|
}
|
|
}
|
|
|
|
foreach ($normalized['settings'] as $block) {
|
|
if (($block['type'] ?? null) === 'table') {
|
|
foreach ($block['rows'] ?? [] as $row) {
|
|
$key = $row['path'] ?? $row['label'] ?? 'entry';
|
|
$map[$key] = $row['value'] ?? null;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
foreach ($block['entries'] ?? [] as $entry) {
|
|
$key = $entry['key'] ?? 'entry';
|
|
$map[$key] = $entry['value'] ?? null;
|
|
}
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $omaSettings
|
|
*/
|
|
private function normalizeOmaSettings(array $omaSettings): array
|
|
{
|
|
$rows = [];
|
|
|
|
foreach ($omaSettings as $setting) {
|
|
if (! is_array($setting)) {
|
|
continue;
|
|
}
|
|
|
|
$rows[] = [
|
|
'path' => $setting['omaUri'] ?? $setting['path'] ?? 'n/a',
|
|
'value' => $setting['value'] ?? $setting['displayValue'] ?? $setting['secretReferenceValueId'] ?? null,
|
|
'label' => $setting['displayName'] ?? null,
|
|
'description' => $setting['description'] ?? null,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'type' => 'table',
|
|
'title' => 'OMA-URI settings',
|
|
'rows' => $rows,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $settings
|
|
*/
|
|
private function normalizeSettingsCatalog(array $settings, string $title = 'Settings'): array
|
|
{
|
|
$entries = [];
|
|
|
|
foreach ($settings as $setting) {
|
|
if (! is_array($setting)) {
|
|
continue;
|
|
}
|
|
|
|
$key = $setting['displayName'] ?? $setting['name'] ?? $setting['definitionId'] ?? 'setting';
|
|
$value = $setting['value'] ?? $setting['settingInstance']['simpleSettingValue'] ?? $setting['simpleSettingValue'] ?? null;
|
|
|
|
if ($value === null && isset($setting['value']['value'])) {
|
|
$value = $setting['value']['value'];
|
|
}
|
|
|
|
if (is_array($value)) {
|
|
$value = json_encode($value, JSON_PRETTY_PRINT);
|
|
}
|
|
|
|
$entries[] = [
|
|
'key' => $key,
|
|
'value' => $value,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'type' => 'keyValue',
|
|
'title' => $title,
|
|
'entries' => $entries,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, mixed> $settings
|
|
* @return array{table: array<string, mixed>, warnings: array<int, string>}
|
|
*/
|
|
private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings'): array
|
|
{
|
|
$flattened = $this->flattenSettingsCatalogSettingInstances($settings);
|
|
|
|
return [
|
|
'table' => [
|
|
'title' => $title,
|
|
'rows' => $flattened['rows'],
|
|
],
|
|
'warnings' => $flattened['warnings'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, mixed> $settings
|
|
* @return array{rows: array<int, array<string, mixed>>, warnings: array<int, string>}
|
|
*/
|
|
private function flattenSettingsCatalogSettingInstances(array $settings): array
|
|
{
|
|
$rows = [];
|
|
$warnings = [];
|
|
$rowCount = 0;
|
|
$warnedDepthLimit = false;
|
|
$warnedRowLimit = false;
|
|
|
|
$walk = function (array $nodes, array $pathParts, int $depth) use (
|
|
&$walk,
|
|
&$rows,
|
|
&$warnings,
|
|
&$rowCount,
|
|
&$warnedDepthLimit,
|
|
&$warnedRowLimit
|
|
): void {
|
|
if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) {
|
|
if (! $warnedRowLimit) {
|
|
$warnings[] = sprintf('Settings truncated after %d rows.', self::SETTINGS_CATALOG_MAX_ROWS);
|
|
$warnedRowLimit = true;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if ($depth > self::SETTINGS_CATALOG_MAX_DEPTH) {
|
|
if (! $warnedDepthLimit) {
|
|
$warnings[] = sprintf('Settings nesting truncated after depth %d.', self::SETTINGS_CATALOG_MAX_DEPTH);
|
|
$warnedDepthLimit = true;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
foreach ($nodes as $node) {
|
|
if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) {
|
|
break;
|
|
}
|
|
|
|
if (! is_array($node)) {
|
|
continue;
|
|
}
|
|
|
|
$instance = $this->extractSettingsCatalogSettingInstance($node);
|
|
$definitionId = $this->extractSettingsCatalogDefinitionId($node, $instance);
|
|
$instanceType = is_array($instance) ? ($instance['@odata.type'] ?? $node['@odata.type'] ?? null) : ($node['@odata.type'] ?? null);
|
|
$instanceType = $this->formatSettingsCatalogInstanceType(is_string($instanceType) ? ltrim($instanceType, '#') : null);
|
|
|
|
$currentPathParts = array_merge($pathParts, [$definitionId]);
|
|
$path = implode(' > ', $currentPathParts);
|
|
|
|
$value = $this->extractSettingsCatalogValue($node, $instance);
|
|
|
|
$rows[] = [
|
|
'definition' => $definitionId,
|
|
'type' => $instanceType ?? '-',
|
|
'value' => $this->stringifySettingsCatalogValue($value),
|
|
'path' => $path,
|
|
'raw' => $this->pruneSettingsCatalogRaw($instance ?? $node),
|
|
];
|
|
|
|
$rowCount++;
|
|
|
|
if (! is_array($instance)) {
|
|
continue;
|
|
}
|
|
|
|
$nested = $this->extractSettingsCatalogChildren($instance);
|
|
|
|
if (! empty($nested)) {
|
|
$walk($nested, $currentPathParts, $depth + 1);
|
|
}
|
|
|
|
if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance)) {
|
|
$collections = $instance['groupSettingCollectionValue'] ?? [];
|
|
|
|
if (! is_array($collections)) {
|
|
continue;
|
|
}
|
|
|
|
foreach (array_values($collections) as $index => $collection) {
|
|
if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) {
|
|
break;
|
|
}
|
|
|
|
if (! is_array($collection)) {
|
|
continue;
|
|
}
|
|
|
|
$children = $collection['children'] ?? [];
|
|
|
|
if (! is_array($children) || empty($children)) {
|
|
continue;
|
|
}
|
|
|
|
$walk($children, array_merge($currentPathParts, ['['.($index + 1).']']), $depth + 1);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
$walk($settings, [], 1);
|
|
|
|
return [
|
|
'rows' => array_slice($rows, 0, self::SETTINGS_CATALOG_MAX_ROWS),
|
|
'warnings' => $warnings,
|
|
];
|
|
}
|
|
|
|
private function extractSettingsCatalogSettingInstance(array $setting): ?array
|
|
{
|
|
$instance = $setting['settingInstance'] ?? null;
|
|
|
|
if (is_array($instance)) {
|
|
return $instance;
|
|
}
|
|
|
|
if (isset($setting['@odata.type']) && (isset($setting['settingDefinitionId']) || isset($setting['definitionId']))) {
|
|
return $setting;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function extractSettingsCatalogDefinitionId(array $setting, ?array $instance): string
|
|
{
|
|
$candidates = [
|
|
$setting['definitionId'] ?? null,
|
|
$setting['settingDefinitionId'] ?? null,
|
|
$setting['name'] ?? null,
|
|
$setting['displayName'] ?? null,
|
|
$instance['settingDefinitionId'] ?? null,
|
|
$instance['definitionId'] ?? null,
|
|
];
|
|
|
|
foreach ($candidates as $candidate) {
|
|
if (is_string($candidate) && $candidate !== '') {
|
|
return $candidate;
|
|
}
|
|
}
|
|
|
|
return 'setting';
|
|
}
|
|
|
|
private function formatSettingsCatalogInstanceType(?string $type): ?string
|
|
{
|
|
if (! $type) {
|
|
return null;
|
|
}
|
|
|
|
$type = Str::afterLast($type, '.');
|
|
|
|
foreach (['deviceManagementConfiguration', 'deviceManagement'] as $prefix) {
|
|
if (Str::startsWith($type, $prefix)) {
|
|
$type = substr($type, strlen($prefix));
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $type !== '' ? $type : null;
|
|
}
|
|
|
|
private function isSettingsCatalogGroupSettingCollectionInstance(array $instance): bool
|
|
{
|
|
$type = $instance['@odata.type'] ?? null;
|
|
|
|
if (! is_string($type)) {
|
|
return false;
|
|
}
|
|
|
|
return Str::contains($type, 'GroupSettingCollectionInstance', ignoreCase: true);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, mixed>
|
|
*/
|
|
private function extractSettingsCatalogChildren(array $instance): array
|
|
{
|
|
foreach (['children', 'groupSettingValue.children'] as $path) {
|
|
$children = Arr::get($instance, $path);
|
|
|
|
if (is_array($children) && ! empty($children)) {
|
|
return $children;
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
private function extractSettingsCatalogValue(array $setting, ?array $instance): mixed
|
|
{
|
|
if ($instance === null) {
|
|
return $setting['value'] ?? null;
|
|
}
|
|
|
|
$type = $instance['@odata.type'] ?? null;
|
|
$type = is_string($type) ? $type : '';
|
|
|
|
if (Str::contains($type, 'SimpleSettingInstance', ignoreCase: true)) {
|
|
$simple = $instance['simpleSettingValue'] ?? null;
|
|
|
|
if (is_array($simple)) {
|
|
return $simple['value'] ?? $simple;
|
|
}
|
|
|
|
return $simple;
|
|
}
|
|
|
|
if (Str::contains($type, 'ChoiceSettingInstance', ignoreCase: true)) {
|
|
$choice = $instance['choiceSettingValue'] ?? null;
|
|
|
|
if (is_array($choice)) {
|
|
return $choice['value'] ?? $choice;
|
|
}
|
|
|
|
return $choice;
|
|
}
|
|
|
|
if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance) || Str::contains($type, 'GroupSettingInstance', ignoreCase: true)) {
|
|
return '(group)';
|
|
}
|
|
|
|
$fallback = $instance;
|
|
unset($fallback['children']);
|
|
|
|
return $fallback;
|
|
}
|
|
|
|
private function stringifySettingsCatalogValue(mixed $value): string
|
|
{
|
|
if ($value === null) {
|
|
return '-';
|
|
}
|
|
|
|
if (is_bool($value)) {
|
|
return $value ? 'true' : 'false';
|
|
}
|
|
|
|
if (is_scalar($value)) {
|
|
return (string) $value;
|
|
}
|
|
|
|
if (is_array($value)) {
|
|
return (string) json_encode($value, JSON_PRETTY_PRINT);
|
|
}
|
|
|
|
return (string) $value;
|
|
}
|
|
|
|
private function pruneSettingsCatalogRaw(mixed $raw): mixed
|
|
{
|
|
if (! is_array($raw)) {
|
|
return $raw;
|
|
}
|
|
|
|
$pruned = $raw;
|
|
unset($pruned['children'], $pruned['groupSettingCollectionValue']);
|
|
|
|
return $pruned;
|
|
}
|
|
|
|
private function normalizeStandard(array $snapshot): array
|
|
{
|
|
$metadataKeys = [
|
|
'@odata.context',
|
|
'@odata.type',
|
|
'id',
|
|
'version',
|
|
'createdDateTime',
|
|
'lastModifiedDateTime',
|
|
'supportsScopeTags',
|
|
'roleScopeTagIds',
|
|
'assignments',
|
|
'createdBy',
|
|
'lastModifiedBy',
|
|
'omaSettings',
|
|
'settings',
|
|
'settingsDelta',
|
|
];
|
|
|
|
$filtered = Arr::except($snapshot, $metadataKeys);
|
|
$entries = [];
|
|
|
|
foreach ($filtered as $key => $value) {
|
|
if (is_array($value)) {
|
|
$value = json_encode($value, JSON_PRETTY_PRINT);
|
|
}
|
|
|
|
$entries[] = [
|
|
'key' => Str::headline((string) $key),
|
|
'value' => $value,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'type' => 'keyValue',
|
|
'title' => 'General',
|
|
'entries' => $entries,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Normalize Settings Catalog policy with grouped, readable settings (T011-T014).
|
|
*
|
|
* @param array<int, mixed> $settings
|
|
* @return array{type: string, groups: array<int, array<string, mixed>>}
|
|
*/
|
|
public function normalizeSettingsCatalogGrouped(array $settings): array
|
|
{
|
|
// Extract all definition IDs
|
|
$definitionIds = $this->extractAllDefinitionIds($settings);
|
|
|
|
// Resolve definitions
|
|
$definitions = $this->definitionResolver->resolve($definitionIds);
|
|
|
|
// Flatten settings
|
|
$flattened = $this->flattenSettingsCatalogForGrouping($settings);
|
|
|
|
// Group by category
|
|
$groups = $this->groupSettingsByCategory($flattened, $definitions);
|
|
|
|
return [
|
|
'type' => 'settings_catalog_grouped',
|
|
'groups' => $groups,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Extract all definition IDs from settings array recursively.
|
|
*/
|
|
private function extractAllDefinitionIds(array $settings): array
|
|
{
|
|
$ids = [];
|
|
|
|
foreach ($settings as $setting) {
|
|
// Top-level settings have settingInstance wrapper
|
|
if (isset($setting['settingInstance']['settingDefinitionId'])) {
|
|
$ids[] = $setting['settingInstance']['settingDefinitionId'];
|
|
$instance = $setting['settingInstance'];
|
|
}
|
|
// Nested children have settingDefinitionId directly (they ARE the instance)
|
|
elseif (isset($setting['settingDefinitionId'])) {
|
|
$ids[] = $setting['settingDefinitionId'];
|
|
$instance = $setting;
|
|
} else {
|
|
continue;
|
|
}
|
|
|
|
// Handle nested children in group collections
|
|
if (isset($instance['groupSettingCollectionValue'])) {
|
|
foreach ($instance['groupSettingCollectionValue'] as $group) {
|
|
if (isset($group['children']) && is_array($group['children'])) {
|
|
$childIds = $this->extractAllDefinitionIds($group['children']);
|
|
$ids = array_merge($ids, $childIds);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_unique($ids);
|
|
}
|
|
|
|
/**
|
|
* Flatten settings for grouping with value formatting.
|
|
*/
|
|
private function flattenSettingsCatalogForGrouping(array $settings): array
|
|
{
|
|
$rows = [];
|
|
|
|
$walk = function (array $nodes, array $pathParts) use (&$walk, &$rows): void {
|
|
foreach ($nodes as $node) {
|
|
if (! is_array($node)) {
|
|
continue;
|
|
}
|
|
|
|
$instance = $this->extractSettingsCatalogSettingInstance($node);
|
|
$definitionId = $this->extractSettingsCatalogDefinitionId($node, $instance);
|
|
$value = $this->extractSettingsCatalogValue($node, $instance);
|
|
$isGroupCollection = $this->isSettingsCatalogGroupSettingCollectionInstance($instance);
|
|
|
|
// Only add to rows if NOT a group collection (those are containers)
|
|
if (! $isGroupCollection) {
|
|
$rows[] = [
|
|
'definition_id' => $definitionId,
|
|
'value_raw' => $value,
|
|
'value_display' => $this->formatSettingsCatalogValue($value),
|
|
'instance_type' => is_array($instance) ? ($instance['@odata.type'] ?? null) : null,
|
|
];
|
|
}
|
|
|
|
// Handle nested children
|
|
if (is_array($instance)) {
|
|
$nested = $this->extractSettingsCatalogChildren($instance);
|
|
if (! empty($nested)) {
|
|
$walk($nested, array_merge($pathParts, [$definitionId]));
|
|
}
|
|
|
|
// Handle group collections
|
|
if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance)) {
|
|
$collections = $instance['groupSettingCollectionValue'] ?? [];
|
|
if (is_array($collections)) {
|
|
foreach ($collections as $collection) {
|
|
if (isset($collection['children']) && is_array($collection['children'])) {
|
|
$walk($collection['children'], array_merge($pathParts, [$definitionId]));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
$walk($settings, []);
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* Format setting value for display (T012).
|
|
*/
|
|
private function formatSettingsCatalogValue(mixed $value): string
|
|
{
|
|
if (is_bool($value)) {
|
|
return $value ? 'Enabled' : 'Disabled';
|
|
}
|
|
|
|
if (is_int($value)) {
|
|
return number_format($value);
|
|
}
|
|
|
|
if (is_string($value)) {
|
|
// Remove {tenantid} placeholder
|
|
$value = str_replace(['{tenantid}', '_tenantid_'], ['', '_'], $value);
|
|
$value = preg_replace('/_+/', '_', $value);
|
|
|
|
// Extract choice label from choice values (last meaningful part)
|
|
// Example: "device_vendor_msft_...lowercaseletters_0" -> "Not Required (0)"
|
|
if (str_contains($value, 'device_vendor_msft') || str_contains($value, '#microsoft.graph')) {
|
|
$parts = explode('_', $value);
|
|
$lastPart = end($parts);
|
|
|
|
// Check for boolean-like values
|
|
if (in_array(strtolower($lastPart), ['true', 'false'])) {
|
|
return strtolower($lastPart) === 'true' ? 'Enabled' : 'Disabled';
|
|
}
|
|
|
|
// If last part is just a number, take second-to-last too
|
|
if (is_numeric($lastPart) && count($parts) > 1) {
|
|
$secondLast = $parts[count($parts) - 2];
|
|
// Map common values
|
|
$mapping = [
|
|
'lowercaseletters' => 'Lowercase Letters',
|
|
'uppercaseletters' => 'Uppercase Letters',
|
|
'specialcharacters' => 'Special Characters',
|
|
'digits' => 'Digits',
|
|
];
|
|
$label = $mapping[strtolower($secondLast)] ?? Str::title($secondLast);
|
|
|
|
return $label.': '.$lastPart;
|
|
}
|
|
|
|
return Str::title($lastPart);
|
|
}
|
|
|
|
// Truncate long strings
|
|
return Str::limit($value, 100);
|
|
}
|
|
|
|
if (is_array($value)) {
|
|
return json_encode($value);
|
|
}
|
|
|
|
return (string) $value;
|
|
}
|
|
|
|
/**
|
|
* Group settings by category (T013).
|
|
*/
|
|
private function groupSettingsByCategory(array $rows, array $definitions): array
|
|
{
|
|
$grouped = [];
|
|
|
|
foreach ($rows as $row) {
|
|
$definitionId = $row['definition_id'];
|
|
$definition = $definitions[$definitionId] ?? null;
|
|
|
|
// Determine category
|
|
$categoryId = $definition['categoryId'] ?? $this->extractCategoryFromDefinitionId($definitionId);
|
|
$categoryTitle = $this->formatCategoryTitle($categoryId);
|
|
|
|
if (! isset($grouped[$categoryId])) {
|
|
$grouped[$categoryId] = [
|
|
'title' => $categoryTitle,
|
|
'description' => null,
|
|
'settings' => [],
|
|
];
|
|
}
|
|
|
|
$grouped[$categoryId]['settings'][] = [
|
|
'label' => $definition['displayName'] ?? $row['definition_id'],
|
|
'value' => $row['value_display'], // Primary value for display
|
|
'value_display' => $row['value_display'],
|
|
'value_raw' => $row['value_raw'],
|
|
'help_text' => $definition['helpText'] ?? $definition['description'] ?? null,
|
|
'definition_id' => $definitionId,
|
|
'instance_type' => $row['instance_type'],
|
|
'is_fallback' => $definition['isFallback'] ?? false,
|
|
];
|
|
}
|
|
|
|
// Sort groups by title
|
|
uasort($grouped, fn ($a, $b) => strcmp($a['title'], $b['title']));
|
|
|
|
// Sort settings within each group by label for stable ordering
|
|
foreach ($grouped as $cid => $g) {
|
|
if (isset($g['settings']) && is_array($g['settings'])) {
|
|
usort($g['settings'], function ($a, $b) {
|
|
return strcmp(strtolower($a['label'] ?? ''), strtolower($b['label'] ?? ''));
|
|
});
|
|
|
|
$grouped[$cid]['settings'] = $g['settings'];
|
|
}
|
|
}
|
|
|
|
return array_values($grouped);
|
|
}
|
|
|
|
/**
|
|
* Extract category from definition ID (fallback grouping).
|
|
*/
|
|
private function extractCategoryFromDefinitionId(string $definitionId): string
|
|
{
|
|
$parts = explode('_', $definitionId);
|
|
|
|
// Use first 2-3 segments as category
|
|
return implode('_', array_slice($parts, 0, min(3, count($parts))));
|
|
}
|
|
|
|
/**
|
|
* Format category ID into readable title.
|
|
*/
|
|
private function formatCategoryTitle(string $categoryId): string
|
|
{
|
|
// Try to prettify known patterns
|
|
if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-/i', $categoryId)) {
|
|
// It's a UUID - likely a category ID from Graph
|
|
return 'Additional Settings';
|
|
}
|
|
|
|
// Clean up common prefixes
|
|
$title = str_replace('device_vendor_msft_', '', $categoryId);
|
|
$title = Str::title(str_replace('_', ' ', $title));
|
|
|
|
// Known mappings
|
|
$mappings = [
|
|
'Passportforwork' => 'Windows Hello for Business',
|
|
];
|
|
|
|
foreach ($mappings as $search => $replace) {
|
|
$title = str_replace($search, $replace, $title);
|
|
}
|
|
|
|
return $title;
|
|
}
|
|
}
|