Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions Apps.Contentful/Actions/EntryActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Apps.Contentful.Models.Requests;
using Apps.Contentful.Models.Requests.Tags;
using Apps.Contentful.Models.Responses;
using Apps.Contentful.Services;
using Apps.Contentful.Utils;
using Blackbird.Applications.Sdk.Common;
using Blackbird.Applications.Sdk.Common.Actions;
Expand Down Expand Up @@ -43,6 +44,8 @@ namespace Apps.Contentful.Actions;
public class EntryActions(InvocationContext invocationContext, IFileManagementClient fileManagementClient)
: BaseInvocable(invocationContext)
{
private readonly CustomSizeValidationService _customSizeValidationService = new();

private IEnumerable<AuthenticationCredentialsProvider> Creds =>
InvocationContext.AuthenticationCredentialsProviders;

Expand Down Expand Up @@ -393,18 +396,8 @@ public async Task<DownloadContentOutput> SetEntryLocalizableFieldsFromHtmlFile(
throw new PluginMisconfigurationException("Only .html, .xliff and .xlf files are supported. Please specify a different file");
}

var client = new ContentfulClient(Creds, input.Environment);
var errors = new List<ContentProcessingError>();

var locales = await client.ExecuteWithErrorHandling(async () => await client.GetLocalesCollection());
if (locales.All(x => x.Code != input.Locale))
{
var allLocales = string.Join(", ", locales.Select(x => x.Code));
throw new PluginMisconfigurationException(
$"Locale {input.Locale} not found. Please specify a valid locale. " +
$"Available locales: {allLocales}");
}

var file = await fileManagementClient.DownloadAsync(input.Content);
var content = Encoding.UTF8.GetString(await file.GetByteData());

Expand All @@ -417,6 +410,19 @@ public async Task<DownloadContentOutput> SetEntryLocalizableFieldsFromHtmlFile(
throw new PluginMisconfigurationException("XLIFF did not contain any files");
}

errors.AddRange(_customSizeValidationService.Validate(content, input.Locale, input.SkipCustomValidationStep == true));

var client = new ContentfulClient(Creds, input.Environment);

var locales = await client.ExecuteWithErrorHandling(async () => await client.GetLocalesCollection());
if (locales.All(x => x.Code != input.Locale))
{
var allLocales = string.Join(", ", locales.Select(x => x.Code));
throw new PluginMisconfigurationException(
$"Locale {input.Locale} not found. Please specify a valid locale. " +
$"Available locales: {allLocales}");
}

var mainEntryInfo = EntryToJsonConverter.GetMainEntryInfo(content);
var entriesToUpdate = EntryToJsonConverter.GetEntriesInfo(content);

Expand Down Expand Up @@ -1290,4 +1296,4 @@ private static void ValidateDates(DateTime? after, DateTime? before, string name
}

#endregion
}
}
2 changes: 1 addition & 1 deletion Apps.Contentful/Apps.Contentful.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<Product>Contentful</Product>
<Description>The headless content management system</Description>
<Version>1.8.9</Version>
<Version>1.8.10</Version>
<AssemblyName>Apps.Contentful</AssemblyName>
</PropertyGroup>

Expand Down
3 changes: 3 additions & 0 deletions Apps.Contentful/Models/Requests/Tags/UploadEntryRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,7 @@ public class UploadEntryRequest : EnvironmentIdentifier, IUploadContentInput

[Display("Don't update reference fields")]
public bool? DontUpdateReferenceFields { get; set; }

[Display("Skip custom validation step")]
public bool? SkipCustomValidationStep { get; set; }
}
174 changes: 174 additions & 0 deletions Apps.Contentful/Services/CustomSizeValidationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
using Apps.Contentful.HtmlHelpers.Constants;
using Apps.Contentful.Models;
using Blackbird.Applications.Sdk.Common.Exceptions;
using HtmlAgilityPack;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text.RegularExpressions;
using System.Web;

namespace Apps.Contentful.Services;

