using Microsoft.SharePoint.Client; using Microsoft.SharePoint.Client.Search.Query; using Serilog; using SharepointToolbox.Core.Helpers; using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services; /// /// CSOM-backed resolver that converts the GUID/identifier embedded in a SharePoint /// system group name into a clickable target. Results are cached per /// (site URL, guid) to avoid repeated round-trips when the same Limited Access /// system group recurs on many objects. /// /// Never throws — on any failure (not found, access denied, claims error), returns /// null and the caller renders the raw group name. /// public class SystemGroupTargetResolver : ISystemGroupTargetResolver { private readonly Dictionary _cache = new(StringComparer.OrdinalIgnoreCase); public async Task ResolveAsync( ClientContext ctx, SystemGroupClassification classification, CancellationToken ct) { ct.ThrowIfCancellationRequested(); var key = BuildCacheKey(ctx.Url, classification); if (key is not null && _cache.TryGetValue(key, out var cached)) return cached; SystemGroupTarget? result = null; try { result = classification.Kind switch { SystemGroupKind.LimitedAccessWeb => await ResolveWebAsync(ctx, classification.WebId!.Value, ct), SystemGroupKind.LimitedAccessList => await ResolveListAsync(ctx, classification.ListId!.Value, ct), SystemGroupKind.SharingLink => await ResolveItemAsync(ctx, classification.ItemUniqueId!.Value, classification.LinkType, ct), _ => null }; } catch (Exception ex) { Log.Debug("System group target resolution failed for {Kind} on {Site}: {Error}", classification.Kind, ctx.Url, ex.Message); } if (key is not null) _cache[key] = result; return result; } private static string? BuildCacheKey(string siteUrl, SystemGroupClassification c) => c.Kind switch { SystemGroupKind.LimitedAccessWeb => $"{siteUrl}|web|{c.WebId}", SystemGroupKind.LimitedAccessList => $"{siteUrl}|list|{c.ListId}", SystemGroupKind.SharingLink => $"{siteUrl}|item|{c.ItemUniqueId}", _ => null }; private static async Task ResolveWebAsync( ClientContext ctx, Guid webId, CancellationToken ct) { var web = ctx.Site.OpenWebById(webId); ctx.Load(web, w => w.Title, w => w.Url); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct); return new SystemGroupTarget(SystemGroupKind.LimitedAccessWeb, web.Title, web.Url); } private static async Task ResolveListAsync( ClientContext ctx, Guid listId, CancellationToken ct) { var list = ctx.Web.Lists.GetById(listId); ctx.Load(list, l => l.Title, l => l.DefaultViewUrl); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct); var url = BuildAbsoluteUrl(ctx.Url, list.DefaultViewUrl); return new SystemGroupTarget(SystemGroupKind.LimitedAccessList, list.Title, url); } private static async Task ResolveItemAsync( ClientContext ctx, Guid itemUniqueId, string? linkType, CancellationToken ct) { // 1. Try as file on current web (most sharing links target files). try { var file = ctx.Web.GetFileById(itemUniqueId); ctx.Load(file, f => f.Name, f => f.ServerRelativeUrl, f => f.Exists); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct); if (file.Exists) { var url = BuildAbsoluteUrl(ctx.Url, file.ServerRelativeUrl); return new SystemGroupTarget(SystemGroupKind.SharingLink, file.Name, url, linkType); } } catch (ServerException) { /* fall through */ } // 2. Try as folder on current web. try { var folder = ctx.Web.GetFolderById(itemUniqueId); ctx.Load(folder, f => f.Name, f => f.ServerRelativeUrl); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct); var url = BuildAbsoluteUrl(ctx.Url, folder.ServerRelativeUrl); return new SystemGroupTarget(SystemGroupKind.SharingLink, folder.Name, url, linkType); } catch (ServerException) { /* fall through */ } // 3. Search-index fallback — covers items moved to a different subsite or // deleted recently (the index may lag the deletion by minutes/hours). // Search is permission-trimmed, so this only returns hits the caller // can still see; results carry a "(via index)" hint in the label. return await TryResolveViaSearchAsync(ctx, itemUniqueId, linkType, ct); } /// /// Queries the SharePoint search index for an item by its UniqueId. Returns /// a target with the last-indexed path and a label prefix flagging that the /// hit came from the index (so admins know the live lookup failed). /// private static async Task TryResolveViaSearchAsync( ClientContext ctx, Guid itemUniqueId, string? linkType, CancellationToken ct) { ct.ThrowIfCancellationRequested(); try { // KQL: UniqueId managed property. Braces around the GUID are how the // SP search engine matches the canonical form for content unique ids. var kq = new KeywordQuery(ctx) { QueryText = $"UniqueId:{{{itemUniqueId}}}", RowLimit = 1, TrimDuplicates = false }; kq.SelectProperties.Add("Path"); kq.SelectProperties.Add("Title"); kq.SelectProperties.Add("FileExtension"); var executor = new SearchExecutor(ctx); var result = executor.ExecuteQuery(kq); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct); var table = result.Value .FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults); if (table is null || table.RowCount == 0) return null; var row = ToDict(table.ResultRows.First()); var path = row.TryGetValue("Path", out var p) ? p?.ToString() : null; var title = row.TryGetValue("Title", out var t) ? t?.ToString() : null; if (string.IsNullOrEmpty(path)) return null; // Derive a leaf name if Title is empty (folders often have no Title). var leaf = !string.IsNullOrWhiteSpace(title) ? title! : Uri.UnescapeDataString(path.TrimEnd('/').Split('/').Last()); return new SystemGroupTarget( SystemGroupKind.SharingLink, $"{leaf} (via index)", path, linkType); } catch (Exception ex) { Log.Debug("UniqueId search fallback failed for {Item} on {Site}: {Error}", itemUniqueId, ctx.Url, ex.Message); return null; } } /// /// CSOM SearchExecutor returns ResultRows as either generic Dictionary or legacy /// IDictionary depending on CSOM version — normalise to a single shape. /// private static IDictionary ToDict(object rawRow) { if (rawRow is IDictionary generic) return generic; var dict = new Dictionary(); if (rawRow is System.Collections.IDictionary legacy) { foreach (System.Collections.DictionaryEntry e in legacy) dict[e.Key.ToString()!] = e.Value ?? string.Empty; } return dict; } private static string BuildAbsoluteUrl(string contextUrl, string? serverRelative) { if (string.IsNullOrEmpty(serverRelative)) return contextUrl; if (Uri.TryCreate(serverRelative, UriKind.Absolute, out _)) return serverRelative; var uri = new Uri(contextUrl); return $"{uri.Scheme}://{uri.Host}{serverRelative}"; } }