using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Text.RegularExpressions; using Manager.DTOs; using ManagerService.Data; using ManagerService.Data.SubSection; using ManagerService.DTOs; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; namespace ManagerService.Services { /// /// Index inverse des usages : quelle ressource est employée par quel contenu. /// Parcourt dans l'autre sens — la même /// méthode qui fait marcher l'export hors ligne, donc le même périmètre de champs. /// /// /// Quatre points de vigilance, tous traités ici : /// 1. Langue nulle = toutes les langues (voir SectionText.ResourceIds). Passer une langue /// ferait passer pour orpheline une image posée uniquement en NL. /// 2. Configuration.ImageId et LoaderImageId vivent hors des sections : ajoutés à part, /// comme le fait ConfigurationController.Export. /// 3. GuidedStep.ImageUrl est une URL absolue, pas un id : une image posée là est comptée /// orpheline. Limite connue, migrée en V2, signalée dans l'UI. /// 4. Un GuidedPath peut pendre d'un SectionEvent, dont GetReferencedResourceIds ne /// descend pas dans les parcours. Les parcours sont donc parcourus séparément, et les /// GuidedPaths ne sont volontairement pas chargés sur SectionParcours (pas de doublon). /// public class ResourceUsageService { private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(30); private readonly MyInfoMateDbContext _db; private readonly IMemoryCache _cache; public ResourceUsageService(MyInfoMateDbContext db, IMemoryCache cache) { _db = db; _cache = cache; } /// Usages de toutes les ressources d'une instance, indexés par id de ressource. public Dictionary> GetUsageMap(string instanceId) { return _cache.GetOrCreate($"resource-usages:{instanceId}", entry => { entry.AbsoluteExpirationRelativeToNow = CacheDuration; return Build(instanceId); }); } /// /// Ce que le rail de facettes consomme : un résumé par ressource — zéro compris, sans /// quoi « Jamais utilisées » n'aurait rien à lire — et le libellé des configurations. /// public ResourceUsageMapDTO GetUsageSummary(string instanceId) { var usages = GetUsageMap(instanceId); var summary = new ResourceUsageMapDTO(); foreach (var id in _db.Resources.AsNoTracking() .Where(r => r.InstanceId == instanceId) .Select(r => r.Id) .ToList()) { usages.TryGetValue(id, out var list); summary.usages[id] = new ResourceUsageSummaryDTO { count = list?.Count ?? 0, configurationIds = (list ?? new List()) .Where(u => u.configurationId != null) .Select(u => u.configurationId) .Distinct() .ToList() }; } foreach (var configuration in _db.Configurations.AsNoTracking() .Where(c => c.InstanceId == instanceId) .ToList()) summary.configurations[configuration.Id] = configuration.Label; return summary; } /// Usages d'une ressource. Liste vide si la ressource n'existe pas. public List GetUsages(string resourceId) { var instanceId = _db.Resources.AsNoTracking() .Where(r => r.Id == resourceId) .Select(r => r.InstanceId) .FirstOrDefault(); if (instanceId == null) return new List(); return GetUsageMap(instanceId).TryGetValue(resourceId, out var usages) ? usages : new List(); } public void Invalidate(string instanceId) => _cache.Remove($"resource-usages:{instanceId}"); private Dictionary> Build(string instanceId) { var map = new Dictionary>(); var configurations = _db.Configurations.AsNoTracking() .Where(c => c.InstanceId == instanceId) .ToList(); var configurationLabels = configurations.ToDictionary(c => c.Id, c => c.Label); foreach (var configuration in configurations) { Add(map, configuration.ImageId, new ResourceUsageDTO { kind = "Configuration", id = configuration.Id, label = configuration.Label, configurationId = configuration.Id, configurationLabel = configuration.Label, field = "image", path = $"{configuration.Label} › image d'accueil" }); Add(map, configuration.LoaderImageId, new ResourceUsageDTO { kind = "Configuration", id = configuration.Id, label = configuration.Label, configurationId = configuration.Id, configurationLabel = configuration.Label, field = "loaderImage", path = $"{configuration.Label} › écran de chargement" }); } var sections = LoadSections(instanceId); foreach (var section in sections) { var configurationLabel = Label(configurationLabels, section.ConfigurationId); foreach (var resourceId in section.GetReferencedResourceIds().Distinct()) { var field = resourceId == section.ImageId ? "image" : "contenu"; Add(map, resourceId, new ResourceUsageDTO { kind = "Section", id = section.Id, label = section.Label, configurationId = section.ConfigurationId, configurationLabel = configurationLabel, sectionId = section.Id, field = field, path = $"{configurationLabel} › {section.Label} › {field}" }); } } var sectionsById = sections.ToDictionary(s => s.Id, s => s); var guidedPaths = _db.GuidedPaths .Where(p => p.InstanceId == instanceId) .Include(p => p.Steps).ThenInclude(s => s.QuizQuestions) .ToList(); foreach (var guidedPath in guidedPaths) { var parentId = guidedPath.SectionParcoursId ?? guidedPath.SectionEventId; var parent = parentId != null && sectionsById.TryGetValue(parentId, out var s) ? s : null; var configurationId = parent?.ConfigurationId; var configurationLabel = Label(configurationLabels, configurationId); var pathLabel = FirstTranslation(guidedPath.Title) ?? "Parcours"; var prefix = parent != null ? $"{configurationLabel} › {parent.Label} › {pathLabel}" : $"{configurationLabel} › {pathLabel}"; var steps = guidedPath.Steps ?? new List(); var stepResourceIds = steps.SelectMany(step => step.GetReferencedResourceIds()).ToHashSet(); // Les ids propres au parcours : ceux de l'agrégat moins ceux de ses étapes, // pour ne pas attribuer au parcours ce qui appartient à une étape. foreach (var resourceId in guidedPath.GetReferencedResourceIds().Distinct() .Where(id => !stepResourceIds.Contains(id))) { Add(map, resourceId, new ResourceUsageDTO { kind = "GuidedPath", id = guidedPath.Id, label = pathLabel, configurationId = configurationId, configurationLabel = configurationLabel, sectionId = parent?.Id, field = resourceId == guidedPath.ImageResourceId ? "image" : "contenu", path = prefix }); } foreach (var step in steps) { var stepLabel = FirstTranslation(step.Title) ?? $"Étape {step.Order + 1}"; foreach (var resourceId in step.GetReferencedResourceIds().Distinct()) { Add(map, resourceId, new ResourceUsageDTO { kind = "GuidedStep", id = step.Id, label = stepLabel, configurationId = configurationId, configurationLabel = configurationLabel, sectionId = parent?.Id, field = "contenu", path = $"{prefix} › {stepLabel}" }); } } } return map; } /// /// Pas d'AsNoTracking : les colonnes jsonb passent par un convertisseur, et le /// provider InMemory des tests rend des collections vides sur une requête détachée. /// Le service ne modifie rien, le suivi ne coûte donc que de la mémoire. /// /// Les sous-types dont le contenu vit dans des tables liées ont besoin d'un Include : /// sans lui, la collection est vide et la section paraît ne référencer que sa vignette. /// SectionParcours en est volontairement exclu — ses parcours sont traités à part. /// private List
LoadSections(string instanceId) { var sections = new List
(); sections.AddRange(_db.Sections.OfType() .Where(s => s.InstanceId == instanceId) .Include(s => s.QuizQuestions)); sections.AddRange(_db.Sections.OfType() .Where(s => s.InstanceId == instanceId) .Include(s => s.EventAgendas)); sections.AddRange(_db.Sections.OfType() .Where(s => s.InstanceId == instanceId) .Include(s => s.Programme).ThenInclude(b => b.MapAnnotations) .Include(s => s.GlobalMapAnnotations)); sections.AddRange(_db.Sections.OfType() .Where(s => s.InstanceId == instanceId) .Include(s => s.MapPoints)); var loaded = new[] { SectionType.Quiz, SectionType.Agenda, SectionType.Event, SectionType.Map }; sections.AddRange(_db.Sections .Where(s => s.InstanceId == instanceId && !loaded.Contains(s.Type))); return sections; } private static string Label(Dictionary labels, string configurationId) => configurationId != null && labels.TryGetValue(configurationId, out var label) ? label : "Sans configuration"; /// /// Les traductions sont stockées en HTML (l'éditeur Quill de manager-app les écrit /// ainsi) : un titre de parcours vaut « <p>Chasse au trésor</p> ». Le chemin /// affiché dans « Utilisée dans » est un fil d'Ariane sur une ligne, pas du contenu /// riche — on en retire donc les balises plutôt que de les rendre. /// private static string FirstTranslation(IEnumerable translations) { var value = translations?.FirstOrDefault(t => !string.IsNullOrWhiteSpace(StripHtml(t.value)))?.value; return value == null ? null : StripHtml(value); } private static string StripHtml(string text) => text == null ? null : Regex.Replace(WebUtility.HtmlDecode(Regex.Replace(text, "<[^>]+>", " ")), @"\s+", " ").Trim(); private static void Add(Dictionary> map, string resourceId, ResourceUsageDTO usage) { if (string.IsNullOrWhiteSpace(resourceId)) return; if (!map.TryGetValue(resourceId, out var usages)) map[resourceId] = usages = new List(); usages.Add(usage); } } }