diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/contactstore.cpp | 215 | ||||
| -rw-r--r-- | src/contactstore.h | 79 |
3 files changed, 295 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b933f3d..26314dc 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -42,6 +42,7 @@ add_library(qtmaildir_lib STATIC querycompleter.cpp rulequery.cpp searchterm.cpp + contactstore.cpp signatures.cpp ) diff --git a/src/contactstore.cpp b/src/contactstore.cpp new file mode 100644 index 0000000..219e8f3 --- /dev/null +++ b/src/contactstore.cpp @@ -0,0 +1,215 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#include "contactstore.h" + +#include <QDirIterator> +#include <QFile> +#include <QHash> +#include <QStringList> + +#include <algorithm> + +namespace { + +/// Index of the colon separating property from value: the first one that is +/// not inside a double-quoted parameter value. +/// +/// `EMAIL;TYPE=INTERNET;LABEL="work: main":a@example.org` has a colon inside +/// the quoted label, and splitting on the first colon would hand the parser +/// `main":a@example.org` as the address. A backslash inside the quotes escapes +/// the next character, so `\"` does not close the quote. +int colonOutsideQuotes(const QString &line) +{ + bool inQuotes = false; + for (int i = 0; i < line.size(); ++i) { + const QChar c = line.at(i); + if (c == QLatin1Char('\\')) { + ++i; // Skip the escaped character; it cannot be a delimiter. + continue; + } + if (c == QLatin1Char('"')) { + inQuotes = !inQuotes; + continue; + } + if (c == QLatin1Char(':') && !inQuotes) + return i; + } + return -1; +} + +/// Decodes the vCard TEXT escape set, applied to FN: a name written +/// `Rossi\, Mario` is one name with a comma, and left escaped the backslash +/// reaches the completion popup. N carries the same escapes, but the struct +/// holds no N and FN is what a display name is. +QString unescapeText(const QString &value) +{ + QString out; + out.reserve(value.size()); + for (int i = 0; i < value.size(); ++i) { + const QChar c = value.at(i); + if (c != QLatin1Char('\\') || i + 1 >= value.size()) { + out.append(c); + continue; + } + + const QChar escaped = value.at(++i); + switch (escaped.unicode()) { + case 'n': + case 'N': + out.append(QLatin1Char('\n')); + break; + case '\\': + case ',': + case ';': + out.append(escaped); + break; + default: + // An unknown escape keeps its character, dropping the backslash + // rather than guessing at a meaning it does not have. + out.append(escaped); + break; + } + } + return out; +} + +} // namespace + +namespace ContactStore { + +QString unfold(const QString &text) +{ + QStringList logical; + const QStringList lines = text.split(QLatin1Char('\n')); + + for (const QString &rawLine : lines) { + // The store's own files are LF; a CRLF server is handled here rather + // than left to reach a value with a stray carriage return in it. + QString line = rawLine; + if (line.endsWith(QLatin1Char('\r'))) + line.chop(1); + + const bool isContinuation = + !line.isEmpty() + && (line.at(0) == QLatin1Char(' ') || line.at(0) == QLatin1Char('\t')); + + if (isContinuation && !logical.isEmpty()) { + // Join to the line that precedes it, removing only the marker + // whitespace character. A trailing space on the first line is + // content and survives. + logical.last() += line.mid(1); + } else { + logical.append(line); + } + } + + return logical.join(QLatin1Char('\n')); +} + +QList<Contact> parseCard(const QString &unfoldedText) +{ + QString name; + QStringList emails; + + const QStringList lines = unfoldedText.split(QLatin1Char('\n')); + for (QString line : lines) { + if (line.endsWith(QLatin1Char('\r'))) + line.chop(1); + if (line.isEmpty()) + continue; + + const int colon = colonOutsideQuotes(line); + if (colon < 0) + continue; + + // The property name is everything before the first ';', and the + // parameters that follow belong to it. Matched case-insensitively per + // RFC 2426, so `email:` is an EMAIL. + const QString property = + line.left(colon).section(QLatin1Char(';'), 0, 0).trimmed(); + const QString value = line.mid(colon + 1).trimmed(); + + if (property.compare(QLatin1String("FN"), Qt::CaseInsensitive) == 0) { + // Last one wins if a malformed card carries two, rather than + // merging two names into one. + name = unescapeText(value); + } else if (property.compare(QLatin1String("EMAIL"), Qt::CaseInsensitive) == 0) { + // PHOTO, ADR, TEL and the rest are not matched and fall through + // unread; N is deliberately not used as a fallback name, because + // FN is what a display name is. + if (!value.isEmpty()) + emails.append(value); + } + } + + QList<Contact> contacts; + contacts.reserve(emails.size()); + for (const QString &email : emails) + contacts.append(Contact{ name, email }); + return contacts; +} + +QList<Contact> loadDirectory(const QString &directory) +{ + QList<Contact> found; + if (directory.isEmpty()) + return found; + + // Recursive because a vdir keeps one subdirectory per collection. A + // missing root simply yields nothing; no warning, since a machine with no + // vdir is the ordinary case. + QDirIterator it(directory, QStringList{ QStringLiteral("*.vcf") }, + QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + QFile file(it.next()); + if (!file.open(QIODevice::ReadOnly)) + continue; // Unreadable: skip it, do not lose the rest. + const QString text = QString::fromUtf8(file.readAll()); + found += parseCard(unfold(text)); + } + + // De-duplicated on the address, case-insensitively: two collections + // holding the same person is the ordinary case, not an error. The first + // card seen wins. + QList<Contact> unique; + QHash<QString, int> seen; + unique.reserve(found.size()); + for (const Contact &contact : found) { + const QString key = contact.email.toLower(); + if (seen.contains(key)) + continue; + seen.insert(key, unique.size()); + unique.append(contact); + } + + // Sorted by name then address, case-insensitively. A locale-aware compare + // is deliberately not used: the order is a completion list, not prose. + std::sort(unique.begin(), unique.end(), + [](const Contact &a, const Contact &b) { + const int byName = + QString::compare(a.name, b.name, Qt::CaseInsensitive); + if (byName != 0) + return byName < 0; + return QString::compare(a.email, b.email, Qt::CaseInsensitive) < 0; + }); + + return unique; +} + +} // namespace ContactStore diff --git a/src/contactstore.h b/src/contactstore.h new file mode 100644 index 0000000..307e87b --- /dev/null +++ b/src/contactstore.h @@ -0,0 +1,79 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#pragma once + +#include <QList> +#include <QString> + +/// One completion candidate from the address book. +/// +/// Deliberately only two fields. PHOTO, ADR and TEL are read past and dropped: +/// the user was asked and did not want a second use for the store, so anything +/// the completion cannot use is not carried. +struct Contact +{ + /// The card's FN, already unescaped. Empty is valid: a card with an + /// address but no name completes on the address alone. + QString name; + + /// The value of one EMAIL line. The identity the store de-duplicates on. + QString email; +}; + +/// Reads a vdirsyncer contacts directory of vCard 3.0 files. +/// +/// A namespace of free functions over values, like SearchTerm and MimeParser, +/// so the parse is testable without a widget and without a QCompleter. The +/// caller (MainWindow) holds the returned list and hands it to both consumers. +/// +/// **No new dependency for this.** libical is installed but its vCard parser is +/// 4.0 while this store is 3.0.20, and libicalvcal reads vCalendar 1.0 rather +/// than vCard. Two fields and a directory walk are the proportionate answer. +namespace ContactStore { + +/// Joins folded lines back into one logical line. +/// +/// A vCard line may be split between any two characters by inserting CRLF and +/// one linear whitespace character (RFC 2426). Unfolding removes the newline +/// and that one whitespace character and nothing else, so a trailing space on +/// the first line survives. A fold may land mid-token or inside base64, which +/// is why this runs over the whole file before any field is looked at. +QString unfold(const QString &text); + +/// Parses one card's already-unfolded text into one Contact per EMAIL line. +/// +/// Every entry carries the card's single FN. A card with no EMAIL yields an +/// empty list; a card with no FN yields a Contact whose name is empty. The +/// property name is matched case-insensitively, and the field/value split is +/// on the first colon that is not inside a double-quoted parameter value, so +/// `EMAIL;TYPE=WORK:a@example.org` parses. +QList<Contact> parseCard(const QString &unfoldedText); + +/// Walks `directory` recursively and returns every card found, de-duplicated +/// and sorted. +/// +/// Every `*.vcf` at any depth is read, because a vdir keeps one subdirectory +/// per collection. A file that cannot be read or contributes no EMAIL is +/// skipped rather than fatal: one bad card must not cost the other 116. The +/// result is de-duplicated on the address, case-insensitively, and sorted by +/// name then address. A non-existent or empty directory yields an empty list +/// and no warning, since a machine with no vdir is an ordinary machine. +QList<Contact> loadDirectory(const QString &directory); + +} // namespace ContactStore |
