Skip to content
This repository has been archived by the owner on Dec 25, 2024. It is now read-only.

Template translations #176

Merged
merged 7 commits into from
Jul 13, 2023
17 changes: 10 additions & 7 deletions src/FaluCli/Commands/Messages/MessagesSendCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,22 @@ public AsbtractMessagesSendCommand(string name, string? description = null) : ba
});

this.AddOption(new[] { "--stream", "-s", },
description: "The stream to use, either the name or unique identifier. Example: mstr_610010be9228355f14ce6e08 or transactional",
description: "The stream to use, either the name or unique identifier.\r\nExample: mstr_610010be9228355f14ce6e08 or transactional",
defaultValue: "transactional",
configure: o => o.IsRequired = true);

this.AddOption<Uri?>(new[] { "--media-url", },
description: "Publicly accessible URL of the media to include in the message(s). Example: https://c1.staticflickr.com/3/2899/14341091933_1e92e62d12_b.jpg");
description: "Publicly accessible URL of the media to include in the message(s).\r\nExample: https://c1.staticflickr.com/3/2899/14341091933_1e92e62d12_b.jpg");

this.AddOption(new[] { "--media-file-id", },
description: "The unique identifier of the pre-uploaded file containing the media to include in the message(s). Example: file_602a8dd0a54847479a874de4",
description: "The unique identifier of the pre-uploaded file containing the media to include in the message(s).\r\nExample: file_602a8dd0a54847479a874de4",
format: Constants.Iso8061DurationFormat);

this.AddOption<DateTimeOffset?>(new[] { "--schedule-time", "--time", },
description: $"The time at which the message(s) should be in the future. Example: {DateTime.Today.AddDays(1):O}");
description: $"The time at which the message(s) should be in the future.\r\nExample: {DateTime.Today.AddDays(1):O}");

this.AddOption(new[] { "--schedule-delay", "--delay", },
description: "The delay (in ISO8601 duration format) to be applied by the server before sending the message(s). Example: PT10M for 10 minutes",
description: "The delay (in ISO8601 duration format) to be applied by the server before sending the message(s).\r\nExample: PT10M for 10 minutes",
format: Constants.Iso8061DurationFormat);
}

