Commit 2e7026d6 authored by Daniel Vrátil's avatar Daniel Vrátil 🤖

Merge branch 'dev/facebook'

parents cdb42aa4 843ce171
...@@ -49,7 +49,7 @@ add_subdirectory( imap ) ...@@ -49,7 +49,7 @@ add_subdirectory( imap )
if (Libkolab_FOUND AND Libkolabxml_FOUND) if (Libkolab_FOUND AND Libkolabxml_FOUND)
add_subdirectory( kolab ) add_subdirectory( kolab )
endif() endif()
add_subdirectory( facebook )
add_subdirectory( maildir ) add_subdirectory( maildir )
add_subdirectory( openxchange ) add_subdirectory( openxchange )
......
include_directories(BEFORE ${CMAKE_CURRENT_BINARY_DIR})
set(fbresource_SRCS
resource.cpp
listjob.cpp
eventslistjob.cpp
birthdaylistjob.cpp
settingsdialog.cpp
tokenjobs.cpp
graph.cpp
)
qt5_wrap_ui(fbresource_SRCS
settingsdialog.ui
)
ecm_qt_declare_logging_category(fbresource_SRCS HEADER resource_debug.h IDENTIFIER RESOURCE_LOG CATEGORY_NAME org.kde.pim.fbresource)
kcfg_generate_dbus_interface(settings.kcfg org.kde.Akonadi.Facebook.Settings )
kconfig_add_kcfg_files(fbresource_SRCS settings.kcfgc)
add_executable(akonadi_facebook_resource ${fbresource_SRCS})
target_link_libraries(akonadi_facebook_resource
KF5::AkonadiAgentBase
KF5::CalendarCore
KF5::I18n
KF5::Wallet
KF5::Codecs
Qt5::WebEngineWidgets
)
install(TARGETS akonadi_facebook_resource ${KDE_INSTALL_TARGETS_DEFAULT_ARGS})
install(
FILES facebookresource.desktop
DESTINATION "${KDE_INSTALL_DATAROOTDIR}/akonadi/agents"
)
/*
* Copyright (C) 2017 Daniel Vrátil <dvratil@kde.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "birthdaylistjob.h"
#include "settings.h"
#include "tokenjobs.h"
#include "resource.h"
#include <QDate>
#include <QNetworkCookie>
#include <QByteArrayMatcher>
#include <KIO/Job>
#include <KLocalizedString>
#include <KCharsets>
#include <KCalCore/MemoryCalendar>
#include <KCalCore/ICalFormat>
BirthdayListJob::BirthdayListJob(const Akonadi::Collection &collection, FacebookResource *parent)
: KJob(parent)
, mCollection(collection)
{
}
BirthdayListJob::~BirthdayListJob()
{
}
QVector<Akonadi::Item> BirthdayListJob::items() const
{
return mItems;
}
void BirthdayListJob::start()
{
auto tokenJob = new GetTokenJob(qobject_cast<FacebookResource*>(parent()));
connect(tokenJob, &GetTokenJob::result,
this, [this, tokenJob]() {
if (tokenJob->error()) {
setError(tokenJob->error());
setErrorText(tokenJob->errorText());
emitResult();
return;
}
// Convert the cookies into a HTTP Cookie header that we can pass
// to KIO
mCookies = QStringLiteral("Cookie: ");
const auto parsedCookies = QNetworkCookie::parseCookies(tokenJob->cookies());
for (const auto &cookie : parsedCookies) {
mCookies += QStringLiteral("%1=%2; ").arg(QString::fromUtf8(cookie.name()),
QString::fromUtf8(cookie.value()));
}
fetchFacebookEventsPage();
});
tokenJob->start();
}
KIO::StoredTransferJob *BirthdayListJob::createGetJob(const QUrl &url) const
{
auto job = KIO::storedGet(url, KIO::NoReload, KIO::HideProgressInfo);
job->setMetaData({ QMap<QString,QString>{
{ QStringLiteral("cookies"), QStringLiteral("manual") },
{ QStringLiteral("setcookies"), mCookies } }
});
return job;
}
void BirthdayListJob::emitError(const QString& errorText)
{
setError(KJob::UserDefinedError);
setErrorText(errorText);
emitResult();
}
void BirthdayListJob::fetchFacebookEventsPage()
{
auto job = createGetJob(QUrl(QStringLiteral("https://www.facebook.com/events/birthdays")));
connect(job, &KJob::result,
this, [this, job]() {
if (job->error()) {
emitError(i18n("Failed to retrieve birthday calendar"));
return;
}
auto url = findBirthdayIcalLink(job->data());
if (url.isEmpty()) {
emitError(i18n("Failed to retrieve birthday calendar"));
return;
}
// switch webcal scheme for https so we can fetch it with KIO
url.setScheme(QStringLiteral("https"));
fetchBirthdayIcal(url);
});
job->start();
}
QUrl BirthdayListJob::findBirthdayIcalLink(const QByteArray &data)
{
// QXmlStreamParser cannot deal with Facebook's broken HTML and refuses
// to parse it. But since we know very well what we are looking for and the
// address is very unique in the source code, using QBAMatcher is much more
// efficient than QXmlStreamParser anyway...
QByteArrayMatcher matcher("webcal://www.facebook.com/ical/b.php");
const int start = matcher.indexIn(data);
if (start == -1) {
return {};
}
const int end = data.indexOf('\"', start);
if (end == -1) {
return {};
}
auto str = QString::fromUtf8(data.constData() + start, end - start);
return QUrl(KCharsets::resolveEntities(str));
}
void BirthdayListJob::fetchBirthdayIcal(const QUrl &url)
{
auto job = createGetJob(url);
connect(job, &KJob::result,
this, [this, job]() {
if (job->error()) {
emitError(job->errorText());
return;
}
auto cal = KCalCore::MemoryCalendar::Ptr::create(KDateTime::LocalZone);
KCalCore::ICalFormat format;
if (!format.fromRawString(cal, job->data(), false)) {
emitError(i18n("Failed to parse birthday calendar"));
return;
}
const auto events = cal->events();
for (const auto &event : events) {
processEvent(event);
}
emitResult();
});
}
void BirthdayListJob::processEvent(const KCalCore::Event::Ptr &event)
{
if (Settings::self()->birthdayReminders()) {
auto alarm = KCalCore::Alarm::Ptr::create(event.data());
alarm->setDisplayAlarm(event->summary());
alarm->setStartOffset({ -Settings::self()->birthdayReminderDays(),
KCalCore::Duration::Days });
alarm->setEnabled(true);
event->addAlarm(alarm);
}
const auto uid = event->uid(); // b123456789@facebook.com
const auto id = uid.mid(1, uid.indexOf(QLatin1Char('@')) - 2); // 123456789
event->setDescription(QStringLiteral("https://www.facebook.com/%1").arg(id));
Akonadi::Item item;
item.setRemoteId(uid);
item.setGid(uid);
item.setMimeType(KCalCore::Event::eventMimeType());
item.setParentCollection(mCollection);
item.setPayload(event);
mItems.push_back(item);
}
/*
* Copyright (C) 2017 Daniel Vrátil <dvratil@kde.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef FACEBOOK_BIRTHDAYLISTJOB_H_
#define FACEBOOK_BIRTHDAYLISTJOB_H_
#include <KJob>
#include <AkonadiCore/Collection>
#include <AkonadiCore/Item>
#include <KCalCore/Event>
class QUrl;
class FacebookResource;
namespace KIO {
class StoredTransferJob;
}
class BirthdayListJob : public KJob
{
Q_OBJECT
public:
BirthdayListJob(const Akonadi::Collection &collection, FacebookResource *parent);
~BirthdayListJob() override;
void start() override;
QVector<Akonadi::Item> items() const;
private:
KIO::StoredTransferJob *createGetJob(const QUrl &url) const;
void emitError(const QString &errorText);
void fetchFacebookEventsPage();
QUrl findBirthdayIcalLink(const QByteArray &data);
void fetchBirthdayIcal(const QUrl &url);
void processEvent(const KCalCore::Event::Ptr &event);
Akonadi::Collection mCollection;
QVector<Akonadi::Item> mItems;
QString mCookies;
};
#endif
#include <qcompilerdetection.h>
/*
* Copyright (C) 2017 Daniel Vrátil <dvratil@kde.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "eventslistjob.h"
#include "settings.h"
#include "graph.h"
#include <QJsonObject>
#include <QDateTime>
#include <KCalCore/Event>
EventsListJob::EventsListJob(const Akonadi::Collection &col, QObject *parent)
: ListJob(col, parent)
{
setRequest(QStringLiteral("me/events"),
{ QStringLiteral("id"),
QStringLiteral("name"),
QStringLiteral("description"),
QStringLiteral("place"),
QStringLiteral("start_time"),
QStringLiteral("end_time"),
QStringLiteral("owner"),
QStringLiteral("is_canceled")
},
{ { QStringLiteral("type"), col.remoteId() } });
}
EventsListJob::~EventsListJob()
{
}
KCalCore::Incidence::Status EventsListJob::parseStatus(const QJsonObject &data) const
{
const auto isCanceled = data.constFind(QLatin1String("is_canceled"));
if (isCanceled != data.constEnd()) {
if (isCanceled->toBool() == true) {
return KCalCore::Incidence::StatusCanceled;
}
}
switch (Graph::rsvpFromString(collection().remoteId())) {
case Graph::Attending:
return KCalCore::Incidence::StatusConfirmed;
case Graph::MaybeAttending:
return KCalCore::Incidence::StatusTentative;
case Graph::NotResponded:
return KCalCore::Incidence::StatusNeedsAction;
case Graph::Declined:
Q_FALLTHROUGH();
case Graph::Birthday:
Q_FALLTHROUGH();
}
return KCalCore::Incidence::StatusNone;
}
bool EventsListJob::shouldHaveAlarm(const Akonadi::Collection &col) const
{
const auto s = Settings::self();
const auto rsvp = Graph::rsvpFromString(col.remoteId());
return (rsvp == Graph::Attending && s->attendingReminders())
|| (rsvp == Graph::MaybeAttending && s->maybeAttendingReminders())
|| (rsvp == Graph::Declined && s->notAttendingReminders())
|| (rsvp == Graph::NotResponded && s->notRespondedToReminders());
}
QDateTime EventsListJob::parseDateTime(const QString &str) const
{
// Format is: 2017-06-20T16:30:00+0200
// Parse the absolute time
auto dt = QDateTime::fromString(str.left(19), QStringLiteral("yyyy-MM-ddTHH:mm:ss"));
// Parse the offset
const auto tz = str.rightRef(5);
const int sec = (tz.left(1) == QLatin1Char('+') ? 1 : -1) * tz.mid(1, 2).toInt() * 3600 + tz.right(2).toInt() * 60;
dt.setOffsetFromUtc(sec);
// Convert to local time
return dt.toLocalTime();
}
Akonadi::Item EventsListJob::handleResponse(const QJsonObject &data)
{
auto event = KCalCore::Event::Ptr::create();
event->setSummary(data.value(QLatin1String("name")).toString());
const auto dataEnd = data.constEnd();
const auto placeIt = data.constFind(QLatin1String("place"));
if (placeIt != dataEnd) {
const auto place = placeIt->toObject();
QStringList locationStr;
const auto placeEnd = place.constEnd();
auto it = place.constFind(QLatin1String("name"));
if (it != placeEnd) {
locationStr << it->toString();
}
it = place.constFind(QLatin1String("location"));
if (it != placeEnd) {
auto location = it->toObject();
for (const auto &loc : { QLatin1String("street"), QLatin1String("city"),
QLatin1String("zip"), QLatin1String("country") }) {
it = place.constFind(loc);
if (it != placeEnd) {
locationStr << it->toString();
}
}
// no name, no address, try GPS coordinates
if (locationStr.size() < 1) {
it = place.constFind(QLatin1String("longitude"));
if (it != placeEnd) {
event->setGeoLongitude(it->toDouble());
}
it = place.constFind(QLatin1String("latitude"));
if (it != placeEnd) {
event->setGeoLatitude(it->toDouble());
}
}
}
if (!locationStr.isEmpty()) {
event->setLocation(locationStr.join(QStringLiteral(", ")));
}
}
const QString dtStart = data.value(QLatin1String("start_time")).toString();
event->setDtStart(KDateTime(parseDateTime(dtStart)));
auto it = data.constFind(QLatin1String("end_time"));
if (it != dataEnd) {
event->setDtEnd(KDateTime(parseDateTime(it->toString())));
}
QString description = data.value(QLatin1String("description")).toString();
description += QStringLiteral("\n\nhttps://www.facebook.com/events/%1").arg(data.value(QLatin1String("id")).toString());
event->setDescription(description);
auto status = parseStatus(data);
event->setStatus(status);
if (status == KCalCore::Incidence::StatusCanceled) {
event->setTransparency(KCalCore::Event::Transparent);
} else {
event->setTransparency(KCalCore::Event::Opaque);
}
event->setUid(data.value(QLatin1String("id")).toString());
const auto owner = data.constFind(QLatin1String("owner"));
if (owner != dataEnd) {
event->setOrganizer(owner->toObject().value(QLatin1String("name")).toString());
}
if (shouldHaveAlarm(collection())) {
auto alarm = KCalCore::Alarm::Ptr::create(event.data());
alarm->setDisplayAlarm(event->summary());
alarm->setStartOffset({ -Settings::self()->eventReminderHours() * 3600,
KCalCore::Duration::Seconds });
alarm->setEnabled(true);
event->addAlarm(alarm);
}
Akonadi::Item item;
item.setMimeType(KCalCore::Event::eventMimeType());
item.setRemoteId(event->uid());
item.setGid(event->uid());
item.setParentCollection(collection());
item.setPayload(event);
return item;
}
/*
* Copyright (C) 2017 Daniel Vrátil <dvratil@kde.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef FACEBOOK_EVENTSLISTJOB_H_
#define FACEBOOK_EVENTSLISTJOB_H_
#include "listjob.h"
#include <KCalCore/Incidence>
class EventsListJob : public ListJob
{
Q_OBJECT
public:
explicit EventsListJob(const Akonadi::Collection &col, QObject *parent = nullptr);
~EventsListJob() override;
protected:
Akonadi::Item handleResponse(const QJsonObject &data) override;
private:
QDateTime parseDateTime(const QString &dt) const;
bool shouldHaveAlarm(const Akonadi::Collection &collection) const;
KCalCore::Incidence::Status parseStatus(const QJsonObject &data) const;
};
#endif
[Desktop Entry]
Name=Facebook Events
Comment=Access your Facebook events from KDE
Type=AkonadiResource
Exec=akonadi_facebook_resource
X-Akonadi-MimeTypes=text/calendar,application/x-vnd.akonadi.calendar.event
X-Akonadi-Identifier=akonadi_facebook_resource
X-Akonadi-Capabilities=Resource
Icon=im-facebook
/*
* Copyright (C) 2017 Daniel Vrátil <dvratil@kde.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "graph.h"
#include "resource_debug.h"
#include <QUrl>
QString Graph::appId()
{
return QStringLiteral("1723356724632790");
}
QString Graph::scopes()
{
return QStringLiteral("user_birthday,user_friends,user_events");
}
QUrl Graph::url(const QString &endpoint, const QString &accessToken,
const QStringList &fields, const QMap<QString, QString> &queries)
{
QUrl url(QStringLiteral("https://graph.facebook.com/v2.9/%1").arg(endpoint));
url.addQueryItem(QStringLiteral("access_token"), accessToken);
if( !fields.isEmpty()) {
url.addQueryItem(QStringLiteral("fields"), fields.join(QLatin1Char(',')));
}
for (auto it = queries.cbegin(), end = queries.cend(); it != end; ++it) {
url.addQueryItem(it.key(), it.value());
}
return url;
}
KJob *Graph::job(const QString &endpoint, const QString &accessToken,
const QStringList &fields, const QMap<QString, QString> &queries)
{
return job(url(endpoint, accessToken, fields, queries));
}
KJob *Graph::job(const QUrl &url)
{
return KIO::storedGet(url, KIO::NoReload, KIO::HideProgressInfo);
}
Graph::RSVP Graph::rsvpFromString(const QString &rsvp)
{
if (rsvp == QLatin1String("attending")) {
return Attending;
} else if (rsvp == QLatin1String("maybe")) {
return MaybeAttending;
} else if (rsvp == QLatin1String("declined")) {
return Declined;
} else if (rsvp == QLatin1String("not_replied")) {
return NotResponded;
} else if (rsvp == QLatin1String("birthday")) {
return Birthday;
} else {
qCDebug(RESOURCE_LOG) << "Unknown RSVP value" << rsvp;
return NotResponded;
}
}
QString Graph::rsvpToString(Graph::RSVP rsvp)
{
switch (rsvp) {
case Attending:
return QStringLiteral("attending");
case MaybeAttending:
return QStringLiteral("maybe");
case Declined:
return QStringLiteral("declined");
case NotResponded:
return QStringLiteral("not_replied");
case Birthday:
return QStringLiteral("birthday");
}
Q_UNREACHABLE();
}
/*
* Copyright (C) 2017 Daniel Vrátil <dvratil@kde.org>
*
* This program is free software: you can redistr