diff --git a/framework/src/domain/eventoccurrencemodel.cpp b/framework/src/domain/eventoccurrencemodel.cpp index 7776188e..98f6c5b8 100644 --- a/framework/src/domain/eventoccurrencemodel.cpp +++ b/framework/src/domain/eventoccurrencemodel.cpp @@ -1,246 +1,264 @@ /* Copyright (c) 2018 Michael Bohlender Copyright (c) 2018 Christian Mollekopf Copyright (c) 2018 Rémi Nicole This library is free software; you can redistribute it and/or modify it under the terms of the GNU Library General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This library 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 Library General Public License for more details. You should have received a copy of the GNU Library General Public License along with this library; see the file COPYING.LIB. If not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ #include "eventoccurrencemodel.h" #include #include #include #include #include #include #include #include #include using namespace Sink; EventOccurrenceModel::EventOccurrenceModel(QObject *parent) : QAbstractItemModel(parent), - mCalendarCache{EntityCache::Ptr::create(QByteArrayList{{ApplicationDomain::Calendar::Color::name}})}, - mCalendar{new KCalCore::MemoryCalendar{QTimeZone::systemTimeZone()}} + mCalendarCache{EntityCache::Ptr::create(QByteArrayList{{ApplicationDomain::Calendar::Color::name}})} { mRefreshTimer.setSingleShot(true); QObject::connect(&mRefreshTimer, &QTimer::timeout, this, &EventOccurrenceModel::updateFromSource); } void EventOccurrenceModel::setStart(const QDate &start) { if (start != mStart) { mStart = start; updateQuery(); } } QDate EventOccurrenceModel::start() const { return mStart; } void EventOccurrenceModel::setLength(int length) { mLength = length; updateQuery(); } int EventOccurrenceModel::length() const { return mLength; } void EventOccurrenceModel::setCalendarFilter(const QSet &calendarFilter) { mCalendarFilter = calendarFilter; updateQuery(); } void EventOccurrenceModel::setFilter(const QVariantMap &filter) { mFilter = filter; updateQuery(); } void EventOccurrenceModel::updateQuery() { using namespace Sink::ApplicationDomain; if (mCalendarFilter.isEmpty() || !mLength || !mStart.isValid()) { refreshView(); return; } mEnd = mStart.addDays(mLength); Sink::Query query; query.setFlags(Sink::Query::LiveQuery); query.request(); query.request(); query.request(); query.request(); query.request(); query.request(); query.request(); query.filter(Sink::Query::Comparator(QVariantList{mStart, mEnd}, Sink::Query::Comparator::Overlap)); mSourceModel = Store::loadModel(query); QObject::connect(mSourceModel.data(), &QAbstractItemModel::dataChanged, this, &EventOccurrenceModel::refreshView); QObject::connect(mSourceModel.data(), &QAbstractItemModel::layoutChanged, this, &EventOccurrenceModel::refreshView); QObject::connect(mSourceModel.data(), &QAbstractItemModel::modelReset, this, &EventOccurrenceModel::refreshView); QObject::connect(mSourceModel.data(), &QAbstractItemModel::rowsInserted, this, &EventOccurrenceModel::refreshView); QObject::connect(mSourceModel.data(), &QAbstractItemModel::rowsMoved, this, &EventOccurrenceModel::refreshView); QObject::connect(mSourceModel.data(), &QAbstractItemModel::rowsRemoved, this, &EventOccurrenceModel::refreshView); refreshView(); } void EventOccurrenceModel::refreshView() { if (!mRefreshTimer.isActive()) { //Instant update, but then only refresh every 50ms max. updateFromSource(); mRefreshTimer.start(50); } } void EventOccurrenceModel::updateFromSource() { beginResetModel(); mEvents.clear(); if (mSourceModel) { + QMap recurringEvents; + QMultiMap exceptions; + QMap> events; for (int i = 0; i < mSourceModel->rowCount(); ++i) { auto event = mSourceModel->index(i, 0).data(Sink::Store::DomainObjectRole).value(); const bool skip = [&] { if (!mCalendarFilter.contains(event->getCalendar())) { return true; } for (auto it = mFilter.constBegin(); it!= mFilter.constEnd(); it++) { if (event->getProperty(it.key().toLatin1()) != it.value()) { return true; } } return false; }(); if (skip) { continue; } //Parse the event auto icalEvent = KCalCore::ICalFormat().readIncidence(event->getIcal()).dynamicCast(); if(!icalEvent) { SinkWarning() << "Invalid ICal to process, ignoring..."; continue; } + //Collect recurring events and add the rest immediately if (icalEvent->recurs()) { - KCalCore::OccurrenceIterator occurrenceIterator{*mCalendar, icalEvent, QDateTime{mStart, {0, 0, 0}}, QDateTime{mEnd, {12, 59, 59}}}; - while (occurrenceIterator.hasNext()) { - occurrenceIterator.next(); - const auto start = occurrenceIterator.occurrenceStartDate(); - const auto end = icalEvent->endDateForStart(start); - if (start.date() < mEnd && end.date() >= mStart) { - mEvents.append({start, end, occurrenceIterator.incidence(), getColor(event->getCalendar()), event->getAllDay(), event}); - } - } + recurringEvents.insert(icalEvent->uid().toLatin1(), icalEvent); + events.insert(icalEvent->instanceIdentifier().toLatin1(), event); + } else if(icalEvent->recurrenceId().isValid()) { + exceptions.insert(icalEvent->uid().toLatin1(), icalEvent); + events.insert(icalEvent->instanceIdentifier().toLatin1(), event); } else { if (icalEvent->dtStart().date() < mEnd && icalEvent->dtEnd().date() >= mStart) { mEvents.append({icalEvent->dtStart(), icalEvent->dtEnd(), icalEvent, getColor(event->getCalendar()), event->getAllDay(), event}); } } } + //process all recurring events and their exceptions. + for (const auto &uid : recurringEvents.keys()) { + KCalCore::MemoryCalendar calendar{QTimeZone::systemTimeZone()}; + calendar.addIncidence(recurringEvents.value(uid)); + for (const auto &event : exceptions.values(uid)) { + calendar.addIncidence(event); + } + KCalCore::OccurrenceIterator occurrenceIterator{calendar, QDateTime{mStart, {0, 0, 0}}, QDateTime{mEnd, {12, 59, 59}}}; + while (occurrenceIterator.hasNext()) { + occurrenceIterator.next(); + const auto incidence = occurrenceIterator.incidence(); + const auto event = events.value(incidence->instanceIdentifier().toLatin1()); + const auto start = occurrenceIterator.occurrenceStartDate(); + const auto end = incidence->endDateForStart(start); + if (start.date() < mEnd && end.date() >= mStart) { + mEvents.append({start, end, incidence, getColor(event->getCalendar()), event->getAllDay(), event}); + } + } + } } endResetModel(); } QModelIndex EventOccurrenceModel::index(int row, int column, const QModelIndex &parent) const { if (!hasIndex(row, column, parent)) { return {}; } if (!parent.isValid()) { return createIndex(row, column); } return {}; } QModelIndex EventOccurrenceModel::parent(const QModelIndex &) const { return {}; } int EventOccurrenceModel::rowCount(const QModelIndex &parent) const { if (!parent.isValid()) { return mEvents.size(); } return 0; } int EventOccurrenceModel::columnCount(const QModelIndex &) const { return 1; } QByteArray EventOccurrenceModel::getColor(const QByteArray &calendar) const { const auto color = mCalendarCache->getProperty(calendar, "color").toByteArray(); if (color.isEmpty()) { qWarning() << "Failed to get color for calendar " << calendar; } return color; } QVariant EventOccurrenceModel::data(const QModelIndex &idx, int role) const { if (!hasIndex(idx.row(), idx.column())) { return {}; } auto event = mEvents.at(idx.row()); auto icalEvent = event.incidence; switch (role) { case Summary: return icalEvent->summary(); case Description: return icalEvent->description(); case StartTime: return event.start; case EndTime: return event.end; case Color: return event.color; case AllDay: return event.allDay; case Event: return QVariant::fromValue(event.domainObject); case EventOccurrence: return QVariant::fromValue(event); default: SinkWarning() << "Unknown role for event:" << QMetaEnum::fromType().valueToKey(role); return {}; } } diff --git a/framework/src/domain/eventoccurrencemodel.h b/framework/src/domain/eventoccurrencemodel.h index c97854f0..b7de31c6 100644 --- a/framework/src/domain/eventoccurrencemodel.h +++ b/framework/src/domain/eventoccurrencemodel.h @@ -1,120 +1,119 @@ /* Copyright (c) 2018 Michael Bohlender Copyright (c) 2018 Christian Mollekopf Copyright (c) 2018 Rémi Nicole This library is free software; you can redistribute it and/or modify it under the terms of the GNU Library General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This library 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 Library General Public License for more details. You should have received a copy of the GNU Library General Public License along with this library; see the file COPYING.LIB. If not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ #pragma once #include "kube_export.h" #include #include #include #include #include #include namespace KCalCore { class MemoryCalendar; class Incidence; } namespace Sink { namespace ApplicationDomain { struct Event; } } class EntityCacheInterface; /** * Loads all event occurrences within the given period and matching the given filter. * * Recurrences are expanded */ class KUBE_EXPORT EventOccurrenceModel : public QAbstractItemModel { Q_OBJECT Q_PROPERTY(QDate start READ start WRITE setStart) Q_PROPERTY(int length READ length WRITE setLength) Q_PROPERTY(QSet calendarFilter WRITE setCalendarFilter) Q_PROPERTY(QVariantMap filter WRITE setFilter) public: enum Roles { Summary = Qt::UserRole + 1, Description, StartTime, EndTime, Color, AllDay, Event, EventOccurrence, LastRole }; Q_ENUM(Roles); EventOccurrenceModel(QObject *parent = nullptr); ~EventOccurrenceModel() = default; QModelIndex index(int row, int column, const QModelIndex &parent = {}) const override; QModelIndex parent(const QModelIndex &index) const override; int rowCount(const QModelIndex &parent = {}) const override; int columnCount(const QModelIndex &parent) const override; QVariant data(const QModelIndex &index, int role) const override; void updateQuery(const QDate &start, const QDate &end, const QSet &calendarFilter); void setStart(const QDate &); QDate start() const; void setLength(int); int length() const; void setCalendarFilter(const QSet &); void setFilter(const QVariantMap &); struct Occurrence { QDateTime start; QDateTime end; QSharedPointer incidence; QByteArray color; bool allDay; QSharedPointer domainObject; }; private: void updateQuery(); void refreshView(); void updateFromSource(); QByteArray getColor(const QByteArray &calendar) const; QSharedPointer mSourceModel; QSet mCalendarFilter; QDate mStart; QDate mEnd; int mLength{0}; QSharedPointer mCalendarCache; - QSharedPointer mCalendar; QTimer mRefreshTimer; QList mEvents; QVariantMap mFilter; }; Q_DECLARE_METATYPE(EventOccurrenceModel::Occurrence); diff --git a/framework/src/tests/eventoccurrencemodeltest.cpp b/framework/src/tests/eventoccurrencemodeltest.cpp index 3c9933d5..9a9cf1af 100644 --- a/framework/src/tests/eventoccurrencemodeltest.cpp +++ b/framework/src/tests/eventoccurrencemodeltest.cpp @@ -1,193 +1,258 @@ #include #include #include #include #include #include #include #include #include "eventoccurrencemodel.h" #include "multidayeventmodel.h" #include "perioddayeventmodel.h" class EventOccurrenceModelTest : public QObject { Q_OBJECT private slots: void initTestCase() { Sink::Test::initTest(); } void testEventOccurrenceModel() { Sink::ApplicationDomain::DummyResource::create("account1"); using namespace Sink::ApplicationDomain; auto account = ApplicationDomainType::createEntity(); Sink::Store::create(account).exec().waitForFinished(); auto resource = Sink::ApplicationDomain::DummyResource::create(account.identifier()); Sink::Store::create(resource).exec().waitForFinished(); auto calendar1 = ApplicationDomainType::createEntity(resource.identifier()); Sink::Store::create(calendar1).exec().waitForFinished(); const QDateTime start{{2018, 04, 17}, {6, 0, 0}}; { auto event1 = ApplicationDomainType::createEntity(resource.identifier()); auto calcoreEvent = QSharedPointer::create(); calcoreEvent->setUid("event1"); calcoreEvent->setSummary("summary1"); calcoreEvent->setDescription("description"); calcoreEvent->setDtStart(start); calcoreEvent->setDuration(3600); calcoreEvent->setAllDay(false); event1.setIcal(KCalCore::ICalFormat().toICalString(calcoreEvent).toUtf8()); event1.setCalendar(calendar1); Sink::Store::create(event1).exec().waitForFinished(); } { //1st indent level auto event2 = ApplicationDomainType::createEntity(resource.identifier()); auto calcoreEvent = QSharedPointer::create(); calcoreEvent->setUid("event2"); calcoreEvent->setSummary("summary2"); calcoreEvent->setDescription("description"); calcoreEvent->setDtStart(start.addDays(1)); calcoreEvent->setDuration(3600); calcoreEvent->setAllDay(false); calcoreEvent->recurrence()->setDaily(1); event2.setIcal(KCalCore::ICalFormat().toICalString(calcoreEvent).toUtf8()); event2.setCalendar(calendar1); Sink::Store::create(event2).exec().waitForFinished(); } { //2rd indent level auto event3 = ApplicationDomainType::createEntity(resource.identifier()); auto calcoreEvent = QSharedPointer::create(); calcoreEvent->setUid("event3"); calcoreEvent->setSummary("summary3"); calcoreEvent->setDtStart(start.addDays(1)); calcoreEvent->setDuration(3600); calcoreEvent->setAllDay(false); event3.setIcal(KCalCore::ICalFormat().toICalString(calcoreEvent).toUtf8()); event3.setCalendar(calendar1); Sink::Store::create(event3).exec().waitForFinished(); } { auto event4 = ApplicationDomainType::createEntity(resource.identifier()); auto calcoreEvent = QSharedPointer::create(); calcoreEvent->setUid("event4"); calcoreEvent->setSummary("summary4"); calcoreEvent->setDtStart(start.addDays(2)); calcoreEvent->setAllDay(true); event4.setIcal(KCalCore::ICalFormat().toICalString(calcoreEvent).toUtf8()); event4.setCalendar(calendar1); Sink::Store::create(event4).exec().waitForFinished(); } { auto event1 = ApplicationDomainType::createEntity(resource.identifier()); auto calcoreEvent = QSharedPointer::create(); calcoreEvent->setUid("event5"); calcoreEvent->setSummary("summary5"); calcoreEvent->setDescription("description"); calcoreEvent->setDtStart(start); calcoreEvent->setDuration(3600); calcoreEvent->setAllDay(false); event1.setIcal(KCalCore::ICalFormat().toICalString(calcoreEvent).toUtf8()); event1.setCalendar(calendar1); Sink::Store::create(event1).exec().waitForFinished(); } { //3rd indent level auto event6 = ApplicationDomainType::createEntity(resource.identifier()); auto calcoreEvent = QSharedPointer::create(); calcoreEvent->setUid("event6"); calcoreEvent->setSummary("summary6"); calcoreEvent->setDtStart(start.addDays(1)); calcoreEvent->setDuration(3600); calcoreEvent->setAllDay(false); event6.setIcal(KCalCore::ICalFormat().toICalString(calcoreEvent).toUtf8()); event6.setCalendar(calendar1); Sink::Store::create(event6).exec().waitForFinished(); } { //Start matches end of previous event auto event7 = ApplicationDomainType::createEntity(resource.identifier()); auto calcoreEvent = QSharedPointer::create(); calcoreEvent->setUid("event7"); calcoreEvent->setSummary("summary7"); calcoreEvent->setDtStart(start.addDays(1).addSecs(3600)); calcoreEvent->setDuration(3600); calcoreEvent->setAllDay(false); event7.setIcal(KCalCore::ICalFormat().toICalString(calcoreEvent).toUtf8()); event7.setCalendar(calendar1); Sink::Store::create(event7).exec().waitForFinished(); } Sink::ResourceControl::flushMessageQueue(resource.identifier()).exec().waitForFinished(); { const int expectedNumberOfOccurreces = 12; const int numberOfDays = 7; EventOccurrenceModel model; model.setStart(start.date()); model.setLength(numberOfDays); model.setCalendarFilter({calendar1.identifier()}); QTRY_COMPARE(model.rowCount({}), expectedNumberOfOccurreces); auto countEvents = [&] (const QVariantList &lines) { int count = 0; for (const auto &line : lines) { count += line.toList().size(); } return count; }; //Check the multidayevent model { MultiDayEventModel multiDayModel; multiDayModel.setModel(&model); QTRY_COMPARE(multiDayModel.rowCount({}), 1); QTRY_COMPARE(countEvents(multiDayModel.index(0, 0, {}).data(multiDayModel.roleNames().key("events")).value()), expectedNumberOfOccurreces); //Count lines QTRY_COMPARE(multiDayModel.index(0, 0, {}).data(multiDayModel.roleNames().key("events")).value().size(), 4); } { PeriodDayEventModel multiDayModel; multiDayModel.setModel(&model); QTRY_COMPARE(multiDayModel.rowCount({}), numberOfDays); { const auto events = multiDayModel.index(0, 0, {}).data(multiDayModel.roleNames().key("events")).value(); QCOMPARE(events.size(), 2); QCOMPARE(events[0].toMap()["indentation"].toInt(), 0); QCOMPARE(events[1].toMap()["indentation"].toInt(), 1); } { const auto events = multiDayModel.index(1, 0, {}).data(multiDayModel.roleNames().key("events")).value(); QCOMPARE(events.size(), 4); QCOMPARE(events[0].toMap()["indentation"].toInt(), 0); QCOMPARE(events[1].toMap()["indentation"].toInt(), 1); QCOMPARE(events[2].toMap()["indentation"].toInt(), 2); QCOMPARE(events[3].toMap()["indentation"].toInt(), 0); } { const auto events = multiDayModel.index(2, 0, {}).data(multiDayModel.roleNames().key("events")).value(); QCOMPARE(events.size(), 1); QCOMPARE(events[0].toMap()["indentation"].toInt(), 0); } } model.setCalendarFilter({}); QTRY_COMPARE(model.rowCount({}), 0); } } + + void testRecurrenceException() + { + Sink::ApplicationDomain::DummyResource::create("account1"); + + using namespace Sink::ApplicationDomain; + auto account = ApplicationDomainType::createEntity(); + Sink::Store::create(account).exec().waitForFinished(); + + auto resource = Sink::ApplicationDomain::DummyResource::create(account.identifier()); + Sink::Store::create(resource).exec().waitForFinished(); + + auto calendar1 = ApplicationDomainType::createEntity(resource.identifier()); + Sink::Store::create(calendar1).exec().waitForFinished(); + + const QDateTime start{{2018, 04, 17}, {6, 0, 0}}; + { + auto event = ApplicationDomainType::createEntity(resource.identifier()); + auto calcoreEvent = QSharedPointer::create(); + calcoreEvent->setUid("event"); + calcoreEvent->setSummary("summary2"); + calcoreEvent->setDescription("description"); + calcoreEvent->setDtStart(start.addDays(1)); + calcoreEvent->setDuration(3600); + calcoreEvent->setAllDay(false); + calcoreEvent->recurrence()->setDaily(1); + event.setIcal(KCalCore::ICalFormat().toICalString(calcoreEvent).toUtf8()); + event.setCalendar(calendar1); + Sink::Store::create(event).exec().waitForFinished(); + } + + //Exception + { + auto event = ApplicationDomainType::createEntity(resource.identifier()); + auto calcoreEvent = QSharedPointer::create(); + calcoreEvent->setUid("event"); + calcoreEvent->setSummary("summary2"); + calcoreEvent->setDescription("description"); + calcoreEvent->setRecurrenceId(start.addDays(2)); + calcoreEvent->setDtStart(start.addDays(2).addSecs(3600)); + calcoreEvent->setDuration(7200); + calcoreEvent->setAllDay(false); + event.setIcal(KCalCore::ICalFormat().toICalString(calcoreEvent).toUtf8()); + event.setCalendar(calendar1); + Sink::Store::create(event).exec().waitForFinished(); + } + + Sink::ResourceControl::flushMessageQueue(resource.identifier()).exec().waitForFinished(); + + { + EventOccurrenceModel model; + model.setStart(start.date()); + model.setLength(7); + model.setCalendarFilter({calendar1.identifier()}); + QTRY_COMPARE(model.rowCount({}), 6); + + auto getOccurrence = [&] (int index) { + return model.index(index, 0, {}).data(EventOccurrenceModel::EventOccurrence).value(); + }; + QCOMPARE(getOccurrence(0).start, start.addDays(1)); + QCOMPARE(getOccurrence(1).start, start.addDays(2).addSecs(3600)); //The exception + QCOMPARE(getOccurrence(2).start, start.addDays(3)); + } + } + }; QTEST_MAIN(EventOccurrenceModelTest) #include "eventoccurrencemodeltest.moc"