// 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 { /// /// Implements serialization of results using Handlebars templates. /// This allows users to fully customize the report output format. /// public class TemplateFormatSerializer : IResultFormatSerializer { /// /// The Handlebars instance configured for report rendering /// private readonly IHandlebars _handlebars; /// /// The compiled template function /// private readonly HandlebarsTemplate _template; /// /// The template source string (for debugging/logging) /// private readonly string _templateSource; /// /// Default template name to use when no custom template is provided /// public const string DefaultTemplateName = "default"; /// /// Creates a new instance with the default embedded template /// public TemplateFormatSerializer() : this(LoadEmbeddedTemplate(DefaultTemplateName), false) { } /// /// Creates a new instance with the specified template source or name /// /// The Handlebars template source or template name /// If true, templateSourceOrName is treated as a template name; if false, as template content 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); } /// /// Creates a serializer from a template file path /// /// Path to the template file /// A new TemplateFormatSerializer instance 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); } /// /// Registers custom Handlebars helpers for report formatting /// 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 dict) { foreach (var kvp in dict) { options.Template(output, new { Key = kvp.Key, Value = kvp.Value }); } } else if (obj is IDictionary 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"; } /// /// Loads an embedded template by name /// 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(); } /// /// Returns a simple fallback template if no embedded templates are found /// 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}} "; } /// /// Returns a list of available embedded template names /// public static IEnumerable 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); } /// /// Serialize the specified result using the Handlebars template /// public string Serialize(object result, Exception exception, IEnumerable loglines, Dictionary additional) { var context = new Dictionary { ["Data"] = result, ["Exception"] = exception?.ToString(), ["LogLines"] = loglines?.ToList() ?? new List() }; // Add additional parameters directly to the context if (additional != null) { foreach (var kvp in additional) { context[kvp.Key] = kvp.Value; } } return _template(context); } /// /// Returns the format that the serializer represents /// public ResultExportFormat Format => ResultExportFormat.Template; } }