Expand Down Expand Up @@ -89,15 +89,18 @@ public class MessagesSendTemplatedCommand : AsbtractMessagesSendCommand
public MessagesSendTemplatedCommand() : base("templated", "Send a templated message.")
{
this.AddOption(new[] { "--id", "-i", },
description: "The unique template identifier. Example: mtpl_610010be9228355f14ce6e08",
description: "The unique template identifier.\r\nExample: mtpl_610010be9228355f14ce6e08",
format: Constants.MessageTemplateIdFormat);

this.AddOption(new[] { "--alias", "-a", },
description: "The template alias, unique to your workspace.",
format: Constants.MessageTemplateAliasFormat);

this.AddOption<string>(new[] { "--language", "--lang", },
description: "The language or translation to use in the template. This is represented as the ISO-639-3 code.\r\nExample: swa for Swahili or fra for French");

this.AddOption(new[] { "--model", "-m", },
description: "The model to use with the template. Example --model '{\"name\": \"John\"}'",
description: "The model to use with the template.\r\nExample --model '{\"name\": \"John\"}'",
defaultValue: "{}",
validate: (or) =>
{
Expand Down
3 changes: 2 additions & 1 deletion src/FaluCli/Commands/Messages/MessagesSendCommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ public async Task<int> InvokeAsync(InvocationContext context)
{
var id = context.ParseResult.ValueForOption<string>("--id");
var alias = context.ParseResult.ValueForOption<string>("--alias");
var language = context.ParseResult.ValueForOption<string>("--language");
var modelJson = context.ParseResult.ValueForOption<string>("--model")!; // marked required in the command
var model = new MessageTemplateModel(System.Text.Json.Nodes.JsonNode.Parse(modelJson)!.AsObject());

Expand All @@ -107,7 +108,7 @@ public async Task<int> InvokeAsync(InvocationContext context)
return -1;
}

template = new MessageCreateRequestTemplate { Id = id, Alias = alias, Model = model, };
template = new MessageCreateRequestTemplate { Id = id, Alias = alias, Language = language, Model = model, };
}
else throw new InvalidOperationException($"Command of type '{command.GetType().FullName}' is not supported here.");

Expand Down
2 changes: 1 addition & 1 deletion src/FaluCli/Commands/Templates/TemplateInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ internal class TemplateInfo : IHasDescription, IHasMetadata
public TemplateInfo(MessageTemplate template)
{
Alias = template.Alias;
Description = template.Alias;
Description = template.Description;
Metadata = template.Metadata;
}

Expand Down
5 changes: 4 additions & 1 deletion src/FaluCli/Commands/Templates/TemplateManifest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

internal class TemplateManifest
{
public TemplateManifest(TemplateInfo info, string body)
public TemplateManifest(TemplateInfo info, string body, Dictionary<string, string> translations)
{
Info = info ?? throw new ArgumentNullException(nameof(info));
Body = body ?? throw new ArgumentNullException(nameof(body));
Translations = translations ?? throw new ArgumentNullException(nameof(translations));
}

public TemplateInfo Info { get; }
Expand All @@ -14,6 +15,8 @@ public TemplateManifest(TemplateInfo info, string body)

public string Body { get; }

public Dictionary<string, string> Translations { get; set; }

public ChangeType ChangeType { get; set; } = ChangeType.Unmodified;

public string? Id { get; set; }
Expand Down
143 changes: 114 additions & 29 deletions src/FaluCli/Commands/Templates/TemplatesCommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
using Falu.MessageTemplates;
using Spectre.Console;
using System.Text.Json;
using System.Text.RegularExpressions;
using Tingle.Extensions.JsonPatch;

namespace Falu.Commands.Templates;

internal class TemplatesCommandHandler : ICommandHandler
internal partial class TemplatesCommandHandler : ICommandHandler
{
private const string BodyFileName = "content.txt";
private const string InfoFileName = "info.json";
private const string DefaultBodyFileName = "content.txt";
private const string TranslatedBodyFileNameFormat = "content-{0}.txt";
private static readonly Regex TranslatedBodyFileNamePattern = GetTranslatedBodyFileNamePattern();

private readonly FaluCliClient client;
private readonly ILogger logger;
Expand Down Expand Up @@ -66,10 +69,17 @@ private async Task SaveTemplateAsync(MessageTemplate template, string outputPath
var dirPath = Path.Combine(outputPath, template.Alias!);
if (!Directory.Exists(dirPath)) Directory.CreateDirectory(dirPath);

// write the template body
var contentPath = Path.Combine(dirPath, BodyFileName);
// write the default body
var contentPath = Path.Combine(dirPath, DefaultBodyFileName);
await WriteToFileAsync(contentPath, overwrite, template.Body!, cancellationToken);

// write the translations
foreach (var (language, translation) in template.Translations)
{
contentPath = Path.Combine(dirPath, string.Format(TranslatedBodyFileNameFormat, language));
await WriteToFileAsync(contentPath, overwrite, translation.Body!, cancellationToken);
}

// write the template info
var infoPath = Path.Combine(dirPath, InfoFileName);
var info = new TemplateInfo(template);
Expand All @@ -94,6 +104,11 @@ private async Task WriteToFileAsync(string path, bool overwrite, Stream contents
return;
}

// delete existing file
if (exists) File.Delete(path);

// write to file
logger.LogDebug("Writing to file at {Path}", path);
using var stream = File.OpenWrite(path);
await contents.CopyToAsync(stream, cancellationToken);
}
Expand Down Expand Up @@ -122,6 +137,8 @@ public async Task<int> HandlePushAsync(InvocationContext context)
var manifests = await ReadManifestsAsync(templatesDirectory, cancellationToken);
if (all)
{
// TODO: seek prompt to push the changes (with an option override: -y/--yes)

logger.LogInformation("Pushing {Count} templates to Falu servers.", manifests.Count);
await PushTemplatesAsync(manifests, cancellationToken);
}
Expand Down Expand Up @@ -160,32 +177,45 @@ private async Task PushTemplatesAsync(IReadOnlyList<TemplateManifest> manifests,
{
foreach (var mani in manifests)
{
if (mani.ChangeType == ChangeType.Added)
var changeType = mani.ChangeType;
var alias = mani.Alias;
var body = mani.Body;
var translations = mani.Translations.ToDictionary(p => p.Key, p => new MessageTemplateTranslation { Body = p.Value, });
var description = mani.Info.Description;
var metadata = mani.Info.Metadata;
if (changeType is ChangeType.Added)
{
// prepare the request and send to server
var request = new MessageTemplateCreateRequest
{
Alias = mani.Alias,
Body = mani.Body,
Description = mani.Info.Description,
Metadata = mani.Info.Metadata,
Alias = alias,
Body = body,
Translations = mani.Translations.ToDictionary(p => p.Key, p => new MessageTemplateTranslation { Body = p.Value, }),
Description = description,
Metadata = metadata,
};
await client.MessageTemplates.CreateAsync(request, cancellationToken: cancellationToken);
logger.LogDebug("Creating template with alias {Alias} ...", alias);
var response = await client.MessageTemplates.CreateAsync(request, cancellationToken: cancellationToken);
response.EnsureSuccess();
logger.LogDebug("Template with alias {Alias} created with Id: '{Id}'", alias, response.Resource!.Id);
}
else if (mani.ChangeType == ChangeType.Modified)
else if (changeType is ChangeType.Modified)
{
// prepare the patch details and send to server
var patch = new JsonPatchDocument<MessageTemplatePatchModel>()
.Replace(mt => mt.Alias, mani.Alias)
.Replace(mt => mt.Body, mani.Body)
.Replace(mt => mt.Description, mani.Info.Description)
.Replace(mt => mt.Metadata, mani.Info.Metadata);
await client.MessageTemplates.UpdateAsync(mani.Id!, patch, cancellationToken: cancellationToken);
.Replace(mt => mt.Alias, alias)
.Replace(mt => mt.Body, body)
.Replace(mt => mt.Translations, translations)
.Replace(mt => mt.Description, description)
.Replace(mt => mt.Metadata, metadata);
logger.LogDebug("Updating template with alias {Alias} ...", alias);
var response = await client.MessageTemplates.UpdateAsync(mani.Id!, patch, cancellationToken: cancellationToken);
response.EnsureSuccess();
}
}
}

private static void GenerateChanges(in IReadOnlyList<MessageTemplate> templates, in IReadOnlyList<TemplateManifest> manifests)
private void GenerateChanges(in IReadOnlyList<MessageTemplate> templates, in IReadOnlyList<TemplateManifest> manifests)
{
if (templates is null) throw new ArgumentNullException(nameof(templates));
if (manifests is null) throw new ArgumentNullException(nameof(manifests));
Expand All @@ -196,24 +226,47 @@ private static void GenerateChanges(in IReadOnlyList<MessageTemplate> templates,
var remote = templates.SingleOrDefault(t => string.Equals(t.Alias, local.Alias, StringComparison.OrdinalIgnoreCase));
if (remote is null)
{
logger.LogDebug("Template with alias {Alias} does not exist on the server. It will be created.", local.Alias);
local.ChangeType = ChangeType.Added;
continue;
}

local.Id = remote.Id;
local.ChangeType = HasChanged(remote, local) ? ChangeType.Modified : ChangeType.Unmodified;
logger.LogDebug("Template with alias {Alias} has {Suffix}.", local.ChangeType is ChangeType.Modified ? "changed" : "not changed");
}
}

private static bool HasChanged(MessageTemplate remote, TemplateManifest local)
private bool HasChanged(MessageTemplate remote, TemplateManifest local)
{
// check if the default body changed
var bodyChanged = !string.Equals(remote.Body, local.Body, StringComparison.InvariantCulture);

// check if translations changed (either it is null or the counts are different)
var translationsChanged = remote.Translations is null && local.Translations is not null
|| remote.Translations is not null && local.Translations is null
|| remote.Translations?.Count != local.Translations?.Count;
if (!translationsChanged && remote.Translations is not null && local.Translations is not null)
{
// if a key does not exist or the body does not match, it changed
foreach (var kvp in local.Translations)
{
if (!remote.Translations.TryGetValue(kvp.Key, out var translation)
|| !string.Equals(kvp.Value, translation.Body, StringComparison.InvariantCulture))
{
translationsChanged = true;
break;
}
}
}

// check if description changed
var descriptionChanged = !string.Equals(remote.Description, local.Info.Description, StringComparison.InvariantCulture);

// if either is null or the counts are different, metadata changed
var metadataChanged = (remote.Metadata is null && local.Info.Metadata is not null)
|| (remote.Metadata is not null && local.Info.Metadata is null)
|| (remote.Metadata?.Count != local.Info.Metadata?.Count);
// check if metadata changed (either it is null or the counts are different)
var metadataChanged = remote.Metadata is null && local.Info.Metadata is not null
|| remote.Metadata is not null && local.Info.Metadata is null
|| remote.Metadata?.Count != local.Info.Metadata?.Count;
if (!metadataChanged && remote.Metadata is not null && local.Info.Metadata is not null)
{
// if a key does not exist or the value does not match, it changed
Expand All @@ -228,34 +281,63 @@ private static bool HasChanged(MessageTemplate remote, TemplateManifest local)
}
}

return bodyChanged || descriptionChanged || metadataChanged;
logger.LogDebug("Checked for changes on template alias '{Alias}'."
+ "\r\nBody:{bodyChanged}, Translations:{translationsChanged}, Description:{descriptionChanged}, Metadata:{metadataChanged}",
remote.Alias,
bodyChanged,
translationsChanged,
descriptionChanged,
metadataChanged);
return bodyChanged || translationsChanged || descriptionChanged || metadataChanged;
}

private static async Task<IReadOnlyList<TemplateManifest>> ReadManifestsAsync(string templatesDirectory, CancellationToken cancellationToken)
private async Task<IReadOnlyList<TemplateManifest>> ReadManifestsAsync(string templatesDirectory, CancellationToken cancellationToken)
{
var results = new List<TemplateManifest>();
var directories = Directory.EnumerateDirectories(templatesDirectory);
foreach (var dirPath in directories)
{
// there is no info file, we skip the folder/directory
var infoPath = Path.Combine(dirPath, InfoFileName);
if (!File.Exists(infoPath)) continue;
if (!File.Exists(infoPath))
{
logger.LogDebug("Skipping directory at {Directory} because it does not have an info file", dirPath);
continue;
}

logger.LogDebug("Reading manifest from {Directory}", dirPath);

// read the info
using var stream = File.OpenRead(infoPath);
var info = (await JsonSerializer.DeserializeAsync(stream, FaluCliJsonSerializerContext.Default.TemplateInfo, cancellationToken))!;

var contentPath = Path.Combine(dirPath, BodyFileName);
// read default content
var contentPath = Path.Combine(dirPath, DefaultBodyFileName);
var body = await ReadFromFileAsync(contentPath, cancellationToken);

results.Add(new TemplateManifest(info, body));
// read translations
var translations = new Dictionary<string, string>();
var files = Directory.EnumerateFiles(dirPath);
foreach (var file in files)
{
var match = TranslatedBodyFileNamePattern.Match(file);
if (!match.Success) continue;

contentPath = file;
var translated = await ReadFromFileAsync(contentPath, cancellationToken);
var language = match.Groups[1].Value;
translations[language] = translated;
}

results.Add(new TemplateManifest(info, body, translations));
}

return results;
}

private static async Task<string> ReadFromFileAsync(string path, CancellationToken cancellationToken)
private async Task<string> ReadFromFileAsync(string path, CancellationToken cancellationToken)
{
logger.LogDebug("Reading file at {Path}", path);
using var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync(cancellationToken);
Expand All @@ -265,7 +347,7 @@ private static async Task<string> ReadFromFileAsync(string path, CancellationTok

private async Task<IReadOnlyList<MessageTemplate>> DownloadTemplatesAsync(CancellationToken cancellationToken)
{
logger.LogInformation("Fetching templates ...");
logger.LogInformation("Fetching templates from server ...");
var result = new List<MessageTemplate>();
var options = new MessageTemplatesListOptions { Count = 100, };
var templates = client.MessageTemplates.ListRecursivelyAsync(options, cancellationToken: cancellationToken);
Expand All @@ -277,4 +359,7 @@ private async Task<IReadOnlyList<MessageTemplate>> DownloadTemplatesAsync(Cancel

return result;
}

[GeneratedRegex("content-([a-zA-Z0-9]{3}).txt")]
private static partial Regex GetTranslatedBodyFileNamePattern();
}