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 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 251 insertions(+), 4 deletions(-) (limited to 'src/repeatrule.cpp') 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; +} -- cgit v1.2.3