Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F33237261
D268.1768980382.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
20 KB
Referenced Files
None
Subscribers
None
D268.1768980382.diff
View Options
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
Details
Attached
Mime Type
text/plain
Expires
Tue, Jan 20, 11:26 PM (13 h, 12 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
982198
Default Alt Text
D268.1768980382.diff (20 KB)
Attached To
Mode
D268: Import all current stored events into database
Attached
Detach File
Event Timeline
Log In to Comment