From 3303c7855cb22a6bb4f36aa1a17707187320f5d2 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 18 Sep 2026 14:58:58 +0200 Subject: feat: add ContactStore, the vCard address-book parse Reads a vdirsyncer contacts directory of vCard 3.0 files into a QList for the completion work that follows. Pure over values, no widget and no QCompleter, so the parse is testable without a window. unfold() joins folded lines before any field is looked at; parseCard() splits property from value on the first colon outside a quoted parameter, unescapes FN, and yields one contact per EMAIL line; loadDirectory() walks recursively, skips unreadable or addressless cards, de-duplicates on the address case-insensitively, and sorts by name then address. N, PHOTO, ADR and TEL are deliberately not used. 23 new tests. No user-facing strings, so no tr() change. --- src/contactstore.cpp | 215 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 src/contactstore.cpp (limited to 'src/contactstore.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. + * + * 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 +#include +#include +#include + +#include + +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 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 contacts; + contacts.reserve(emails.size()); + for (const QString &email : emails) + contacts.append(Contact{ name, email }); + return contacts; +} + +QList loadDirectory(const QString &directory) +{ + QList 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 unique; + QHash 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 -- cgit v1.2.3