This commit is contained in:
Dev
2026-04-24 10:54:47 +02:00
14 changed files with 681 additions and 0 deletions
@@ -536,7 +536,14 @@
<data name="report.col.error" xml:space="preserve"><value>Erreur</value></data> <data name="report.col.error" xml:space="preserve"><value>Erreur</value></data>
<data name="report.col.timestamp" xml:space="preserve"><value>Horodatage</value></data> <data name="report.col.timestamp" xml:space="preserve"><value>Horodatage</value></data>
<data name="report.col.number" xml:space="preserve"><value>#</value></data> <data name="report.col.number" xml:space="preserve"><value>#</value></data>
<<<<<<< HEAD
<data name="report.col.group" xml:space="preserve"><value>Groupe</value></data> <data name="report.col.group" xml:space="preserve"><value>Groupe</value></data>
=======
<<<<<<< HEAD
<data name="report.col.group" xml:space="preserve"><value>Groupe</value></data>
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<data name="report.col.total_size_mb" xml:space="preserve"><value>Taille totale (Mo)</value></data> <data name="report.col.total_size_mb" xml:space="preserve"><value>Taille totale (Mo)</value></data>
<data name="report.col.version_size_mb" xml:space="preserve"><value>Taille des versions (Mo)</value></data> <data name="report.col.version_size_mb" xml:space="preserve"><value>Taille des versions (Mo)</value></data>
<data name="report.col.size_mb" xml:space="preserve"><value>Taille (Mo)</value></data> <data name="report.col.size_mb" xml:space="preserve"><value>Taille (Mo)</value></data>
@@ -559,6 +566,10 @@
<data name="report.text.high_priv" xml:space="preserve"><value>priv. &#233;lev&#233;</value></data> <data name="report.text.high_priv" xml:space="preserve"><value>priv. &#233;lev&#233;</value></data>
<data name="report.section.storage_by_file_type" xml:space="preserve"><value>Stockage par type de fichier</value></data> <data name="report.section.storage_by_file_type" xml:space="preserve"><value>Stockage par type de fichier</value></data>
<data name="report.section.library_details" xml:space="preserve"><value>D&#233;tails des biblioth&#232;ques</value></data> <data name="report.section.library_details" xml:space="preserve"><value>D&#233;tails des biblioth&#232;ques</value></data>
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<!-- Site picker dialog --> <!-- Site picker dialog -->
<data name="sitepicker.title" xml:space="preserve"><value>S&#233;lectionner les sites</value></data> <data name="sitepicker.title" xml:space="preserve"><value>S&#233;lectionner les sites</value></data>
<data name="sitepicker.filter" xml:space="preserve"><value>Filtre&#160;:</value></data> <data name="sitepicker.filter" xml:space="preserve"><value>Filtre&#160;:</value></data>
@@ -650,4 +661,9 @@
<data name="report.text.users_parens" xml:space="preserve"><value>utilisateur(s)</value></data> <data name="report.text.users_parens" xml:space="preserve"><value>utilisateur(s)</value></data>
<data name="report.text.files_unit" xml:space="preserve"><value>fichiers</value></data> <data name="report.text.files_unit" xml:space="preserve"><value>fichiers</value></data>
<data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data> <data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data>
<<<<<<< HEAD
=======
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
</root> </root>
@@ -536,7 +536,14 @@
<data name="report.col.error" xml:space="preserve"><value>Error</value></data> <data name="report.col.error" xml:space="preserve"><value>Error</value></data>
<data name="report.col.timestamp" xml:space="preserve"><value>Timestamp</value></data> <data name="report.col.timestamp" xml:space="preserve"><value>Timestamp</value></data>
<data name="report.col.number" xml:space="preserve"><value>#</value></data> <data name="report.col.number" xml:space="preserve"><value>#</value></data>
<<<<<<< HEAD
<data name="report.col.group" xml:space="preserve"><value>Group</value></data> <data name="report.col.group" xml:space="preserve"><value>Group</value></data>
=======
<<<<<<< HEAD
<data name="report.col.group" xml:space="preserve"><value>Group</value></data>
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<data name="report.col.total_size_mb" xml:space="preserve"><value>Total Size (MB)</value></data> <data name="report.col.total_size_mb" xml:space="preserve"><value>Total Size (MB)</value></data>
<data name="report.col.version_size_mb" xml:space="preserve"><value>Version Size (MB)</value></data> <data name="report.col.version_size_mb" xml:space="preserve"><value>Version Size (MB)</value></data>
<data name="report.col.size_mb" xml:space="preserve"><value>Size (MB)</value></data> <data name="report.col.size_mb" xml:space="preserve"><value>Size (MB)</value></data>
@@ -559,6 +566,10 @@
<data name="report.text.high_priv" xml:space="preserve"><value>high-priv</value></data> <data name="report.text.high_priv" xml:space="preserve"><value>high-priv</value></data>
<data name="report.section.storage_by_file_type" xml:space="preserve"><value>Storage by File Type</value></data> <data name="report.section.storage_by_file_type" xml:space="preserve"><value>Storage by File Type</value></data>
<data name="report.section.library_details" xml:space="preserve"><value>Library Details</value></data> <data name="report.section.library_details" xml:space="preserve"><value>Library Details</value></data>
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<!-- Site picker dialog --> <!-- Site picker dialog -->
<data name="sitepicker.title" xml:space="preserve"><value>Select Sites</value></data> <data name="sitepicker.title" xml:space="preserve"><value>Select Sites</value></data>
<data name="sitepicker.filter" xml:space="preserve"><value>Filter:</value></data> <data name="sitepicker.filter" xml:space="preserve"><value>Filter:</value></data>
@@ -650,4 +661,9 @@
<data name="report.text.users_parens" xml:space="preserve"><value>user(s)</value></data> <data name="report.text.users_parens" xml:space="preserve"><value>user(s)</value></data>
<data name="report.text.files_unit" xml:space="preserve"><value>files</value></data> <data name="report.text.files_unit" xml:space="preserve"><value>files</value></data>
<data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data> <data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data>
<<<<<<< HEAD
=======
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
</root> </root>
@@ -12,12 +12,23 @@ namespace SharepointToolbox.Services.Export;
/// </summary> /// </summary>
public class DuplicatesCsvExportService public class DuplicatesCsvExportService
{ {
<<<<<<< HEAD
/// <summary>Writes the CSV to <paramref name="filePath"/> with UTF-8 BOM (Excel-compatible).</summary> /// <summary>Writes the CSV to <paramref name="filePath"/> with UTF-8 BOM (Excel-compatible).</summary>
=======
<<<<<<< HEAD
/// <summary>Writes the CSV to <paramref name="filePath"/> with UTF-8 BOM (Excel-compatible).</summary>
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
public async Task WriteAsync( public async Task WriteAsync(
IReadOnlyList<DuplicateGroup> groups, IReadOnlyList<DuplicateGroup> groups,
string filePath, string filePath,
CancellationToken ct) CancellationToken ct)
{ {
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
var csv = BuildCsv(groups); var csv = BuildCsv(groups);
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct); await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
} }
@@ -59,6 +70,11 @@ public class DuplicatesCsvExportService
/// </summary> /// </summary>
public string BuildCsv(IReadOnlyList<DuplicateGroup> groups) public string BuildCsv(IReadOnlyList<DuplicateGroup> groups)
{ {
<<<<<<< HEAD
=======
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
var T = TranslationSource.Instance; var T = TranslationSource.Instance;
var sb = new StringBuilder(); var sb = new StringBuilder();
@@ -72,9 +88,20 @@ public class DuplicatesCsvExportService
sb.AppendLine(string.Join(",", new[] sb.AppendLine(string.Join(",", new[]
{ {
Csv(T["report.col.number"]), Csv(T["report.col.number"]),
<<<<<<< HEAD
Csv(T["report.col.group"]), Csv(T["report.col.group"]),
Csv(T["report.text.copies"]), Csv(T["report.text.copies"]),
Csv(T["report.col.site"]), Csv(T["report.col.site"]),
=======
<<<<<<< HEAD
Csv(T["report.col.group"]),
Csv(T["report.text.copies"]),
Csv(T["report.col.site"]),
=======
Csv("Group"),
Csv(T["report.text.copies"]),
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
Csv(T["report.col.name"]), Csv(T["report.col.name"]),
Csv(T["report.col.library"]), Csv(T["report.col.library"]),
Csv(T["report.col.path"]), Csv(T["report.col.path"]),
@@ -83,6 +110,13 @@ public class DuplicatesCsvExportService
Csv(T["report.col.modified"]), Csv(T["report.col.modified"]),
})); }));
<<<<<<< HEAD
=======
<<<<<<< HEAD
=======
// Rows
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
foreach (var g in groups) foreach (var g in groups)
{ {
int i = 0; int i = 0;
@@ -94,7 +128,14 @@ public class DuplicatesCsvExportService
Csv(i.ToString()), Csv(i.ToString()),
Csv(g.Name), Csv(g.Name),
Csv(g.Items.Count.ToString()), Csv(g.Items.Count.ToString()),
<<<<<<< HEAD
Csv(item.SiteTitle), Csv(item.SiteTitle),
=======
<<<<<<< HEAD
Csv(item.SiteTitle),
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
Csv(item.Name), Csv(item.Name),
Csv(item.Library), Csv(item.Library),
Csv(item.Path), Csv(item.Path),
@@ -105,8 +146,26 @@ public class DuplicatesCsvExportService
} }
} }
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
return sb.ToString(); return sb.ToString();
} }
private static string Csv(string value) => CsvSanitizer.Escape(value); private static string Csv(string value) => CsvSanitizer.Escape(value);
<<<<<<< HEAD
=======
=======
await File.WriteAllTextAsync(filePath, sb.ToString(),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
}
private static string Csv(string value)
{
if (string.IsNullOrEmpty(value)) return "\"\"";
return $"\"{value.Replace("\"", "\"\"")}\"";
}
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
} }
@@ -2,7 +2,14 @@ using System.IO;
using System.Text; using System.Text;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization; using SharepointToolbox.Localization;
<<<<<<< HEAD
using static SharepointToolbox.Services.Export.PermissionHtmlFragments; using static SharepointToolbox.Services.Export.PermissionHtmlFragments;
=======
<<<<<<< HEAD
using static SharepointToolbox.Services.Export.PermissionHtmlFragments;
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
namespace SharepointToolbox.Services.Export; namespace SharepointToolbox.Services.Export;
@@ -30,6 +37,10 @@ public class HtmlExportService
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null) IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{ {
var T = TranslationSource.Instance; var T = TranslationSource.Instance;
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats( var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
entries.Count, entries.Count,
entries.Select(e => e.PermissionLevels), entries.Select(e => e.PermissionLevels),
@@ -43,6 +54,87 @@ public class HtmlExportService
AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers); AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers);
AppendFilterInput(sb); AppendFilterInput(sb);
AppendTableOpen(sb); AppendTableOpen(sb);
<<<<<<< HEAD
=======
=======
// Compute stats
var totalEntries = entries.Count;
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
var distinctUsers = entries
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
.Select(u => u.Trim())
.Where(u => u.Length > 0)
.Distinct()
.Count();
var sb = new StringBuilder();
// ── HTML HEAD ──────────────────────────────────────────────────────────
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.permissions"]}</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
.filter-wrap { padding: 0 24px 12px; }
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; }
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafafa; }
/* Type badges */
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
.badge.site-coll { background: #dbeafe; color: #1e40af; }
.badge.site { background: #dcfce7; color: #166534; }
.badge.list { background: #fef9c3; color: #854d0e; }
.badge.folder { background: #f3f4f6; color: #374151; }
/* Unique/Inherited badges */
.badge.unique { background: #dcfce7; color: #166534; }
.badge.inherited { background: #f3f4f6; color: #374151; }
/* User pills */
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
.user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; }
.group-expandable { cursor: pointer; }
.group-expandable:hover { opacity: 0.8; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
// ── BODY ───────────────────────────────────────────────────────────────
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.permissions"]}</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">{T["report.stat.total_entries"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">{T["report.stat.unique_permission_sets"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">{T["report.stat.distinct_users_groups"]}</div></div>");
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_permissions"]}\" onkeyup=\"filterTable()\" />");
sb.AppendLine("</div>");
// Table
sb.AppendLine("<div class=\"table-wrap\">");
sb.AppendLine("<table id=\"permTable\">");
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
sb.AppendLine("<thead><tr>"); sb.AppendLine("<thead><tr>");
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.granted_through"]}</th>"); sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>"); sb.AppendLine("</tr></thead>");
@@ -55,9 +147,58 @@ public class HtmlExportService
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited"; var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"]; var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
<<<<<<< HEAD
var (pills, subRows) = BuildUserPillsCell( var (pills, subRows) = BuildUserPillsCell(
entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers, entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers,
colSpan: 7, grpMemIdx: ref grpMemIdx); colSpan: 7, grpMemIdx: ref grpMemIdx);
=======
<<<<<<< HEAD
var (pills, subRows) = BuildUserPillsCell(
entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers,
colSpan: 7, grpMemIdx: ref grpMemIdx);
=======
// Build user pills: zip UserLogins and Users (both semicolon-delimited)
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
var pillsBuilder = new StringBuilder();
var memberSubRows = new StringBuilder();
for (int i = 0; i < logins.Length; i++)
{
var login = logins[i].Trim();
var name = i < names.Length ? names[i].Trim() : login;
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
bool isExpandableGroup = entry.PrincipalType == "SharePointGroup"
&& groupMembers != null
&& groupMembers.TryGetValue(name, out var members);
if (isExpandableGroup && groupMembers != null && groupMembers.TryGetValue(name, out var resolvedMembers))
{
var grpId = $"grpmem{grpMemIdx}";
pillsBuilder.Append($"<span class=\"user-pill group-expandable\" onclick=\"toggleGroup('{grpId}')\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} &#9660;</span>");
string memberContent;
if (resolvedMembers.Count > 0)
{
var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} &lt;{HtmlEncode(m.Login)}&gt;");
memberContent = string.Join(" &bull; ", memberParts);
}
else
{
memberContent = $"<em style=\"color:#888\">{T["report.text.members_unavailable"]}</em>";
}
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"7\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
grpMemIdx++;
}
else
{
var pillCss = isExt ? "user-pill external-user" : "user-pill";
pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
}
}
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
sb.AppendLine("<tr>"); sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>"); sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
@@ -291,4 +432,235 @@ public class HtmlExportService
RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"), RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"),
_ => ("#F3F4F6", "#374151", "#E5E7EB") _ => ("#F3F4F6", "#374151", "#E5E7EB")
}; };
<<<<<<< HEAD
=======
<<<<<<< HEAD
=======
/// <summary>
/// Builds a self-contained HTML string from simplified permission entries.
/// Includes risk-level summary cards, color-coded rows, and simplified labels column.
/// When <paramref name="groupMembers"/> is provided, SharePoint group pills become expandable.
/// </summary>
public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
var T = TranslationSource.Instance;
var summaries = PermissionSummaryBuilder.Build(entries);
var totalEntries = entries.Count;
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
var distinctUsers = entries
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
.Select(u => u.Trim())
.Where(u => u.Length > 0)
.Distinct()
.Count();
var sb = new StringBuilder();
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.permissions_simplified"]}</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
.risk-cards { display: flex; gap: 12px; padding: 0 24px 16px; flex-wrap: wrap; }
.risk-card { border-radius: 8px; padding: 12px 18px; min-width: 140px; border: 1px solid; }
.risk-card .count { font-size: 1.5rem; font-weight: 700; }
.risk-card .rlabel { font-size: .8rem; margin-top: 2px; }
.risk-card .users { font-size: .7rem; margin-top: 2px; opacity: 0.8; }
.filter-wrap { padding: 0 24px 12px; }
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; }
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(0,0,0,.03); }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
.badge.site-coll { background: #dbeafe; color: #1e40af; }
.badge.site { background: #dcfce7; color: #166534; }
.badge.list { background: #fef9c3; color: #854d0e; }
.badge.folder { background: #f3f4f6; color: #374151; }
.badge.unique { background: #dcfce7; color: #166534; }
.badge.inherited { background: #f3f4f6; color: #374151; }
.risk-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; border: 1px solid; }
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
.user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; }
.group-expandable { cursor: pointer; }
.group-expandable:hover { opacity: 0.8; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.permissions_simplified"]}</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">{T["report.stat.total_entries"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">{T["report.stat.unique_permission_sets"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">{T["report.stat.distinct_users_groups"]}</div></div>");
sb.AppendLine("</div>");
// Risk-level summary cards
sb.AppendLine("<div class=\"risk-cards\">");
foreach (var summary in summaries)
{
var (bg, text, border) = RiskLevelColors(summary.RiskLevel);
sb.AppendLine($" <div class=\"risk-card\" style=\"background:{bg};color:{text};border-color:{border}\">");
sb.AppendLine($" <div class=\"count\">{summary.Count}</div>");
sb.AppendLine($" <div class=\"rlabel\">{HtmlEncode(summary.Label)}</div>");
sb.AppendLine($" <div class=\"users\">{summary.DistinctUsers} user(s)</div>");
sb.AppendLine(" </div>");
}
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_permissions"]}\" onkeyup=\"filterTable()\" />");
sb.AppendLine("</div>");
// Table with simplified columns
sb.AppendLine("<div class=\"table-wrap\">");
sb.AppendLine("<table id=\"permTable\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.simplified"]}</th><th>{T["report.col.risk"]}</th><th>{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>");
int grpMemIdx = 0;
foreach (var entry in entries)
{
var typeCss = ObjectTypeCss(entry.ObjectType);
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = entry.Inner.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
var pillsBuilder = new StringBuilder();
var memberSubRows = new StringBuilder();
for (int i = 0; i < logins.Length; i++)
{
var login = logins[i].Trim();
var name = i < names.Length ? names[i].Trim() : login;
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
bool isExpandableGroup = entry.Inner.PrincipalType == "SharePointGroup"
&& groupMembers != null
&& groupMembers.TryGetValue(name, out _);
if (isExpandableGroup && groupMembers != null && groupMembers.TryGetValue(name, out var resolvedMembers))
{
var grpId = $"grpmem{grpMemIdx}";
pillsBuilder.Append($"<span class=\"user-pill group-expandable\" onclick=\"toggleGroup('{grpId}')\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} &#9660;</span>");
string memberContent;
if (resolvedMembers.Count > 0)
{
var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} &lt;{HtmlEncode(m.Login)}&gt;");
memberContent = string.Join(" &bull; ", memberParts);
}
else
{
memberContent = $"<em style=\"color:#888\">{T["report.text.members_unavailable"]}</em>";
}
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"9\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
grpMemIdx++;
}
else
{
var pillCss = isExt ? "user-pill external-user" : "user-pill";
pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
}
}
sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>");
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
sb.AppendLine($" <td>{pillsBuilder}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</td>");
sb.AppendLine($" <td><span class=\"risk-badge\" style=\"background:{riskBg};color:{riskText};border-color:{riskBorder}\">{HtmlEncode(entry.RiskLevel.ToString())}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine("</tr>");
if (memberSubRows.Length > 0)
sb.Append(memberSubRows);
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
sb.AppendLine("<script>");
sb.AppendLine(@"function filterTable() {
var input = document.getElementById('filter').value.toLowerCase();
var rows = document.querySelectorAll('#permTable tbody tr');
rows.forEach(function(row) {
if (row.hasAttribute('data-group')) return;
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none';
});
}
function toggleGroup(id) {
var rows = document.querySelectorAll('tr[data-group=""' + id + '""]');
rows.forEach(function(r) { r.style.display = r.style.display === 'none' ? '' : 'none'; });
}");
sb.AppendLine("</script>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
/// <summary>
/// Writes the simplified HTML report to the specified file path.
/// </summary>
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
var html = BuildHtml(entries, branding, groupMembers);
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
}
/// <summary>Returns the CSS class for the object-type badge.</summary>
private static string ObjectTypeCss(string t) => t switch
{
"Site Collection" => "badge site-coll",
"Site" => "badge site",
"List" => "badge list",
"Folder" => "badge folder",
_ => "badge"
};
/// <summary>Minimal HTML encoding for text content and attribute values.</summary>
private static string HtmlEncode(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
return value
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
} }
@@ -180,7 +180,15 @@ public class StorageHtmlExportService
var totalFiles = fileTypeMetrics.Sum(m => m.FileCount); var totalFiles = fileTypeMetrics.Sum(m => m.FileCount);
sb.AppendLine("<div class=\"chart-section\">"); sb.AppendLine("<div class=\"chart-section\">");
<<<<<<< HEAD
sb.AppendLine($"<h2>{T["report.section.storage_by_file_type"]} ({totalFiles:N0} {T["report.text.files_unit"]}, {FormatSize(totalSize)})</h2>"); sb.AppendLine($"<h2>{T["report.section.storage_by_file_type"]} ({totalFiles:N0} {T["report.text.files_unit"]}, {FormatSize(totalSize)})</h2>");
=======
<<<<<<< HEAD
sb.AppendLine($"<h2>{T["report.section.storage_by_file_type"]} ({totalFiles:N0} {T["report.text.files_unit"]}, {FormatSize(totalSize)})</h2>");
=======
sb.AppendLine($"<h2>{T["report.section.storage_by_file_type"]} ({totalFiles:N0} files, {FormatSize(totalSize)})</h2>");
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578", var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578",
"#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" }; "#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" };
@@ -214,7 +214,15 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
if (dialog.ShowDialog() != true) return; if (dialog.ShowDialog() != true) return;
try try
{ {
<<<<<<< HEAD
await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CancellationToken.None); await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CancellationToken.None);
=======
<<<<<<< HEAD
await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CancellationToken.None);
=======
await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CancellationToken.None);
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true }); Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
} }
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "CSV export failed."); } catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "CSV export failed."); }
@@ -176,7 +176,14 @@ public partial class StorageViewModel : FeatureViewModelBase
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
<<<<<<< HEAD
ApplyChartThemeColors(); ApplyChartThemeColors();
=======
<<<<<<< HEAD
ApplyChartThemeColors();
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
if (_themeManager is not null) if (_themeManager is not null)
_themeManager.ThemeChanged += (_, _) => UpdateChartSeries(); _themeManager.ThemeChanged += (_, _) => UpdateChartSeries();
} }
@@ -398,6 +405,10 @@ public partial class StorageViewModel : FeatureViewModelBase
private SKColor ChartFgColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0xE7, 0xEA, 0xF1) : new SKColor(0x1F, 0x24, 0x30); private SKColor ChartFgColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0xE7, 0xEA, 0xF1) : new SKColor(0x1F, 0x24, 0x30);
private SKColor ChartSeparatorColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x32, 0x38, 0x49) : new SKColor(0xE3, 0xE6, 0xEC); private SKColor ChartSeparatorColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x32, 0x38, 0x49) : new SKColor(0xE3, 0xE6, 0xEC);
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
private SKColor ChartSurfaceColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x1E, 0x22, 0x30) : new SKColor(0xFF, 0xFF, 0xFF); private SKColor ChartSurfaceColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x1E, 0x22, 0x30) : new SKColor(0xFF, 0xFF, 0xFF);
private void ApplyChartThemeColors() private void ApplyChartThemeColors()
@@ -407,6 +418,11 @@ public partial class StorageViewModel : FeatureViewModelBase
TooltipTextPaint.Color = ChartFgColor; TooltipTextPaint.Color = ChartFgColor;
TooltipBackgroundPaint.Color = ChartSurfaceColor; TooltipBackgroundPaint.Color = ChartSurfaceColor;
} }
<<<<<<< HEAD
=======
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
private void UpdateChartSeries() private void UpdateChartSeries()
{ {
@@ -16,8 +16,17 @@
<!-- Action bar: new folder (destination mode only) --> <!-- Action bar: new folder (destination mode only) -->
<StackPanel x:Name="ActionBar" DockPanel.Dock="Top" Orientation="Horizontal" <StackPanel x:Name="ActionBar" DockPanel.Dock="Top" Orientation="Horizontal"
Margin="0,0,0,6" Visibility="Collapsed"> Margin="0,0,0,6" Visibility="Collapsed">
<<<<<<< HEAD
<Button x:Name="NewFolderButton" <Button x:Name="NewFolderButton"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.new_folder]}" Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.new_folder]}"
=======
<<<<<<< HEAD
<Button x:Name="NewFolderButton"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.new_folder]}"
=======
<Button x:Name="NewFolderButton" Content="+ New Folder"
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
Padding="8,3" Click="NewFolder_Click" IsEnabled="False" /> Padding="8,3" Click="NewFolder_Click" IsEnabled="False" />
</StackPanel> </StackPanel>
@@ -22,7 +22,14 @@ public partial class FolderBrowserDialog : Window
public IReadOnlyList<string> SelectedFilePaths { get; private set; } = Array.Empty<string>(); public IReadOnlyList<string> SelectedFilePaths { get; private set; } = Array.Empty<string>();
private readonly List<CheckBox> _fileCheckboxes = new(); private readonly List<CheckBox> _fileCheckboxes = new();
<<<<<<< HEAD
private readonly List<TreeViewItem> _expandedNodes = new(); private readonly List<TreeViewItem> _expandedNodes = new();
=======
<<<<<<< HEAD
private readonly List<TreeViewItem> _expandedNodes = new();
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
/// <summary> /// <summary>
/// Dialog for browsing library folders. Set <paramref name="allowFileSelection"/> /// Dialog for browsing library folders. Set <paramref name="allowFileSelection"/>
@@ -81,7 +88,14 @@ public partial class FolderBrowserDialog : Window
// Placeholder child so the expand arrow appears. // Placeholder child so the expand arrow appears.
node.Items.Add(new TreeViewItem { Header = "Loading..." }); node.Items.Add(new TreeViewItem { Header = "Loading..." });
node.Expanded += FolderNode_Expanded; node.Expanded += FolderNode_Expanded;
<<<<<<< HEAD
_expandedNodes.Add(node); _expandedNodes.Add(node);
=======
<<<<<<< HEAD
_expandedNodes.Add(node);
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
return node; return node;
} }
@@ -101,9 +115,24 @@ public partial class FolderBrowserDialog : Window
{ {
var folder = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl); var folder = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl);
_ctx.Load(folder, f => f.StorageMetrics.TotalSize, _ctx.Load(folder, f => f.StorageMetrics.TotalSize,
<<<<<<< HEAD
f => f.StorageMetrics.TotalFileCount); f => f.StorageMetrics.TotalFileCount);
var list = _ctx.Web.Lists.GetByTitle(info.LibraryTitle); var list = _ctx.Web.Lists.GetByTitle(info.LibraryTitle);
_ctx.Load(list, l => l.Title); _ctx.Load(list, l => l.Title);
=======
<<<<<<< HEAD
f => f.StorageMetrics.TotalFileCount);
var list = _ctx.Web.Lists.GetByTitle(info.LibraryTitle);
_ctx.Load(list, l => l.Title);
=======
f => f.StorageMetrics.TotalFileCount,
f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl,
sf => sf.StorageMetrics.TotalSize,
sf => sf.StorageMetrics.TotalFileCount),
f => f.Files.Include(fi => fi.Name, fi => fi.Length,
fi => fi.ServerRelativeUrl));
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
var progress = new Progress<Core.Models.OperationProgress>(); var progress = new Progress<Core.Models.OperationProgress>();
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
@@ -114,6 +143,10 @@ public partial class FolderBrowserDialog : Window
folder.StorageMetrics.TotalFileCount, folder.StorageMetrics.TotalFileCount,
folder.StorageMetrics.TotalSize); folder.StorageMetrics.TotalSize);
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
// Enumerate direct children via paginated CAML — Folder.Folders / // Enumerate direct children via paginated CAML — Folder.Folders /
// Folder.Files lazy loading hits the list-view threshold on libraries // Folder.Files lazy loading hits the list-view threshold on libraries
// above 5,000 items even when only a small folder is being expanded. // above 5,000 items even when only a small folder is being expanded.
@@ -191,6 +224,56 @@ public partial class FolderBrowserDialog : Window
node.Items.Add(fileItem); node.Items.Add(fileItem);
} }
} }
<<<<<<< HEAD
=======
=======
// Child folders first
foreach (var subFolder in folder.Folders)
{
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
continue;
var childRelative = string.IsNullOrEmpty(info.RelativePath)
? subFolder.Name
: $"{info.RelativePath}/{subFolder.Name}";
var childInfo = new FolderNodeInfo(
info.LibraryTitle, childRelative, subFolder.ServerRelativeUrl);
var childNode = MakeFolderNode(
FormatFolderHeader(subFolder.Name,
subFolder.StorageMetrics.TotalFileCount,
subFolder.StorageMetrics.TotalSize),
childInfo);
node.Items.Add(childNode);
}
// Files under this folder — only shown when selection is enabled.
if (_allowFileSelection)
{
foreach (var file in folder.Files)
{
// Library-relative path for the file (used by the transfer service)
var fileRel = string.IsNullOrEmpty(info.RelativePath)
? file.Name
: $"{info.RelativePath}/{file.Name}";
var cb = new CheckBox
{
Content = $"{file.Name} ({FormatSize(file.Length)})",
Tag = new FileNodeInfo(info.LibraryTitle, fileRel),
Margin = new Thickness(4, 2, 0, 2),
};
cb.Checked += FileCheckbox_Toggled;
cb.Unchecked += FileCheckbox_Toggled;
_fileCheckboxes.Add(cb);
var fileItem = new TreeViewItem { Header = cb, Focusable = false };
node.Items.Add(fileItem);
}
}
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -305,6 +388,10 @@ public partial class FolderBrowserDialog : Window
Close(); Close();
} }
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
protected override void OnClosed(EventArgs e) protected override void OnClosed(EventArgs e)
{ {
Loaded -= OnLoaded; Loaded -= OnLoaded;
@@ -320,6 +407,11 @@ public partial class FolderBrowserDialog : Window
base.OnClosed(e); base.OnClosed(e);
} }
<<<<<<< HEAD
=======
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
private record FolderNodeInfo(string LibraryTitle, string RelativePath, string ServerRelativeUrl); private record FolderNodeInfo(string LibraryTitle, string RelativePath, string ServerRelativeUrl);
private record FileNodeInfo(string LibraryTitle, string RelativePath); private record FileNodeInfo(string LibraryTitle, string RelativePath);
} }
@@ -1,8 +1,17 @@
<Window x:Class="SharepointToolbox.Views.Dialogs.InputDialog" <Window x:Class="SharepointToolbox.Views.Dialogs.InputDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
<<<<<<< HEAD
xmlns:loc="clr-namespace:SharepointToolbox.Localization" xmlns:loc="clr-namespace:SharepointToolbox.Localization"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[input.title]}" Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[input.title]}"
=======
<<<<<<< HEAD
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[input.title]}"
=======
Title="Input"
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
Width="340" Height="140" Width="340" Height="140"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
Background="{DynamicResource AppBgBrush}" Background="{DynamicResource AppBgBrush}"
@@ -12,8 +21,17 @@
<DockPanel Margin="12"> <DockPanel Margin="12">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,10,0,0"> HorizontalAlignment="Right" Margin="0,10,0,0">
<<<<<<< HEAD
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Width="70" IsCancel="True" Margin="0,0,8,0" Width="70" IsCancel="True" Margin="0,0,8,0"
=======
<<<<<<< HEAD
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Width="70" IsCancel="True" Margin="0,0,8,0"
=======
<Button Content="Cancel" Width="70" IsCancel="True" Margin="0,0,8,0"
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
Click="Cancel_Click" /> Click="Cancel_Click" />
<Button Content="OK" Width="70" IsDefault="True" <Button Content="OK" Width="70" IsDefault="True"
Click="Ok_Click" /> Click="Ok_Click" />
@@ -60,8 +60,17 @@
<!-- Site list with checkboxes --> <!-- Site list with checkboxes -->
<ListView x:Name="SiteList" Grid.Row="2" Margin="0,0,0,8" <ListView x:Name="SiteList" Grid.Row="2" Margin="0,0,0,8"
SelectionMode="Single" SelectionMode="Single"
<<<<<<< HEAD
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}"
GridViewColumnHeader.Click="SiteList_ColumnHeaderClick"> GridViewColumnHeader.Click="SiteList_ColumnHeaderClick">
=======
<<<<<<< HEAD
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}"
GridViewColumnHeader.Click="SiteList_ColumnHeaderClick">
=======
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}">
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<ListView.View> <ListView.View>
<GridView> <GridView>
<GridViewColumn Width="32"> <GridViewColumn Width="32">
@@ -104,7 +113,15 @@
</ListView> </ListView>
<!-- Status text --> <!-- Status text -->
<<<<<<< HEAD
<TextBlock x:Name="StatusText" Grid.Row="3" Margin="0,0,0,8" <TextBlock x:Name="StatusText" Grid.Row="3" Margin="0,0,0,8"
=======
<<<<<<< HEAD
<TextBlock x:Name="StatusText" Grid.Row="3" Margin="0,0,0,8"
=======
<TextBlock x:Name="StatusText" Grid.Row="2" Margin="0,0,0,8"
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
Foreground="{DynamicResource TextMutedBrush}" FontSize="11" /> Foreground="{DynamicResource TextMutedBrush}" FontSize="11" />
<!-- Button row --> <!-- Button row -->
@@ -44,6 +44,10 @@
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Command="{Binding CancelCommand}" Height="28" Margin="0,0,0,8" /> Command="{Binding CancelCommand}" Height="28" Margin="0,0,0,8" />
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.label]}" Margin="0,4,0,0" /> <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.label]}" Margin="0,4,0,0" />
<ComboBox SelectedIndex="{Binding SplitModeIndex}" Height="26" Margin="0,0,0,4"> <ComboBox SelectedIndex="{Binding SplitModeIndex}" Height="26" Margin="0,0,0,4">
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.single]}" /> <ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.single]}" />
@@ -55,6 +59,11 @@
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.tabbed]}" /> <ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.tabbed]}" />
</ComboBox> </ComboBox>
<<<<<<< HEAD
=======
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<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]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportHtml]}"
@@ -83,12 +92,24 @@
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.size]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.size]}"
Binding="{Binding SizeBytes, Converter={StaticResource BytesConverter}}" Binding="{Binding SizeBytes, Converter={StaticResource BytesConverter}}"
Width="90" ElementStyle="{StaticResource RightAlignStyle}" /> Width="90" ElementStyle="{StaticResource RightAlignStyle}" />
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.created]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.created]}"
Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" /> Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.modified]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.modified]}"
Binding="{Binding Modified, StringFormat=yyyy-MM-dd}" Width="100" /> Binding="{Binding Modified, StringFormat=yyyy-MM-dd}" Width="100" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.path]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.path]}"
Binding="{Binding Path}" Width="400" /> Binding="{Binding Path}" Width="400" />
<<<<<<< HEAD
=======
=======
<DataGridTextColumn Header="Created" Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" />
<DataGridTextColumn Header="Modified" Binding="{Binding Modified, StringFormat=yyyy-MM-dd}" Width="100" />
<DataGridTextColumn Header="Path" Binding="{Binding Path}" Width="400" />
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
</DockPanel> </DockPanel>
@@ -18,6 +18,10 @@
Click="BrowseSource_Click" Margin="0,0,0,5" /> Click="BrowseSource_Click" Margin="0,0,0,5" />
<TextBlock Text="{Binding SourceLibrary}" FontWeight="SemiBold" /> <TextBlock Text="{Binding SourceLibrary}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SourceFolderPath}" Foreground="{DynamicResource TextMutedBrush}" /> <TextBlock Text="{Binding SourceFolderPath}" Foreground="{DynamicResource TextMutedBrush}" />
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11" <TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11"
Text="{Binding SelectedFileCount, Mode=OneWay}" /> Text="{Binding SelectedFileCount, Mode=OneWay}" />
@@ -32,6 +36,23 @@
IsChecked="{Binding CopyFolderContents}" IsChecked="{Binding CopyFolderContents}"
Margin="0,4,0,0" Margin="0,4,0,0"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents.tooltip]}" /> ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents.tooltip]}" />
<<<<<<< HEAD
=======
=======
<TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11">
<Run Text="{Binding SelectedFileCount, Mode=OneWay}" />
<Run Text=" file(s) selected" />
</TextBlock>
<CheckBox Content="Include source folder at destination"
IsChecked="{Binding IncludeSourceFolder}"
Margin="0,6,0,0"
ToolTip="When on, recreate the source folder under the destination. When off, drop contents directly into the destination folder." />
<CheckBox Content="Copy folder contents"
IsChecked="{Binding CopyFolderContents}"
Margin="0,4,0,0"
ToolTip="When on (default), transfer files inside the folder. When off, only the folder is created at the destination." />
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
</StackPanel> </StackPanel>
</GroupBox> </GroupBox>
@@ -44,7 +44,15 @@
</GroupBox.Style> </GroupBox.Style>
<StackPanel> <StackPanel>
<TextBox Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,2" /> <TextBox Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,2" />
<<<<<<< HEAD
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.searching]}" FontStyle="Italic" FontSize="10" Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,2"> <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.searching]}" FontStyle="Italic" FontSize="10" Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,2">
=======
<<<<<<< HEAD
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.searching]}" FontStyle="Italic" FontSize="10" Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,2">
=======
<TextBlock Text="Searching..." FontStyle="Italic" FontSize="10" Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,2">
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<TextBlock.Style> <TextBlock.Style>
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" /> <Setter Property="Visibility" Value="Collapsed" />