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:
2026-08-25 18:53:51 +02:00
parent fa699b5399
commit 6e6fdf9c21
75 changed files with 25631 additions and 12574 deletions
+28 -2
View File
@@ -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();
}
+1 -1
View File
@@ -80,5 +80,5 @@ private:
QComboBox *m_displayModeCombo;
CompactMailDelegate *m_compactDelegate = nullptr;
bool m_compactMode = false;
int m_compactThreshold = 500;
int m_compactThreshold = 650;
};
+266 -11
View File
@@ -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);
}
}
}
+19 -3
View File
@@ -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;
};