Page MenuHomePhorge

D268.1768974790.diff
No OneTemporary

Size
20 KB
Referenced Files
None
Subscribers
None

D268.1768974790.diff

diff --git a/CMakeLists.txt b/CMakeLists.txt
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -52,9 +52,12 @@
find_package(Qt${QT_MAJOR_VERSION} ${QT_MIN_VERSION} REQUIRED COMPONENTS
Core Gui Qml QuickControls2 Svg Concurrent Widgets
- Multimedia Test Network QuickTest HttpServer
+ Multimedia Test Network QuickTest HttpServer Sql
)
qt6_policy(SET QTP0001 NEW)
+find_package(QCoro6 REQUIRED COMPONENTS Core)
+qcoro_enable_coroutines()
+
set(kazv_KF_EXTRA_MODULES)
if(kazv_LINK_BREEZE_ICONS)
list(APPEND kazv_KF_EXTRA_MODULES BreezeIcons)
diff --git a/packaging/GNU-Linux/appimage/build.sh b/packaging/GNU-Linux/appimage/build.sh
--- a/packaging/GNU-Linux/appimage/build.sh
+++ b/packaging/GNU-Linux/appimage/build.sh
@@ -53,6 +53,9 @@
cmark
qt6-httpserver-dev
qt6-websockets-dev
+ libqt6sql6-sqlite
+ libqcoro6core0t64
+ qcoro-qt6-dev
)
export QMAKE=qmake6
cp -v packaging/GNU-Linux/appimage/kde-neon-noble.list /etc/apt/sources.list.d/
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -77,6 +77,7 @@
kazv-markdown.cpp
kazv-clipboard.cpp
+ db-store.cpp
)
ecm_qt_declare_logging_category(kazvqmlmodule_SRCS
@@ -104,9 +105,11 @@
Qt${QT_MAJOR_VERSION}::Svg
Qt${QT_MAJOR_VERSION}::Concurrent
Qt${QT_MAJOR_VERSION}::Widgets
+ Qt${QT_MAJOR_VERSION}::Sql
KF${KF_MAJOR_VERSION}::ConfigCore KF${KF_MAJOR_VERSION}::KIOCore
KF${KF_MAJOR_VERSION}::Notifications
KF${KF_MAJOR_VERSION}::CoreAddons
+ QCoro::Core
${CMARK_TARGET_NAME}
)
diff --git a/src/db-store.hpp b/src/db-store.hpp
new file mode 100644
--- /dev/null
+++ b/src/db-store.hpp
@@ -0,0 +1,63 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#include <kazv-defs.hpp>
+#include <tuple>
+#include <functional>
+#include <QString>
+#include <QThread>
+#include <QFuture>
+#include <QSqlDatabase>
+#include <QCoroTask>
+#include <sdk-model.hpp>
+
+/**
+ * This implements the event storage system using a relational database.
+ *
+ * The functions in this class are implemented as async because we do not
+ * want to block the calling thread. Just like in MatrixSdk, we create a
+ * dedicated thread for the database, and perform all work there.
+ * Results are sent back using coroutines as they are easier to use than QFuture.
+ */
+class DbStore
+{
+public:
+ static inline constexpr std::string dbDirName{"database"};
+ static inline constexpr std::string dbBaseName{"db.sqlite3"};
+ static QString getHandleFor(std::string userId, std::string deviceId);
+ DbStore();
+ ~DbStore();
+
+ /**
+ * Open the database for a specific session.
+ * @param userDataDir The user data dir from MatrixSdk.
+ * @param userId The user id.
+ * @param deviceId The device id.
+ * @return A pair of (status, error text). status is true iff the setup is successful. error text contains the error text reported by the database.
+ */
+ QCoro::Task<std::pair<bool, QString>> setup(std::string userDataDir, std::string userId, std::string deviceId);
+
+ /// @return Whether the DbStore is valid
+ bool valid() const;
+
+public Q_SLOTS:
+ /**
+ * Import all events from a model.
+ * @param model The model.
+ */
+ QCoro::Task<std::pair<bool, QString>> importAllFrom(const Kazv::SdkModel &model);
+
+private:
+ void cleanup();
+ QCoro::Task<std::pair<bool, QString>> migrate();
+
+ QCoro::Task<std::pair<bool, QString>> asyncQuery(QString qs, std::function<void(QSqlQuery &)> tf = [](auto &) {});
+
+ QThread *m_thread;
+ QObject *m_obj;
+ std::optional<QSqlDatabase> m_d;
+ QString m_handle;
+};
diff --git a/src/db-store.cpp b/src/db-store.cpp
new file mode 100644
--- /dev/null
+++ b/src/db-store.cpp
@@ -0,0 +1,190 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#include "db-store.hpp"
+#include "matrix-sdk.hpp"
+#include "event.hpp"
+#include "kazv-log.hpp"
+#include <QPromise>
+#include <QSqlError>
+#include <QSqlQuery>
+#include <QCoroFuture>
+
+using namespace Qt::Literals::StringLiterals;
+using namespace Kazv;
+
+QString DbStore::getHandleFor(std::string userId, std::string deviceId)
+{
+ return QString::fromStdString(userId + "/" + deviceId);
+}
+
+DbStore::DbStore()
+ : m_thread(new QThread)
+ , m_obj(new QObject)
+ , m_d(std::nullopt)
+ , m_handle()
+{
+ m_obj->moveToThread(m_thread);
+}
+
+DbStore::~DbStore()
+{
+ cleanup();
+}
+
+QCoro::Task<std::pair<bool, QString>> DbStore::setup(std::string userDataDir, std::string userId, std::string deviceId)
+{
+ m_handle = getHandleFor(userId, deviceId);
+ m_d = QSqlDatabase::addDatabase(u"QSQLITE"_s, m_handle);
+ auto dbFullDir = sessionDirForUserAndDeviceId(userDataDir, userId, deviceId) / dbDirName;
+ std::error_code err;
+ if ((! std::filesystem::create_directories(dbFullDir, err))
+ && err) {
+ qCWarning(kazvLog) << "DbStore: setup: cannot create db dir:" << dbFullDir.string() << ", error:" << err.message();
+ co_return {false, u"Cannot create db dir"_s};
+ }
+ auto dbName = QString::fromStdString((dbFullDir / dbBaseName).string());
+ qCDebug(kazvLog) << "DbStore: setup: data file name" << dbName;
+ m_d->setDatabaseName(dbName);
+ bool status = m_d->open();
+ if (!status) {
+ qCWarning(kazvLog) << "DbStore: Cannot open database";
+ std::pair<bool, QString> res = {status, m_d->lastError().text()};
+ m_d.reset();
+ QSqlDatabase::removeDatabase(m_handle);
+ co_return res;
+ }
+ qCDebug(kazvLog) << "DbStore: Opened database";
+ m_d->moveToThread(m_thread);
+ m_thread->start();
+ qCDebug(kazvLog) << "DbStore: Running database migrations";
+ auto res = co_await migrate();
+ co_return res;
+}
+
+QCoro::Task<std::pair<bool, QString>> DbStore::importAllFrom(const Kazv::SdkModel &model)
+{
+ static const auto qs = uR"xxx(INSERT INTO events (
+ room_id, event_id, encrypted, decrypted,
+ original_json, decrypted_json
+ ) VALUES (
+ :room_id, :event_id, :encrypted, :decrypted,
+ :original_json, :decrypted_json
+ ) ON CONFLICT (room_id, event_id) DO UPDATE SET
+ encrypted = :encrypted, decrypted = :decrypted,
+ original_json = :original_json, decrypted_json = :decrypted_json
+ ;)xxx"_s;
+ std::size_t count = 0;
+ for (const auto &[roomId, room] : model.c().roomList.rooms) {
+ auto roomIdQs = QString::fromStdString(roomId);
+ for (const auto &[eventId, event] : room.messages) {
+
+ auto eventIdQs = QString::fromStdString(eventId);
+ auto encrypted = event.encrypted();
+ auto decrypted = event.decrypted();
+ auto originalJson = QString::fromStdString(event.originalJson().get().dump());
+ QVariant decryptedJson = event.encrypted() ? QString::fromStdString(event.decryptedJson().get().dump()) : QVariant(QMetaType::fromType<QString>());
+ auto tf = [=](QSqlQuery &q) {
+ q.bindValue(u":room_id"_s, roomIdQs);
+ q.bindValue(u":event_id"_s, eventIdQs);
+ q.bindValue(u":encrypted"_s, encrypted);
+ q.bindValue(u":decrypted"_s, decrypted);
+ q.bindValue(u":original_json"_s, originalJson);
+ q.bindValue(u":decrypted_json"_s, decryptedJson);
+ };
+ auto res = co_await asyncQuery(qs, tf);
+ if (!res.first) {
+ qCWarning(kazvLog) << "Error inserting value:" << res.second;
+ co_return res;
+ }
+ ++count;
+ if (count % 100 == 0) {
+ qCInfo(kazvLog) << "Imported" << count << "events";
+ }
+ }
+ }
+ co_return {true, QString()};
+}
+
+bool DbStore::valid() const
+{
+ return m_d.has_value();
+}
+
+void DbStore::cleanup()
+{
+ if (m_d.has_value()) {
+ m_d->close();
+ m_d.reset();
+ QSqlDatabase::removeDatabase(m_handle);
+ m_handle = QString();
+ }
+ QMetaObject::invokeMethod(m_obj, [thread=m_thread]() {
+ thread->quit();
+ });
+
+ m_thread->wait();
+ m_thread->deleteLater();
+ m_obj->deleteLater();
+}
+
+QCoro::Task<std::pair<bool, QString>> DbStore::migrate()
+{
+ auto res = co_await asyncQuery(uR"xxx(CREATE TABLE IF NOT EXISTS events (
+ id INTEGER PRIMARY KEY NOT NULL,
+ room_id TEXT NOT NULL,
+ event_id TEXT NOT NULL,
+ encrypted INTEGER NOT NULL,
+ decrypted INTEGER NOT NULL,
+ original_json TEXT NOT NULL,
+ decrypted_json TEXT
+ );)xxx"_s);
+ if (!res.first) { co_return res; }
+
+ res = co_await asyncQuery(uR"xxx(CREATE UNIQUE INDEX IF NOT EXISTS events_room_id_event_id_index
+ ON events (
+ room_id,
+ event_id
+ );)xxx"_s);
+ co_return res;
+}
+
+QCoro::Task<std::pair<bool, QString>> DbStore::asyncQuery(QString qs, std::function<void(QSqlQuery &)> tf)
+{
+ // This function exists because QSqlQuery must be used in the thread where
+ // the QSqlDatabase is in.
+ // We invoke the query inside the thread of the database, then use the
+ // promise-future protocol to pass the result back as a coroutine.
+ QPromise<std::pair<bool, QString>> p;
+ qCDebug(kazvLog) << "DbStore: Async Query:" << qs;
+ auto fut = p.future();
+ QMetaObject::invokeMethod(m_obj, [this, p=std::move(p), s=std::move(qs), tf]() mutable {
+ p.start();
+ QSqlQuery q(m_d.value());
+ qCDebug(kazvLog) << "DbStore: Invoking Query:" << s;
+ auto res = q.prepare(std::move(s));
+ if (!res) {
+ qCDebug(kazvLog) << "DbStore: Async Query Prepare Failed: Driver:" << q.lastError().driverText();
+ qCDebug(kazvLog) << "DbStore: Async Query Prepare Failed: Db:" << q.lastError().databaseText();
+ p.addResult({false, q.lastError().text()});
+ p.finish();
+ return;
+ }
+ tf(q);
+ qCDebug(kazvLog) << "DbStore: where:" << q.boundValueNames() << q.boundValues();
+ res = q.exec();
+ qCDebug(kazvLog) << "DbStore: Async Query Done:" << res;
+ if (!res) {
+ qCDebug(kazvLog) << "DbStore: Async Query Failed: Driver:" << q.lastError().driverText();
+ qCDebug(kazvLog) << "DbStore: Async Query Failed: Db:" << q.lastError().databaseText();
+ p.addResult({false, q.lastError().text()});
+ } else {
+ p.addResult({true, QString()});
+ }
+ p.finish();
+ });
+ co_return co_await fut;
+}
diff --git a/src/matrix-sdk.hpp b/src/matrix-sdk.hpp
--- a/src/matrix-sdk.hpp
+++ b/src/matrix-sdk.hpp
@@ -38,7 +38,7 @@
class MatrixStickerPackList;
class MatrixUserGivenAttrsMap;
class KazvSessionLockGuard;
-
+class DbStore;
struct MatrixSdkPrivate;
std::filesystem::path sessionDirForUserAndDeviceId(std::filesystem::path userDataDir, std::string userId, std::string deviceId);
@@ -48,7 +48,6 @@
Q_OBJECT
QML_ELEMENT
- QString m_userDataDir;
std::unique_ptr<MatrixSdkPrivate> m_d;
/// @param d A dynamically allocated d-pointer, whose ownership
@@ -121,7 +120,7 @@
private:
// Replaces the store with another one
- void emplace(std::optional<Kazv::SdkModel> model, std::unique_ptr<KazvSessionLockGuard> lockGuard);
+ void emplace(std::optional<Kazv::SdkModel> model, std::unique_ptr<KazvSessionLockGuard> lockGuard, std::unique_ptr<DbStore> dbStore);
static std::string validateHomeserverUrl(const QString &url);
diff --git a/src/matrix-sdk.cpp b/src/matrix-sdk.cpp
--- a/src/matrix-sdk.cpp
+++ b/src/matrix-sdk.cpp
@@ -16,10 +16,9 @@
#include <QMutexLocker>
#include <QtConcurrent>
#include <QThreadPool>
-
#include <KConfig>
#include <KConfigGroup>
-
+#include <QCoroTask>
#include <eventemitter/lagerstoreeventemitter.hpp>
#include <client/sdk.hpp>
#include <client/notification-handler.hpp>
@@ -48,6 +47,7 @@
#include "kazv-log.hpp"
#include "matrix-utils.hpp"
#include "kazv-session-lock-guard.hpp"
+#include "db-store.hpp"
using namespace Qt::Literals::StringLiterals;
using namespace Kazv;
@@ -94,12 +94,13 @@
struct MatrixSdkPrivate
{
- MatrixSdkPrivate(MatrixSdk *q, bool testing, std::unique_ptr<KazvSessionLockGuard> lockGuard);
- MatrixSdkPrivate(MatrixSdk *q, bool testing, SdkModel model, std::unique_ptr<KazvSessionLockGuard> lockGuard);
+ MatrixSdkPrivate(MatrixSdk *q, bool testing, std::unique_ptr<KazvSessionLockGuard> lockGuard, std::unique_ptr<DbStore> dbStore);
+ MatrixSdkPrivate(MatrixSdk *q, bool testing, SdkModel model, std::unique_ptr<KazvSessionLockGuard> lockGuard, std::unique_ptr<DbStore> dbStore);
bool testing;
std::string userDataDir;
std::unique_ptr<KazvSessionLockGuard> lockGuard;
RandomInterface randomGenerator;
+ std::unique_ptr<DbStore> dbStore;
QThread *thread;
QObject *obj;
QtJobHandler *jobHandler;
@@ -234,15 +235,16 @@
KConfig metadata(QString::fromStdString(metadataFile.string()));
KConfigGroup mdGroup(&metadata, u"Metadata"_s);
mdGroup.writeEntry("kazvVersion", QString::fromStdString(kazvVersionString()));
- mdGroup.writeEntry("archiveFormat", "text");
+ mdGroup.writeEntry("archiveFormat", "text-with-sqlite");
}
}
-MatrixSdkPrivate::MatrixSdkPrivate(MatrixSdk *q, bool testing, std::unique_ptr<KazvSessionLockGuard> lockGuard)
+MatrixSdkPrivate::MatrixSdkPrivate(MatrixSdk *q, bool testing, std::unique_ptr<KazvSessionLockGuard> lockGuard, std::unique_ptr<DbStore> dbStore)
: testing(testing)
, userDataDir{kazvUserDataDir().toStdString()}
, lockGuard(std::move(lockGuard))
, randomGenerator(QtRandAdapter{})
+ , dbStore(std::move(dbStore))
, thread(new QThread())
, obj(new QObject())
, jobHandler(new QtJobHandler(obj))
@@ -262,11 +264,12 @@
obj->moveToThread(thread);
}
-MatrixSdkPrivate::MatrixSdkPrivate(MatrixSdk *q, bool testing, SdkModel model, std::unique_ptr<KazvSessionLockGuard> lockGuard)
+MatrixSdkPrivate::MatrixSdkPrivate(MatrixSdk *q, bool testing, SdkModel model, std::unique_ptr<KazvSessionLockGuard> lockGuard, std::unique_ptr<DbStore> dbStore)
: testing(testing)
, userDataDir{kazvUserDataDir().toStdString()}
, lockGuard(std::move(lockGuard))
, randomGenerator(QtRandAdapter{})
+ , dbStore(std::move(dbStore))
, thread(new QThread())
, obj(new QObject())
, jobHandler(new QtJobHandler(obj))
@@ -288,7 +291,6 @@
MatrixSdk::MatrixSdk(std::unique_ptr<MatrixSdkPrivate> d, QObject *parent)
: QObject(parent)
- , m_userDataDir(kazvUserDataDir())
, m_d(std::move(d))
{
init();
@@ -343,12 +345,23 @@
}
MatrixSdk::MatrixSdk(QObject *parent)
- : MatrixSdk(std::make_unique<MatrixSdkPrivate>(this, /* testing = */ false, std::unique_ptr<KazvSessionLockGuard>()), parent)
+ : MatrixSdk(std::make_unique<MatrixSdkPrivate>(
+ this,
+ /* testing = */ false,
+ std::unique_ptr<KazvSessionLockGuard>(),
+ std::unique_ptr<DbStore>()
+ ), parent)
{
}
MatrixSdk::MatrixSdk(SdkModel model, bool testing, QObject *parent)
- : MatrixSdk(std::make_unique<MatrixSdkPrivate>(this, testing, std::move(model), std::unique_ptr<KazvSessionLockGuard>()), parent)
+ : MatrixSdk(std::make_unique<MatrixSdkPrivate>(
+ this,
+ testing,
+ std::move(model),
+ std::unique_ptr<KazvSessionLockGuard>(),
+ std::unique_ptr<DbStore>()
+ ), parent)
{
}
@@ -551,7 +564,7 @@
return new MatrixRoomList(m_d->clientOnSecondaryRoot);
}
-void MatrixSdk::emplace(std::optional<SdkModel> model, std::unique_ptr<KazvSessionLockGuard> lockGuard)
+void MatrixSdk::emplace(std::optional<SdkModel> model, std::unique_ptr<KazvSessionLockGuard> lockGuard, std::unique_ptr<DbStore> dbStore)
{
auto testing = m_d->testing;
auto userDataDir = m_d->userDataDir;
@@ -560,9 +573,13 @@
cleanupDPointer(std::move(m_d));
}
- m_d = (model.has_value()
- ? std::make_unique<MatrixSdkPrivate>(this, testing, std::move(model.value()), std::move(lockGuard))
- : std::make_unique<MatrixSdkPrivate>(this, testing, std::move(lockGuard)));
+ m_d = model.has_value()
+ ? std::make_unique<MatrixSdkPrivate>(
+ this, testing, std::move(model.value()),
+ std::move(lockGuard), std::move(dbStore))
+ : std::make_unique<MatrixSdkPrivate>(
+ this, testing, std::move(lockGuard), std::move(dbStore)
+ );
m_d->userDataDir = userDataDir;
// Re-initialize lager-qt cursors and watchable connections
@@ -631,7 +648,7 @@
KConfig metadata(QString::fromStdString(metadataFile.string()));
KConfigGroup mdGroup(&metadata, u"Metadata"_s);
auto format = mdGroup.readEntry(u"archiveFormat"_s);
- if (format != QStringLiteral("text")) {
+ if (format != u"text"_s && format != u"text-with-sqlite"_s) {
qDebug() << "Unknown archive format:" << format;
Q_EMIT loadSessionFinished(sessionName, SessionFormatUnknown);
return;
@@ -667,14 +684,33 @@
using IAr = boost::archive::text_iarchive;
auto archive = IAr{storeStream};
archive >> model;
- qDebug() << "Finished loading session";
+ qDebug() << "Finished loading session store file";
} catch (const std::exception &e) {
qDebug() << "Error when loading session:" << QString::fromStdString(e.what());
Q_EMIT loadSessionFinished(sessionName, SessionDeserializeFailed);
return;
}
- QMetaObject::invokeMethod(this, [this, model=std::move(model), lockGuard=std::move(lockGuard)]() mutable {
- emplace(std::move(model), std::move(lockGuard));
+
+ auto dbStore = std::make_unique<DbStore>();
+ auto stat = QCoro::waitFor([](SdkModel model, DbStore *dbStore, std::string userDataDir) -> QCoro::Task<std::pair<bool, QString>> {
+ qCDebug(kazvLog) << "open db:" << userDataDir << model.c().userId << model.c().deviceId;
+ auto res = co_await dbStore->setup(
+ userDataDir,
+ model.c().userId,
+ model.c().deviceId
+ );
+ if (!res.first) {
+ co_return res;
+ }
+ co_return co_await dbStore->importAllFrom(model);
+ }(model, dbStore.get(), m_d->userDataDir));
+ if (!stat.first) {
+ qCWarning(kazvLog) << "Unable to open db store:" << stat.second;
+ Q_EMIT loadSessionFinished(sessionName, SessionDeserializeFailed);
+ return;
+ }
+ QMetaObject::invokeMethod(this, [this, model=std::move(model), lockGuard=std::move(lockGuard), dbStore=std::move(dbStore)]() mutable {
+ emplace(std::move(model), std::move(lockGuard), std::move(dbStore));
});
Q_EMIT loadSessionFinished(sessionName, SessionLoadSuccess);
};
@@ -706,7 +742,7 @@
bool MatrixSdk::deleteSession(QString sessionName) {
using StdPath = std::filesystem::path;
qDebug() << "in deleteSession(), sessionName=" << sessionName;
- auto userDataDir = StdPath(kazvUserDataDir().toStdString());
+ auto userDataDir = StdPath(m_d->userDataDir);
auto parts = sessionName.split(u'/');
if (parts.size() == 2) {
auto userId = parts[0].toStdString();
@@ -729,7 +765,7 @@
bool MatrixSdk::startNewSession()
{
- emplace(std::nullopt, std::unique_ptr<KazvSessionLockGuard>());
+ emplace(std::nullopt, std::unique_ptr<KazvSessionLockGuard>(), std::unique_ptr<DbStore>());
return true;
}
diff --git a/src/tests/matrix-sdk-sessions-test.cpp b/src/tests/matrix-sdk-sessions-test.cpp
--- a/src/tests/matrix-sdk-sessions-test.cpp
+++ b/src/tests/matrix-sdk-sessions-test.cpp
@@ -16,6 +16,7 @@
#include <crypto/base64.hpp>
#include "test-temp.hpp"
#include "matrix-sdk-sessions-test.hpp"
+#include "db-store.hpp"
using namespace Qt::Literals::StringLiterals;
using namespace Kazv;
@@ -125,7 +126,7 @@
};
proc1.start(program, args);
proc1.waitForReadyRead();
- auto line = proc1.readLine();
+ auto line = proc1.readAllStandardOutput();
QCOMPARE(line, QByteArray("loaded session\n"));
QProcess proc2;
@@ -151,8 +152,16 @@
auto sessionDir = sessionDirForUserAndDeviceId(Fs::path(m_userDataDir), userId, deviceId);
// there is store, metadata and lock under the sessionDir
-
+ auto dbFullDir = sessionDir / DbStore::dbDirName;
+ auto dbName = dbFullDir / DbStore::dbBaseName;
std::error_code err;
+ Fs::create_directories(dbFullDir, err);
+ QVERIFY(!err);
+ {
+ std::ofstream s(dbName, std::ios_base::binary);
+ s.flush();
+ }
+
// make the sessionDir readonly, so that new file cannot be created
Fs::permissions(
sessionDir,

File Metadata

Mime Type
text/plain
Expires
Tue, Jan 20, 9:53 PM (14 h, 47 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
982198
Default Alt Text
D268.1768974790.diff (20 KB)

Event Timeline