Created
January 27, 2026 22:27
-
-
Save PNergard/f3e2e363bbee0e9c1781348de1550b8a to your computer and use it in GitHub Desktop.
Scheduledjob for creating csv file of overview of content in an Optimizely site
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using EPiServer; | |
| using EPiServer.Core; | |
| using EPiServer.DataAbstraction; | |
| using EPiServer.PlugIn; | |
| using EPiServer.Scheduler; | |
| using EPiServer.Web; | |
| using System; | |
| using System.Collections.Generic; | |
| using System.IO; | |
| using System.Linq; | |
| using System.Net.Mail; | |
| using System.Net.Mime; | |
| using System.Text; | |
| namespace Pnergard.ScheduledJobs | |
| { | |
| /// <summary> | |
| /// Analyzes all content types and reports usage in CSV format (semicolon-delimited for Excel). | |
| /// | |
| /// Configuration: | |
| /// - ReportEmailRecipients: Comma-separated list of email addresses to receive the report | |
| /// - ContentTypeIdsForDetailedLinks: Comma-separated list of ContentTypeIds to include edit links for | |
| /// - SiteUrl: Base URL for generating edit links (e.g., "https://intranat.folktandvardenskane.se") | |
| /// | |
| /// Output format: Semicolon-delimited CSV file attachment that can be opened directly in Excel. | |
| /// Edit links format: {SiteUrl}/EPiServer/CMS/#context=epi.cms.contentdata:///{ContentId} | |
| /// </summary> | |
| [ScheduledPlugIn(DisplayName = "Content Type Usage Analysis", Description = "Analyzes all content types and reports which ones have actual content created. Emails CSV file.")] | |
| public class ContentTypeUsageAnalysisJob : ScheduledJobBase | |
| { | |
| private bool _stopSignaled; | |
| private readonly IContentTypeRepository _contentTypeRepository; | |
| private readonly IContentModelUsage _contentModelUsage; | |
| private readonly IContentLoader _contentLoader; | |
| private const int MaxJobMessageLength = 5000; | |
| // Email configuration | |
| private const string FromEmailAddress = "your email from address"; | |
| private const string ReportEmailRecipients = "[email protected]"; // Comma-separated list | |
| // Content types to generate detailed edit links for (comma-separated ContentTypeIds) | |
| private const string ContentTypeIdsForDetailedLinks = ""; // e.g., "42,55,67" | |
| // Site URL for generating edit links | |
| private const string SiteUrl = "https://intranat.folktandvardenskane.se"; | |
| public ContentTypeUsageAnalysisJob( | |
| IContentTypeRepository contentTypeRepository, | |
| IContentModelUsage contentModelUsage, | |
| IContentLoader contentLoader) | |
| { | |
| _contentTypeRepository = contentTypeRepository; | |
| _contentModelUsage = contentModelUsage; | |
| _contentLoader = contentLoader; | |
| IsStoppable = true; | |
| } | |
| public override void Stop() | |
| { | |
| _stopSignaled = true; | |
| } | |
| public override string Execute() | |
| { | |
| OnStatusChanged($"Starting execution of {GetType().Name}"); | |
| var contentTypeResults = new List<ContentTypeUsageInfo>(); | |
| var detailedContentTypeIds = ParseContentTypeIds(ContentTypeIdsForDetailedLinks); | |
| var allContentTypes = _contentTypeRepository.List().ToList(); | |
| var totalTypes = allContentTypes.Count; | |
| var processedTypes = 0; | |
| foreach (var contentType in allContentTypes) | |
| { | |
| if (_stopSignaled) | |
| { | |
| return "Stop of job was called"; | |
| } | |
| processedTypes++; | |
| OnStatusChanged($"Processing {processedTypes}/{totalTypes}: {contentType.Name}"); | |
| try | |
| { | |
| var usages = _contentModelUsage.ListContentOfContentType(contentType); | |
| var usageList = usages?.ToList() ?? new List<ContentUsage>(); | |
| var usageCount = usageList.Count; | |
| // Generate edit links if this content type is in the detailed list | |
| var editLinks = new List<string>(); | |
| if (detailedContentTypeIds.Contains(contentType.ID) && usageCount > 0) | |
| { | |
| editLinks = GenerateEditLinks(usageList); | |
| } | |
| contentTypeResults.Add(new ContentTypeUsageInfo | |
| { | |
| Name = contentType.Name, | |
| DisplayName = contentType.DisplayName ?? contentType.Name, | |
| ContentTypeId = contentType.ID, | |
| ModelType = contentType.ModelType?.FullName ?? "Unknown", | |
| BaseType = GetBaseTypeName(contentType), | |
| UsageCount = usageCount, | |
| HasContent = usageCount > 0, | |
| IsAvailable = contentType.IsAvailable, | |
| EditLinks = editLinks | |
| }); | |
| } | |
| catch (Exception ex) | |
| { | |
| contentTypeResults.Add(new ContentTypeUsageInfo | |
| { | |
| Name = contentType.Name, | |
| DisplayName = contentType.DisplayName ?? contentType.Name, | |
| ContentTypeId = contentType.ID, | |
| ModelType = contentType.ModelType?.FullName ?? "Unknown", | |
| BaseType = GetBaseTypeName(contentType), | |
| UsageCount = -1, | |
| HasContent = false, | |
| IsAvailable = contentType.IsAvailable, | |
| Error = ex.Message, | |
| EditLinks = new List<string>() | |
| }); | |
| } | |
| } | |
| // Sort by usage count descending, then by name | |
| contentTypeResults = contentTypeResults | |
| .OrderByDescending(x => x.UsageCount) | |
| .ThenBy(x => x.Name) | |
| .ToList(); | |
| // Generate CSV output | |
| var csvContent = GenerateCsvReport(contentTypeResults); | |
| // Check if result exceeds max length and send email | |
| if (csvContent.Length > MaxJobMessageLength) | |
| { | |
| var emailResult = SendReportEmail(csvContent); | |
| return emailResult.Success | |
| ? $"OK - Report emailed to {ReportEmailRecipients}" | |
| : $"Error: {emailResult.ErrorMessage}"; | |
| } | |
| return csvContent; | |
| } | |
| private HashSet<int> ParseContentTypeIds(string contentTypeIds) | |
| { | |
| var result = new HashSet<int>(); | |
| if (string.IsNullOrWhiteSpace(contentTypeIds)) | |
| return result; | |
| foreach (var idStr in contentTypeIds.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) | |
| { | |
| if (int.TryParse(idStr.Trim(), out int id)) | |
| { | |
| result.Add(id); | |
| } | |
| } | |
| return result; | |
| } | |
| private List<string> GenerateEditLinks(List<ContentUsage> usages) | |
| { | |
| var links = new List<string>(); | |
| foreach (var usage in usages) | |
| { | |
| try | |
| { | |
| // Edit mode URL format: {SiteUrl}/EPiServer/CMS/#context=epi.cms.contentdata:///{ContentId} | |
| var editLink = $"{SiteUrl.TrimEnd('/')}/EPiServer/CMS/#context=epi.cms.contentdata:///{usage.ContentLink.ID}"; | |
| // Try to get content name for better readability | |
| var content = _contentLoader.Get<IContent>(usage.ContentLink); | |
| var contentName = content?.Name ?? $"Content {usage.ContentLink.ID}"; | |
| links.Add($"{contentName}: {editLink}"); | |
| } | |
| catch | |
| { | |
| links.Add($"Content {usage.ContentLink.ID}: {SiteUrl.TrimEnd('/')}/EPiServer/CMS/#context=epi.cms.contentdata:///{usage.ContentLink.ID}"); | |
| } | |
| } | |
| return links; | |
| } | |
| private string GenerateCsvReport(List<ContentTypeUsageInfo> contentTypes) | |
| { | |
| var sb = new StringBuilder(); | |
| // CSV header (semicolon-delimited for Excel) | |
| // Note: Using semicolon as delimiter since it's the default for Excel in Swedish locale | |
| sb.AppendLine("Name;DisplayName;ContentTypeId;BaseType;UsageCount;HasContent;IsAvailable;ModelType;Error;ContentName;EditLink"); | |
| // CSV data rows | |
| foreach (var ct in contentTypes) | |
| { | |
| // If there are edit links, create one row per link | |
| if (ct.EditLinks != null && ct.EditLinks.Any()) | |
| { | |
| foreach (var editLink in ct.EditLinks) | |
| { | |
| // Parse content name and link from "ContentName: url" format | |
| var parts = editLink.Split(new[] { ": " }, 2, StringSplitOptions.None); | |
| var contentName = parts.Length > 0 ? parts[0] : ""; | |
| var link = parts.Length > 1 ? parts[1] : editLink; | |
| sb.AppendLine(string.Join(";", | |
| EscapeCsvValue(ct.Name), | |
| EscapeCsvValue(ct.DisplayName), | |
| ct.ContentTypeId, | |
| EscapeCsvValue(ct.BaseType), | |
| ct.UsageCount, | |
| ct.HasContent ? "Yes" : "No", | |
| ct.IsAvailable ? "Yes" : "No", | |
| EscapeCsvValue(ct.ModelType), | |
| EscapeCsvValue(ct.Error ?? ""), | |
| EscapeCsvValue(contentName), | |
| EscapeCsvValue(link) | |
| )); | |
| } | |
| } | |
| else | |
| { | |
| // No edit links - single row | |
| sb.AppendLine(string.Join(";", | |
| EscapeCsvValue(ct.Name), | |
| EscapeCsvValue(ct.DisplayName), | |
| ct.ContentTypeId, | |
| EscapeCsvValue(ct.BaseType), | |
| ct.UsageCount, | |
| ct.HasContent ? "Yes" : "No", | |
| ct.IsAvailable ? "Yes" : "No", | |
| EscapeCsvValue(ct.ModelType), | |
| EscapeCsvValue(ct.Error ?? ""), | |
| "", | |
| "" | |
| )); | |
| } | |
| } | |
| return sb.ToString(); | |
| } | |
| private string EscapeCsvValue(string value) | |
| { | |
| if (string.IsNullOrEmpty(value)) | |
| return ""; | |
| // If value contains semicolon, newline or quote, wrap in quotes and escape quotes | |
| if (value.Contains(";") || value.Contains("\n") || value.Contains("\"")) | |
| { | |
| return "\"" + value.Replace("\"", "\"\"") + "\""; | |
| } | |
| return value; | |
| } | |
| private EmailResult SendReportEmail(string csvContent) | |
| { | |
| try | |
| { | |
| var recipients = ReportEmailRecipients | |
| .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) | |
| .Select(e => e.Trim()) | |
| .Where(e => !string.IsNullOrEmpty(e)) | |
| .ToList(); | |
| if (!recipients.Any()) | |
| { | |
| return new EmailResult { Success = false, ErrorMessage = "No valid email recipients configured" }; | |
| } | |
| using (var mail = new MailMessage()) | |
| { | |
| mail.From = new MailAddress(FromEmailAddress); | |
| foreach (var recipient in recipients) | |
| { | |
| mail.To.Add(recipient); | |
| } | |
| var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HHmm"); | |
| mail.Subject = $"Content Type Usage Analysis Report - {timestamp}"; | |
| mail.Body = $"Content Type Usage Analysis Report\n\nGenerated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}\n\nPlease find the CSV report attached. Open it in Excel for filtering and analysis."; | |
| mail.IsBodyHtml = false; | |
| // Create CSV attachment with BOM for Excel to recognize UTF-8 | |
| var csvBytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csvContent)).ToArray(); | |
| var attachmentStream = new MemoryStream(csvBytes); | |
| var attachment = new Attachment(attachmentStream, $"ContentTypeUsageReport_{timestamp}.csv", MediaTypeNames.Text.Plain); | |
| mail.Attachments.Add(attachment); | |
| using (var client = new SmtpClient()) | |
| { | |
| client.Send(mail); | |
| } | |
| } | |
| return new EmailResult { Success = true }; | |
| } | |
| catch (Exception ex) | |
| { | |
| return new EmailResult { Success = false, ErrorMessage = ex.Message }; | |
| } | |
| } | |
| private class EmailResult | |
| { | |
| public bool Success { get; set; } | |
| public string ErrorMessage { get; set; } | |
| } | |
| private string GetBaseTypeName(EPiServer.DataAbstraction.ContentType contentType) | |
| { | |
| if (contentType is PageType) | |
| return "PageData"; | |
| if (contentType is BlockType) | |
| return "BlockData"; | |
| if (contentType.ModelType != null) | |
| { | |
| if (typeof(MediaData).IsAssignableFrom(contentType.ModelType)) | |
| return "MediaData"; | |
| } | |
| return "Other"; | |
| } | |
| } | |
| public class ContentTypeUsageInfo | |
| { | |
| public string Name { get; set; } | |
| public string DisplayName { get; set; } | |
| public int ContentTypeId { get; set; } | |
| public string ModelType { get; set; } | |
| public string BaseType { get; set; } | |
| public int UsageCount { get; set; } | |
| public bool HasContent { get; set; } | |
| public bool IsAvailable { get; set; } | |
| public string Error { get; set; } | |
| public List<string> EditLinks { get; set; } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment