From f18d018adb6484311f48515f9b75248890864c15 Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Mon, 15 Jun 2026 13:22:30 +0100 Subject: [PATCH] feat: add support for Quartz special characters L, W, and # MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #4 - L in day-of-month: "0 0 L * ?" → "Last day of the month at 12:00 AM" - LW in day-of-month: "0 0 LW * ?" → "Last weekday of the month at 12:00 AM" - W modifier: "0 0 15W * ?" → "Nearest weekday to the 15th of the month at 12:00 AM" - # in day-of-week: "0 0 ? * 2#1" → "Every first Monday of the month at 12:00 AM" - L suffix on day-of-week: "0 0 ? * 2L" → "Last Monday of the month at 12:00 AM" Quartz day-of-week is 1-based (1=Sun…7=Sat); converted to 0-based before looking up in daysMap so day names resolve correctly across all DayNameFormat modes. 10 new tests covering all special character combinations including 6-part Quartz expressions. --- CronCraft.Test/CronHelperTest.cs | 82 ++++++++++++++++++++++++++++++ CronCraft/Extensions/CronHelper.cs | 57 +++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/CronCraft.Test/CronHelperTest.cs b/CronCraft.Test/CronHelperTest.cs index dfd6b68..8ba0447 100644 --- a/CronCraft.Test/CronHelperTest.cs +++ b/CronCraft.Test/CronHelperTest.cs @@ -194,4 +194,86 @@ public void Test_InvalidCronExpressionException_ContainsOffendingExpression() Assert.AreEqual(badCron, ex.CronExpression); } + + // --- Quartz special characters: L, W, # --- + + [TestMethod] + public void Test_L_LastDayOfMonth() + { + var settings = new CronSettings { Language = "en" }; + string result = CronHelper.ToHumanReadable("0 0 L * ?", settings); + Assert.AreEqual("Last day of the month at 12:00 AM", result); + } + + [TestMethod] + public void Test_LW_LastWeekdayOfMonth() + { + var settings = new CronSettings { Language = "en" }; + string result = CronHelper.ToHumanReadable("0 0 LW * ?", settings); + Assert.AreEqual("Last weekday of the month at 12:00 AM", result); + } + + [TestMethod] + public void Test_W_NearestWeekday() + { + var settings = new CronSettings { Language = "en" }; + string result = CronHelper.ToHumanReadable("0 0 15W * ?", settings); + Assert.AreEqual("Nearest weekday to the 15th of the month at 12:00 AM", result); + } + + [TestMethod] + public void Test_W_NearestWeekday_1st() + { + var settings = new CronSettings { Language = "en" }; + string result = CronHelper.ToHumanReadable("0 9 1W * ?", settings); + Assert.AreEqual("Nearest weekday to the 1st of the month at 09:00 AM", result); + } + + [TestMethod] + public void Test_Hash_NthWeekdayOfMonth_FirstMonday() + { + var settings = new CronSettings { Language = "en", DayNameFormat = "full" }; + string result = CronHelper.ToHumanReadable("0 0 ? * 2#1", settings); + Assert.AreEqual("Every first Monday of the month at 12:00 AM", result); + } + + [TestMethod] + public void Test_Hash_NthWeekdayOfMonth_ThirdFriday() + { + var settings = new CronSettings { Language = "en", DayNameFormat = "full" }; + string result = CronHelper.ToHumanReadable("0 0 ? * 6#3", settings); + Assert.AreEqual("Every third Friday of the month at 12:00 AM", result); + } + + [TestMethod] + public void Test_Hash_NthWeekdayOfMonth_SecondWednesday() + { + var settings = new CronSettings { Language = "en", DayNameFormat = "full" }; + string result = CronHelper.ToHumanReadable("0 10 ? * 4#2", settings); + Assert.AreEqual("Every second Wednesday of the month at 10:00 AM", result); + } + + [TestMethod] + public void Test_L_LastDayOfWeek_LastMonday() + { + var settings = new CronSettings { Language = "en", DayNameFormat = "full" }; + string result = CronHelper.ToHumanReadable("0 0 ? * 2L", settings); + Assert.AreEqual("Last Monday of the month at 12:00 AM", result); + } + + [TestMethod] + public void Test_L_LastDayOfWeek_LastFriday() + { + var settings = new CronSettings { Language = "en", DayNameFormat = "full" }; + string result = CronHelper.ToHumanReadable("0 17 ? * 6L", settings); + Assert.AreEqual("Last Friday of the month at 05:00 PM", result); + } + + [TestMethod] + public void Test_Hash_QuartzSixPart_FirstTuesday() + { + var settings = new CronSettings { Language = "en", DayNameFormat = "full" }; + string result = CronHelper.ToHumanReadable("0 0 9 ? * 3#1", settings); + Assert.AreEqual("Every first Tuesday of the month at 09:00 AM", result); + } } diff --git a/CronCraft/Extensions/CronHelper.cs b/CronCraft/Extensions/CronHelper.cs index 241032b..a6760a8 100644 --- a/CronCraft/Extensions/CronHelper.cs +++ b/CronCraft/Extensions/CronHelper.cs @@ -97,6 +97,42 @@ string Phrase(string key, params object[] args) => ? string.Format(value, args) : string.Format(key, args); + // --- Quartz special characters: L, W, # --- + + // day-of-week: 2#1 → "Every first Monday of the month" + // Quartz day-of-week is 1-based (1=Sun…7=Sat); daysMap is 0-based (0=Sun…6=Sat) + if (dayOfWeek.Contains('#')) + { + var nthResult = DescribeNthWeekday(dayOfWeek, daysMap); + return $"{nthResult} {Phrase("AtTime", time)}"; + } + + // day-of-week: 2L → "Last Monday of the month" (Quartz 1-based, convert to 0-based) + if (dayOfWeek.EndsWith('L') && dayOfWeek != "L") + { + var quartzDay = int.TryParse(dayOfWeek.TrimEnd('L'), out var qd) ? qd : -1; + var standardDay = (quartzDay - 1).ToString(); + var dayName = daysMap.TryGetValue(standardDay, out var n) ? n : $"Day {standardDay}"; + return $"Last {dayName} of the month {Phrase("AtTime", time)}"; + } + + // day-of-month: LW → "Last weekday of the month" + if (dayOfMonth.Equals("LW", StringComparison.OrdinalIgnoreCase)) + return $"Last weekday of the month {Phrase("AtTime", time)}"; + + // day-of-month: L → "Last day of the month" + if (dayOfMonth == "L") + return $"Last day of the month {Phrase("AtTime", time)}"; + + // day-of-month: 15W → "Nearest weekday to the 15th" + if (dayOfMonth.EndsWith('W')) + { + var baseDay = Ordinal(dayOfMonth.TrimEnd('W')); + return $"Nearest weekday to the {baseDay} of the month {Phrase("AtTime", time)}"; + } + + // --- Standard patterns --- + if (minute.StartsWith("*/") && hour == "*") return Phrase("EveryXMinutes", minute.Replace("*/", "")); @@ -130,6 +166,26 @@ string Phrase(string key, params object[] args) => return cron; } + // "2#1" → "Every first Monday of the month" (Quartz day 2 = Mon in 1-based, so subtract 1) + // "6#3" → "Every third Friday of the month" + private static string DescribeNthWeekday(string dayOfWeek, Dictionary daysMap) + { + var hashParts = dayOfWeek.Split('#'); + // Quartz uses 1-based day-of-week; daysMap is 0-based + var standardDay = int.TryParse(hashParts[0], out var qd) ? (qd - 1).ToString() : hashParts[0]; + var dayName = daysMap.TryGetValue(standardDay, out var n) ? n : $"Day {standardDay}"; + string occurrence = hashParts[1] switch + { + "1" => "first", + "2" => "second", + "3" => "third", + "4" => "fourth", + "5" => "fifth", + _ => $"{hashParts[1]}th" + }; + return $"Every {occurrence} {dayName} of the month"; + } + private static string ConvertQuartzToCronos(string quartzCron) { string[] parts = quartzCron.Split(' ', StringSplitOptions.RemoveEmptyEntries); @@ -140,6 +196,7 @@ private static string ConvertQuartzToCronos(string quartzCron) string hour = parts[2]; string day = parts[3]; string month = parts[4]; + // Preserve L/#-suffixed values; only replace a bare "?" with "*" string dayOfWeek = parts[5] == "?" ? "*" : parts[5]; return $"{minute} {hour} {day} {month} {dayOfWeek}";