Skip to content

Instantly share code, notes, and snippets.

@PNergard
Created January 27, 2026 22:27
Show Gist options
  • Select an option

  • Save PNergard/f3e2e363bbee0e9c1781348de1550b8a to your computer and use it in GitHub Desktop.

Select an option

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
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