Files
duplicati/Duplicati/Library/ResultSerialization/TemplateFormatSerializer.cs
T

374 lines
13 KiB
C#

// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using HandlebarsDotNet;
namespace Duplicati.Library.ResultSerialization
{
/// <summary>
/// Implements serialization of results using Handlebars templates.
/// This allows users to fully customize the report output format.
/// </summary>
public class TemplateFormatSerializer : IResultFormatSerializer
{
/// <summary>
/// The Handlebars instance configured for report rendering
/// </summary>
private readonly IHandlebars _handlebars;
/// <summary>
/// The compiled template function
/// </summary>
private readonly HandlebarsTemplate<object, object> _template;
/// <summary>
/// The template source string (for debugging/logging)
/// </summary>
private readonly string _templateSource;
/// <summary>
/// Default template name to use when no custom template is provided
/// </summary>
public const string DefaultTemplateName = "default";
/// <summary>
/// Creates a new instance with the default embedded template
/// </summary>
public TemplateFormatSerializer()
: this(LoadEmbeddedTemplate(DefaultTemplateName), false)
{
}
/// <summary>
/// Creates a new instance with the specified template source or name
/// </summary>
/// <param name="templateSourceOrName">The Handlebars template source or template name</param>
/// <param name="isTemplateName">If true, templateSourceOrName is treated as a template name; if false, as template content</param>
public TemplateFormatSerializer(string templateSourceOrName, bool isTemplateName)
{
string templateSource;
if (isTemplateName)
templateSource = LoadEmbeddedTemplate(templateSourceOrName);
else
templateSource = templateSourceOrName;
_templateSource = templateSource ?? throw new ArgumentNullException(nameof(templateSourceOrName));
_handlebars = Handlebars.Create(new HandlebarsConfiguration
{
FormatProvider = CultureInfo.InvariantCulture
});
RegisterHelpers();
_template = _handlebars.Compile(templateSource);
}
/// <summary>
/// Creates a serializer from a template file path
/// </summary>
/// <param name="filePath">Path to the template file</param>
/// <returns>A new TemplateFormatSerializer instance</returns>
public static TemplateFormatSerializer FromFile(string filePath)
{
if (!File.Exists(filePath))
throw new FileNotFoundException($"Template file not found: {filePath}", filePath);
var templateContent = File.ReadAllText(filePath);
return new TemplateFormatSerializer(templateContent, false);
}
/// <summary>
/// Registers custom Handlebars helpers for report formatting
/// </summary>
private void RegisterHelpers()
{
// Helper to format file sizes in human-readable form
_handlebars.RegisterHelper("formatSize", (output, context, arguments) =>
{
if (arguments.Length == 0 || arguments[0] == null)
{
output.Write("0 B");
return;
}
if (!long.TryParse(arguments[0].ToString(), out var bytes))
{
output.Write(arguments[0].ToString());
return;
}
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
int order = 0;
double size = bytes;
while (size >= 1024 && order < sizes.Length - 1)
{
order++;
size /= 1024;
}
output.Write($"{size.ToString("0.##", CultureInfo.InvariantCulture)} {sizes[order]}");
});
// Helper to format durations
_handlebars.RegisterHelper("formatDuration", (output, context, arguments) =>
{
if (arguments.Length == 0 || arguments[0] == null)
{
output.Write("0s");
return;
}
if (arguments[0] is TimeSpan ts)
{
output.Write(FormatTimeSpan(ts));
return;
}
if (double.TryParse(arguments[0].ToString(), out var seconds))
{
output.Write(FormatTimeSpan(TimeSpan.FromSeconds(seconds)));
return;
}
output.Write(arguments[0].ToString());
});
// Helper to format dates
_handlebars.RegisterHelper("formatDate", (output, context, arguments) =>
{
if (arguments.Length == 0 || arguments[0] == null)
{
output.Write("");
return;
}
var format = arguments.Length > 1 ? arguments[1].ToString() : "yyyy-MM-dd HH:mm:ss";
if (arguments[0] is DateTime dt)
{
output.Write(dt.ToString(format));
return;
}
if (DateTime.TryParse(arguments[0].ToString(), out var parsed))
{
output.Write(parsed.ToString(format));
return;
}
output.Write(arguments[0].ToString());
});
// Helper for conditional equality
_handlebars.RegisterHelper("eq", (output, options, context, arguments) =>
{
if (arguments.Length < 2)
{
options.Inverse(output, context);
return;
}
var left = arguments[0]?.ToString() ?? "";
var right = arguments[1]?.ToString() ?? "";
if (string.Equals(left, right, StringComparison.OrdinalIgnoreCase))
options.Template(output, context);
else
options.Inverse(output, context);
});
// Helper for status-based CSS classes or indicators
_handlebars.RegisterHelper("statusClass", (output, context, arguments) =>
{
if (arguments.Length == 0 || arguments[0] == null)
{
output.Write("unknown");
return;
}
var status = arguments[0].ToString().ToLowerInvariant();
var result = status switch
{
"success" => "success",
"warning" => "warning",
"error" => "error",
"fatal" => "fatal",
_ => "unknown"
};
output.Write(result);
});
// Helper to iterate over object properties
_handlebars.RegisterHelper("eachProperty", (output, options, context, arguments) =>
{
if (arguments.Length == 0 || arguments[0] == null)
return;
var obj = arguments[0];
if (obj is IDictionary<string, object> dict)
{
foreach (var kvp in dict)
{
options.Template(output, new { Key = kvp.Key, Value = kvp.Value });
}
}
else if (obj is IDictionary<string, string> stringDict)
{
foreach (var kvp in stringDict)
{
options.Template(output, new { Key = kvp.Key, Value = kvp.Value });
}
}
else
{
var type = obj.GetType();
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
try
{
var value = prop.GetValue(obj);
options.Template(output, new { Key = prop.Name, Value = value });
}
catch
{
// Skip properties that throw exceptions
}
}
}
});
}
private static string FormatTimeSpan(TimeSpan ts)
{
if (ts.TotalDays >= 1)
return $"{(int)ts.TotalDays}d {ts.Hours}h {ts.Minutes}m";
if (ts.TotalHours >= 1)
return $"{(int)ts.TotalHours}h {ts.Minutes}m {ts.Seconds}s";
if (ts.TotalMinutes >= 1)
return $"{(int)ts.TotalMinutes}m {ts.Seconds}s";
return $"{ts.TotalSeconds.ToString("0.#", CultureInfo.InvariantCulture)}s";
}
/// <summary>
/// Loads an embedded template by name
/// </summary>
private static string LoadEmbeddedTemplate(string templateName)
{
var assembly = typeof(TemplateFormatSerializer).Assembly;
var resourceName = $"Duplicati.Library.Modules.Builtin.Templates.{templateName}.hbs";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
{
// Fall back to the default template if the requested one does not exist
var defaultResource = $"Duplicati.Library.Modules.Builtin.Templates.{DefaultTemplateName}.hbs";
using var defaultStream = assembly.GetManifestResourceStream(defaultResource);
if (defaultStream == null)
return GetFallbackTemplate();
using var defaultReader = new StreamReader(defaultStream);
return defaultReader.ReadToEnd();
}
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
/// <summary>
/// Returns a simple fallback template if no embedded templates are found
/// </summary>
private static string GetFallbackTemplate()
{
return @"Duplicati {{OperationName}} Report
======================================
Status: {{ParsedResult}}
Backup Name: {{BackupName}}
Machine: {{MachineName}}
{{#if Data}}
{{#eachProperty Data}}
{{Key}}: {{Value}}
{{/eachProperty}}
{{/if}}
{{#if LogLines}}
Log Messages:
{{#each LogLines}}
{{this}}
{{/each}}
{{/if}}
{{#if Exception}}
Exception: {{Exception}}
{{/if}}
";
}
/// <summary>
/// Returns a list of available embedded template names
/// </summary>
public static IEnumerable<string> GetAvailableTemplates()
{
var assembly = typeof(TemplateFormatSerializer).Assembly;
var prefix = "Duplicati.Library.Modules.Builtin.Templates.";
var suffix = ".hbs";
return assembly.GetManifestResourceNames()
.Where(n => n.StartsWith(prefix) && n.EndsWith(suffix))
.Select(n => n.Substring(prefix.Length, n.Length - prefix.Length - suffix.Length))
.OrderBy(n => n);
}
/// <summary>
/// Serialize the specified result using the Handlebars template
/// </summary>
public string Serialize(object result, Exception exception, IEnumerable<string> loglines, Dictionary<string, string> additional)
{
var context = new Dictionary<string, object>
{
["Data"] = result,
["Exception"] = exception?.ToString(),
["LogLines"] = loglines?.ToList() ?? new List<string>()
};
// Add additional parameters directly to the context
if (additional != null)
{
foreach (var kvp in additional)
{
context[kvp.Key] = kvp.Value;
}
}
return _template(context);
}
/// <summary>
/// Returns the format that the serializer represents
/// </summary>
public ResultExportFormat Format => ResultExportFormat.Template;
}
}