feat: Pagination/virtualization (fetchMore) for EmailListModel

- EmailListModel: QAbstractListModel with canFetchMore/fetchMore
  * Batch loading (default 50 emails per fetch)
  * Total count query for scrollbar
  * TotalCountChanged signal
  * Paginated DB queries: findAllPaginated, findByFolderIdPaginated
  * Count queries: countAll, countByFolderId

- MailItemDao: new paginated methods
  * findAllPaginated(offset, limit, filters...)
  * findByFolderIdPaginated(folderId, offset, limit, filters...)
  * countAll(filters...), countByFolderId(folderId, filters...)

- MailListView: scroll-triggered fetchMore
  * Connects verticalScrollBar valueChanged on both TreeView and ListView
  * Triggers fetchMore at 90% scroll threshold
  * onRowsInserted updates EmailTreeModel when new emails arrive
  * Works in both normal (tree) and compact (list) views
This commit is contained in:
2026-08-25 22:20:06 +02:00
parent 6e6fdf9c21
commit 68d237c873
77 changed files with 16718 additions and 18159 deletions
+198
View File
@@ -437,3 +437,201 @@ int MailItemDao::countUnreadByFolderId(int folderId)
}
return 0;
}
int MailItemDao::countAll(const QString& searchFilter, bool unreadOnly, bool flaggedOnly, bool hasAttachments)
{
QSqlDatabase db = DatabaseManager::instance().database();
QString sql = "SELECT COUNT(*) FROM MailCopy WHERE 1=1";
QSqlQuery query(db);
if (!searchFilter.isEmpty()) {
sql += " AND (subject LIKE :search OR sender LIKE :search OR recipient LIKE :search OR bodyHtml LIKE :search)";
query.bindValue(":search", "%" + searchFilter + "%");
}
if (unreadOnly) {
sql += " AND read = 0";
}
if (flaggedOnly) {
sql += " AND flagged = 1";
}
if (hasAttachments) {
sql += " AND hasAttachment = 1";
}
query.prepare(sql);
if (!query.exec()) {
qWarning() << "Failed to count all mails:" << query.lastError().text();
return 0;
}
if (query.next()) {
return query.value(0).toInt();
}
return 0;
}
int MailItemDao::countByFolderId(int folderId, const QString& searchFilter, bool unreadOnly, bool flaggedOnly, bool hasAttachments)
{
QSqlDatabase db = DatabaseManager::instance().database();
QString sql = "SELECT COUNT(*) FROM MailCopy WHERE folderId = :folderId";
QSqlQuery query(db);
query.bindValue(":folderId", folderId);
if (!searchFilter.isEmpty()) {
sql += " AND (subject LIKE :search OR sender LIKE :search OR recipient LIKE :search OR bodyHtml LIKE :search)";
query.bindValue(":search", "%" + searchFilter + "%");
}
if (unreadOnly) {
sql += " AND read = 0";
}
if (flaggedOnly) {
sql += " AND flagged = 1";
}
if (hasAttachments) {
sql += " AND hasAttachment = 1";
}
query.prepare(sql);
if (!query.exec()) {
qWarning() << "Failed to count mails by folder:" << query.lastError().text();
return 0;
}
if (query.next()) {
return query.value(0).toInt();
}
return 0;
}
QVector<MailItem> MailItemDao::findAllPaginated(int offset, int limit, const QString& searchFilter, bool unreadOnly, bool flaggedOnly, bool hasAttachments)
{
QVector<MailItem> items;
QSqlDatabase db = DatabaseManager::instance().database();
QString sql = "SELECT id, folderId, messageId, subject, sender, recipient, date, read, flagged, hasAttachment, size, fileId, uid, bodyHtml, toAddr, ccAddr, bccAddr, threadId, inReplyTo, \"references\", isPinned FROM MailCopy WHERE 1=1";
if (!searchFilter.isEmpty()) {
sql += " AND (subject LIKE :search OR sender LIKE :search OR recipient LIKE :search OR bodyHtml LIKE :search)";
}
if (unreadOnly) {
sql += " AND read = 0";
}
if (flaggedOnly) {
sql += " AND flagged = 1";
}
if (hasAttachments) {
sql += " AND hasAttachment = 1";
}
sql += " ORDER BY date DESC LIMIT :limit OFFSET :offset";
QSqlQuery query(db);
query.prepare(sql);
if (!searchFilter.isEmpty()) {
query.bindValue(":search", "%" + searchFilter + "%");
}
query.bindValue(":limit", limit);
query.bindValue(":offset", offset);
if (!query.exec()) {
qWarning() << "Failed to fetch paginated mails:" << query.lastError().text();
return items;
}
while (query.next()) {
MailItem item;
item.setId(query.value(0).toInt());
item.setFolderId(query.value(1).toInt());
item.setMessageId(query.value(2).toString());
item.setSubject(query.value(3).toString());
item.setSender(query.value(4).toString());
item.setRecipient(query.value(5).toString());
item.setDate(query.value(6).toDateTime());
item.setRead(query.value(7).toBool());
item.setFlagged(query.value(8).toBool());
item.setSize(query.value(10).toLongLong());
item.setFileId(query.value(11).toString());
item.setUid(query.value(12).toLongLong());
item.setBodyHtml(query.value(13).toString());
item.setTo(query.value(14).toString());
item.setCc(query.value(15).toString());
item.setBcc(query.value(16).toString());
item.setThreadId(query.value(17).toString());
item.setInReplyTo(query.value(18).toString());
QString refs = query.value(19).toString();
if (!refs.isEmpty()) item.setReferences(refs.split(" ", Qt::SkipEmptyParts));
item.setPinned(query.value(20).toBool());
QVector<QString> names;
for (const auto &attachment : attachmentsForMail(item.id())) names.append(attachment.fileName);
item.setAttachments(names);
items.append(item);
}
return items;
}
QVector<MailItem> MailItemDao::findByFolderIdPaginated(int folderId, int offset, int limit, const QString& searchFilter, bool unreadOnly, bool flaggedOnly, bool hasAttachments)
{
QVector<MailItem> items;
QSqlDatabase db = DatabaseManager::instance().database();
QString sql = "SELECT id, folderId, messageId, subject, sender, recipient, date, read, flagged, hasAttachment, size, fileId, uid, bodyHtml, toAddr, ccAddr, bccAddr, threadId, inReplyTo, \"references\", isPinned FROM MailCopy WHERE folderId = :folderId";
QSqlQuery query(db);
if (!searchFilter.isEmpty()) {
sql += " AND (subject LIKE :search OR sender LIKE :search OR recipient LIKE :search OR bodyHtml LIKE :search)";
query.bindValue(":search", "%" + searchFilter + "%");
}
if (unreadOnly) {
sql += " AND read = 0";
}
if (flaggedOnly) {
sql += " AND flagged = 1";
}
if (hasAttachments) {
sql += " AND hasAttachment = 1";
}
sql += " ORDER BY date DESC LIMIT :limit OFFSET :offset";
query.prepare(sql);
query.bindValue(":folderId", folderId);
query.bindValue(":limit", limit);
query.bindValue(":offset", offset);
if (!searchFilter.isEmpty()) {
query.bindValue(":search", "%" + searchFilter + "%");
}
if (!query.exec()) {
qWarning() << "Failed to fetch paginated mails by folder:" << query.lastError().text();
return items;
}
while (query.next()) {
MailItem item;
item.setId(query.value(0).toInt());
item.setFolderId(query.value(1).toInt());
item.setMessageId(query.value(2).toString());
item.setSubject(query.value(3).toString());
item.setSender(query.value(4).toString());
item.setRecipient(query.value(5).toString());
item.setDate(query.value(6).toDateTime());
item.setRead(query.value(7).toBool());
item.setFlagged(query.value(8).toBool());
item.setSize(query.value(10).toLongLong());
item.setFileId(query.value(11).toString());
item.setUid(query.value(12).toLongLong());
item.setBodyHtml(query.value(13).toString());
item.setTo(query.value(14).toString());
item.setCc(query.value(15).toString());
item.setBcc(query.value(16).toString());
item.setThreadId(query.value(17).toString());
item.setInReplyTo(query.value(18).toString());
QString refs = query.value(19).toString();
if (!refs.isEmpty()) item.setReferences(refs.split(" ", Qt::SkipEmptyParts));
item.setPinned(query.value(20).toBool());
QVector<QString> names;
for (const auto &attachment : attachmentsForMail(item.id())) names.append(attachment.fileName);
item.setAttachments(names);
items.append(item);
}
return items;
}
+4
View File
@@ -33,4 +33,8 @@ public:
static bool replaceAttachments(qint64 mailItemId, const QVector<StoredAttachmentRecord>& attachments);
static QVector<StoredAttachmentRecord> attachmentsForMail(qint64 mailItemId);
static int countUnreadByFolderId(int folderId);
static int countAll(const QString& searchFilter, bool unreadOnly, bool flaggedOnly, bool hasAttachments);
static int countByFolderId(int folderId, const QString& searchFilter, bool unreadOnly, bool flaggedOnly, bool hasAttachments);
static QVector<MailItem> findAllPaginated(int offset, int limit, const QString& searchFilter, bool unreadOnly, bool flaggedOnly, bool hasAttachments);
static QVector<MailItem> findByFolderIdPaginated(int folderId, int offset, int limit, const QString& searchFilter, bool unreadOnly, bool flaggedOnly, bool hasAttachments);
};
+47 -1
View File
@@ -14,6 +14,7 @@
#include <QResizeEvent>
#include <QListView>
#include <QStyledItemDelegate>
#include <QScrollBar>
#include "ui/delegates/CompactMailDelegate.h"
MailListView::MailListView(QWidget *parent) : QWidget(parent), m_sourceModel(nullptr)
@@ -217,8 +218,13 @@ void MailListView::setModel(EmailListModel *model)
// Connect to source model changes to refresh both views
connect(m_sourceModel, &QAbstractItemModel::modelReset, this, &MailListView::refreshViews);
connect(m_sourceModel, &QAbstractItemModel::layoutChanged, this, &MailListView::refreshViews);
connect(m_sourceModel, &QAbstractItemModel::rowsInserted, this, &MailListView::refreshViews);
connect(m_sourceModel, &QAbstractItemModel::rowsInserted, this, &MailListView::onRowsInserted);
connect(m_sourceModel, &QAbstractItemModel::rowsRemoved, this, &MailListView::refreshViews);
connect(m_sourceModel, &EmailListModel::totalCountChanged, this, &MailListView::onTotalCountChanged);
// Connect tree view scroll for fetchMore
connect(m_treeView->verticalScrollBar(), &QScrollBar::valueChanged, this, &MailListView::onTreeViewScrolled);
connect(m_listView->verticalScrollBar(), &QScrollBar::valueChanged, this, &MailListView::onListViewScrolled);
// Initial population
refreshViews();
@@ -228,6 +234,7 @@ void MailListView::refreshViews()
{
if (!m_sourceModel) return;
// For paginated model, just update the tree model with currently loaded emails
const QVector<MailItem> &emails = m_sourceModel->emails();
m_treeModel->setEmails(emails);
m_treeView->expandAll();
@@ -346,4 +353,43 @@ void MailListView::onDisplayModeChanged(int index)
CompactMailDelegate::DisplayMode mode = static_cast<CompactMailDelegate::DisplayMode>(index);
m_compactDelegate->setDisplayMode(mode);
m_listView->update();
}
void MailListView::onTotalCountChanged(int count)
{
Q_UNUSED(count);
// Could update a status label showing "X of Y emails"
}
void MailListView::onTreeViewScrolled(int value)
{
if (!m_sourceModel) return;
QScrollBar* scrollBar = m_treeView->verticalScrollBar();
int maxValue = scrollBar->maximum();
if (maxValue > 0 && value >= maxValue * 0.9) { // 90% threshold
m_sourceModel->fetchMore(QModelIndex());
}
}
void MailListView::onListViewScrolled(int value)
{
if (!m_sourceModel) return;
QScrollBar* scrollBar = m_listView->verticalScrollBar();
int maxValue = scrollBar->maximum();
if (maxValue > 0 && value >= maxValue * 0.9) { // 90% threshold
m_sourceModel->fetchMore(QModelIndex());
}
}
void MailListView::onRowsInserted(const QModelIndex &parent, int first, int last)
{
Q_UNUSED(parent);
Q_UNUSED(first);
Q_UNUSED(last);
// New emails loaded via fetchMore - refresh tree model
if (m_sourceModel) {
const QVector<MailItem> &emails = m_sourceModel->emails();
m_treeModel->setEmails(emails);
m_treeView->expandAll();
}
}
+4
View File
@@ -56,6 +56,10 @@ private slots:
void onCompactForwardRequested(int mailId);
void onCompactMarkUnreadRequested(int mailId);
void onDisplayModeChanged(int index);
void onTotalCountChanged(int count);
void onTreeViewScrolled(int value);
void onListViewScrolled(int value);
void onRowsInserted(const QModelIndex &parent, int first, int last);
protected:
void resizeEvent(QResizeEvent *event) override;
+71 -71
View File
@@ -3,7 +3,7 @@
#include <QDateTime>
EmailListModel::EmailListModel(QObject *parent)
: QAbstractTableModel(parent)
: QAbstractListModel(parent)
{
refresh();
}
@@ -15,12 +15,6 @@ int EmailListModel::rowCount(const QModelIndex &parent) const
return m_emails.size();
}
int EmailListModel::columnCount(const QModelIndex &parent) const
{
Q_UNUSED(parent);
return ColCount;
}
QVariant EmailListModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= m_emails.size())
@@ -30,16 +24,7 @@ QVariant EmailListModel::data(const QModelIndex &index, int role) const
switch (role) {
case Qt::DisplayRole:
case Qt::EditRole:
switch (index.column()) {
case ColSubject:
return item.subject();
case ColSender:
return item.sender();
case ColDate:
return item.date();
}
break;
return item.subject();
case IdRole:
return item.id();
case SubjectRole:
@@ -68,24 +53,14 @@ QVariant EmailListModel::data(const QModelIndex &index, int role) const
return QString();
return QString(sender.at(0)).toUpper();
}
default:
return QVariant();
}
return QVariant();
}
QVariant EmailListModel::headerData(int section, Qt::Orientation orientation, int role) const
{
if (orientation != Qt::Horizontal || role != Qt::DisplayRole)
return QVariant();
switch (section) {
case ColSubject:
return tr("Asunto");
case ColSender:
return tr("De");
case ColDate:
return tr("Fecha");
case ThreadIdRole:
return item.threadId();
case InReplyToRole:
return item.inReplyTo();
case ReferencesRole:
return item.references().join(" ");
case IsPinnedRole:
return item.isPinned();
default:
return QVariant();
}
@@ -106,9 +81,48 @@ QHash<int, QByteArray> EmailListModel::roleNames() const
roles[SizeRole] = "size";
roles[MessageIdRole] = "messageId";
roles[SenderInitialRole] = "senderInitial";
roles[ThreadIdRole] = "threadId";
roles[InReplyToRole] = "inReplyTo";
roles[ReferencesRole] = "references";
roles[IsPinnedRole] = "isPinned";
return roles;
}
bool EmailListModel::canFetchMore(const QModelIndex &parent) const
{
if (parent.isValid())
return false;
return m_loadedCount < m_totalCount;
}
void EmailListModel::fetchMore(const QModelIndex &parent)
{
if (parent.isValid())
return;
int remaining = m_totalCount - m_loadedCount;
if (remaining <= 0)
return;
int fetchCount = qMin(m_batchSize, remaining);
int offset = m_loadedCount;
QVector<MailItem> newEmails;
if (m_folderId == -1) {
newEmails = MailItemDao::findAllPaginated(offset, fetchCount, m_searchFilter, m_unreadOnly, m_flaggedOnly, m_hasAttachments);
} else {
newEmails = MailItemDao::findByFolderIdPaginated(m_folderId, offset, fetchCount, m_searchFilter, m_unreadOnly, m_flaggedOnly, m_hasAttachments);
}
if (newEmails.isEmpty())
return;
beginInsertRows(QModelIndex(), m_loadedCount, m_loadedCount + newEmails.size() - 1);
m_emails.append(newEmails);
m_loadedCount += newEmails.size();
endInsertRows();
}
void EmailListModel::setFolderId(int folderId)
{
if (m_folderId == folderId)
@@ -120,45 +134,31 @@ void EmailListModel::setFolderId(int folderId)
void EmailListModel::refresh()
{
beginResetModel();
QVector<MailItem> allEmails;
if (m_folderId == -1) {
allEmails = MailItemDao::findAll();
} else {
allEmails = MailItemDao::findByFolderId(m_folderId);
}
// Apply filters
m_emails.clear();
for (const auto &item : allEmails) {
// Search filter
if (!m_searchFilter.isEmpty()) {
if (!item.subject().contains(m_searchFilter, Qt::CaseInsensitive) &&
!item.sender().contains(m_searchFilter, Qt::CaseInsensitive) &&
!item.recipient().contains(m_searchFilter, Qt::CaseInsensitive) &&
!item.bodyHtml().contains(m_searchFilter, Qt::CaseInsensitive)) {
continue;
}
}
// Unread only
if (m_unreadOnly && item.isRead()) {
continue;
}
// Flagged only
if (m_flaggedOnly && !item.isFlagged()) {
continue;
}
// Has attachments
if (m_hasAttachments && item.attachments().isEmpty()) {
continue;
}
m_emails.append(item);
m_loadedCount = 0;
// Get total count
if (m_folderId == -1) {
m_totalCount = MailItemDao::countAll(m_searchFilter, m_unreadOnly, m_flaggedOnly, m_hasAttachments);
} else {
m_totalCount = MailItemDao::countByFolderId(m_folderId, m_searchFilter, m_unreadOnly, m_flaggedOnly, m_hasAttachments);
}
emit totalCountChanged(m_totalCount);
// Load first batch
int fetchCount = qMin(m_batchSize, m_totalCount);
QVector<MailItem> firstBatch;
if (m_folderId == -1) {
firstBatch = MailItemDao::findAllPaginated(0, fetchCount, m_searchFilter, m_unreadOnly, m_flaggedOnly, m_hasAttachments);
} else {
firstBatch = MailItemDao::findByFolderIdPaginated(m_folderId, 0, fetchCount, m_searchFilter, m_unreadOnly, m_flaggedOnly, m_hasAttachments);
}
m_emails = firstBatch;
m_loadedCount = firstBatch.size();
endResetModel();
qDebug() << "EmailListModel refreshed with" << m_emails.size() << "emails for folderId" << m_folderId;
qDebug() << "EmailListModel refreshed: total=" << m_totalCount << "loaded=" << m_loadedCount << "for folderId" << m_folderId;
}
void EmailListModel::setSearchFilter(const QString &filter)
+20 -12
View File
@@ -6,7 +6,7 @@
#include <QByteArray>
#include "../db/dao/mailitemdao.h"
class EmailListModel : public QAbstractTableModel
class EmailListModel : public QAbstractListModel
{
Q_OBJECT
public:
@@ -25,22 +25,19 @@ public:
FileIdRole,
SizeRole,
MessageIdRole,
SenderInitialRole // computed from sender
};
// Column indices (match role order for simplicity)
enum Columns {
ColSubject = 0,
ColSender = 1,
ColDate = 2,
ColCount = 3 // number of visible columns
SenderInitialRole, // computed from sender
ThreadIdRole,
InReplyToRole,
ReferencesRole,
IsPinnedRole
};
// QAbstractListModel interface
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
int columnCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
bool canFetchMore(const QModelIndex &parent) const override;
void fetchMore(const QModelIndex &parent) override;
// Set the folderId to filter emails; -1 means all folders
void setFolderId(int folderId);
@@ -52,10 +49,18 @@ public:
void setShowUnreadOnly(bool show);
void setShowFlaggedOnly(bool show);
void setShowHasAttachments(bool show);
// Set batch size for pagination
void setBatchSize(int size) { m_batchSize = size; }
// Get all emails currently in the model (after filtering)
const QVector<MailItem>& emails() const { return m_emails; }
// Total count of emails matching filters (including unloaded)
int totalCount() const { return m_totalCount; }
signals:
void totalCountChanged(int count);
private:
QVector<MailItem> m_emails;
int m_folderId{-1}; // -1 means all folders
@@ -63,5 +68,8 @@ private:
bool m_unreadOnly{false};
bool m_flaggedOnly{false};
bool m_hasAttachments{false};
int m_batchSize{50}; // emails per fetch
int m_loadedCount{0}; // how many emails currently loaded
int m_totalCount{0}; // total matching emails in DB
};
#endif // EMAILLISTMODEL_H