From 6dcc0847e2089aeec1320d2a9227591c8767baf0 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 24 Sep 2026 16:03:52 +0200 Subject: feat: model the calendar's repeat control as RRULE text --- src/repeatrule.cpp | 255 +++++++++++++++++++++++++++++++++++++++++++++- tests/test_repeatrule.cpp | 110 ++++++++++++++++++++ 2 files changed, 361 insertions(+), 4 deletions(-) diff --git a/src/repeatrule.cpp b/src/repeatrule.cpp index df16c5e..cc11c91 100644 --- a/src/repeatrule.cpp +++ b/src/repeatrule.cpp @@ -18,7 +18,254 @@ #include "repeatrule.h" -RepeatRule RepeatRule::fromRRule(const QString &, const QDate &) { return {}; } -QString RepeatRule::toRRule(const QString &) const { return {}; } -QString RepeatRule::describe() const { return {}; } -bool RepeatRule::operator==(const RepeatRule &) const { return false; } +#include +#include +#include + +#include + +namespace { + +// iCalendar weekday codes, indexed by Qt::DayOfWeek - 1. +const char *const kDays[] = { "MO", "TU", "WE", "TH", "FR", "SA", "SU" }; + +int dayFromCode(const QString &code) +{ + for (int i = 0; i < 7; ++i) + if (code == QLatin1String(kDays[i])) + return i + 1; + return 0; +} + +/// Splits "-1FR" into ordinal -1 and Friday. A plain "FR" gives ordinal 0. +bool splitByDay(const QString &token, int *ordinal, int *day) +{ + if (token.size() < 2) + return false; + *day = dayFromCode(token.right(2)); + const QString head = token.left(token.size() - 2); + bool ok = true; + *ordinal = head.isEmpty() ? 0 : head.toInt(&ok); + return *day != 0 && ok; +} + +// Array literals have no enclosing class, so the context must be named on each +// literal (AGENTS.md: QT_TR_NOOP here compiles and extracts nothing). +const char *const kOrdinals[] = { + QT_TRANSLATE_NOOP("RepeatRule", "first"), + QT_TRANSLATE_NOOP("RepeatRule", "second"), + QT_TRANSLATE_NOOP("RepeatRule", "third"), + QT_TRANSLATE_NOOP("RepeatRule", "fourth"), +}; + +QString tr(const char *text) { return QCoreApplication::translate("RepeatRule", text); } + +QString ordinalWord(int ordinal) +{ + if (ordinal == -1) + return tr("last"); + return (ordinal >= 1 && ordinal <= 4) ? tr(kOrdinals[ordinal - 1]) : QString(); +} + +} // namespace + +RepeatRule RepeatRule::fromRRule(const QString &value, const QDate &start) +{ + RepeatRule rule; + auto markCustom = [&]() { + RepeatRule custom; + custom.custom = true; + custom.customText = value; + return custom; + }; + + QMap parts; + for (const QString &pair : value.split(QLatin1Char(';'), Qt::SkipEmptyParts)) { + const int eq = pair.indexOf(QLatin1Char('=')); + if (eq <= 0) + return markCustom(); + parts.insert(pair.left(eq).toUpper(), pair.mid(eq + 1).toUpper()); + } + + const QString freq = parts.take(QStringLiteral("FREQ")); + if (freq == QLatin1String("DAILY")) rule.freq = Freq::Daily; + else if (freq == QLatin1String("WEEKLY")) rule.freq = Freq::Weekly; + else if (freq == QLatin1String("MONTHLY")) rule.freq = Freq::Monthly; + else if (freq == QLatin1String("YEARLY")) rule.freq = Freq::Yearly; + else return markCustom(); + + if (parts.contains(QStringLiteral("INTERVAL"))) { + rule.interval = parts.take(QStringLiteral("INTERVAL")).toInt(); + if (rule.interval < 1) + return markCustom(); + } + if (parts.contains(QStringLiteral("COUNT"))) { + rule.end = End::Count; + rule.count = parts.take(QStringLiteral("COUNT")).toInt(); + if (rule.count < 1) + return markCustom(); + } + if (parts.contains(QStringLiteral("UNTIL"))) { + const QString until = parts.take(QStringLiteral("UNTIL")); + rule.end = End::Until; + rule.until = QDate::fromString(until.left(8), QStringLiteral("yyyyMMdd")); + if (!rule.until.isValid()) + return markCustom(); + } + rule.wkst = parts.take(QStringLiteral("WKST")); + + const QStringList byDay = parts.contains(QStringLiteral("BYDAY")) + ? parts.take(QStringLiteral("BYDAY")).split(QLatin1Char(',')) : QStringList(); + const QStringList byMonthDay = parts.contains(QStringLiteral("BYMONTHDAY")) + ? parts.take(QStringLiteral("BYMONTHDAY")).split(QLatin1Char(',')) : QStringList(); + const QStringList byMonth = parts.contains(QStringLiteral("BYMONTH")) + ? parts.take(QStringLiteral("BYMONTH")).split(QLatin1Char(',')) : QStringList(); + + if (!parts.isEmpty()) // BYSETPOS, BYWEEKNO, BYHOUR, ... : outside the table. + return markCustom(); + + switch (rule.freq) { + case Freq::Daily: + if (!byDay.isEmpty() || !byMonthDay.isEmpty() || !byMonth.isEmpty()) + return markCustom(); + break; + case Freq::Weekly: + if (!byMonthDay.isEmpty() || !byMonth.isEmpty()) + return markCustom(); + for (const QString &token : byDay) { + int ordinal = 0, day = 0; + if (!splitByDay(token, &ordinal, &day) || ordinal != 0) + return markCustom(); + rule.weekdays.append(day); + } + if (rule.weekdays.isEmpty()) + rule.weekdays.append(start.dayOfWeek()); + std::sort(rule.weekdays.begin(), rule.weekdays.end()); + break; + case Freq::Monthly: + case Freq::Yearly: { + const bool yearly = rule.freq == Freq::Yearly; + if (!byMonthDay.isEmpty() && !byDay.isEmpty()) + return markCustom(); + if (!byDay.isEmpty()) { + int ordinal = 0, day = 0; + if (byDay.size() != 1 || !splitByDay(byDay.first(), &ordinal, &day) + || !(ordinal == -1 || (ordinal >= 1 && ordinal <= 4))) + return markCustom(); + if (yearly && byMonth.size() != 1) + return markCustom(); + rule.by = By::Weekday; + rule.ordinal = ordinal; + rule.weekday = day; + if (yearly) + rule.month = byMonth.first().toInt(); + } else if (yearly) { + if (!byMonth.isEmpty() || !byMonthDay.isEmpty()) + return markCustom(); // "on the start date" writes neither + } else { + if (!byMonth.isEmpty() || byMonthDay.size() > 1) + return markCustom(); + rule.monthDay = byMonthDay.isEmpty() ? start.day() : byMonthDay.first().toInt(); + if (rule.monthDay < 1 || rule.monthDay > 31) + return markCustom(); + } + break; + } + case Freq::None: + break; + } + return rule; +} + +QString RepeatRule::toRRule(const QString &untilValue) const +{ + if (custom) + return customText; + if (freq == Freq::None) + return {}; + + QStringList parts; + static const char *const kFreq[] = { "", "DAILY", "WEEKLY", "MONTHLY", "YEARLY" }; + parts << QStringLiteral("FREQ=%1").arg(QLatin1String(kFreq[static_cast(freq)])); + if (interval > 1) + parts << QStringLiteral("INTERVAL=%1").arg(interval); + + auto byDayToken = [](int ordinal, int day) { + return (ordinal ? QString::number(ordinal) : QString()) + QLatin1String(kDays[day - 1]); + }; + if (freq == Freq::Weekly && !weekdays.isEmpty()) { + QStringList days; + for (int d : weekdays) + days << QLatin1String(kDays[d - 1]); + parts << QStringLiteral("BYDAY=%1").arg(days.join(QLatin1Char(','))); + } else if (freq == Freq::Monthly) { + if (by == By::Weekday) + parts << QStringLiteral("BYDAY=%1").arg(byDayToken(ordinal, weekday)); + else + parts << QStringLiteral("BYMONTHDAY=%1").arg(monthDay); + } else if (freq == Freq::Yearly && by == By::Weekday) { + parts << QStringLiteral("BYMONTH=%1").arg(month) + << QStringLiteral("BYDAY=%1").arg(byDayToken(ordinal, weekday)); + } + + if (end == End::Count) + parts << QStringLiteral("COUNT=%1").arg(count); + else if (end == End::Until) + parts << QStringLiteral("UNTIL=%1").arg(untilValue); + if (!wkst.isEmpty()) + parts << QStringLiteral("WKST=%1").arg(wkst); + return parts.join(QLatin1Char(';')); +} + +QString RepeatRule::describe() const +{ + if (custom) + return tr("Custom rule"); + if (freq == Freq::None) + return tr("Does not repeat"); + + const QLocale locale; + QString text; + switch (freq) { + case Freq::Daily: + text = interval == 1 ? tr("Daily") : tr("Every %1 days").arg(interval); + break; + case Freq::Weekly: { + QStringList days; + for (int d : weekdays) + days << locale.dayName(d, QLocale::ShortFormat); + text = (interval == 1 ? tr("Weekly") : tr("Every %1 weeks").arg(interval)) + + tr(", on %1").arg(days.join(QStringLiteral(", "))); + break; + } + case Freq::Monthly: + text = interval == 1 ? tr("Monthly") : tr("Every %1 months").arg(interval); + text += by == By::Weekday + ? tr(", on the %1 %2").arg(ordinalWord(ordinal), locale.dayName(weekday)) + : tr(", on day %1").arg(monthDay); + break; + case Freq::Yearly: + text = interval == 1 ? tr("Yearly") : tr("Every %1 years").arg(interval); + if (by == By::Weekday) + text += tr(", on the %1 %2 of %3") + .arg(ordinalWord(ordinal), locale.dayName(weekday), + locale.monthName(month)); + break; + case Freq::None: + break; + } + if (end == End::Count) + text += tr(", %1 times").arg(count); + else if (end == End::Until) + text += tr(", until %1").arg(locale.toString(until, QLocale::ShortFormat)); + return text; +} + +bool RepeatRule::operator==(const RepeatRule &o) const +{ + return freq == o.freq && interval == o.interval && weekdays == o.weekdays + && by == o.by && monthDay == o.monthDay && ordinal == o.ordinal + && weekday == o.weekday && month == o.month && end == o.end + && until == o.until && count == o.count && wkst == o.wkst + && custom == o.custom && customText == o.customText; +} diff --git a/tests/test_repeatrule.cpp b/tests/test_repeatrule.cpp index 3171296..8962d14 100644 --- a/tests/test_repeatrule.cpp +++ b/tests/test_repeatrule.cpp @@ -23,7 +23,117 @@ class TestRepeatRule : public QObject { Q_OBJECT + +private slots: + void roundTripsEveryRowOfTheTable_data(); + void roundTripsEveryRowOfTheTable(); + void fillsWhatAnRruleLeavesImplicit(); + void recognisesACustomRule_data(); + void recognisesACustomRule(); + void keepsWkst(); + void readsUntilInEveryForm(); + void describesInWords(); }; +void TestRepeatRule::roundTripsEveryRowOfTheTable_data() +{ + QTest::addColumn("rrule"); + // Each row of the spec's repeat table, written the way toRRule writes it. + QTest::newRow("daily") << QStringLiteral("FREQ=DAILY"); + QTest::newRow("every 3 days") << QStringLiteral("FREQ=DAILY;INTERVAL=3"); + QTest::newRow("weekly on days") << QStringLiteral("FREQ=WEEKLY;BYDAY=MO,WE,FR"); + QTest::newRow("monthly on day") << QStringLiteral("FREQ=MONTHLY;BYMONTHDAY=18"); + QTest::newRow("monthly last friday") << QStringLiteral("FREQ=MONTHLY;BYDAY=-1FR"); + QTest::newRow("monthly 2nd tuesday") << QStringLiteral("FREQ=MONTHLY;INTERVAL=2;BYDAY=2TU"); + QTest::newRow("yearly") << QStringLiteral("FREQ=YEARLY"); + QTest::newRow("yearly last fri of sep") << QStringLiteral("FREQ=YEARLY;BYMONTH=9;BYDAY=-1FR"); + QTest::newRow("count") << QStringLiteral("FREQ=MONTHLY;BYMONTHDAY=18;COUNT=12"); + QTest::newRow("until date") << QStringLiteral("FREQ=YEARLY;UNTIL=20301231"); +} + +void TestRepeatRule::roundTripsEveryRowOfTheTable() +{ + QFETCH(QString, rrule); + const QDate start(2026, 9, 18); + const RepeatRule rule = RepeatRule::fromRRule(rrule, start); + QVERIFY2(!rule.custom, qPrintable(rrule)); + // UNTIL in the "until date" row is a DATE, so the all-day form (no suffix). + QCOMPARE(rule.toRRule(rule.end == RepeatRule::End::Until + ? rule.until.toString(QStringLiteral("yyyyMMdd")) + : QString()), + rrule); + QCOMPARE(RepeatRule::fromRRule(rule.toRRule(rule.until.toString(QStringLiteral("yyyyMMdd"))), + start), rule); +} + +void TestRepeatRule::fillsWhatAnRruleLeavesImplicit() +{ + // 2026-09-18 is a Friday. + const RepeatRule weekly = RepeatRule::fromRRule(QStringLiteral("FREQ=WEEKLY"), QDate(2026, 9, 18)); + QCOMPARE(weekly.weekdays, QList{ Qt::Friday }); + const RepeatRule monthly = RepeatRule::fromRRule(QStringLiteral("FREQ=MONTHLY"), QDate(2026, 9, 18)); + QCOMPARE(monthly.by, RepeatRule::By::MonthDay); + QCOMPARE(monthly.monthDay, 18); +} + +void TestRepeatRule::recognisesACustomRule_data() +{ + QTest::addColumn("rrule"); + QTest::newRow("bysetpos") << QStringLiteral("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1"); + QTest::newRow("byweekno") << QStringLiteral("FREQ=YEARLY;BYWEEKNO=20"); + QTest::newRow("hourly") << QStringLiteral("FREQ=HOURLY"); + QTest::newRow("two months") << QStringLiteral("FREQ=YEARLY;BYMONTH=3,9;BYDAY=-1FR"); + QTest::newRow("two monthdays") << QStringLiteral("FREQ=MONTHLY;BYMONTHDAY=1,15"); + QTest::newRow("weekly ordinal") << QStringLiteral("FREQ=WEEKLY;BYDAY=1MO"); + QTest::newRow("fifth monday") << QStringLiteral("FREQ=MONTHLY;BYDAY=5MO"); +} + +void TestRepeatRule::recognisesACustomRule() +{ + QFETCH(QString, rrule); + const RepeatRule rule = RepeatRule::fromRRule(rrule, QDate(2026, 9, 18)); + QVERIFY(rule.custom); + // An edit never rewrites a custom rule: it comes back exactly. + QCOMPARE(rule.toRRule(), rrule); +} + +void TestRepeatRule::keepsWkst() +{ + const RepeatRule rule = RepeatRule::fromRRule(QStringLiteral("FREQ=YEARLY;WKST=TU"), QDate(2026, 9, 18)); + QVERIFY(!rule.custom); + QCOMPARE(rule.toRRule(), QStringLiteral("FREQ=YEARLY;WKST=TU")); +} + +void TestRepeatRule::readsUntilInEveryForm() +{ + const QDate start(2026, 9, 18); + QCOMPARE(RepeatRule::fromRRule(QStringLiteral("FREQ=DAILY;UNTIL=20261231"), start).until, + QDate(2026, 12, 31)); + QCOMPARE(RepeatRule::fromRRule(QStringLiteral("FREQ=DAILY;UNTIL=20261231T120000"), start).until, + QDate(2026, 12, 31)); + // A UTC UNTIL keeps its UTC date: the caller writes 23:59:59 local as UTC, + // which in a zone east of UTC is still the same date. + QCOMPARE(RepeatRule::fromRRule(QStringLiteral("FREQ=DAILY;UNTIL=20261231T215959Z"), start).until, + QDate(2026, 12, 31)); + QCOMPARE(RepeatRule::fromRRule(QStringLiteral("FREQ=DAILY;UNTIL=20261231"), start).end, + RepeatRule::End::Until); +} + +void TestRepeatRule::describesInWords() +{ + QLocale::setDefault(QLocale::c()); + const QDate start(2026, 9, 18); + QCOMPARE(RepeatRule::fromRRule(QStringLiteral("FREQ=DAILY"), start).describe(), + QStringLiteral("Daily")); + QCOMPARE(RepeatRule::fromRRule(QStringLiteral("FREQ=MONTHLY;BYDAY=-1FR"), start).describe(), + QStringLiteral("Monthly, on the last Friday")); + QCOMPARE(RepeatRule::fromRRule(QStringLiteral("FREQ=MONTHLY;BYMONTHDAY=18;COUNT=12"), start).describe(), + QStringLiteral("Monthly, on day 18, 12 times")); + QCOMPARE(RepeatRule::fromRRule(QStringLiteral("FREQ=YEARLY;BYMONTH=9;BYDAY=-1FR"), start).describe(), + QStringLiteral("Yearly, on the last Friday of September")); + QCOMPARE(RepeatRule::fromRRule(QStringLiteral("FREQ=YEARLY;BYWEEKNO=20"), start).describe(), + QStringLiteral("Custom rule")); +} + QTEST_MAIN(TestRepeatRule) #include "test_repeatrule.moc" -- cgit v1.2.3