feat: EmailTreeModel grouping modes + MailListView integration
- EmailTreeModel: 4 grouping modes * NoGrouping - flat list * GroupByDate - chronological groups (Hoy, Ayer, Esta semana...) * GroupByThread - conversations with expandable threads (ThreadId from References/In-Reply-To/Message-ID) * GroupBySender - groups by sender email/name - EmailTreeModel thread support: * ThreadItem type with expand/collapse * ThreadId extraction via ThreadUtils (References > In-Reply-To > Message-ID) * Double-click thread node toggles expansion * Threads sorted by newest email first, emails within thread oldest first - EmailTreeModel sender grouping: * Groups by sender name/email * Senders sorted by newest email first * Emails within group newest first - MailListView: Added 'Agrupar por remitente' to group-by combo - MailListView: Double-click thread node toggles expansion (not open mail) - ThreadUtils: parseReferencesHeader, extractThreadId, generateThreadId
This commit is contained in:
@@ -7,7 +7,11 @@ MailItem::MailItem(qint64 id, int folderId, const QString& subject, const QStrin
|
||||
const QString& fileId,
|
||||
qint64 size,
|
||||
const QString& messageId,
|
||||
qint64 uid)
|
||||
qint64 uid,
|
||||
const QString& threadId,
|
||||
const QString& inReplyTo,
|
||||
const QVector<QString>& references,
|
||||
bool isPinned)
|
||||
: m_id(id)
|
||||
, m_folderId(folderId)
|
||||
, m_subject(subject)
|
||||
@@ -21,5 +25,9 @@ MailItem::MailItem(qint64 id, int folderId, const QString& subject, const QStrin
|
||||
, m_size(size)
|
||||
, m_messageId(messageId)
|
||||
, m_uid(uid)
|
||||
, m_threadId(threadId)
|
||||
, m_inReplyTo(inReplyTo)
|
||||
, m_references(references)
|
||||
, m_isPinned(isPinned)
|
||||
{
|
||||
}
|
||||
+21
-1
@@ -16,7 +16,11 @@ public:
|
||||
const QString& fileId = QString(),
|
||||
qint64 size = 0,
|
||||
const QString& messageId = QString(),
|
||||
qint64 uid = 0);
|
||||
qint64 uid = 0,
|
||||
const QString& threadId = QString(),
|
||||
const QString& inReplyTo = QString(),
|
||||
const QVector<QString>& references = QVector<QString>(),
|
||||
bool isPinned = false);
|
||||
|
||||
qint64 id() const { return m_id; }
|
||||
void setId(qint64 id) { m_id = id; }
|
||||
@@ -57,6 +61,18 @@ public:
|
||||
qint64 uid() const { return m_uid; }
|
||||
void setUid(qint64 uid) { m_uid = uid; }
|
||||
|
||||
QString threadId() const { return m_threadId; }
|
||||
void setThreadId(const QString& threadId) { m_threadId = threadId; }
|
||||
|
||||
QString inReplyTo() const { return m_inReplyTo; }
|
||||
void setInReplyTo(const QString& inReplyTo) { m_inReplyTo = inReplyTo; }
|
||||
|
||||
QVector<QString> references() const { return m_references; }
|
||||
void setReferences(const QVector<QString>& references) { m_references = references; }
|
||||
|
||||
bool isPinned() const { return m_isPinned; }
|
||||
void setPinned(bool pinned) { m_isPinned = pinned; }
|
||||
|
||||
QString bodyHtml() const { return m_bodyHtml; }
|
||||
void setBodyHtml(const QString& body) { m_bodyHtml = body; }
|
||||
|
||||
@@ -89,6 +105,10 @@ private:
|
||||
qint64 m_size{0};
|
||||
QString m_messageId;
|
||||
qint64 m_uid{0};
|
||||
QString m_threadId;
|
||||
QString m_inReplyTo;
|
||||
QVector<QString> m_references;
|
||||
bool m_isPinned{false};
|
||||
QString m_bodyHtml;
|
||||
QString m_to;
|
||||
QString m_cc;
|
||||
|
||||
@@ -28,8 +28,8 @@ bool MailItemDao::insert(MailItem& item)
|
||||
|
||||
QSqlQuery query(db);
|
||||
query.prepare(
|
||||
"INSERT INTO MailCopy (folderId, messageId, subject, sender, recipient, date, read, flagged, hasAttachment, size, fileId, uid, bodyHtml, toAddr, ccAddr, bccAddr) "
|
||||
"VALUES (:folderId, :messageId, :subject, :sender, :recipient, :date, :read, :flagged, :hasAttachment, :size, :fileId, :uid, :bodyHtml, :toAddr, :ccAddr, :bccAddr)"
|
||||
"INSERT INTO MailCopy (folderId, messageId, subject, sender, recipient, date, read, flagged, hasAttachment, size, fileId, uid, bodyHtml, toAddr, ccAddr, bccAddr, threadId, inReplyTo, \"references\", isPinned) "
|
||||
"VALUES (:folderId, :messageId, :subject, :sender, :recipient, :date, :read, :flagged, :hasAttachment, :size, :fileId, :uid, :bodyHtml, :toAddr, :ccAddr, :bccAddr, :threadId, :inReplyTo, :references, :isPinned)"
|
||||
);
|
||||
query.bindValue(":folderId", item.folderId());
|
||||
query.bindValue(":messageId", item.messageId().isEmpty() ? QVariant() : QVariant(item.messageId()));
|
||||
@@ -47,6 +47,10 @@ bool MailItemDao::insert(MailItem& item)
|
||||
query.bindValue(":toAddr", item.to());
|
||||
query.bindValue(":ccAddr", item.cc());
|
||||
query.bindValue(":bccAddr", item.bcc());
|
||||
query.bindValue(":threadId", item.threadId().isEmpty() ? QVariant() : QVariant(item.threadId()));
|
||||
query.bindValue(":inReplyTo", item.inReplyTo().isEmpty() ? QVariant() : QVariant(item.inReplyTo()));
|
||||
query.bindValue(":references", item.references().join(" "));
|
||||
query.bindValue(":isPinned", item.isPinned() ? 1 : 0);
|
||||
|
||||
if (!query.exec()) {
|
||||
qWarning() << "Failed to insert mail item:" << query.lastError().text();
|
||||
@@ -82,7 +86,11 @@ bool MailItemDao::update(const MailItem& item)
|
||||
"bodyHtml = :bodyHtml, "
|
||||
"toAddr = :toAddr, "
|
||||
"ccAddr = :ccAddr, "
|
||||
"bccAddr = :bccAddr "
|
||||
"bccAddr = :bccAddr, "
|
||||
"threadId = :threadId, "
|
||||
"inReplyTo = :inReplyTo, "
|
||||
"\"references\" = :references, "
|
||||
"isPinned = :isPinned "
|
||||
"WHERE id = :id"
|
||||
);
|
||||
query.bindValue(":id", item.id());
|
||||
@@ -102,6 +110,10 @@ bool MailItemDao::update(const MailItem& item)
|
||||
query.bindValue(":toAddr", item.to());
|
||||
query.bindValue(":ccAddr", item.cc());
|
||||
query.bindValue(":bccAddr", item.bcc());
|
||||
query.bindValue(":threadId", item.threadId().isEmpty() ? QVariant() : QVariant(item.threadId()));
|
||||
query.bindValue(":inReplyTo", item.inReplyTo().isEmpty() ? QVariant() : QVariant(item.inReplyTo()));
|
||||
query.bindValue(":references", item.references().join(" "));
|
||||
query.bindValue(":isPinned", item.isPinned() ? 1 : 0);
|
||||
|
||||
if (!query.exec()) {
|
||||
qWarning() << "Failed to update mail item:" << query.lastError().text();
|
||||
@@ -128,7 +140,7 @@ std::optional<MailItem> MailItemDao::findById(qint64 id)
|
||||
{
|
||||
QSqlDatabase db = DatabaseManager::instance().database();
|
||||
QSqlQuery query(db);
|
||||
query.prepare("SELECT id, folderId, messageId, subject, sender, recipient, date, read, flagged, hasAttachment, size, fileId, uid, bodyHtml, toAddr, ccAddr, bccAddr FROM MailCopy WHERE id = :id");
|
||||
query.prepare("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 id = :id");
|
||||
query.bindValue(":id", id);
|
||||
|
||||
if (!query.exec()) {
|
||||
@@ -154,6 +166,11 @@ std::optional<MailItem> MailItemDao::findById(qint64 id)
|
||||
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);
|
||||
@@ -167,7 +184,7 @@ QVector<MailItem> MailItemDao::findAll()
|
||||
QVector<MailItem> items;
|
||||
QSqlDatabase db = DatabaseManager::instance().database();
|
||||
QSqlQuery query(db);
|
||||
if (!query.exec("SELECT id, folderId, messageId, subject, sender, recipient, date, read, flagged, hasAttachment, size, fileId, uid, bodyHtml, toAddr, ccAddr, bccAddr FROM MailCopy")) {
|
||||
if (!query.exec("SELECT id, folderId, messageId, subject, sender, recipient, date, read, flagged, hasAttachment, size, fileId, uid, bodyHtml, toAddr, ccAddr, bccAddr, threadId, inReplyTo, \"references\", isPinned FROM MailCopy")) {
|
||||
qWarning() << "Failed to fetch all mail items:" << query.lastError().text();
|
||||
return items;
|
||||
}
|
||||
@@ -190,6 +207,11 @@ QVector<MailItem> MailItemDao::findAll()
|
||||
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);
|
||||
@@ -203,7 +225,7 @@ QVector<MailItem> MailItemDao::findByFolderId(int folderId)
|
||||
QVector<MailItem> items;
|
||||
QSqlDatabase db = DatabaseManager::instance().database();
|
||||
QSqlQuery query(db);
|
||||
query.prepare("SELECT id, folderId, messageId, subject, sender, recipient, date, read, flagged, hasAttachment, size, fileId, uid, bodyHtml, toAddr, ccAddr, bccAddr FROM MailCopy WHERE folderId = :folderId");
|
||||
query.prepare("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");
|
||||
query.bindValue(":folderId", folderId);
|
||||
|
||||
if (!query.exec()) {
|
||||
@@ -229,6 +251,11 @@ QVector<MailItem> MailItemDao::findByFolderId(int folderId)
|
||||
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);
|
||||
@@ -242,7 +269,7 @@ QVector<MailItem> MailItemDao::findByFolderIdSinceUid(int folderId, qint64 since
|
||||
QVector<MailItem> items;
|
||||
QSqlDatabase db = DatabaseManager::instance().database();
|
||||
QSqlQuery query(db);
|
||||
query.prepare("SELECT id, folderId, messageId, subject, sender, recipient, date, read, flagged, hasAttachment, size, fileId, uid, bodyHtml, toAddr, ccAddr, bccAddr FROM MailCopy WHERE folderId = :folderId AND uid > :sinceUid");
|
||||
query.prepare("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 AND uid > :sinceUid");
|
||||
query.bindValue(":folderId", folderId);
|
||||
query.bindValue(":sinceUid", sinceUid);
|
||||
|
||||
|
||||
@@ -124,6 +124,10 @@ bool DatabaseManager::initialize(const QString& databasePath)
|
||||
"toAddr TEXT, "
|
||||
"ccAddr TEXT, "
|
||||
"bccAddr TEXT, "
|
||||
"threadId TEXT, "
|
||||
"inReplyTo TEXT, "
|
||||
"\"references\" TEXT, "
|
||||
"isPinned BOOLEAN DEFAULT 0, "
|
||||
"FOREIGN KEY(folderId) REFERENCES Folder(id) ON DELETE CASCADE"
|
||||
");")) {
|
||||
qWarning() << "Failed to create MailCopy table:" << query.lastError().text();
|
||||
|
||||
+28
-2
@@ -90,6 +90,8 @@ void MailListView::setupUI()
|
||||
m_groupByCombo = new QComboBox();
|
||||
m_groupByCombo->addItem("Sin agrupación");
|
||||
m_groupByCombo->addItem("Agrupar por fecha");
|
||||
m_groupByCombo->addItem("Agrupar por conversación");
|
||||
m_groupByCombo->addItem("Agrupar por remitente");
|
||||
m_groupByCombo->setFixedWidth(180);
|
||||
m_groupByCombo->setStyleSheet(
|
||||
"QComboBox { border: 1px solid #d1d1d6; border-radius: 4px; padding: 4px 8px; "
|
||||
@@ -143,6 +145,18 @@ void MailListView::setupUI()
|
||||
connect(m_treeView, &QTreeView::doubleClicked, this, [this](const QModelIndex &index) {
|
||||
if (!index.isValid()) return;
|
||||
QModelIndex proxyIdx = m_proxyModel->mapToSource(index);
|
||||
|
||||
// Check if it's a thread node - toggle expansion
|
||||
if (proxyIdx.isValid() && proxyIdx.data(EmailTreeModel::IsThreadRole).toBool()) {
|
||||
QString threadId = proxyIdx.data(EmailTreeModel::ThreadIdRole).toString();
|
||||
if (!threadId.isEmpty()) {
|
||||
bool expanded = proxyIdx.data(EmailTreeModel::IsThreadExpandedRole).toBool();
|
||||
m_treeModel->setThreadExpanded(threadId, !expanded);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise open mail
|
||||
if (proxyIdx.isValid() && proxyIdx.data(EmailTreeModel::MailIdRole).isValid()) {
|
||||
int mailId = proxyIdx.data(EmailTreeModel::MailIdRole).toInt();
|
||||
emit emailOpenRequested(mailId);
|
||||
@@ -270,8 +284,20 @@ void MailListView::onFilterChanged()
|
||||
|
||||
void MailListView::onGroupByChanged(int index)
|
||||
{
|
||||
bool enabled = (index == 1); // 0 = Sin agrupación, 1 = Agrupar por fecha
|
||||
m_treeModel->setGroupMode(enabled ? EmailTreeModel::GroupByDate : EmailTreeModel::NoGrouping);
|
||||
switch (index) {
|
||||
case 0: // Sin agrupación
|
||||
m_treeModel->setGroupMode(EmailTreeModel::NoGrouping);
|
||||
break;
|
||||
case 1: // Agrupar por fecha
|
||||
m_treeModel->setGroupMode(EmailTreeModel::GroupByDate);
|
||||
break;
|
||||
case 2: // Agrupar por conversación/hilo
|
||||
m_treeModel->setGroupMode(EmailTreeModel::GroupByThread);
|
||||
break;
|
||||
case 3: // Agrupar por remitente
|
||||
m_treeModel->setGroupMode(EmailTreeModel::GroupBySender);
|
||||
break;
|
||||
}
|
||||
m_treeView->expandAll();
|
||||
}
|
||||
|
||||
|
||||
@@ -80,5 +80,5 @@ private:
|
||||
QComboBox *m_displayModeCombo;
|
||||
CompactMailDelegate *m_compactDelegate = nullptr;
|
||||
bool m_compactMode = false;
|
||||
int m_compactThreshold = 500;
|
||||
int m_compactThreshold = 650;
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <QDate>
|
||||
#include <QDebug>
|
||||
#include <algorithm>
|
||||
#include "utils/threadutils.h"
|
||||
|
||||
EmailTreeModel::EmailTreeModel(QObject *parent)
|
||||
: QAbstractItemModel(parent), m_rootItem(new TreeItem(TreeItem::Group))
|
||||
@@ -75,6 +76,11 @@ int EmailTreeModel::rowCount(const QModelIndex &parent) const
|
||||
else
|
||||
parentItem = static_cast<TreeItem*>(parent.internalPointer());
|
||||
|
||||
// If it's a thread, only show children if expanded
|
||||
if (parent.isValid() && parentItem->type == TreeItem::Thread) {
|
||||
if (!parentItem->expanded) return 0;
|
||||
}
|
||||
|
||||
return parentItem->children.size();
|
||||
}
|
||||
|
||||
@@ -100,14 +106,39 @@ QVariant EmailTreeModel::data(const QModelIndex &index, int role) const
|
||||
}
|
||||
|
||||
if (role == IsGroupRole) {
|
||||
return item->type == TreeItem::Group;
|
||||
return item->type == TreeItem::Group || item->type == TreeItem::Thread;
|
||||
}
|
||||
|
||||
if (role == GroupNameRole && item->type == TreeItem::Group) {
|
||||
if (role == IsThreadRole) {
|
||||
return item->type == TreeItem::Thread;
|
||||
}
|
||||
|
||||
if (role == ThreadIdRole) {
|
||||
if (item->type == TreeItem::Thread) {
|
||||
return item->threadId;
|
||||
}
|
||||
if (item->type == TreeItem::Mail) {
|
||||
const MailItem& mail = m_emails[item->mailIndex];
|
||||
return mail.threadId();
|
||||
}
|
||||
return QVariant();
|
||||
}
|
||||
|
||||
if (role == IsThreadExpandedRole) {
|
||||
if (item->type == TreeItem::Thread) {
|
||||
return item->expanded;
|
||||
}
|
||||
return QVariant();
|
||||
}
|
||||
|
||||
if (role == GroupNameRole && (item->type == TreeItem::Group || item->type == TreeItem::Thread)) {
|
||||
if (item->type == TreeItem::Thread) {
|
||||
return QString("%1 (%2)").arg(item->threadId.left(8)).arg(item->children.size());
|
||||
}
|
||||
return item->groupName;
|
||||
}
|
||||
|
||||
if (role == GroupIndexRole && item->type == TreeItem::Group) {
|
||||
if (role == GroupIndexRole && (item->type == TreeItem::Group || item->type == TreeItem::Thread)) {
|
||||
return item->groupIndex;
|
||||
}
|
||||
|
||||
@@ -125,13 +156,46 @@ QVariant EmailTreeModel::data(const QModelIndex &index, int role) const
|
||||
return QVariant();
|
||||
}
|
||||
|
||||
if (item->type == TreeItem::Thread) {
|
||||
if (index.column() == ColSubject) {
|
||||
// Get subject from first mail in thread
|
||||
if (!item->children.isEmpty()) {
|
||||
const MailItem& mail = m_emails[item->children.first()->mailIndex];
|
||||
return mail.subject().isEmpty() ? "(Sin asunto)" : mail.subject();
|
||||
}
|
||||
return QString("Thread %1 (%2)").arg(item->threadId.left(8)).arg(item->children.size());
|
||||
}
|
||||
if (index.column() == ColSender) {
|
||||
return QString("%1 emails").arg(item->children.size());
|
||||
}
|
||||
if (index.column() == ColDate) {
|
||||
if (!item->children.isEmpty()) {
|
||||
const MailItem& mail = m_emails[item->children.first()->mailIndex];
|
||||
if (!mail.date().isValid()) return QVariant();
|
||||
QDateTime dt = mail.date();
|
||||
QDate today = QDate::currentDate();
|
||||
if (dt.date() == today) {
|
||||
return dt.toString("HH:mm");
|
||||
} else if (dt.date() == today.addDays(-1)) {
|
||||
return "Ayer " + dt.toString("HH:mm");
|
||||
} else if (dt.date().year() == today.year()) {
|
||||
return dt.toString("ddd dd/MM HH:mm");
|
||||
} else {
|
||||
return dt.toString("dd/MM/yyyy HH:mm");
|
||||
}
|
||||
}
|
||||
return QVariant();
|
||||
}
|
||||
return QVariant();
|
||||
}
|
||||
|
||||
// Mail item
|
||||
if (item->type == TreeItem::Mail) {
|
||||
if (item->mailIndex < 0 || item->mailIndex >= m_emails.size()) {
|
||||
return QVariant();
|
||||
}
|
||||
const MailItem& mail = m_emails[item->mailIndex];
|
||||
|
||||
|
||||
switch (index.column()) {
|
||||
case ColSubject:
|
||||
return mail.subject().isEmpty() ? "(Sin asunto)" : mail.subject();
|
||||
@@ -177,10 +241,10 @@ Qt::ItemFlags EmailTreeModel::flags(const QModelIndex &index) const
|
||||
return Qt::NoItemFlags;
|
||||
|
||||
TreeItem* item = static_cast<TreeItem*>(index.internalPointer());
|
||||
|
||||
|
||||
Qt::ItemFlags f = Qt::ItemIsEnabled | Qt::ItemIsSelectable;
|
||||
if (item->type == TreeItem::Group) {
|
||||
// Groups don't need special flags for expand/collapse in Qt6
|
||||
if (item->type == TreeItem::Group || item->type == TreeItem::Thread) {
|
||||
// Groups/threads can be expanded/collapsed
|
||||
}
|
||||
return f;
|
||||
}
|
||||
@@ -189,6 +253,7 @@ void EmailTreeModel::setEmails(const QVector<MailItem> &emails)
|
||||
{
|
||||
beginResetModel();
|
||||
m_emails = emails;
|
||||
m_expandedThreads.clear();
|
||||
// Clear tree properly without deleting root
|
||||
qDeleteAll(m_rootItem->children);
|
||||
m_rootItem->children.clear();
|
||||
@@ -200,6 +265,7 @@ void EmailTreeModel::clear()
|
||||
{
|
||||
beginResetModel();
|
||||
m_emails.clear();
|
||||
m_expandedThreads.clear();
|
||||
deleteTree(m_rootItem);
|
||||
m_rootItem = new TreeItem(TreeItem::Group);
|
||||
m_rootItem->type = TreeItem::Group;
|
||||
@@ -210,7 +276,7 @@ void EmailTreeModel::clear()
|
||||
void EmailTreeModel::setGroupMode(GroupMode mode)
|
||||
{
|
||||
if (m_groupMode == mode) return;
|
||||
|
||||
|
||||
// Clear existing tree first
|
||||
beginResetModel();
|
||||
m_groupMode = mode;
|
||||
@@ -220,6 +286,33 @@ void EmailTreeModel::setGroupMode(GroupMode mode)
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
void EmailTreeModel::setThreadExpanded(const QString& threadId, bool expanded)
|
||||
{
|
||||
if (expanded) {
|
||||
m_expandedThreads.insert(threadId);
|
||||
} else {
|
||||
m_expandedThreads.remove(threadId);
|
||||
}
|
||||
|
||||
// Find the thread item and update it
|
||||
for (TreeItem* child : m_rootItem->children) {
|
||||
if (child->type == TreeItem::Thread && child->threadId == threadId) {
|
||||
child->expanded = expanded;
|
||||
QModelIndex idx = createIndex(child->row, 0, child);
|
||||
emit dataChanged(idx, idx, {IsThreadExpandedRole});
|
||||
// Notify layout change for children visibility
|
||||
beginResetModel();
|
||||
endResetModel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool EmailTreeModel::isThreadExpanded(const QString& threadId) const
|
||||
{
|
||||
return m_expandedThreads.contains(threadId);
|
||||
}
|
||||
|
||||
void EmailTreeModel::setupModelData()
|
||||
{
|
||||
if (m_groupMode == NoGrouping) {
|
||||
@@ -231,9 +324,15 @@ void EmailTreeModel::setupModelData()
|
||||
mailItem->row = m_rootItem->children.size();
|
||||
m_rootItem->children.append(mailItem);
|
||||
}
|
||||
} else {
|
||||
} else if (m_groupMode == GroupByDate) {
|
||||
// Grouped by date
|
||||
buildGroups();
|
||||
} else if (m_groupMode == GroupByThread) {
|
||||
// Grouped by thread/conversation
|
||||
buildThreads();
|
||||
} else if (m_groupMode == GroupBySender) {
|
||||
// Grouped by sender
|
||||
buildSenderGroups();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,12 +357,12 @@ void EmailTreeModel::buildGroups()
|
||||
QVector<int> mailIndices;
|
||||
};
|
||||
QMap<QString, GroupData> groupsMap;
|
||||
|
||||
|
||||
for (int idx : indices) {
|
||||
if (idx < 0 || idx >= m_emails.size()) continue;
|
||||
const QDateTime& dt = m_emails[idx].date();
|
||||
if (!dt.isValid()) continue;
|
||||
|
||||
|
||||
QString groupName = groupNameForDate(dt);
|
||||
auto& g = groupsMap[groupName];
|
||||
g.name = groupName;
|
||||
@@ -309,6 +408,91 @@ void EmailTreeModel::buildGroups()
|
||||
}
|
||||
}
|
||||
|
||||
void EmailTreeModel::buildThreads()
|
||||
{
|
||||
// Build thread tree using ThreadUtils
|
||||
QMap<QString, QVector<int>> threadMap; // threadId -> mail indices
|
||||
|
||||
for (int i = 0; i < m_emails.size(); ++i) {
|
||||
if (i < 0 || i >= m_emails.size()) continue;
|
||||
const MailItem& mail = m_emails[i];
|
||||
QString threadId = extractThreadId(mail);
|
||||
if (threadId.isEmpty()) {
|
||||
threadId = ThreadUtils::generateThreadId(mail.messageId(), mail.subject(), mail.sender());
|
||||
}
|
||||
threadMap[threadId].append(i);
|
||||
}
|
||||
|
||||
// Sort threads by newest email date descending
|
||||
QVector<QString> threadIds = threadMap.keys();
|
||||
std::sort(threadIds.begin(), threadIds.end(), [this, &threadMap](const QString& a, const QString& b) {
|
||||
const QVector<int>& indicesA = threadMap[a];
|
||||
const QVector<int>& indicesB = threadMap[b];
|
||||
QDateTime latestA, latestB;
|
||||
for (int idx : indicesA) {
|
||||
if (idx >= 0 && idx < m_emails.size() && m_emails[idx].date().isValid()) {
|
||||
if (!latestA.isValid() || m_emails[idx].date() > latestA) {
|
||||
latestA = m_emails[idx].date();
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int idx : indicesB) {
|
||||
if (idx >= 0 && idx < m_emails.size() && m_emails[idx].date().isValid()) {
|
||||
if (!latestB.isValid() || m_emails[idx].date() > latestB) {
|
||||
latestB = m_emails[idx].date();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!latestA.isValid() && !latestB.isValid()) return false;
|
||||
if (!latestA.isValid()) return false;
|
||||
if (!latestB.isValid()) return true;
|
||||
return latestA > latestB; // Newest first
|
||||
});
|
||||
|
||||
int threadIdx = 0;
|
||||
for (const QString& threadId : threadIds) {
|
||||
const QVector<int>& mailIndices = threadMap[threadId];
|
||||
if (mailIndices.isEmpty()) continue;
|
||||
|
||||
// Sort emails in thread by date ascending (oldest first, like conversation)
|
||||
QVector<int> sortedIndices = mailIndices;
|
||||
std::sort(sortedIndices.begin(), sortedIndices.end(), [this](int a, int b) {
|
||||
const QDateTime& dateA = m_emails[a].date();
|
||||
const QDateTime& dateB = m_emails[b].date();
|
||||
if (!dateA.isValid() && !dateB.isValid()) return false;
|
||||
if (!dateA.isValid()) return false;
|
||||
if (!dateB.isValid()) return true;
|
||||
return dateA < dateB; // Oldest first
|
||||
});
|
||||
|
||||
TreeItem* threadItem = new TreeItem(TreeItem::Thread, m_rootItem);
|
||||
threadItem->threadId = threadId;
|
||||
threadItem->groupIndex = threadIdx++;
|
||||
threadItem->row = m_rootItem->children.size();
|
||||
threadItem->expanded = m_expandedThreads.contains(threadId);
|
||||
m_rootItem->children.append(threadItem);
|
||||
|
||||
for (int mailIdx : sortedIndices) {
|
||||
if (mailIdx < 0 || mailIdx >= m_emails.size()) continue;
|
||||
TreeItem* mailItem = new TreeItem(TreeItem::Mail, threadItem);
|
||||
mailItem->mailIndex = mailIdx;
|
||||
mailItem->row = threadItem->children.size();
|
||||
threadItem->children.append(mailItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QString EmailTreeModel::extractThreadId(const MailItem& mail) const
|
||||
{
|
||||
return ThreadUtils::extractThreadId(mail.messageId(), mail.inReplyTo(), mail.references());
|
||||
}
|
||||
|
||||
void EmailTreeModel::buildThreadTree()
|
||||
{
|
||||
// Called when thread structure needs rebuild
|
||||
buildThreads();
|
||||
}
|
||||
|
||||
QString EmailTreeModel::groupNameForDate(const QDateTime &date) const
|
||||
{
|
||||
if (!date.isValid()) return "Sin fecha";
|
||||
@@ -346,4 +530,75 @@ int EmailTreeModel::groupIndexForDate(const QDateTime &date) const
|
||||
{
|
||||
// Not used directly but kept for reference
|
||||
return 0;
|
||||
}
|
||||
|
||||
void EmailTreeModel::buildSenderGroups()
|
||||
{
|
||||
// Group by sender email/name
|
||||
QMap<QString, QVector<int>> senderMap; // sender -> mail indices
|
||||
|
||||
for (int i = 0; i < m_emails.size(); ++i) {
|
||||
if (i < 0 || i >= m_emails.size()) continue;
|
||||
const MailItem& mail = m_emails[i];
|
||||
QString sender = mail.sender();
|
||||
if (sender.isEmpty()) sender = "(Desconocido)";
|
||||
senderMap[sender].append(i);
|
||||
}
|
||||
|
||||
// Sort senders by newest email date descending
|
||||
QStringList senders = senderMap.keys();
|
||||
std::sort(senders.begin(), senders.end(), [this, &senderMap](const QString& a, const QString& b) {
|
||||
const QVector<int>& indicesA = senderMap[a];
|
||||
const QVector<int>& indicesB = senderMap[b];
|
||||
QDateTime latestA, latestB;
|
||||
for (int idx : indicesA) {
|
||||
if (idx >= 0 && idx < m_emails.size() && m_emails[idx].date().isValid()) {
|
||||
if (!latestA.isValid() || m_emails[idx].date() > latestA) {
|
||||
latestA = m_emails[idx].date();
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int idx : indicesB) {
|
||||
if (idx >= 0 && idx < m_emails.size() && m_emails[idx].date().isValid()) {
|
||||
if (!latestB.isValid() || m_emails[idx].date() > latestB) {
|
||||
latestB = m_emails[idx].date();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!latestA.isValid() && !latestB.isValid()) return false;
|
||||
if (!latestA.isValid()) return false;
|
||||
if (!latestB.isValid()) return true;
|
||||
return latestA > latestB; // Newest first
|
||||
});
|
||||
|
||||
int groupIdx = 0;
|
||||
for (const QString& sender : senders) {
|
||||
const QVector<int>& mailIndices = senderMap[sender];
|
||||
if (mailIndices.isEmpty()) continue;
|
||||
|
||||
// Sort emails in group by date descending (newest first)
|
||||
QVector<int> sortedIndices = mailIndices;
|
||||
std::sort(sortedIndices.begin(), sortedIndices.end(), [this](int a, int b) {
|
||||
const QDateTime& dateA = m_emails[a].date();
|
||||
const QDateTime& dateB = m_emails[b].date();
|
||||
if (!dateA.isValid() && !dateB.isValid()) return false;
|
||||
if (!dateA.isValid()) return false;
|
||||
if (!dateB.isValid()) return true;
|
||||
return dateA > dateB; // Newest first
|
||||
});
|
||||
|
||||
TreeItem* groupItem = new TreeItem(TreeItem::Group, m_rootItem);
|
||||
groupItem->groupName = sender;
|
||||
groupItem->groupIndex = groupIdx++;
|
||||
groupItem->row = m_rootItem->children.size();
|
||||
m_rootItem->children.append(groupItem);
|
||||
|
||||
for (int mailIdx : sortedIndices) {
|
||||
if (mailIdx < 0 || mailIdx >= m_emails.size()) continue;
|
||||
TreeItem* mailItem = new TreeItem(TreeItem::Mail, groupItem);
|
||||
mailItem->mailIndex = mailIdx;
|
||||
mailItem->row = groupItem->children.size();
|
||||
groupItem->children.append(mailItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,10 @@ public:
|
||||
IsGroupRole = Qt::UserRole + 2,
|
||||
GroupNameRole = Qt::UserRole + 3,
|
||||
GroupIndexRole = Qt::UserRole + 4,
|
||||
MailIndexRole = Qt::UserRole + 5
|
||||
MailIndexRole = Qt::UserRole + 5,
|
||||
IsThreadRole = Qt::UserRole + 6,
|
||||
ThreadIdRole = Qt::UserRole + 7,
|
||||
IsThreadExpandedRole = Qt::UserRole + 8
|
||||
};
|
||||
|
||||
explicit EmailTreeModel(QObject *parent = nullptr);
|
||||
@@ -47,20 +50,28 @@ public:
|
||||
// Grouping
|
||||
enum GroupMode {
|
||||
NoGrouping = 0,
|
||||
GroupByDate = 1
|
||||
GroupByDate = 1,
|
||||
GroupByThread = 2,
|
||||
GroupBySender = 3
|
||||
};
|
||||
void setGroupMode(GroupMode mode);
|
||||
GroupMode groupMode() const { return m_groupMode; }
|
||||
|
||||
// Thread expansion
|
||||
void setThreadExpanded(const QString& threadId, bool expanded);
|
||||
bool isThreadExpanded(const QString& threadId) const;
|
||||
|
||||
private:
|
||||
struct TreeItem {
|
||||
enum Type { Group, Mail } type;
|
||||
enum Type { Group, Mail, Thread } type;
|
||||
QString groupName; // for Group type
|
||||
QString threadId; // for Thread type
|
||||
int groupIndex = -1; // for Group type
|
||||
int mailIndex = -1; // for Mail type (index in m_emails)
|
||||
QVector<TreeItem*> children;
|
||||
TreeItem* parent = nullptr;
|
||||
int row = -1;
|
||||
bool expanded = true; // for Thread type
|
||||
|
||||
TreeItem(Type t, TreeItem* p = nullptr) : type(t), parent(p) {}
|
||||
~TreeItem() { qDeleteAll(children); }
|
||||
@@ -69,10 +80,15 @@ private:
|
||||
GroupMode m_groupMode = NoGrouping;
|
||||
QVector<MailItem> m_emails;
|
||||
TreeItem* m_rootItem = nullptr;
|
||||
QSet<QString> m_expandedThreads;
|
||||
|
||||
void setupModelData();
|
||||
void buildGroups();
|
||||
void buildThreads();
|
||||
void buildSenderGroups();
|
||||
QString groupNameForDate(const QDateTime &date) const;
|
||||
int groupIndexForDate(const QDateTime &date) const;
|
||||
void deleteTree(TreeItem* item);
|
||||
void buildThreadTree();
|
||||
QString extractThreadId(const MailItem& mail) const;
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
#include "threadutils.h"
|
||||
#include <QRegularExpression>
|
||||
#include <QCryptographicHash>
|
||||
|
||||
namespace ThreadUtils
|
||||
{
|
||||
// Parse References header into vector of message IDs
|
||||
// Format: <msg1@example.com> <msg2@example.com> ...
|
||||
QVector<QString> parseReferencesHeader(const QString& referencesHeader)
|
||||
{
|
||||
QVector<QString> result;
|
||||
if (referencesHeader.isEmpty()) return result;
|
||||
|
||||
QRegularExpression re("<([^>]+)>");
|
||||
QRegularExpressionMatchIterator it = re.globalMatch(referencesHeader);
|
||||
while (it.hasNext()) {
|
||||
QRegularExpressionMatch match = it.next();
|
||||
QString msgId = match.captured(1).trimmed();
|
||||
if (!msgId.isEmpty()) {
|
||||
result.append(msgId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Extract Thread-ID from email headers
|
||||
// Priority: References (last) > In-Reply-To > Message-ID
|
||||
QString extractThreadId(const QString& messageId, const QString& inReplyTo, const QVector<QString>& references)
|
||||
{
|
||||
// If we have References, use the LAST one (root of thread)
|
||||
if (!references.isEmpty()) {
|
||||
QString lastRef = references.last();
|
||||
// Extract just the message-id part if it's in angle brackets
|
||||
QRegularExpression re("<([^>]+)>");
|
||||
QRegularExpressionMatch match = re.match(lastRef);
|
||||
if (match.hasMatch()) {
|
||||
return match.captured(1);
|
||||
}
|
||||
return lastRef;
|
||||
}
|
||||
|
||||
// Fallback to In-Reply-To
|
||||
if (!inReplyTo.isEmpty()) {
|
||||
QRegularExpression re("<([^>]+)>");
|
||||
QRegularExpressionMatch match = re.match(inReplyTo);
|
||||
if (match.hasMatch()) {
|
||||
return match.captured(1);
|
||||
}
|
||||
return inReplyTo;
|
||||
}
|
||||
|
||||
// Fallback to Message-ID
|
||||
if (!messageId.isEmpty()) {
|
||||
QRegularExpression re("<([^>]+)>");
|
||||
QRegularExpressionMatch match = re.match(messageId);
|
||||
if (match.hasMatch()) {
|
||||
return match.captured(1);
|
||||
}
|
||||
return messageId;
|
||||
}
|
||||
|
||||
return QString();
|
||||
}
|
||||
|
||||
// Check if two emails belong to the same thread
|
||||
bool sameThread(const QString& threadId1, const QString& threadId2)
|
||||
{
|
||||
if (threadId1.isEmpty() || threadId2.isEmpty()) return false;
|
||||
return threadId1 == threadId2;
|
||||
}
|
||||
|
||||
// Generate a thread ID from message ID (fallback when no proper threading headers)
|
||||
// Uses subject + sender as fallback
|
||||
QString generateThreadId(const QString& messageId, const QString& subject, const QString& sender)
|
||||
{
|
||||
QString base = messageId.isEmpty() ? (subject + "|" + sender) : messageId;
|
||||
QByteArray hash = QCryptographicHash::hash(base.toUtf8(), QCryptographicHash::Md5);
|
||||
return QString("thread-%1").arg(hash.toHex().left(16));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
#include <QRegularExpression>
|
||||
|
||||
namespace ThreadUtils
|
||||
{
|
||||
// Extract Thread-ID from email headers (Message-ID, References, In-Reply-To)
|
||||
// Returns normalized thread ID for grouping conversations
|
||||
QString extractThreadId(const QString& messageId, const QString& inReplyTo, const QVector<QString>& references);
|
||||
|
||||
// Parse References header into vector of message IDs
|
||||
QVector<QString> parseReferencesHeader(const QString& referencesHeader);
|
||||
|
||||
// Check if two emails belong to the same thread
|
||||
bool sameThread(const QString& threadId1, const QString& threadId2);
|
||||
|
||||
// Generate a thread ID from message ID (fallback when no proper threading headers)
|
||||
QString generateThreadId(const QString& messageId, const QString& subject, const QString& sender);
|
||||
}
|
||||
Reference in New Issue
Block a user