public class CustomSizeValidationService
{
public IReadOnlyCollection<ContentProcessingError> Validate(string content, string locale, bool skipValidation)
{
var violations = GetViolations(content);
if (!violations.Any())
{
return [];
}

if (!skipValidation)
{
throw new PluginMisconfigurationException(BuildExceptionMessage(violations, locale));
}

return violations.Select(violation => new ContentProcessingError
{
EntryId = violation.EntryId,
ParentEntryId = null,
ErrorMessage = $"Warning: {BuildViolationMessage(violation, locale)} Contentful may reject this field during upload."
}).ToArray();
}

private static List<CustomSizeConstraintViolation> GetViolations(string content)
{
var doc = new HtmlDocument();
doc.LoadHtml(content);

var nodes = doc.DocumentNode.SelectNodes("//*[@data-blackbird-size and @data-contentful-field-id]");
if (nodes == null)
{
return [];
}

var violations = new List<CustomSizeConstraintViolation>();
foreach (var node in nodes)
{
var rawConstraint = node.GetAttributeValue("data-blackbird-size", string.Empty);
if (!TryGetMaximumSize(rawConstraint, out var maximumLength))
{
continue;
}

var fieldValue = HttpUtility.HtmlDecode(node.InnerText ?? string.Empty);
var actualLength = fieldValue.Length;
if (actualLength <= maximumLength)
{
continue;
}

var fieldId = node.GetAttributeValue(ConvertConstants.FieldIdAttribute, string.Empty);
var entryId = node.AncestorsAndSelf()
.FirstOrDefault(x => x.Attributes[ConvertConstants.EntryIdAttribute] != null)?
.GetAttributeValue(ConvertConstants.EntryIdAttribute, string.Empty) ?? string.Empty;

violations.Add(new(entryId, fieldId, actualLength, maximumLength));
}

return violations;
}

private static bool TryGetMaximumSize(string rawConstraint, out int maximumLength)
{
maximumLength = 0;
if (string.IsNullOrWhiteSpace(rawConstraint))
{
return false;
}

if (int.TryParse(rawConstraint, out maximumLength) && maximumLength > 0)
{
return true;
}

if (TryGetMaximumSizeFromJson(rawConstraint, out maximumLength))
{
return true;
}

var labeledMatch = Regex.Match(rawConstraint, @"(?i)(?:maximumsize|max)\D*(\d+)");
if (labeledMatch.Success &&
int.TryParse(labeledMatch.Groups[1].Value, out maximumLength) &&
maximumLength > 0)
{
return true;
}

var rangeMatch = Regex.Match(rawConstraint, @"^\s*\d+\s*[-:;,]\s*(\d+)\s*$");
if (rangeMatch.Success &&
int.TryParse(rangeMatch.Groups[1].Value, out maximumLength) &&
maximumLength > 0)
{
return true;
}

return false;
}

private static bool TryGetMaximumSizeFromJson(string rawConstraint, out int maximumLength)
{
maximumLength = 0;

if (!rawConstraint.TrimStart().StartsWith('{') && !rawConstraint.TrimStart().StartsWith('['))
{
return false;
}

try
{
var token = JToken.Parse(rawConstraint);
var maximumToken = FindFirstPropertyValue(token, "MaximumSize", "Max");

return maximumToken != null &&
int.TryParse(maximumToken.ToString(), out maximumLength) &&
maximumLength > 0;
}
catch (JsonException)
{
return false;
}
}

private static JToken? FindFirstPropertyValue(JToken token, params string[] propertyNames)
{
if (token is JProperty property &&
propertyNames.Any(name => property.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
{
return property.Value;
}

foreach (var child in token.Children())
{
var result = FindFirstPropertyValue(child, propertyNames);
if (result != null)
{
return result;
}
}

return null;
}

private static string BuildExceptionMessage(IEnumerable<CustomSizeConstraintViolation> violations, string locale)
{
var messages = violations.Select(violation => $"- {BuildViolationMessage(violation, locale)}");

return string.Join(Environment.NewLine,
[
"The translated content violates custom size restrictions from the exported HTML.",
"Fix the values below or set 'Skip custom validation step' to true if you want to continue and receive warnings instead.",
..messages
]);
}

private static string BuildViolationMessage(CustomSizeConstraintViolation violation, string locale)
=> $"Field '{violation.FieldId}' of entry '{violation.EntryId}' violates specified constraint for locale '{locale}'. Actual length: {violation.ActualLength}. Maximum allowed length: {violation.MaximumLength}.";

private sealed record CustomSizeConstraintViolation(
string EntryId,
string FieldId,
int ActualLength,
int MaximumLength);
}
51 changes: 51 additions & 0 deletions Tests.Contentful/CustomSizeValidationServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using Apps.Contentful.Services;
using Blackbird.Applications.Sdk.Common.Exceptions;
using Blackbird.Applications.Sdk.Common.Files;
using System.Text;
using Tests.Contentful.Base;
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;

namespace Tests.Contentful;

[TestClass]
public class CustomSizeValidationServiceTests : TestBase
{
[TestMethod]
public async Task Validate_WithExceededCustomSize_ShouldThrowDetailedException()
{
var content = await ReadInputFile("MyRepairs.Online_en-US_fr-FR.html");
var service = new CustomSizeValidationService();

var exception = ThrowsException<PluginMisconfigurationException>(() =>
service.Validate(content, "fr-FR", false));

StringAssert.Contains(exception.Message, "Field 'description'");
StringAssert.Contains(exception.Message, "entry '3uACdsR62YrTMkBY3T6geU'");
StringAssert.Contains(exception.Message, "Maximum allowed length: 256");
}

[TestMethod]
public async Task Validate_WithExceededCustomSizeAndSkipEnabled_ShouldReturnWarnings()
{
var content = await ReadInputFile("MyRepairs.Online_en-US_fr-FR.html");
var service = new CustomSizeValidationService();

var warnings = service.Validate(content, "fr-FR", true);

AreEqual(1, warnings.Count);
StringAssert.Contains(warnings.Single().ErrorMessage, "Warning:");
StringAssert.Contains(warnings.Single().ErrorMessage, "Field 'description'");
}

private async Task<string> ReadInputFile(string fileName)
{
using var stream = await FileManager.DownloadAsync(new FileReference
{
Name = fileName,
ContentType = "text/html"
});

using var reader = new StreamReader(stream, Encoding.UTF8);
return await reader.ReadToEndAsync();
}
}
28 changes: 27 additions & 1 deletion Tests.Contentful/EntryActionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
using Apps.Contentful.Models.Requests;
using Apps.Contentful.Models.Requests.Tags;
using Blackbird.Applications.Sdk.Common.Exceptions;
using Blackbird.Applications.Sdk.Common.Files;
using Blackbird.Applications.Sdk.Common.Invocation;
using Blackbird.Filters.Coders;
using Newtonsoft.Json;
using Tests.Contentful.Base;
Expand Down Expand Up @@ -252,6 +254,30 @@ public async Task SetEntryLocalizableFieldsFromHtmlFile_MxliffFile_ShouldFailWit
Console.WriteLine(Newtonsoft.Json.JsonConvert.SerializeObject(response));
}

[TestMethod]
public async Task SetEntryLocalizableFieldsFromHtmlFile_WithExceededCustomSize_ShouldFailBeforeContentfulCall()
{
var entryActions = new EntryActions(new InvocationContext(), new FileManager());
var entryIdentifier = new UploadEntryRequest
{
Locale = "fr-FR",
Content = new FileReference
{
Name = "MyRepairs.Online_en-US_fr-FR.html",
ContentType = "text/html"
}
};

var response = await ThrowsExceptionAsync<PluginMisconfigurationException>(
async () => await entryActions.SetEntryLocalizableFieldsFromHtmlFile(entryIdentifier)
);

StringAssert.Contains(response.Message, "Field 'description'");
StringAssert.Contains(response.Message, "entry '3uACdsR62YrTMkBY3T6geU'");
StringAssert.Contains(response.Message, "Maximum allowed length: 256");
Console.WriteLine(JsonConvert.SerializeObject(response, Formatting.Indented));
}

[TestMethod]
public async Task GetEntry_ValidEntryWithoutLocale_ShouldReturnEntryWithTitle()
{
Expand Down Expand Up @@ -682,4 +708,4 @@ public async Task UnpublishEntry_IsSuccess()
// Act
await actions.UnpublishEntry(entry, input);
}
}
}