Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox
This commit is contained in:
@@ -144,6 +144,7 @@ public partial class App : Application
|
|||||||
|
|
||||||
// Versions cleanup
|
// Versions cleanup
|
||||||
services.AddTransient<IVersionCleanupService, VersionCleanupService>();
|
services.AddTransient<IVersionCleanupService, VersionCleanupService>();
|
||||||
|
services.AddTransient<VersionCleanupHtmlExportService>();
|
||||||
services.AddTransient<VersionCleanupViewModel>();
|
services.AddTransient<VersionCleanupViewModel>();
|
||||||
services.AddTransient<VersionCleanupView>();
|
services.AddTransient<VersionCleanupView>();
|
||||||
|
|
||||||
|
|||||||
@@ -592,6 +592,8 @@ Cette action est irréversible.</value>
|
|||||||
<data name="report.title.duplicates_short" xml:space="preserve"><value>Rapport de détection de doublons</value></data>
|
<data name="report.title.duplicates_short" xml:space="preserve"><value>Rapport de détection de doublons</value></data>
|
||||||
<data name="report.title.search" xml:space="preserve"><value>Résultats de recherche de fichiers SharePoint</value></data>
|
<data name="report.title.search" xml:space="preserve"><value>Résultats de recherche de fichiers SharePoint</value></data>
|
||||||
<data name="report.title.search_short" xml:space="preserve"><value>Résultats de recherche de fichiers</value></data>
|
<data name="report.title.search_short" xml:space="preserve"><value>Résultats de recherche de fichiers</value></data>
|
||||||
|
<data name="report.title.versions" xml:space="preserve"><value>Rapport de nettoyage des versions SharePoint</value></data>
|
||||||
|
<data name="report.title.versions_short" xml:space="preserve"><value>Rapport de nettoyage des versions</value></data>
|
||||||
<data name="report.stat.total_accesses" xml:space="preserve"><value>Accès totaux</value></data>
|
<data name="report.stat.total_accesses" xml:space="preserve"><value>Accès totaux</value></data>
|
||||||
<data name="report.stat.users_audited" xml:space="preserve"><value>Utilisateurs audités</value></data>
|
<data name="report.stat.users_audited" xml:space="preserve"><value>Utilisateurs audités</value></data>
|
||||||
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites analysés</value></data>
|
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites analysés</value></data>
|
||||||
|
|||||||
@@ -592,6 +592,8 @@ This cannot be undone.</value>
|
|||||||
<data name="report.title.duplicates_short" xml:space="preserve"><value>Duplicate Detection Report</value></data>
|
<data name="report.title.duplicates_short" xml:space="preserve"><value>Duplicate Detection Report</value></data>
|
||||||
<data name="report.title.search" xml:space="preserve"><value>SharePoint File Search Results</value></data>
|
<data name="report.title.search" xml:space="preserve"><value>SharePoint File Search Results</value></data>
|
||||||
<data name="report.title.search_short" xml:space="preserve"><value>File Search Results</value></data>
|
<data name="report.title.search_short" xml:space="preserve"><value>File Search Results</value></data>
|
||||||
|
<data name="report.title.versions" xml:space="preserve"><value>SharePoint Version Cleanup Report</value></data>
|
||||||
|
<data name="report.title.versions_short" xml:space="preserve"><value>Version Cleanup Report</value></data>
|
||||||
<data name="report.stat.total_accesses" xml:space="preserve"><value>Total Accesses</value></data>
|
<data name="report.stat.total_accesses" xml:space="preserve"><value>Total Accesses</value></data>
|
||||||
<data name="report.stat.users_audited" xml:space="preserve"><value>Users Audited</value></data>
|
<data name="report.stat.users_audited" xml:space="preserve"><value>Users Audited</value></data>
|
||||||
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites Scanned</value></data>
|
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites Scanned</value></data>
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
using System.Text;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exports VersionCleanupResult list to a self-contained sortable/filterable HTML report.
|
||||||
|
/// Summary header shows totals (files trimmed, versions deleted, bytes freed); a single
|
||||||
|
/// table lists every processed file with sort/filter controls. No external assets.
|
||||||
|
/// </summary>
|
||||||
|
public class VersionCleanupHtmlExportService
|
||||||
|
{
|
||||||
|
public string BuildHtml(IReadOnlyList<VersionCleanupResult> results, ReportBranding? branding = null)
|
||||||
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
long totalBytes = results.Sum(r => r.BytesFreed);
|
||||||
|
int totalDeleted = results.Sum(r => r.VersionsDeleted);
|
||||||
|
int totalFiles = results.Count(r => r.VersionsDeleted > 0);
|
||||||
|
|
||||||
|
sb.AppendLine("<!DOCTYPE html>");
|
||||||
|
sb.AppendLine("<html lang=\"en\">");
|
||||||
|
sb.AppendLine("<head>");
|
||||||
|
sb.AppendLine("<meta charset=\"UTF-8\">");
|
||||||
|
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
||||||
|
sb.AppendLine($"<title>{T["report.title.versions"]}</title>");
|
||||||
|
sb.AppendLine("""
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
||||||
|
h1 { color: #0078d4; }
|
||||||
|
.summary { display: flex; gap: 24px; margin: 12px 0 16px 0; padding: 12px 16px;
|
||||||
|
background: #e8f1fb; border-radius: 6px; }
|
||||||
|
.summary .item { display: flex; flex-direction: column; }
|
||||||
|
.summary .label { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
.summary .value { font-size: 18px; font-weight: 600; color: #0078d4; }
|
||||||
|
.toolbar { margin-bottom: 10px; display: flex; gap: 12px; align-items: center; }
|
||||||
|
.toolbar label { font-weight: 600; }
|
||||||
|
#filterInput { padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; width: 280px; font-size: 13px; }
|
||||||
|
#resultCount { font-size: 12px; color: #666; }
|
||||||
|
table { border-collapse: collapse; width: 100%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
|
||||||
|
th { background: #0078d4; color: #fff; padding: 8px 12px; text-align: left; cursor: pointer;
|
||||||
|
font-weight: 600; user-select: none; white-space: nowrap; }
|
||||||
|
th:hover { background: #106ebe; }
|
||||||
|
th.sorted-asc::after { content: ' ▲'; font-size: 10px; }
|
||||||
|
th.sorted-desc::after { content: ' ▼'; font-size: 10px; }
|
||||||
|
td { padding: 6px 12px; border-bottom: 1px solid #e0e0e0; word-break: break-all; }
|
||||||
|
tr:hover td { background: #f0f7ff; }
|
||||||
|
tr.hidden { display: none; }
|
||||||
|
tr.err td { background: #fff4f4; }
|
||||||
|
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.err-cell { color: #b00020; }
|
||||||
|
.generated { font-size: 11px; color: #888; margin-top: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
""");
|
||||||
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||||
|
sb.AppendLine($"<h1>{T["report.title.versions_short"]}</h1>");
|
||||||
|
|
||||||
|
sb.AppendLine($"""
|
||||||
|
<div class="summary">
|
||||||
|
<div class="item"><span class="label">{T["versions.summary.files"]}</span><span class="value">{totalFiles:N0}</span></div>
|
||||||
|
<div class="item"><span class="label">{T["versions.summary.deleted"]}</span><span class="value">{totalDeleted:N0}</span></div>
|
||||||
|
<div class="item"><span class="label">{T["versions.summary.freed"]}</span><span class="value">{FormatSize(totalBytes)}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<label for="filterInput">{T["report.filter.label"]}</label>
|
||||||
|
<input id="filterInput" type="text" placeholder="{T["report.filter.placeholder_rows"]}" oninput="filterTable()" />
|
||||||
|
<span id="resultCount"></span>
|
||||||
|
</div>
|
||||||
|
""");
|
||||||
|
|
||||||
|
sb.AppendLine($"""
|
||||||
|
<table id="resultsTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th onclick="sortTable(0)">{T["report.col.site"]}</th>
|
||||||
|
<th onclick="sortTable(1)">{T["versions.col.library"]}</th>
|
||||||
|
<th onclick="sortTable(2)">{T["versions.col.file"]}</th>
|
||||||
|
<th onclick="sortTable(3)">{T["versions.col.path"]}</th>
|
||||||
|
<th class="num" onclick="sortTable(4)">{T["versions.col.before"]}</th>
|
||||||
|
<th class="num" onclick="sortTable(5)">{T["versions.col.deleted"]}</th>
|
||||||
|
<th class="num" onclick="sortTable(6)">{T["versions.col.remaining"]}</th>
|
||||||
|
<th class="num" onclick="sortTable(7)">{T["versions.col.freed"]}</th>
|
||||||
|
<th onclick="sortTable(8)">{T["versions.col.error"]}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
""");
|
||||||
|
|
||||||
|
foreach (var r in results)
|
||||||
|
{
|
||||||
|
string rowClass = string.IsNullOrEmpty(r.Error) ? string.Empty : " class=\"err\"";
|
||||||
|
string errCell = string.IsNullOrEmpty(r.Error)
|
||||||
|
? string.Empty
|
||||||
|
: $"<span class=\"err-cell\">{H(r.Error)}</span>";
|
||||||
|
|
||||||
|
sb.AppendLine($"""
|
||||||
|
<tr{rowClass}>
|
||||||
|
<td>{H(r.SiteUrl)}</td>
|
||||||
|
<td>{H(r.Library)}</td>
|
||||||
|
<td>{H(r.FileName)}</td>
|
||||||
|
<td>{H(r.FileServerRelativeUrl)}</td>
|
||||||
|
<td class="num" data-sort="{r.VersionsBefore}">{r.VersionsBefore:N0}</td>
|
||||||
|
<td class="num" data-sort="{r.VersionsDeleted}">{r.VersionsDeleted:N0}</td>
|
||||||
|
<td class="num" data-sort="{r.VersionsRemaining}">{r.VersionsRemaining:N0}</td>
|
||||||
|
<td class="num" data-sort="{r.BytesFreed}">{FormatSize(r.BytesFreed)}</td>
|
||||||
|
<td>{errCell}</td>
|
||||||
|
</tr>
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine(" </tbody>\n</table>");
|
||||||
|
|
||||||
|
int count = results.Count;
|
||||||
|
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} {T["report.text.results_parens"]}</p>");
|
||||||
|
|
||||||
|
sb.AppendLine($$"""
|
||||||
|
<script>
|
||||||
|
var sortDir = {};
|
||||||
|
function sortTable(col) {
|
||||||
|
var tbl = document.getElementById('resultsTable');
|
||||||
|
var tbody = tbl.tBodies[0];
|
||||||
|
var rows = Array.from(tbody.rows);
|
||||||
|
var asc = sortDir[col] !== 'asc';
|
||||||
|
sortDir[col] = asc ? 'asc' : 'desc';
|
||||||
|
rows.sort(function(a, b) {
|
||||||
|
var av = a.cells[col].dataset.sort || a.cells[col].innerText;
|
||||||
|
var bv = b.cells[col].dataset.sort || b.cells[col].innerText;
|
||||||
|
var an = parseFloat(av), bn = parseFloat(bv);
|
||||||
|
if (!isNaN(an) && !isNaN(bn)) return asc ? an - bn : bn - an;
|
||||||
|
return asc ? av.localeCompare(bv) : bv.localeCompare(av);
|
||||||
|
});
|
||||||
|
rows.forEach(function(r) { tbody.appendChild(r); });
|
||||||
|
var ths = tbl.tHead.rows[0].cells;
|
||||||
|
for (var i = 0; i < ths.length; i++) {
|
||||||
|
ths[i].className = (i === col) ? (asc ? 'sorted-asc' : 'sorted-desc') : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function filterTable() {
|
||||||
|
var q = document.getElementById('filterInput').value.toLowerCase();
|
||||||
|
var rows = document.getElementById('resultsTable').tBodies[0].rows;
|
||||||
|
var visible = 0;
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
var match = rows[i].innerText.toLowerCase().indexOf(q) >= 0;
|
||||||
|
rows[i].className = (rows[i].className.indexOf('err') >= 0 ? 'err ' : '') + (match ? '' : 'hidden');
|
||||||
|
if (match) visible++;
|
||||||
|
}
|
||||||
|
document.getElementById('resultCount').innerText = q ? (visible + ' {{T["report.text.of"]}} {{count:N0}} {{T["report.text.shown"]}}') : '';
|
||||||
|
}
|
||||||
|
window.onload = function() {
|
||||||
|
document.getElementById('resultCount').innerText = '{{count:N0}} {{T["report.text.results_parens"]}}';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
|
""");
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the HTML report to <paramref name="filePath"/> as UTF-8.</summary>
|
||||||
|
public async Task WriteAsync(IReadOnlyList<VersionCleanupResult> results, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||||
|
{
|
||||||
|
var html = BuildHtml(results, branding);
|
||||||
|
await System.IO.File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string H(string value) =>
|
||||||
|
System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
|
||||||
|
|
||||||
|
private static string FormatSize(long bytes)
|
||||||
|
{
|
||||||
|
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
|
||||||
|
if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB";
|
||||||
|
if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB";
|
||||||
|
return $"{bytes} B";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ using Microsoft.Win32;
|
|||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
using SharepointToolbox.Localization;
|
using SharepointToolbox.Localization;
|
||||||
using SharepointToolbox.Services;
|
using SharepointToolbox.Services;
|
||||||
|
using SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
namespace SharepointToolbox.ViewModels.Tabs;
|
namespace SharepointToolbox.ViewModels.Tabs;
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ public partial class VersionCleanupViewModel : FeatureViewModelBase
|
|||||||
{
|
{
|
||||||
private readonly IVersionCleanupService _versionService;
|
private readonly IVersionCleanupService _versionService;
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly VersionCleanupHtmlExportService _htmlExportService;
|
||||||
|
private readonly IBrandingService _brandingService;
|
||||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||||
private TenantProfile? _currentProfile;
|
private TenantProfile? _currentProfile;
|
||||||
|
|
||||||
@@ -50,20 +53,26 @@ public partial class VersionCleanupViewModel : FeatureViewModelBase
|
|||||||
public IAsyncRelayCommand SelectLibrariesCommand { get; }
|
public IAsyncRelayCommand SelectLibrariesCommand { get; }
|
||||||
public IRelayCommand ClearLibrariesCommand { get; }
|
public IRelayCommand ClearLibrariesCommand { get; }
|
||||||
public IAsyncRelayCommand ExportCsvCommand { get; }
|
public IAsyncRelayCommand ExportCsvCommand { get; }
|
||||||
|
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
||||||
|
|
||||||
public VersionCleanupViewModel(
|
public VersionCleanupViewModel(
|
||||||
IVersionCleanupService versionService,
|
IVersionCleanupService versionService,
|
||||||
ISessionManager sessionManager,
|
ISessionManager sessionManager,
|
||||||
|
VersionCleanupHtmlExportService htmlExportService,
|
||||||
|
IBrandingService brandingService,
|
||||||
ILogger<FeatureViewModelBase> logger)
|
ILogger<FeatureViewModelBase> logger)
|
||||||
: base(logger)
|
: base(logger)
|
||||||
{
|
{
|
||||||
_versionService = versionService;
|
_versionService = versionService;
|
||||||
_sessionManager = sessionManager;
|
_sessionManager = sessionManager;
|
||||||
|
_htmlExportService = htmlExportService;
|
||||||
|
_brandingService = brandingService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
SelectLibrariesCommand = new AsyncRelayCommand(SelectLibrariesAsync, CanPickLibraries);
|
SelectLibrariesCommand = new AsyncRelayCommand(SelectLibrariesAsync, CanPickLibraries);
|
||||||
ClearLibrariesCommand = new RelayCommand(ClearLibraries);
|
ClearLibrariesCommand = new RelayCommand(ClearLibraries);
|
||||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, () => Results.Count > 0);
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, () => Results.Count > 0);
|
||||||
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, () => Results.Count > 0);
|
||||||
|
|
||||||
SelectedLibraries.CollectionChanged += (_, _) => UpdateSelectedLibrariesLabel();
|
SelectedLibraries.CollectionChanged += (_, _) => UpdateSelectedLibrariesLabel();
|
||||||
Results.CollectionChanged += (_, _) =>
|
Results.CollectionChanged += (_, _) =>
|
||||||
@@ -73,6 +82,7 @@ public partial class VersionCleanupViewModel : FeatureViewModelBase
|
|||||||
OnPropertyChanged(nameof(TotalVersionsDeleted));
|
OnPropertyChanged(nameof(TotalVersionsDeleted));
|
||||||
OnPropertyChanged(nameof(TotalFilesAffected));
|
OnPropertyChanged(nameof(TotalFilesAffected));
|
||||||
ExportCsvCommand.NotifyCanExecuteChanged();
|
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||||
|
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||||
};
|
};
|
||||||
UpdateSelectedLibrariesLabel();
|
UpdateSelectedLibrariesLabel();
|
||||||
}
|
}
|
||||||
@@ -212,8 +222,7 @@ public partial class VersionCleanupViewModel : FeatureViewModelBase
|
|||||||
r.BytesFreed,
|
r.BytesFreed,
|
||||||
Csv(r.Error ?? string.Empty)));
|
Csv(r.Error ?? string.Empty)));
|
||||||
}
|
}
|
||||||
try { Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true }); }
|
OpenFile(dialog.FileName);
|
||||||
catch { }
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -222,6 +231,39 @@ public partial class VersionCleanupViewModel : FeatureViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ExportHtmlAsync()
|
||||||
|
{
|
||||||
|
if (Results.Count == 0) return;
|
||||||
|
var dialog = new SaveFileDialog
|
||||||
|
{
|
||||||
|
Title = "Export version cleanup results to HTML",
|
||||||
|
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
|
||||||
|
DefaultExt = "html",
|
||||||
|
FileName = "version_cleanup",
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() != true) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mspLogo = await _brandingService.GetMspLogoAsync();
|
||||||
|
var clientLogo = _currentProfile?.ClientLogo;
|
||||||
|
var branding = new ReportBranding(mspLogo, clientLogo);
|
||||||
|
|
||||||
|
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding);
|
||||||
|
OpenFile(dialog.FileName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Export failed: {ex.Message}";
|
||||||
|
_logger.LogError(ex, "Version cleanup HTML export failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OpenFile(string filePath)
|
||||||
|
{
|
||||||
|
try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
private static string Csv(string value)
|
private static string Csv(string value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(value)) return string.Empty;
|
if (string.IsNullOrEmpty(value)) return string.Empty;
|
||||||
|
|||||||
@@ -43,6 +43,8 @@
|
|||||||
|
|
||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportCsv]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportCsv]}"
|
||||||
Command="{Binding ExportCsvCommand}" Height="28" Margin="0,0,0,4" />
|
Command="{Binding ExportCsvCommand}" Height="28" Margin="0,0,0,4" />
|
||||||
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportHtml]}"
|
||||||
|
Command="{Binding ExportHtmlCommand}" Height="28" Margin="0,0,0,4" />
|
||||||
|
|
||||||
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" FontSize="11"
|
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" FontSize="11"
|
||||||
Foreground="{DynamicResource TextMutedBrush}" Margin="0,6" />
|
Foreground="{DynamicResource TextMutedBrush}" Margin="0,6" />
|
||||||
|
|||||||
Reference in New Issue
Block a user