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:
+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;
|
||||
};
|
||||
Reference in New Issue
Block a user