diff --git a/CHANGELOG.md b/CHANGELOG.md index 1349f54c..8fece8aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- [SIL.BuildTasks] Added FileUpdate.FileLocalePattern (optional param) to infer a locale (e.g., for a localized release notes file) to use when doing date insertion involving month names or abbreviations. + ### Changed + +- [SIL.BuildTasks] Changed FileUpdate.DatePlaceholder to allow the caller to specify a special placeholder `_DATE(*)_` that will look not only for `_DATE_` but also variants that include a date format specifier, such as `_DATE(MMM d, yyyy)_` or `_DATE(MM/yyyy)_` and will use the date format specified instead of the DateFormat. - [SIL.BuildTasks.AWS] Changed case of text in log message from "Publishing Sourcefiles" "Publishing SourceFiles". If anything is doing a case-sensitive parse of the log file, looking for this text, this could be a breaking change. ### Deprecated diff --git a/SIL.BuildTasks.Tests/FileUpdateTests.cs b/SIL.BuildTasks.Tests/FileUpdateTests.cs index db9dfdb6..fabdd175 100644 --- a/SIL.BuildTasks.Tests/FileUpdateTests.cs +++ b/SIL.BuildTasks.Tests/FileUpdateTests.cs @@ -1,8 +1,11 @@ -// Copyright (c) 2024 SIL Global +// Copyright (c) 2025 SIL Global // This software is licensed under the MIT License (http://opensource.org/licenses/MIT) using System; using NUnit.Framework; +using SIL.Providers; +using SIL.TestUtilities.Providers; + // Sadly, Resharper wants to change Is.EqualTo to NUnit.Framework.Is.EqualTo // ReSharper disable AccessToStaticMemberViaDerivedType @@ -11,6 +14,19 @@ namespace SIL.BuildTasks.Tests [TestFixture] public class FileUpdateTests { + [SetUp] + public void Setup() + { + DateTimeProvider.SetProvider( + new ReproducibleDateTimeProvider(new DateTime(2026, 4, 16))); + } + + [TearDown] + public void TearDown() + { + DateTimeProvider.ResetToDefault(); + } + [TestCase("This is the story of the frog prince.", "frog", "monkey", ExpectedResult = "This is the story of the monkey prince.")] [TestCase("This is the story of the frog prince.", "f[^ ]+g", "toad", @@ -62,6 +78,106 @@ public void GetModifiedContents_RegexTextNotMatched_Throws(string origContents, Assert.That(ex.Message, Is.EqualTo($"No replacements made. Regex: '{regex}'; ReplacementText: '{replacement}'")); } + [TestCase("_DATE_ _VERSION_\r\nStuff", "_DATE_", "M/yyyy", "4/2026 3.2.1\r\nStuff")] + [TestCase("_DATE_ _VERSION_\r\nStuff done before _DATE_", "_DATE_", "M/yyyy", "4/2026 3.2.1\r\nStuff done before 4/2026")] + [TestCase("&DATE; _VERSION_\r\n- point #1", "&DATE;", "dd-MM-yy", "16-04-26 3.2.1\r\n- point #1")] + [TestCase("DATE _VERSION_", "DATE", "dd MMMM, yyyy", "16 April, 2026 3.2.1")] + [TestCase("DATE _VERSION_", "DATE", null, "16/Apr/2026 3.2.1")] // Uses default date format + public void GetModifiedContents_DateLiteral_InsertsDateWithSpecifiedDateFormat( + string origContents, string datePlaceholder, string dateFormat, + string expectedResult) + { + var updater = new FileUpdate + { + Regex = "_VERSION_", + ReplacementText = "3.2.1", + DatePlaceholder = datePlaceholder, + DateFormat = dateFormat + }; + + var result = updater.GetModifiedContents(origContents); + Assert.That(result, Is.EqualTo(expectedResult)); + } + + [TestCase("_DATE_ _VERSION_\r\nStuff", "M/yyyy", "4/2026 3.2.1\r\nStuff")] + [TestCase("_DATE_ _VERSION_\r\nStuff done before _DATE_", "dd-MM-yy", "16-04-26 3.2.1\r\nStuff done before 16-04-26")] + public void GetModifiedContents_SpecialDatePlaceholderButFileDoesNotSpecifyFormat_InsertsDateWithSpecifiedDateFormat( + string origContents, string dateFormat, string expectedResult) + { + var updater = new FileUpdate + { + Regex = "_VERSION_", + ReplacementText = "3.2.1", + DatePlaceholder = "_DATE(*)_", + DateFormat = dateFormat + }; + + var result = updater.GetModifiedContents(origContents); + Assert.That(result, Is.EqualTo(expectedResult)); + } + + [TestCase("MM-yy")] + [TestCase("dd MMMM")] + public void GetModifiedContents_SpecialDatePlaceholderWithFileSpecifyingFormat_InsertsDateWithFormatFromFile( + string format) + { + var origContents = $"_DATE({format})_\r\nStuff"; + + var updater = new FileUpdate + { + Regex = "(.*)", + ReplacementText = "$1", + DatePlaceholder = "_DATE(*)_", + }; + + var currentDate = DateTimeProvider.Current.UtcNow.ToString(format); + + var result = updater.GetModifiedContents(origContents); + Assert.That(result, Is.EqualTo($"{currentDate}\r\nStuff")); + } + + [TestCase("MM-yyyy", "d MMMM yy")] + [TestCase("dd MMMM", "MM/dd/yyyy")] + public void GetModifiedContents_SpecialDatePlaceholderWithFileSpecifyingMultipleFormats_InsertsDateWithFormatsFromFile( + string format1, string format2) + { + var origContents = $"First _DATE({format1})_\r\nSecond _DATE_\r\nLast _DATE({format2})_"; + + var updater = new FileUpdate + { + Regex = "(.*)", + ReplacementText = "$1", + DatePlaceholder = "_DATE(*)_", + }; + + var currentDate = DateTimeProvider.Current.UtcNow; + var currentDate1 = currentDate.ToString(format1); + var currentDate2 = currentDate.ToString(format2); + + var result = updater.GetModifiedContents(origContents); + Assert.That(result, Is.EqualTo($"First {currentDate1}\r\nSecond 16/Apr/2026\r\nLast {currentDate2}")); + } + + [TestCase("es", "abril")] + [TestCase("fr", "avril")] + public void GetModifiedContents_SpecialDatePlaceholderWithLocalizedFileSpecifyingFormat_InsertsLocaleSpecificDateWithFormatFromFile( + string locale, string localizedMonthName) + { + var origContents = "_DATE(d MMMM yyyy)_\r\nStuff"; + + var updater = new FileUpdate + { + File = $"ReleaseNotes.{locale}.md", + FileLocalePattern = @"\.(?[a-z]{2}(-\w+)?)\.md$", + Regex = "(.*)", + ReplacementText = "$1", + DatePlaceholder = "_DATE(*)_", + }; + + var result = updater.GetModifiedContents(origContents); + Assert.That(result, Is.EqualTo($"16 {localizedMonthName} 2026\r\nStuff")); + } + [Test] public void GetModifiedContents_InvalidRegex_Throws() { @@ -74,5 +190,89 @@ public void GetModifiedContents_InvalidRegex_Throws() var ex = Assert.Throws(() => updater.GetModifiedContents("Whatever")); Assert.That(ex.Message, Is.EqualTo($"Invalid regular expression: parsing \"{updater.Regex}\" - Not enough )'s.")); } + + [Test] + public void FileLocalePattern_InvalidRegex_ThrowsArgumentException() + { + const string expr = @"ReleaseNotes\.(.*\.md"; + Assert.That(() => + { + _ = new FileUpdate + { + FileLocalePattern = expr, + ReplacementText = "oops" + }; + }, Throws.ArgumentException.With.Message.EqualTo($"FileLocalePattern: Invalid regular expression: parsing \"{expr}\" - Not enough )'s.")); + } + + [TestCase("es")] + [TestCase("fr")] + [TestCase("zh-CN")] + public void GetCultureFromFileName_MatchLocaleGroupToKnownCulture_GetsSpecifiedCulture(string localeSpecifier) + { + var fileUpdater = new FileUpdate + { + File = $"ReleaseNotes.{localeSpecifier}.md", + FileLocalePattern = @"\.(?[a-z]{2}(-\w+)?)\.md$", + }; + + Assert.That(fileUpdater.GetCultureFromFileName().IetfLanguageTag, + Is.EqualTo(localeSpecifier)); + } + + [TestCase("zz-Unknown")] + [TestCase("qq-Weird")] + public void GetCultureFromFileName_MatchLocaleGroupToUnknownCulture_ReturnsNull(string localeSpecifier) + { + var fileUpdater = new FileUpdate + { + File = $"ReleaseNotes.{localeSpecifier}.md", + FileLocalePattern = @"\.(?[a-z]{2}(-\w+)?)\.md$", + }; + + Assert.That(fileUpdater.GetCultureFromFileName(), Is.Null); + } + + [TestCase("es")] + [TestCase("fr-FR")] + [TestCase("de")] + public void GetCultureFromFileName_EntireMatchIsKnownCulture_GetsSpecifiedCulture(string localeSpecifier) + { + var fileUpdater = new FileUpdate + { + File = $"ReleaseNotes.{localeSpecifier}.md", + FileLocalePattern = @"(?<=\.)es|fr-FR|de(?=\.)", + }; + + Assert.That(fileUpdater.GetCultureFromFileName().IetfLanguageTag, + Is.EqualTo(localeSpecifier)); + } + + [TestCase("My.bat.ate.your.homework.md", @"(?<=\.)[a-z]{4}(?=\.)")] + [TestCase("ReleaseNotes.htm", ".+")] + public void GetCultureFromFileName_EntireMatchIsUnknownCulture_ReturnsNull(string fileName, string pattern) + { + var fileUpdater = new FileUpdate + { + File = fileName, + FileLocalePattern = pattern, + }; + + Assert.That(fileUpdater.GetCultureFromFileName(), Is.Null); + } + + [TestCase("My.bat.ate.your.homework.md", @"(?<=\.)[a-z]{22}(?=\.)")] + [TestCase("ReleaseNotes.htm", @"(?<=\.)es|fr-FR|de(?=\.)")] + [TestCase("ReleaseNotes.htm", @"\.(?[a-z]{2}(-\w+)?)\.md$")] + public void GetCultureFromFileName_NoMatch_ReturnsNull(string fileName, string pattern) + { + var fileUpdater = new FileUpdate + { + File = fileName, + FileLocalePattern = pattern, + }; + + Assert.That(fileUpdater.GetCultureFromFileName(), Is.Null); + } } } diff --git a/SIL.BuildTasks.Tests/SIL.BuildTasks.Tests.csproj b/SIL.BuildTasks.Tests/SIL.BuildTasks.Tests.csproj index b22cf5aa..1f34b6e2 100644 --- a/SIL.BuildTasks.Tests/SIL.BuildTasks.Tests.csproj +++ b/SIL.BuildTasks.Tests/SIL.BuildTasks.Tests.csproj @@ -23,6 +23,7 @@ + diff --git a/SIL.BuildTasks/FileUpdate.cs b/SIL.BuildTasks/FileUpdate.cs index 4f8b45ce..4af317d6 100644 --- a/SIL.BuildTasks/FileUpdate.cs +++ b/SIL.BuildTasks/FileUpdate.cs @@ -2,10 +2,15 @@ // This software is licensed under the MIT License (http://opensource.org/licenses/MIT) using System; using System.Diagnostics; +using System.Globalization; +using System.IO; using System.Text.RegularExpressions; using JetBrains.Annotations; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; +using SIL.Providers; +using static System.IO.File; +using static System.Text.RegularExpressions.RegexOptions; namespace SIL.BuildTasks { @@ -13,6 +18,7 @@ namespace SIL.BuildTasks public class FileUpdate : Task { private string _dateFormat; + private Regex _localeRegex; [Required] public string File { get; set; } @@ -24,12 +30,17 @@ public class FileUpdate : Task public string ReplacementText { get; set; } /// - /// The string pattern to replace with the current date (UTC, dd/MMM/yyyy) + /// The string pattern to replace with the current date. If this is specified as + /// `_DATE(*)_`, then this will be treated as a regex that will match `_DATE_` as well as + /// any string that matches _DATE?<?dateFormat?>([dMy/:.,\-\s']+)_, in which case + /// the `dateFormat` match group will be used to format the date instead of + /// . /// public string DatePlaceholder { get; set; } /// - /// The date format to output (default is dd/MMM/yyyy) + /// Default date format, used unless the finds a match that + /// specifies an alternate format. /// public string DateFormat { @@ -37,19 +48,46 @@ public string DateFormat set => _dateFormat = value; } + /// + /// Optional regex pattern with a named group 'locale' to extract the locale from the + /// filename. + /// Example: @"\.(?<locale>[a-z]{2}(-\w+)?)\.md$" to match "es", "fr", "zh-CN", etc., + /// between dots and preceding the final md (markdown) extension. + /// If there is no named group 'locale', then the entire match will be treated + /// as the locale. + /// Example: @"(?<=\.).es|fr|de(?=\.)" + /// + /// The given pattern is not a well-formed regular expression + /// If the pattern matches more than once, the first match will be used. + public string FileLocalePattern + { + get => _localeRegex?.ToString(); + set + { + try + { + _localeRegex = string.IsNullOrEmpty(value) ? null : new Regex(value); + } + catch (ArgumentException e) + { + throw new ArgumentException("FileLocalePattern: Invalid regular expression: " + e.Message, e); + } + } + } + public override bool Execute() { try { - var content = System.IO.File.ReadAllText(File); + var content = ReadAllText(File); var newContents = GetModifiedContents(content); - System.IO.File.WriteAllText(File, newContents); + WriteAllText(File, newContents); return true; } catch (Exception e) { Console.WriteLine(e); - Debug.WriteLine(e.Message); + Debug.WriteLine(e); SafeLogError(e.Message); return false; } @@ -80,7 +118,28 @@ internal string GetModifiedContents(string content) var newContents = regex.Replace(content, ReplacementText); if (!string.IsNullOrEmpty(DatePlaceholder)) - newContents = newContents.Replace(DatePlaceholder, DateTime.UtcNow.Date.ToString(DateFormat)); + { + var culture = GetCultureFromFileName() ?? CultureInfo.CurrentCulture; + var currentDate = DateTimeProvider.Current.UtcNow.Date; + + if (DatePlaceholder.Equals("_DATE(*)_", StringComparison.Ordinal)) + { + var dateRegex = new Regex( + @"_DATE(\((?[dMy\/:.,\-\s'M]+)\))?_", Compiled); + newContents = dateRegex.Replace(newContents, m => + { + var format = m.Groups["dateFormat"].Success + ? m.Groups["dateFormat"].Value + : DateFormat; + return currentDate.ToString(format, culture); + }); + } + else + { + var formattedDate = currentDate.ToString(DateFormat, culture); + newContents = newContents.Replace(DatePlaceholder, formattedDate); + } + } return newContents; } @@ -90,6 +149,36 @@ internal string GetModifiedContents(string content) } } + internal CultureInfo GetCultureFromFileName() + { + if (_localeRegex == null) + return null; + + var fileName = Path.GetFileName(File); + + try + { + var match = _localeRegex.Match(fileName); + if (match.Success) + { + var locale = match.Groups["locale"].Success + ? match.Groups["locale"].Value + : match.Value; + return new CultureInfo(locale); + } + } + catch (CultureNotFoundException) + { + } + catch (Exception ex) + { + SafeLogError( + $"Failed to extract locale from filename using pattern '{FileLocalePattern}': {ex.Message}"); + } + + return null; + } + private void SafeLogError(string msg) { try @@ -98,8 +187,8 @@ private void SafeLogError(string msg) } catch (Exception) { - //swallow... logging fails in the unit test environment, where the log isn't really set up + //swallow... logging fails in the unit test environment, where the log isn't set up } } } -} \ No newline at end of file +} diff --git a/SIL.BuildTasks/SIL.BuildTasks.csproj b/SIL.BuildTasks/SIL.BuildTasks.csproj index 64235b9d..39db4590 100644 --- a/SIL.BuildTasks/SIL.BuildTasks.csproj +++ b/SIL.BuildTasks/SIL.BuildTasks.csproj @@ -12,6 +12,7 @@ +