");
sb.AppendLine("");
sb.AppendLine("");
sb.AppendLine($"{title}");
sb.AppendLine("");
sb.AppendLine("");
}
///
/// Appends the three stat cards (total entries, unique permission sets,
/// distinct users/groups) inside a single .stats row.
///
internal static void AppendStatsCards(StringBuilder sb, int totalEntries, int uniquePermSets, int distinctUsers)
{
var T = TranslationSource.Instance;
sb.AppendLine("
");
sb.AppendLine($"
{totalEntries}
{T["report.stat.total_entries"]}
");
sb.AppendLine($"
{uniquePermSets}
{T["report.stat.unique_permission_sets"]}
");
sb.AppendLine($"
{distinctUsers}
{T["report.stat.distinct_users_groups"]}
");
sb.AppendLine("
");
}
/// Appends the live-filter input bound to #permTable.
internal static void AppendFilterInput(StringBuilder sb)
{
var T = TranslationSource.Instance;
sb.AppendLine("
");
sb.AppendLine($" ");
sb.AppendLine("
");
}
/// Appends the inline <script> that powers filter and group toggle.
internal static void AppendInlineJs(StringBuilder sb)
{
sb.AppendLine("");
}
///
/// Renders the user-pill cell content plus any group-member sub-rows for a
/// single permission entry. Callers pass their row colspan so sub-rows
/// span the full table; must be mutated
/// across rows so sub-row IDs stay unique.
///
internal static (string Pills, string MemberSubRows) BuildUserPillsCell(
string userLogins,
string userNames,
string? principalType,
IReadOnlyDictionary>? groupMembers,
int colSpan,
ref int grpMemIdx,
string? targetLabel = null,
string? sharingLinkType = null,
bool hideSystemGroupRaw = false)
{
var T = TranslationSource.Instance;
var logins = userLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = userNames.Split(';', StringSplitOptions.RemoveEmptyEntries);
var pills = new StringBuilder();
var subRows = 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);
// When the principal is a resolved system group and the user wants the raw
// name hidden, replace the pill's visible text with the link-type badge
// (sharing links) and/or the target label. Falls back to the raw name when
// resolution failed (no targetLabel).
var classification = principalType == "SharePointGroup"
? PermissionEntryHelper.Classify(name)
: new SystemGroupClassification(SystemGroupKind.None, null, null, null, null, null);
bool isResolvedSystemGroup = hideSystemGroupRaw
&& classification.Kind != SystemGroupKind.None
&& classification.Kind != SystemGroupKind.LimitedAccessBare
&& !string.IsNullOrEmpty(targetLabel);
bool hasResolvedMembers = principalType == "SharePointGroup"
&& groupMembers != null
&& groupMembers.TryGetValue(name, out _);
if (hasResolvedMembers && groupMembers!.TryGetValue(name, out var resolved))
{
var grpId = $"grpmem{grpMemIdx}";
pills.Append("");
if (isResolvedSystemGroup)
{
if (!string.IsNullOrEmpty(sharingLinkType))
pills.Append(BuildSharingLinkBadge(sharingLinkType!));
pills.Append(HtmlEncode(targetLabel!));
}
else
{
pills.Append(HtmlEncode(name));
}
pills.Append(" ▼");
string memberContent;
if (resolved.Count > 0)
{
var parts = resolved.Select(m => $"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>");
memberContent = string.Join(" • ", parts);
}
else
{
memberContent = $"{T["report.text.members_unavailable"]}";
}
subRows.AppendLine($"
{memberContent}
");
grpMemIdx++;
}
else if (isResolvedSystemGroup)
{
pills.Append("");
if (!string.IsNullOrEmpty(sharingLinkType))
pills.Append(BuildSharingLinkBadge(sharingLinkType!));
pills.Append(HtmlEncode(targetLabel!));
pills.Append("");
}
else
{
var cls = isExt ? "user-pill external-user" : "user-pill";
pills.Append($"{HtmlEncode(name)}");
}
}
return (pills.ToString(), subRows.ToString());
}
///
/// Renders the Granted Through cell. When the entry carries a resolved system-group
/// target (Limited Access For Web/List or SharingLinks), a clickable link to the
/// targeted resource is appended on a second line. For sharing links the link type
/// (OrganizationEdit / AnonymousView / …) is surfaced alongside the target.
///
/// When is true and a target was resolved, the
/// raw "SharePoint Group: SharingLinks.{guid}…" / "Limited Access System Group For
/// Web|List {guid}" prefix is suppressed and only the link-type badge + clickable
/// target are shown — keeps the report readable without losing information.
///
internal static string BuildGrantedThroughCell(
string grantedThrough,
string? targetUrl,
string? targetLabel,
string? sharingLinkType,
bool hideSystemGroupRaw = false)
{
var hasTarget = !string.IsNullOrEmpty(targetUrl) && !string.IsNullOrEmpty(targetLabel);
var hasLinkType = !string.IsNullOrEmpty(sharingLinkType);
var suppressRaw = hideSystemGroupRaw && hasTarget;
var sb = new StringBuilder();
if (!suppressRaw)
sb.Append(HtmlEncode(grantedThrough));
if (!hasTarget && !hasLinkType)
return sb.ToString();
if (suppressRaw)
{
// Inline layout — no leading raw text to wrap under.
if (hasLinkType)
sb.Append(BuildSharingLinkBadge(sharingLinkType!));
if (hasTarget)
{
sb.Append("");
sb.Append(HtmlEncode(targetLabel!));
sb.Append("");
}
return sb.ToString();
}
sb.Append("
");
return sb.ToString();
}
///
/// Builds the colored badge for a SharePoint sharing-link type. Translates the
/// raw linkType code (e.g. OrganizationEdit) into a human label
/// (e.g. Org link · Edit) and tints by risk tier; raw code surfaces as a
/// title tooltip so operators can still trace it back to the source.
///
internal static string BuildSharingLinkBadge(string rawLinkType)
{
var (label, risk) = SharingLinkLabels.Describe(rawLinkType);
var (bg, fg) = SharingLinkLabels.Colors(risk);
return $"{HtmlEncode(label)}";
}
/// Returns the CSS class for the object-type badge.
internal static string ObjectTypeCss(string t) => t switch
{
"Site Collection" => "badge site-coll",
"Site" => "badge site",
"List" => "badge list",
"Folder" => "badge folder",
_ => "badge"
};
/// Minimal HTML encoding for text content and attribute values.
internal static string HtmlEncode(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
return value
.Replace("&", "&")
.Replace("<", "<")
.Replace(">", ">")
.Replace("\"", """)
.Replace("'", "'");
}
}