- Remove bin/obj/publish from git tracking - Update .gitignore for .NET project (source only) - Add release.ps1 local publish script (replaces Gitea workflow) - Remove .gitea/workflows/release.yml - Fix duplicate group names to show library names - Fix HTML export to show Name column in duplicates report - Fix consolidated permissions HTML to show folder/library names Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
142 lines
5.9 KiB
C#
142 lines
5.9 KiB
C#
using SharepointToolbox.Core.Models;
|
|
using System.Text;
|
|
|
|
namespace SharepointToolbox.Services.Export;
|
|
|
|
/// <summary>
|
|
/// Exports DuplicateGroup list to a self-contained HTML with collapsible group cards.
|
|
/// Port of PS Export-DuplicatesToHTML (PS lines 2235-2406).
|
|
/// Each group gets a card showing item count badge and a table of paths.
|
|
/// </summary>
|
|
public class DuplicatesHtmlExportService
|
|
{
|
|
public string BuildHtml(IReadOnlyList<DuplicateGroup> groups, ReportBranding? branding = null)
|
|
{
|
|
var sb = new StringBuilder();
|
|
|
|
sb.AppendLine("""
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>SharePoint Duplicate Detection Report</title>
|
|
<style>
|
|
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
|
h1 { color: #0078d4; }
|
|
.summary { margin-bottom: 16px; font-size: 12px; color: #444; }
|
|
.group-card { background: #fff; border: 1px solid #ddd; border-radius: 6px;
|
|
margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.1); overflow: hidden; }
|
|
.group-header { background: #0078d4; color: #fff; padding: 8px 14px;
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
cursor: pointer; user-select: none; }
|
|
.group-header:hover { background: #106ebe; }
|
|
.group-name { font-weight: 600; font-size: 14px; }
|
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px;
|
|
font-size: 11px; font-weight: 700; }
|
|
.badge-dup { background: #e53935; color: #fff; }
|
|
.group-body { padding: 0; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th { background: #f0f7ff; color: #333; padding: 6px 12px; text-align: left;
|
|
font-weight: 600; border-bottom: 1px solid #ddd; font-size: 12px; }
|
|
td { padding: 5px 12px; border-bottom: 1px solid #eee; font-size: 12px; word-break: break-all; }
|
|
tr:last-child td { border-bottom: none; }
|
|
.collapsed { display: none; }
|
|
.generated { font-size: 11px; color: #888; margin-top: 16px; }
|
|
</style>
|
|
<script>
|
|
function toggleGroup(id) {
|
|
var body = document.getElementById('gb-' + id);
|
|
if (body) body.classList.toggle('collapsed');
|
|
}
|
|
</script>
|
|
</head>
|
|
<body>
|
|
""");
|
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
|
sb.AppendLine("""
|
|
<h1>Duplicate Detection Report</h1>
|
|
""");
|
|
|
|
sb.AppendLine($"<p class=\"summary\">{groups.Count:N0} duplicate group(s) found.</p>");
|
|
|
|
for (int i = 0; i < groups.Count; i++)
|
|
{
|
|
var g = groups[i];
|
|
int count = g.Items.Count;
|
|
string badgeClass = "badge-dup";
|
|
|
|
sb.AppendLine($"""
|
|
<div class="group-card">
|
|
<div class="group-header" onclick="toggleGroup({i})">
|
|
<span class="group-name">{H(g.Name)}</span>
|
|
<span class="badge {badgeClass}">{count} copies</span>
|
|
</div>
|
|
<div class="group-body" id="gb-{i}">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>Name</th>
|
|
<th>Library</th>
|
|
<th>Path</th>
|
|
<th>Size</th>
|
|
<th>Created</th>
|
|
<th>Modified</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
""");
|
|
|
|
for (int j = 0; j < g.Items.Count; j++)
|
|
{
|
|
var item = g.Items[j];
|
|
string size = item.SizeBytes.HasValue ? FormatSize(item.SizeBytes.Value) : string.Empty;
|
|
string created = item.Created.HasValue ? item.Created.Value.ToString("yyyy-MM-dd") : string.Empty;
|
|
string modified = item.Modified.HasValue ? item.Modified.Value.ToString("yyyy-MM-dd") : string.Empty;
|
|
|
|
sb.AppendLine($"""
|
|
<tr>
|
|
<td>{j + 1}</td>
|
|
<td>{H(item.Name)}</td>
|
|
<td>{H(item.Library)}</td>
|
|
<td><a href="{H(item.Path)}" target="_blank">{H(item.Path)}</a></td>
|
|
<td>{size}</td>
|
|
<td>{created}</td>
|
|
<td>{modified}</td>
|
|
</tr>
|
|
""");
|
|
}
|
|
|
|
sb.AppendLine("""
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
""");
|
|
}
|
|
|
|
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
|
sb.AppendLine("</body></html>");
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
public async Task WriteAsync(IReadOnlyList<DuplicateGroup> groups, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
|
{
|
|
var html = BuildHtml(groups, 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";
|
|
}
|
|
}
|