Commit ea17e09d authored by Volker Krause's avatar Volker Krause
Browse files

Add new calendar import workflow

This significantly expands what we had for this so far:
- no longer limited to Android
- also supports extracting from events, no longer limiting this to events
the KMail plugin previously exported
- can create new event elements if the extractor didn't find anything
- allows selecting which calendar to import from, which is important when
having shared calendars from others subscribed
parent 4886965a
......@@ -33,6 +33,7 @@ ecm_add_test(transfertest.cpp LINK_LIBRARIES Qt::Test itinerary)
ecm_add_test(livedatamanagertest.cpp LINK_LIBRARIES Qt::Test itinerary)
ecm_add_test(healthcertificatemanagertest.cpp LINK_LIBRARIES Qt::Test itinerary)
ecm_add_test(passmanagertest.cpp LINK_LIBRARIES Qt::Test itinerary)
ecm_add_test(calendarimportmodeltest.cpp LINK_LIBRARIES Qt::Test itinerary)
ecm_add_test(weathertest.cpp LINK_LIBRARIES Qt::Test itinerary-weather)
target_include_directories(weathertest PRIVATE ${CMAKE_BINARY_DIR})
/*
SPDX-FileCopyrightText: 2018 Volker Krause <vkrause@kde.org>
SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "testhelper.h"
#include <calendarimportmodel.h>
#include <KItinerary/Event>
#include <KItinerary/Reservation>
#include <KCalendarCore/ICalFormat>
#include <KCalendarCore/MemoryCalendar>
#include <QAbstractItemModelTester>
#include <QtTest/qtest.h>
#include <QSignalSpy>
#include <QStandardPaths>
using namespace KItinerary;
class CalendarImportModelTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void initTestCase()
{
qputenv("TZ", "UTC");
QStandardPaths::setTestModeEnabled(true);
}
void testImport()
{
CalendarImportModel model;
QAbstractItemModelTester modelTest(&model);
KCalendarCore::MemoryCalendar::Ptr calendar(new KCalendarCore::MemoryCalendar(QTimeZone::utc()));
KCalendarCore::ICalFormat format;
QVERIFY(format.load(calendar, QLatin1String(SOURCE_DIR "/data/randa2017.ics")));
QCOMPARE(model.hasSelection(), false);
model.m_todayOverride = { 2017, 6, 27 };
model.setCalendar(calendar.data());
QCOMPARE(model.rowCount(), 5);
QCOMPARE(model.hasSelection(), true);
QCOMPARE(model.selectedReservations().size(), 3);
auto idx = model.index(0, 0);
QCOMPARE(idx.data(CalendarImportModel::TitleRole).toString(), QLatin1String("Hotel reservation: Haus Randa"));
QCOMPARE(idx.data(CalendarImportModel::IconNameRole).toString(), QLatin1String("meeting-attending"));
QCOMPARE(idx.data(CalendarImportModel::SelectedRole).toBool(), false);
QVERIFY(model.setData(idx, true, CalendarImportModel::SelectedRole));
idx = model.index(1, 0);
QCOMPARE(idx.data(CalendarImportModel::TitleRole).toString(), QLatin1String("Train 241 from Visp to Randa"));
QCOMPARE(idx.data(CalendarImportModel::IconNameRole).toString(), QLatin1String("qrc:///images/train.svg"));
QCOMPARE(idx.data(CalendarImportModel::SelectedRole).toBool(), true);
auto res = idx.data(CalendarImportModel::ReservationsRole).value<QVector<QVariant>>();
QCOMPARE(res.size(), 1);
QVERIFY(JsonLd::isA<TrainReservation>(res.at(0)));
QVERIFY(model.setData(idx, false, CalendarImportModel::SelectedRole));
idx = model.index(2, 0);
QCOMPARE(idx.data(CalendarImportModel::TitleRole).toString(), QLatin1String("KDE Randa Meeting 2017"));
QCOMPARE(idx.data(CalendarImportModel::IconNameRole).toString(), QLatin1String("meeting-attending"));
QCOMPARE(idx.data(CalendarImportModel::SelectedRole).toBool(), false);
QVERIFY(model.setData(idx, true, CalendarImportModel::SelectedRole));
idx = model.index(3, 0);
QCOMPARE(idx.data(CalendarImportModel::TitleRole).toString(), QLatin1String("Restaurant reservation: Raclette"));
QCOMPARE(idx.data(CalendarImportModel::IconNameRole).toString(), QLatin1String("qrc:///images/foodestablishment.svg"));
QCOMPARE(idx.data(CalendarImportModel::SelectedRole).toBool(), true);
res = idx.data(CalendarImportModel::ReservationsRole).value<QVector<QVariant>>();
QCOMPARE(res.size(), 1);
QVERIFY(JsonLd::isA<FoodEstablishmentReservation>(res.at(0)));
QVERIFY(model.setData(idx, false, CalendarImportModel::SelectedRole));
idx = model.index(4, 0);
QCOMPARE(idx.data(CalendarImportModel::TitleRole).toString(), QLatin1String("Randa -> Visp"));
QCOMPARE(idx.data(CalendarImportModel::IconNameRole).toString(), QLatin1String("qrc:///images/train.svg"));
QCOMPARE(idx.data(CalendarImportModel::SelectedRole).toBool(), true);
res = idx.data(CalendarImportModel::ReservationsRole).value<QVector<QVariant>>();
QCOMPARE(res.size(), 1);
QVERIFY(JsonLd::isA<TrainReservation>(res.at(0)));
QVERIFY(model.setData(idx, false, CalendarImportModel::SelectedRole));
QCOMPARE(model.hasSelection(), true);
QCOMPARE(model.selectedReservations().size(), 2);
auto ev = model.selectedReservations().at(0).value<KItinerary::Event>();
QCOMPARE(ev.name(), QLatin1String("Hotel reservation: Haus Randa"));
QVERIFY(ev.startDate().isValid());
QVERIFY(ev.endDate().isValid());
ev = model.selectedReservations().at(1).value<KItinerary::Event>();
QCOMPARE(ev.name(), QLatin1String("KDE Randa Meeting 2017"));
QVERIFY(ev.startDate().isValid());
QVERIFY(ev.endDate().isValid());
}
};
QTEST_GUILESS_MAIN(CalendarImportModelTest)
#include "calendarimportmodeltest.moc"
BEGIN:VCALENDAR
PRODID:-//K Desktop Environment//NONSGML libkcal 4.3//EN
VERSION:2.0
X-KDE-ICAL-IMPLEMENTATION-VERSION:1.0
BEGIN:VEVENT
DTSTAMP:20220626T094245Z
X-KDE-KITINERARY-RESERVATION:[{"@context":"http://schema.org"\,"@type":
"FoodEstablishmentReservation"\,"endTime":{"@type":"QDateTime"\,"@value":
"2017-09-14T22:00:00+02:00"\,"timezone":"Europe/Zurich"}\,"modifiedTime":
"2017-06-22T00:00:00"\,"partySize":20\,"reservationFor":{"@type":
"FoodEstablishment"\,"address":{"@type":"PostalAddress"\,"addressCountry":
"CH"\,"addressLocality":"Randa"\,"addressRegion":"Wallis"\,"postalCode":
"3928"\,"streetAddress":"Haus Maria am Weg"}\,"geo":{"@type":
"GeoCoordinates"\,"latitude":46.099029541015625\,"longitude":
7.783259868621826}\,"name":"Raclette"}\,"reservationStatus":"http:
//schema.org/ReservationConfirmed"\,"startTime":{"@type":
"QDateTime"\,"@value":"2017-09-14T19:00:00+02:00"\,"timezone":
"Europe/Zurich"}}]
CREATED:20220626T094245Z
UID:KIT-64bdd76e-dd7c-4afd-8f53-660427b2f3f5
LAST-MODIFIED:20220626T094245Z
DESCRIPTION:Number of people: 20
SUMMARY:Restaurant reservation: Raclette
LOCATION:Haus Maria am Weg\, 3928 Randa\, Switzerland
DTSTART;TZID=Europe/Zurich:20170914T190000
DTEND;TZID=Europe/Zurich:20170914T220000
TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20220626T094245Z
X-KDE-KITINERARY-RESERVATION:[{"@context":"http://schema.org"\,"@type":
"TrainReservation"\,"modifiedTime":"2017-06-22T00:00:
00"\,"reservationFor":{"@type":"TrainTrip"\,"arrivalStation":{"@type":
"TrainStation"\,"address":{"@type":"PostalAddress"\,"addressCountry":
"CH"}\,"geo":{"@type":"GeoCoordinates"\,"latitude":
46.09989929199219\,"longitude":7.781469821929932}\,"identifier":"ibnr:
8501687"\,"name":"Randa"}\,"arrivalTime":{"@type":"QDateTime"\,"@value":
"2017-09-10T14:53:00+02:00"\,"timezone":"Europe/Zurich"}\,"departureDay":
"2017-09-10"\,"departurePlatform":"3"\,"departureStation":{"@type":
"TrainStation"\,"address":{"@type":"PostalAddress"\,"addressCountry":
"CH"}\,"geo":{"@type":"GeoCoordinates"\,"latitude":
46.29399871826172\,"longitude":7.881460189819336}\,"identifier":"ibnr:
8501605"\,"name":"Visp"}\,"departureTime":{"@type":"QDateTime"\,"@value":
"2017-09-10T14:08:00+02:00"\,"timezone":"Europe/Zurich"}\,"provider":
{"@type":"Organization"\,"name":"SBB"}\,"trainName":"R"\,"trainNumber":
"241"}\,"reservationStatus":"http://schema.org/ReservationConfirmed"}]
CREATED:20220626T094245Z
UID:KIT-0757766c-1235-4dba-a1b4-3243651f0ef3
LAST-MODIFIED:20220626T094245Z
DESCRIPTION:Departure platform: 3
SUMMARY:Train 241 from Visp to Randa
LOCATION:Visp
GEO:46.293999;7.881460
DTSTART;TZID=Europe/Zurich:20170910T140800
DTEND;TZID=Europe/Zurich:20170910T145300
TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20220626T094245Z
CREATED:20220626T094245Z
UID:d59bda31-a28e-4cae-b261-c4e4b2769cf0
LAST-MODIFIED:20220626T094245Z
DESCRIPTION:Check-in: 15:00\nCheck-out: 10:00\nWebsite: https:
//randa-meetings.ch/
SUMMARY:Hotel reservation: Haus Randa
LOCATION:Haus Maria am Weg\, 3928 Randa\, Switzerland
DTSTART;VALUE=DATE:20170910
DTEND;VALUE=DATE:20170916
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20220626T094245Z
CREATED:20220626T094245Z
UID:82d09baa-b4e9-41bd-a77f-2e997f850a38
LAST-MODIFIED:20220626T094245Z
DESCRIPTION:Haus Maria am Weg\n3928 Randa\nSwitzerland\n\nDr Konqui
SUMMARY:KDE Randa Meeting 2017
LOCATION:Haus Randa
GEO:46.099030;7.783260
DTSTART;TZID=Europe/Zurich:20170910T150000
DTEND;TZID=Europe/Zurich:20170910T160000
TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20220626T094245Z
CREATED:20220626T094245Z
UID:bahn20170915145400
LAST-MODIFIED:20220626T094245Z
DESCRIPTION:simulated DB ical to trigger extractor script
SUMMARY:Randa -> Visp
DTSTART;TZID=Europe/Zurich:20170915T145400
DTEND;TZID=Europe/Zurich:20170915T154600
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR
......@@ -7,6 +7,7 @@ configure_file(${CMAKE_CURRENT_SOURCE_DIR}/itinerary_version_detailed.h.cmake ${
add_library(itinerary STATIC)
target_sources(itinerary PRIVATE
applicationcontroller.cpp
calendarimportmodel.cpp
documentmanager.cpp
favoritelocationmodel.cpp
filehelper.cpp
......
/*
SPDX-FileCopyrightText: 2022 Volker Krause <vkrause@kde.org>
SPDX-License-Identifier: LGPL-2.0-or-later
*/
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15 as QQC2
import org.kde.kirigami 2.17 as Kirigami
import org.kde.kcalendarcore 1.0 as KCalendarCore
import org.kde.itinerary 1.0
import "." as App
Kirigami.ScrollablePage {
id: root
property alias calendar: importModel.calendar
title: i18n("Calendar Import")
actions.main: Kirigami.Action {
icon.name: "document-open"
text: i18n("Import selected events")
enabled: importModel.hasSelection
onTriggered: {
importModel.selectedReservations().forEach(r => ReservationManager.importReservation(r));
applicationWindow().pageStack.pop();
}
}
CalendarImportModel {
id: importModel
}
ListView {
id: eventList
model: importModel
delegate: Kirigami.AbstractListItem {
highlighted: model.selected
GridLayout {
columns: 2
rows: 2
Kirigami.Icon {
Layout.rowSpan: 2
isMask: true
source: model.iconName
}
QQC2.Label {
text: model.title
Layout.fillWidth: true
elide: Text.ElideRight
}
QQC2.Label {
text: model.subtitle
Layout.fillWidth: true
elide: Text.ElideRight
font: Kirigami.Theme.smallFont
}
}
onClicked: model.selected = !model.selected
}
}
}
......@@ -10,6 +10,7 @@ import QtQuick.Controls 2.1 as QQC2
import Qt.labs.qmlmodels 1.0 as Models
import Qt.labs.platform 1.1 as Platform
import org.kde.kirigami 2.17 as Kirigami
import org.kde.kcalendarcore 1.0 as KCalendarCore
import org.kde.itinerary 1.0
import "." as App
......@@ -81,6 +82,17 @@ Kirigami.ScrollablePage {
departureStop: departureLocation
});
}
},
Kirigami.Action {
iconName: "view-calendar-day"
text: i18n("Add from calendar...")
onTriggered: PermissionManager.requestPermission(Permission.ReadCalendar, function() {
if (!calendarSelectorListView.model) {
calendarSelectorListView.model = calendarModel.createObject(root);
}
calendarSelector.open();
})
visible: KCalendarCore.CalendarPluginLoader.hasPlugin
}
]
}
......@@ -118,6 +130,31 @@ Kirigami.ScrollablePage {
onAccepted: ApplicationController.exportTripToGpx(tripGroupId, file)
}
Component {
id: calendarModel
// needs to be created on demand, after we have calendar access permissions
KCalendarCore.CalendarListModel {}
}
Component {
id: calendarImportPage
App.CalendarImportPage {}
}
Kirigami.OverlaySheet {
id: calendarSelector
title: i18n("Select Calendar")
ListView {
id: calendarSelectorListView
delegate: Kirigami.BasicListItem {
text: model.name
onClicked: {
applicationWindow().pageStack.push(calendarImportPage, {calendar: model.calendar});
calendarSelector.close();
}
}
}
}
Component {
id: flightDetailsPage
App.FlightPage {}
......
/*
SPDX-FileCopyrightText: 2022 Volker Krause <vkrause@kde.org>
SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "calendarimportmodel.h"
#include <KItinerary/Event>
#include <KItinerary/ExtractorEngine>
#include <KItinerary/ExtractorPostprocessor>
#include <KItinerary/JsonLdDocument>
#include <KItinerary/Reservation>
#include <kcalendarcore_version.h>
#include <QDebug>
#include <QJsonArray>
CalendarImportModel::CalendarImportModel(QObject *parent)
: QAbstractListModel(parent)
{
qRegisterMetaType<QVector<QVariant>>();
}
CalendarImportModel::~CalendarImportModel() = default;
KCalendarCore::Calendar* CalendarImportModel::calendar() const
{
return m_calendar;
}
void CalendarImportModel::setCalendar(KCalendarCore::Calendar *calendar)
{
if (m_calendar == calendar) {
return;
}
if (m_calendar) {
disconnect(m_calendar, nullptr, this, nullptr);
}
m_calendar = calendar;
Q_EMIT calendarChanged();
if (!m_calendar) {
return;
}
#if KCALENDARCORE_VERSION >= QT_VERSION_CHECK(5, 96, 0)
if (!m_calendar->isLoading()) {
reload();
} else {
connect(m_calendar, &KCalendarCore::Calendar::isLoadingChanged, this, &CalendarImportModel::reload);
}
#else
reload();
#endif
}
static QVariant convertToEvent(const KCalendarCore::Event::Ptr &ev)
{
using namespace KItinerary;
Event e;
e.setName(ev->summary());
e.setDescription(ev->description());
e.setStartDate(ev->dtStart());
e.setEndDate(ev->dtEnd());
e.setUrl(ev->url());
Place venue;
venue.setName(ev->location()); // TODO attempt to detect addresses in here
if (ev->hasGeo()) {
venue.setGeo({ev->geoLatitude(), ev->geoLongitude()});
}
e.setLocation(venue);
// TODO attachments?
return e;
}
QVector<QVariant> CalendarImportModel::selectedReservations() const
{
QVector<QVariant> res;
for (const auto &ev : m_events) {
if (!ev.selected) {
continue;
}
if (!ev.data.isEmpty()) {
std::copy(ev.data.begin(), ev.data.end(), std::back_inserter(res));
} else {
res.push_back(convertToEvent(ev.event));
}
}
return res;
}
int CalendarImportModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_events.size();
}
QVariant CalendarImportModel::data(const QModelIndex &index, int role) const
{
using namespace KItinerary;
if (!checkIndex(index)) {
return {};
}
const auto &ev = m_events[index.row()];
switch (role) {
case TitleRole:
return ev.event->summary();
case SubtitleRole:
return QLocale().toString(ev.event->dtStart());
case IconNameRole:
if (!ev.data.isEmpty()) {
const auto res = ev.data.at(0);
if (JsonLd::isA<FlightReservation>(res)) {
return QStringLiteral("qrc:///images/flight.svg");
}
if (JsonLd::isA<TrainReservation>(res)) {
return QStringLiteral("qrc:///images/train.svg");
}
if (JsonLd::isA<BusReservation>(res)) {
return QStringLiteral("qrc:///images/bus.svg");
}
if (JsonLd::isA<LodgingReservation>(res)) {
return QStringLiteral("go-home-symbolic");
}
if (JsonLd::isA<FoodEstablishmentReservation>(res)) {
return QStringLiteral("qrc:///images/foodestablishment.svg");
}
if (JsonLd::isA<RentalCarReservation>(res)) {
return QStringLiteral("qrc:///images/car.svg");
}
}
return QStringLiteral("meeting-attending");
case ReservationsRole:
return QVariant::fromValue(ev.data);
case SelectedRole:
return ev.selected;
}
return {};
}
bool CalendarImportModel::setData(const QModelIndex &index, const QVariant &value, int role)
{
auto &ev = m_events[index.row()];
switch (role) {
case SelectedRole:
ev.selected = value.toBool();
Q_EMIT dataChanged(index, index);
Q_EMIT hasSelectionChanged();
return true;
}
return false;
}
QHash<int, QByteArray> CalendarImportModel::roleNames() const
{
auto n = QAbstractListModel::roleNames();
n.insert(TitleRole, "title");
n.insert(SubtitleRole, "subtitle");
n.insert(IconNameRole, "iconName");
n.insert(ReservationsRole, "reservations");
n.insert(SelectedRole, "selected");
return n;
}
bool CalendarImportModel::hasSelection() const
{
return std::any_of(m_events.begin(), m_events.end(), [](const auto &ev) { return ev.selected; });
}
void CalendarImportModel::reload()
{
beginResetModel();
m_events.clear();
KItinerary::ExtractorEngine extractorEngine;
auto calEvents = m_calendar->events(today().addDays(-5), today().addDays(180));
calEvents = m_calendar->sortEvents(std::move(calEvents), KCalendarCore::EventSortStartDate, KCalendarCore::SortDirectionAscending);
for (const auto &ev : std::as_const(calEvents)) {
if (ev->recurs() || ev->hasRecurrenceId()) {
continue;
}
extractorEngine.clear();
extractorEngine.setContent(QVariant::fromValue(ev), u"internal/event");
KItinerary::ExtractorPostprocessor postProc;
postProc.process(KItinerary::JsonLdDocument::fromJson(extractorEngine.extract()));
const auto res = postProc.result();
m_events.push_back({ev, res, !res.isEmpty()});
}
endResetModel();
Q_EMIT hasSelectionChanged();
}
QDate CalendarImportModel::today() const
{
if (Q_UNLIKELY(m_todayOverride.isValid())) {
return m_todayOverride;
}
return QDate::currentDate();
}
/*
SPDX-FileCopyrightText: 2022 Volker Krause <vkrause@kde.org>
SPDX-License-Identifier: LGPL-2.0-or-later
*/
#ifndef CALENDARIMPORTMODEL_H
#define CALENDARIMPORTMODEL_H
#include <QAbstractListModel>
#include <KCalendarCore/Calendar>
/** List of possible events to import from a selected calendar. */
class CalendarImportModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(KCalendarCore::Calendar* calendar READ calendar WRITE setCalendar NOTIFY calendarChanged)
Q_PROPERTY(bool hasSelection READ hasSelection NOTIFY hasSelectionChanged)
public:
explicit CalendarImportModel(QObject *parent = nullptr);
~CalendarImportModel();
enum Role {
TitleRole = Qt::DisplayRole,
SubtitleRole = Qt::UserRole,
IconNameRole,
ReservationsRole,
SelectedRole,
};
KCalendarCore::Calendar *calendar() const;
void setCalendar(KCalendarCore::Calendar *calendar);
Q_INVOKABLE QVector<QVariant> selectedReservations() const;
int rowCount(const QModelIndex &parent = {}) const override;
QVariant data(const QModelIndex &index, int role) const override;
bool setData(const QModelIndex &index, const QVariant &value, int role) override;
QHash<int, QByteArray> roleNames() const override;
bool hasSelection() const;
Q_SIGNALS:
void calendarChanged();
void hasSelectionChanged();
private:
void reload();
QDate today() const;