diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/CronDescriber.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/CronDescriber.java new file mode 100644 index 00000000..2b2a2c56 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/CronDescriber.java @@ -0,0 +1,344 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.scheduler; + + +import java.text.ParseException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + + +/******************************************************************************* + ** class to give a human-friendly descriptive string from a cron expression. + ** (written in half by my friend Mr. Chatty G) + *******************************************************************************/ +public class CronDescriber +{ + private static final Map DAY_OF_WEEK_MAP = new HashMap<>(); + private static final Map MONTH_MAP = new HashMap<>(); + + static + { + DAY_OF_WEEK_MAP.put("1", "Sunday"); + DAY_OF_WEEK_MAP.put("2", "Monday"); + DAY_OF_WEEK_MAP.put("3", "Tuesday"); + DAY_OF_WEEK_MAP.put("4", "Wednesday"); + DAY_OF_WEEK_MAP.put("5", "Thursday"); + DAY_OF_WEEK_MAP.put("6", "Friday"); + DAY_OF_WEEK_MAP.put("7", "Saturday"); + + //////////////////////////////// + // Quartz also allows SUN-SAT // + //////////////////////////////// + DAY_OF_WEEK_MAP.put("SUN", "Sunday"); + DAY_OF_WEEK_MAP.put("MON", "Monday"); + DAY_OF_WEEK_MAP.put("TUE", "Tuesday"); + DAY_OF_WEEK_MAP.put("WED", "Wednesday"); + DAY_OF_WEEK_MAP.put("THU", "Thursday"); + DAY_OF_WEEK_MAP.put("FRI", "Friday"); + DAY_OF_WEEK_MAP.put("SAT", "Saturday"); + + MONTH_MAP.put("1", "January"); + MONTH_MAP.put("2", "February"); + MONTH_MAP.put("3", "March"); + MONTH_MAP.put("4", "April"); + MONTH_MAP.put("5", "May"); + MONTH_MAP.put("6", "June"); + MONTH_MAP.put("7", "July"); + MONTH_MAP.put("8", "August"); + MONTH_MAP.put("9", "September"); + MONTH_MAP.put("10", "October"); + MONTH_MAP.put("11", "November"); + MONTH_MAP.put("12", "December"); + + //////////////////////////////// + // Quartz also allows JAN-DEC // + //////////////////////////////// + MONTH_MAP.put("JAN", "January"); + MONTH_MAP.put("FEB", "February"); + MONTH_MAP.put("MAR", "March"); + MONTH_MAP.put("APR", "April"); + MONTH_MAP.put("MAY", "May"); + MONTH_MAP.put("JUN", "June"); + MONTH_MAP.put("JUL", "July"); + MONTH_MAP.put("AUG", "August"); + MONTH_MAP.put("SEP", "September"); + MONTH_MAP.put("OCT", "October"); + MONTH_MAP.put("NOV", "November"); + MONTH_MAP.put("DEC", "December"); + } + + /*************************************************************************** + ** + ***************************************************************************/ + public static String getDescription(String cronExpression) throws ParseException + { + String[] parts = cronExpression.split("\\s+"); + if(parts.length < 6 || parts.length > 7) + { + throw new ParseException("Invalid cron expression: " + cronExpression, 0); + } + + String seconds = parts[0]; + String minutes = parts[1]; + String hours = parts[2]; + String dayOfMonth = parts[3]; + String month = parts[4]; + String dayOfWeek = parts[5]; + String year = parts.length == 7 ? parts[6] : "*"; + + StringBuilder description = new StringBuilder(); + + description.append("At "); + description.append(describeTime(seconds, minutes, hours)); + description.append(", on "); + description.append(describeDayOfMonth(dayOfMonth)); + description.append(" of "); + description.append(describeMonth(month)); + description.append(", "); + description.append(describeDayOfWeek(dayOfWeek)); + if(!year.equals("*")) + { + description.append(", in ").append(year); + } + description.append("."); + + return description.toString(); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private static String describeTime(String seconds, String minutes, String hours) + { + return String.format("%s, %s, %s", describePart(seconds, "second"), describePart(minutes, "minute"), describePart(hours, "hour")); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private static String describeDayOfMonth(String dayOfMonth) + { + if(dayOfMonth.equals("?")) + { + return "every day"; + } + else if(dayOfMonth.equals("L")) + { + return "the last day"; + } + else if(dayOfMonth.contains("W")) + { + return "the nearest weekday to day " + dayOfMonth.replace("W", ""); + } + else + { + return (describePart(dayOfMonth, "day")); + } + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private static String describeMonth(String month) + { + if(month.equals("*")) + { + return "every month"; + } + else + { + String[] months = month.split(","); + StringBuilder result = new StringBuilder(); + for(String m : months) + { + result.append(MONTH_MAP.getOrDefault(m, m)).append(", "); + } + return result.substring(0, result.length() - 2); + } + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private static String describeDayOfWeek(String dayOfWeek) + { + if(dayOfWeek.equals("?")) + { + return "every day of the week"; + } + else if(dayOfWeek.equals("L")) + { + return "the last day of the week"; + } + else if(dayOfWeek.contains("#")) + { + String[] parts = dayOfWeek.split("#"); + return String.format("the %s %s of the month", ordinal(parts[1]), DAY_OF_WEEK_MAP.getOrDefault(parts[0], parts[0])); + } + else if(dayOfWeek.contains("-")) + { + String[] parts = dayOfWeek.split("-"); + return String.format("from %s to %s", DAY_OF_WEEK_MAP.getOrDefault(parts[0], parts[0]), DAY_OF_WEEK_MAP.getOrDefault(parts[1], parts[1])); + } + else + { + String[] days = dayOfWeek.split(","); + StringBuilder result = new StringBuilder(); + for(String d : days) + { + result.append(DAY_OF_WEEK_MAP.getOrDefault(d, d)).append(", "); + } + return result.substring(0, result.length() - 2); + } + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private static String describePart(String part, String label) + { + if(part.equals("*")) + { + return "every " + label; + } + else if(part.contains("/")) + { + String[] parts = part.split("/"); + if(parts[0].equals("*")) + { + parts[0] = "0"; + } + return String.format("every %s " + label + "s starting at %s", parts[1], parts[0]); + } + else if(part.contains(",")) + { + if(label.equals("hour")) + { + String[] parts = part.split(","); + List partList = Arrays.stream(parts).map(p -> hourToAmPm(p)).toList(); + return String.join(", ", partList); + } + else + { + if(label.equals("day")) + { + return "days " + part.replace(",", ", "); + } + else + { + return part.replace(",", ", ") + " " + label + "s"; + } + } + } + else if(part.contains("-")) + { + String[] parts = part.split("-"); + if(label.equals("day")) + { + return String.format("%ss from %s to %s", label, parts[0], parts[1]); + } + else if(label.equals("hour")) + { + return String.format("from %s to %s", hourToAmPm(parts[0]), hourToAmPm(parts[1])); + } + else + { + return String.format("from %s to %s %s", parts[0], parts[1], label + "s"); + } + } + else + { + if(label.equals("day")) + { + return label + " " + part; + } + if(label.equals("hour")) + { + return hourToAmPm(part); + } + else + { + return part + " " + label + "s"; + } + } + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private static String hourToAmPm(String part) + { + try + { + int hour = Integer.parseInt(part); + return switch(hour) + { + case 0 -> "midnight"; + case 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 -> hour + " AM"; + case 12 -> "noon"; + case 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23 -> (hour - 12) + " PM"; + default -> hour + " hours"; + }; + } + catch(Exception e) + { + return part + " hours"; + } + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private static String ordinal(String number) + { + int n = Integer.parseInt(number); + if(n >= 11 && n <= 13) + { + return n + "th"; + } + + return switch(n % 10) + { + case 1 -> n + "st"; + case 2 -> n + "nd"; + case 3 -> n + "rd"; + default -> n + "th"; + }; + } +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/CronExpressionTooltipFieldBehavior.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/CronExpressionTooltipFieldBehavior.java new file mode 100644 index 00000000..bd77f6b4 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/CronExpressionTooltipFieldBehavior.java @@ -0,0 +1,90 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.scheduler; + + +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType; +import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment; +import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldDisplayBehavior; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** Field display behavior, to add a human-redable tooltip to cron-expressions. + *******************************************************************************/ +public class CronExpressionTooltipFieldBehavior implements FieldDisplayBehavior +{ + + /*************************************************************************** + ** Add both this behavior, and the tooltip adornment to a field + ** Note, if either was already there, then that part is left alone. + ***************************************************************************/ + public static void addToField(QFieldMetaData fieldMetaData) + { + CronExpressionTooltipFieldBehavior existingBehavior = fieldMetaData.getBehaviorOnlyIfSet(CronExpressionTooltipFieldBehavior.class); + if(existingBehavior == null) + { + fieldMetaData.withBehavior(new CronExpressionTooltipFieldBehavior()); + } + + if(fieldMetaData.getAdornment(AdornmentType.TOOLTIP).isEmpty()) + { + fieldMetaData.withFieldAdornment((new FieldAdornment(AdornmentType.TOOLTIP) + .withValue(AdornmentType.TooltipValues.TOOLTIP_DYNAMIC, true))); + } + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public void apply(ValueBehaviorApplier.Action action, List recordList, QInstance instance, QTableMetaData table, QFieldMetaData field) + { + for(QRecord record : recordList) + { + try + { + String cronExpression = record.getValueString(field.getName()); + if(StringUtils.hasContent(cronExpression)) + { + String description = CronDescriber.getDescription(cronExpression); + record.setDisplayValue(field.getName() + ":" + AdornmentType.TooltipValues.TOOLTIP_DYNAMIC, description); + } + } + catch(Exception e) + { + ///////////////////// + // just leave null // + ///////////////////// + } + } + } + +} diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/CronDescriberTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/CronDescriberTest.java new file mode 100644 index 00000000..b5801d01 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/CronDescriberTest.java @@ -0,0 +1,68 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.scheduler; + + +import java.text.ParseException; +import com.kingsrook.qqq.backend.core.BaseTest; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +/******************************************************************************* + ** Unit test for CronDescriber + *******************************************************************************/ +class CronDescriberTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() throws ParseException + { + assertEquals("At every second, every minute, every hour, on every day of every month, every day of the week.", CronDescriber.getDescription("* * * * * ?")); + assertEquals("At 0 seconds, every minute, every hour, on every day of every month, every day of the week.", CronDescriber.getDescription("0 * * * * ?")); + assertEquals("At 0 seconds, 0 minutes, every hour, on every day of every month, every day of the week.", CronDescriber.getDescription("0 0 * * * ?")); + assertEquals("At 0 seconds, 0, 30 minutes, every hour, on every day of every month, every day of the week.", CronDescriber.getDescription("0 0,30 * * * ?")); + assertEquals("At 0 seconds, 0 minutes, midnight, on every day of every month, every day of the week.", CronDescriber.getDescription("0 0 0 * * ?")); + assertEquals("At 0 seconds, 0 minutes, 1 AM, on every day of every month, every day of the week.", CronDescriber.getDescription("0 0 1 * * ?")); + assertEquals("At 0 seconds, 0 minutes, 11 AM, on every day of every month, every day of the week.", CronDescriber.getDescription("0 0 11 * * ?")); + assertEquals("At 0 seconds, 0 minutes, noon, on every day of every month, every day of the week.", CronDescriber.getDescription("0 0 12 * * ?")); + assertEquals("At 0 seconds, 0 minutes, 1 PM, on every day of every month, every day of the week.", CronDescriber.getDescription("0 0 13 * * ?")); + assertEquals("At 0 seconds, 0 minutes, 11 PM, on every day of every month, every day of the week.", CronDescriber.getDescription("0 0 23 * * ?")); + assertEquals("At 0 seconds, 0 minutes, midnight, on day 10 of every month, every day of the week.", CronDescriber.getDescription("0 0 0 10 * ?")); + assertEquals("At 0 seconds, 0 minutes, midnight, on days 10, 20 of every month, every day of the week.", CronDescriber.getDescription("0 0 0 10,20 * ?")); + assertEquals("At 0 seconds, 0 minutes, midnight, on days from 10 to 15 of every month, every day of the week.", CronDescriber.getDescription("0 0 0 10-15 * ?")); + assertEquals("At from 10 to 15 seconds, 0 minutes, midnight, on every day of every month, every day of the week.", CronDescriber.getDescription("10-15 0 0 * * ?")); + assertEquals("At 30 seconds, 30 minutes, from 8 AM to 4 PM, on every day of every month, every day of the week.", CronDescriber.getDescription("30 30 8-16 * * ?")); + assertEquals("At 0 seconds, 0 minutes, midnight, on every 3 days starting at 0 of every month, every day of the week.", CronDescriber.getDescription("0 0 0 */3 * ?")); + assertEquals("At every 5 seconds starting at 0, 0 minutes, midnight, on every day of every month, every day of the week.", CronDescriber.getDescription("0/5 0 0 * * ?")); + assertEquals("At 0 seconds, every 30 minutes starting at 3, midnight, on every day of every month, every day of the week.", CronDescriber.getDescription("0 3/30 0 * * ?")); + assertEquals("At 0 seconds, 0 minutes, midnight, on every day of every month, Monday, Wednesday, Friday.", CronDescriber.getDescription("0 0 0 * * MON,WED,FRI")); + assertEquals("At 0 seconds, 0 minutes, midnight, on every day of every month, from Monday to Friday.", CronDescriber.getDescription("0 0 0 * * MON-FRI")); + assertEquals("At 0 seconds, 0 minutes, midnight, on every day of every month, Sunday, Saturday.", CronDescriber.getDescription("0 0 0 * * 1,7")); + assertEquals("At 0 seconds, 0 minutes, 2 AM, 6 AM, noon, 4 PM, 8 PM, on every day of every month, every day of the week.", CronDescriber.getDescription("0 0 2,6,12,16,20 * * ?")); + assertEquals("??", CronDescriber.getDescription("0/5 14,18,3-39,52 * ? JAN,MAR,SEP MON-FRI 2002-2010")); + } + +} \ No newline at end of file diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/CronExpressionTooltipFieldBehaviorTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/CronExpressionTooltipFieldBehaviorTest.java new file mode 100644 index 00000000..26dc8879 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/CronExpressionTooltipFieldBehaviorTest.java @@ -0,0 +1,67 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.scheduler; + + +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.utils.TestUtils; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Unit test for CronExpressionTooltipFieldBehavior + *******************************************************************************/ +class CronExpressionTooltipFieldBehaviorTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() throws QException + { + QFieldMetaData field = new QFieldMetaData("cronExpression", QFieldType.STRING); + QContext.getQInstance().getTable(TestUtils.TABLE_NAME_SHAPE) + .addField(field); + + CronExpressionTooltipFieldBehavior.addToField(field); + + new InsertAction().execute(new InsertInput(TestUtils.TABLE_NAME_SHAPE).withRecord( + new QRecord().withValue("name", "Square").withValue("cronExpression", "* * * * * ?"))); + + QRecord record = new GetAction().executeForRecord(new GetInput(TestUtils.TABLE_NAME_SHAPE).withPrimaryKey(1).withShouldGenerateDisplayValues(true)); + assertThat(record.getDisplayValue("cronExpression:" + AdornmentType.TooltipValues.TOOLTIP_DYNAMIC)) + .contains("every second"); + } + +} \ No newline at end of file