Merge pull request #6702 from AndyBodnar/feature/templating-engine-6280
Implement templating engine for reports
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
|
||||
<PackageReference Include="Artalk.Xmpp" Version="1.0.5" />
|
||||
<PackageReference Include="DnsClient" Version="1.8.0" />
|
||||
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="MailKit" Version="4.12.1" />
|
||||
<PackageReference Include="MimeKit" Version="4.12.0" />
|
||||
@@ -39,4 +40,8 @@
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Templates\**\*" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -24,6 +24,7 @@ namespace Duplicati.Library.Modules.Builtin
|
||||
public enum ResultExportFormat
|
||||
{
|
||||
Duplicati,
|
||||
Json
|
||||
Json,
|
||||
Template
|
||||
}
|
||||
}
|
||||
|
||||
+56
-16
@@ -1,22 +1,22 @@
|
||||
// Copyright (C) 2025, 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
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// 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 Duplicati.Library.Localization.Short;
|
||||
@@ -28,16 +28,56 @@ namespace Duplicati.Library.Modules.Builtin.ResultSerialization
|
||||
/// </summary>
|
||||
public static class ResultFormatSerializerProvider
|
||||
{
|
||||
public static IResultFormatSerializer GetSerializer(ResultExportFormat format) {
|
||||
/// <summary>
|
||||
/// Gets a serializer for the specified format
|
||||
/// </summary>
|
||||
/// <param name="format">The format to get a serializer for</param>
|
||||
/// <returns>A serializer for the format</returns>
|
||||
public static IResultFormatSerializer GetSerializer(ResultExportFormat format)
|
||||
{
|
||||
return GetSerializer(format, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a serializer for the specified format with optional template configuration
|
||||
/// </summary>
|
||||
/// <param name="format">The format to get a serializer for</param>
|
||||
/// <param name="templatePathOrName">For Template format: path to custom template file, embedded template name, or null for default</param>
|
||||
/// <returns>A serializer for the format</returns>
|
||||
public static IResultFormatSerializer GetSerializer(ResultExportFormat format, string templatePathOrName)
|
||||
{
|
||||
switch (format)
|
||||
{
|
||||
case ResultExportFormat.Duplicati:
|
||||
return new DuplicatiFormatSerializer();
|
||||
case ResultExportFormat.Json:
|
||||
return new JsonFormatSerializer();
|
||||
case ResultExportFormat.Template:
|
||||
return GetTemplateSerializer(templatePathOrName);
|
||||
default:
|
||||
throw new Interface.UserInformationException(LC.L("The format is not supported: {0}", format), "SerializationFormatNotSupported");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a template serializer with the specified template configuration
|
||||
/// </summary>
|
||||
private static TemplateFormatSerializer GetTemplateSerializer(string templatePathOrName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(templatePathOrName))
|
||||
return new TemplateFormatSerializer();
|
||||
|
||||
// If it looks like a file path (contains path separators or file extension), try to load it as a file
|
||||
if (templatePathOrName.Contains(System.IO.Path.DirectorySeparatorChar) ||
|
||||
templatePathOrName.Contains(System.IO.Path.AltDirectorySeparatorChar) ||
|
||||
templatePathOrName.EndsWith(".hbs", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (System.IO.File.Exists(templatePathOrName))
|
||||
return TemplateFormatSerializer.FromFile(templatePathOrName);
|
||||
}
|
||||
|
||||
// Otherwise, treat it as an embedded template name
|
||||
return new TemplateFormatSerializer(templatePathOrName, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
// Copyright (C) 2025, 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.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using HandlebarsDotNet;
|
||||
|
||||
namespace Duplicati.Library.Modules.Builtin.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();
|
||||
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:0.##} {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:0.#}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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
Duplicati {{OperationName}} Report
|
||||
======================================
|
||||
|
||||
Status: {{ParsedResult}}
|
||||
{{#if BackupName}}Backup: {{BackupName}}{{/if}}
|
||||
{{#if MachineName}}Machine: {{MachineName}}{{/if}}
|
||||
{{#if MachineId}}Machine ID: {{MachineId}}{{/if}}
|
||||
|
||||
{{#if Data}}
|
||||
Results:
|
||||
--------
|
||||
{{#eachProperty Data}}
|
||||
{{Key}}: {{Value}}
|
||||
{{/eachProperty}}
|
||||
{{/if}}
|
||||
|
||||
{{#if Extra}}
|
||||
Additional Information:
|
||||
-----------------------
|
||||
{{#eachProperty Extra}}
|
||||
{{Key}}: {{Value}}
|
||||
{{/eachProperty}}
|
||||
{{/if}}
|
||||
|
||||
{{#if LogLines}}
|
||||
Log Messages:
|
||||
-------------
|
||||
{{#each LogLines}}
|
||||
{{this}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
{{#if Exception}}
|
||||
Error Details:
|
||||
--------------
|
||||
{{Exception}}
|
||||
{{/if}}
|
||||
@@ -0,0 +1,52 @@
|
||||
Duplicati Backup Report: {{ParsedResult}}
|
||||
==========================================
|
||||
|
||||
{{#eq ParsedResult "Success"}}
|
||||
Your backup completed successfully!
|
||||
{{/eq}}
|
||||
{{#eq ParsedResult "Warning"}}
|
||||
Your backup completed with warnings. Please review the details below.
|
||||
{{/eq}}
|
||||
{{#eq ParsedResult "Error"}}
|
||||
Your backup encountered errors. Please check the log messages for details.
|
||||
{{/eq}}
|
||||
{{#eq ParsedResult "Fatal"}}
|
||||
Your backup failed with a fatal error. Immediate attention required.
|
||||
{{/eq}}
|
||||
|
||||
Backup Details
|
||||
--------------
|
||||
Operation: {{OperationName}}
|
||||
{{#if BackupName}}Backup Name: {{BackupName}}{{/if}}
|
||||
{{#if MachineName}}Machine: {{MachineName}}{{/if}}
|
||||
{{#if RemoteUrl}}Destination: {{RemoteUrl}}{{/if}}
|
||||
|
||||
{{#if Data}}
|
||||
Statistics
|
||||
----------
|
||||
{{#if Data.ExaminedFiles}}Files Examined: {{Data.ExaminedFiles}}{{/if}}
|
||||
{{#if Data.SizeOfExaminedFiles}}Data Examined: {{formatSize Data.SizeOfExaminedFiles}}{{/if}}
|
||||
{{#if Data.AddedFiles}}Files Added: {{Data.AddedFiles}}{{/if}}
|
||||
{{#if Data.SizeOfAddedFiles}}Data Added: {{formatSize Data.SizeOfAddedFiles}}{{/if}}
|
||||
{{#if Data.ModifiedFiles}}Files Modified: {{Data.ModifiedFiles}}{{/if}}
|
||||
{{#if Data.SizeOfModifiedFiles}}Data Modified: {{formatSize Data.SizeOfModifiedFiles}}{{/if}}
|
||||
{{#if Data.DeletedFiles}}Files Deleted: {{Data.DeletedFiles}}{{/if}}
|
||||
{{#if Data.Duration}}Duration: {{formatDuration Data.Duration}}{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#if LogLines}}
|
||||
Log Messages
|
||||
------------
|
||||
{{#each LogLines}}
|
||||
{{this}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
{{#if Exception}}
|
||||
Error Details
|
||||
-------------
|
||||
{{Exception}}
|
||||
{{/if}}
|
||||
|
||||
---
|
||||
This report was generated by Duplicati
|
||||
@@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Duplicati Backup Report</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
|
||||
.container { background: white; border-radius: 8px; padding: 30px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
h1 { margin-top: 0; color: #333; }
|
||||
.status { display: inline-block; padding: 8px 16px; border-radius: 4px; font-weight: bold; text-transform: uppercase; }
|
||||
.status.success { background: #d4edda; color: #155724; }
|
||||
.status.warning { background: #fff3cd; color: #856404; }
|
||||
.status.error { background: #f8d7da; color: #721c24; }
|
||||
.status.fatal { background: #721c24; color: white; }
|
||||
.section { margin: 20px 0; padding: 15px; background: #f8f9fa; border-radius: 4px; }
|
||||
.section h2 { margin-top: 0; font-size: 1.1em; color: #495057; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
td { padding: 8px 0; border-bottom: 1px solid #eee; }
|
||||
td:first-child { font-weight: 500; color: #666; width: 40%; }
|
||||
.logs { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 4px; font-family: monospace; font-size: 13px; overflow-x: auto; max-height: 400px; overflow-y: auto; }
|
||||
.logs p { margin: 4px 0; }
|
||||
.error-box { background: #f8d7da; border: 1px solid #f5c6cb; padding: 15px; border-radius: 4px; color: #721c24; }
|
||||
.footer { margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee; color: #888; font-size: 0.9em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Duplicati Backup Report</h1>
|
||||
|
||||
<p><span class="status {{statusClass ParsedResult}}">{{ParsedResult}}</span></p>
|
||||
|
||||
<div class="section">
|
||||
<h2>Backup Details</h2>
|
||||
<table>
|
||||
<tr><td>Operation</td><td>{{OperationName}}</td></tr>
|
||||
{{#if BackupName}}<tr><td>Backup Name</td><td>{{BackupName}}</td></tr>{{/if}}
|
||||
{{#if MachineName}}<tr><td>Machine</td><td>{{MachineName}}</td></tr>{{/if}}
|
||||
{{#if OperatingSystem}}<tr><td>OS</td><td>{{OperatingSystem}}</td></tr>{{/if}}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{#if Data}}
|
||||
<div class="section">
|
||||
<h2>Statistics</h2>
|
||||
<table>
|
||||
{{#if Data.ExaminedFiles}}<tr><td>Files Examined</td><td>{{Data.ExaminedFiles}}</td></tr>{{/if}}
|
||||
{{#if Data.SizeOfExaminedFiles}}<tr><td>Data Examined</td><td>{{formatSize Data.SizeOfExaminedFiles}}</td></tr>{{/if}}
|
||||
{{#if Data.AddedFiles}}<tr><td>Files Added</td><td>{{Data.AddedFiles}}</td></tr>{{/if}}
|
||||
{{#if Data.SizeOfAddedFiles}}<tr><td>Data Added</td><td>{{formatSize Data.SizeOfAddedFiles}}</td></tr>{{/if}}
|
||||
{{#if Data.ModifiedFiles}}<tr><td>Files Modified</td><td>{{Data.ModifiedFiles}}</td></tr>{{/if}}
|
||||
{{#if Data.SizeOfModifiedFiles}}<tr><td>Data Modified</td><td>{{formatSize Data.SizeOfModifiedFiles}}</td></tr>{{/if}}
|
||||
{{#if Data.DeletedFiles}}<tr><td>Files Deleted</td><td>{{Data.DeletedFiles}}</td></tr>{{/if}}
|
||||
{{#if Data.Duration}}<tr><td>Duration</td><td>{{formatDuration Data.Duration}}</td></tr>{{/if}}
|
||||
</table>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if LogLines}}
|
||||
<div class="section">
|
||||
<h2>Log Messages</h2>
|
||||
<div class="logs">
|
||||
{{#each LogLines}}
|
||||
<p>{{this}}</p>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if Exception}}
|
||||
<div class="section">
|
||||
<h2>Error Details</h2>
|
||||
<div class="error-box">
|
||||
<pre>{{Exception}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="footer">
|
||||
Generated by Duplicati
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"text": "Duplicati {{OperationName}} - {{ParsedResult}}",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "Duplicati {{OperationName}} Report"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Status:*\n{{ParsedResult}}"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Backup:*\n{{BackupName}}"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Machine:*\n{{MachineName}}"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Operation:*\n{{OperationName}}"
|
||||
}
|
||||
]
|
||||
}{{#if Data}},
|
||||
{
|
||||
"type": "divider"
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*Statistics*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{{#if Data.ExaminedFiles}}
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Files Examined:* {{Data.ExaminedFiles}}"
|
||||
},
|
||||
{{/if}}
|
||||
{{#if Data.AddedFiles}}
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Files Added:* {{Data.AddedFiles}}"
|
||||
},
|
||||
{{/if}}
|
||||
{{#if Data.ModifiedFiles}}
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Files Modified:* {{Data.ModifiedFiles}}"
|
||||
},
|
||||
{{/if}}
|
||||
{{#if Data.DeletedFiles}}
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Files Deleted:* {{Data.DeletedFiles}}"
|
||||
},
|
||||
{{/if}}
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": " "
|
||||
}
|
||||
]
|
||||
}{{/if}}{{#if Exception}},
|
||||
{
|
||||
"type": "divider"
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*Error:* {{Exception}}"
|
||||
}
|
||||
}{{/if}}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// Copyright (C) 2025, 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.IO;
|
||||
using Duplicati.Library.Modules.Builtin;
|
||||
using Duplicati.Library.Modules.Builtin.ResultSerialization;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Duplicati.UnitTest
|
||||
{
|
||||
[TestFixture]
|
||||
public class TemplateSerializerTests : BasicSetupHelper
|
||||
{
|
||||
[Test]
|
||||
[Category("Serialization")]
|
||||
public void TestGetSerializerGivenTemplateReturnsTemplateSerializer()
|
||||
{
|
||||
IResultFormatSerializer serializer = ResultFormatSerializerProvider.GetSerializer(ResultExportFormat.Template);
|
||||
Assert.That(serializer, Is.InstanceOf<TemplateFormatSerializer>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Serialization")]
|
||||
public void TestTemplateSerializerFormat()
|
||||
{
|
||||
var serializer = new TemplateFormatSerializer();
|
||||
Assert.That(serializer.Format, Is.EqualTo(ResultExportFormat.Template));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Serialization")]
|
||||
public void TestTemplateSerializerWithSimpleTemplate()
|
||||
{
|
||||
var template = "Status: {{ParsedResult}}";
|
||||
var serializer = new TemplateFormatSerializer(template, false);
|
||||
|
||||
var additional = new Dictionary<string, string>
|
||||
{
|
||||
{ "ParsedResult", "Success" }
|
||||
};
|
||||
|
||||
var result = serializer.Serialize(null, null, null, additional);
|
||||
Assert.That(result, Is.EqualTo("Status: Success"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Serialization")]
|
||||
public void TestTemplateSerializerWithLogLines()
|
||||
{
|
||||
var template = "{{#each LogLines}}{{this}}\n{{/each}}";
|
||||
var serializer = new TemplateFormatSerializer(template, false);
|
||||
|
||||
var logLines = new List<string> { "Line 1", "Line 2", "Line 3" };
|
||||
var result = serializer.Serialize(null, null, logLines, null);
|
||||
|
||||
Assert.That(result, Does.Contain("Line 1"));
|
||||
Assert.That(result, Does.Contain("Line 2"));
|
||||
Assert.That(result, Does.Contain("Line 3"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Serialization")]
|
||||
public void TestTemplateSerializerWithException()
|
||||
{
|
||||
var template = "{{#if Exception}}Error: {{Exception}}{{/if}}";
|
||||
var serializer = new TemplateFormatSerializer(template, false);
|
||||
|
||||
var exception = new Exception("Test error message");
|
||||
var result = serializer.Serialize(null, exception, null, null);
|
||||
|
||||
Assert.That(result, Does.Contain("Error:"));
|
||||
Assert.That(result, Does.Contain("Test error message"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Serialization")]
|
||||
public void TestTemplateSerializerFormatSizeHelper()
|
||||
{
|
||||
var template = "Size: {{formatSize Data.Size}}";
|
||||
var serializer = new TemplateFormatSerializer(template, false);
|
||||
|
||||
var result = serializer.Serialize(new { Size = 1536 }, null, null, null);
|
||||
Assert.That(result, Is.EqualTo("Size: 1.5 KB"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Serialization")]
|
||||
public void TestTemplateSerializerFormatDurationHelper()
|
||||
{
|
||||
var template = "Duration: {{formatDuration 125}}";
|
||||
var serializer = new TemplateFormatSerializer(template, false);
|
||||
|
||||
var result = serializer.Serialize(null, null, null, null);
|
||||
Assert.That(result, Is.EqualTo("Duration: 2m 5s"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Serialization")]
|
||||
public void TestTemplateSerializerStatusClassHelper()
|
||||
{
|
||||
var template = "Class: {{statusClass ParsedResult}}";
|
||||
var serializer = new TemplateFormatSerializer(template, false);
|
||||
|
||||
var additional = new Dictionary<string, string> { { "ParsedResult", "Warning" } };
|
||||
var result = serializer.Serialize(null, null, null, additional);
|
||||
|
||||
Assert.That(result, Is.EqualTo("Class: warning"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Serialization")]
|
||||
public void TestTemplateSerializerEqHelper()
|
||||
{
|
||||
var template = "{{#eq ParsedResult \"Success\"}}OK{{else}}FAIL{{/eq}}";
|
||||
var serializer = new TemplateFormatSerializer(template, false);
|
||||
|
||||
var additional = new Dictionary<string, string> { { "ParsedResult", "Success" } };
|
||||
var result = serializer.Serialize(null, null, null, additional);
|
||||
|
||||
Assert.That(result, Is.EqualTo("OK"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Serialization")]
|
||||
public void TestTemplateSerializerFromFile()
|
||||
{
|
||||
var tempFile = Path.GetTempFileName() + ".hbs";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(tempFile, "Custom template: {{OperationName}}");
|
||||
var serializer = TemplateFormatSerializer.FromFile(tempFile);
|
||||
|
||||
var additional = new Dictionary<string, string> { { "OperationName", "Backup" } };
|
||||
var result = serializer.Serialize(null, null, null, additional);
|
||||
|
||||
Assert.That(result, Is.EqualTo("Custom template: Backup"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempFile))
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Serialization")]
|
||||
public void TestTemplateSerializerFromFileMissing()
|
||||
{
|
||||
Assert.Throws<FileNotFoundException>(() =>
|
||||
TemplateFormatSerializer.FromFile("/nonexistent/template.hbs"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Serialization")]
|
||||
public void TestTemplateSerializerGetAvailableTemplates()
|
||||
{
|
||||
var templates = TemplateFormatSerializer.GetAvailableTemplates();
|
||||
Assert.That(templates, Is.Not.Null);
|
||||
// At minimum, we should have the default template
|
||||
Assert.That(templates, Does.Contain("default").Or.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Serialization")]
|
||||
public void TestResultFormatSerializerProviderWithTemplateName()
|
||||
{
|
||||
// Test that we can get a serializer with a template name
|
||||
var serializer = ResultFormatSerializerProvider.GetSerializer(ResultExportFormat.Template, "email");
|
||||
Assert.That(serializer, Is.InstanceOf<TemplateFormatSerializer>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Serialization")]
|
||||
public void TestTemplateWithDataObject()
|
||||
{
|
||||
var template = "Files: {{Data.ExaminedFiles}}, Added: {{Data.AddedFiles}}";
|
||||
var serializer = new TemplateFormatSerializer(template, false);
|
||||
|
||||
var data = new { ExaminedFiles = 100, AddedFiles = 5 };
|
||||
var result = serializer.Serialize(data, null, null, null);
|
||||
|
||||
Assert.That(result, Is.EqualTo("Files: 100, Added: 5"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user