374 lines
13 KiB
C#
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;
|
|
}
|
|
}
|