diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-18 14:58:58 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-18 14:58:58 +0200 |
| commit | 3303c7855cb22a6bb4f36aa1a17707187320f5d2 (patch) | |
| tree | 6a2328cffaab820c8247f267785cc2782d77e154 | |
| parent | 7d4654536de5be4bd3555eb4b81a2e07c1d4148d (diff) | |
| download | qtmaildir-3303c7855cb22a6bb4f36aa1a17707187320f5d2.tar.gz qtmaildir-3303c7855cb22a6bb4f36aa1a17707187320f5d2.zip | |
feat: add ContactStore, the vCard address-book parse
Reads a vdirsyncer contacts directory of vCard 3.0 files into a
QList<Contact> 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.
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/contactstore.cpp | 215 | ||||
| -rw-r--r-- | src/contactstore.h | 79 | ||||
| -rw-r--r-- | tests/CMakeLists.txt | 3 | ||||
| -rw-r--r-- | tests/fixtures/contact_escaped.vcf | 5 | ||||
| -rw-r--r-- | tests/fixtures/contact_extra.vcf | 9 | ||||
| -rw-r--r-- | tests/fixtures/contact_folded.vcf | 7 | ||||
| -rw-r--r-- | tests/fixtures/contact_lowercase.vcf | 5 | ||||
| -rw-r--r-- | tests/fixtures/contact_noemail.vcf | 7 | ||||
| -rw-r--r-- | tests/fixtures/contact_noname.vcf | 4 | ||||
| -rw-r--r-- | tests/fixtures/contact_params.vcf | 6 | ||||
| -rw-r--r-- | tests/fixtures/contact_plain.vcf | 6 | ||||
| -rw-r--r-- | tests/fixtures/contact_twoemails.vcf | 6 | ||||
| -rw-r--r-- | tests/test_contactstore.cpp | 358 |
14 files changed, 711 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 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 24da10b..6cc92ab 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -69,6 +69,9 @@ add_qtmaildir_test(tagdialog) add_qtmaildir_test(tagrules) add_qtmaildir_test(rulequery) add_qtmaildir_test(searchterm) +add_qtmaildir_test(contactstore) +target_compile_definitions(test_contactstore PRIVATE + FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures") add_qtmaildir_test(signatures) add_qtmaildir_test(busyindicator) add_qtmaildir_test(tagstrip) diff --git a/tests/fixtures/contact_escaped.vcf b/tests/fixtures/contact_escaped.vcf new file mode 100644 index 0000000..4467562 --- /dev/null +++ b/tests/fixtures/contact_escaped.vcf @@ -0,0 +1,5 @@ +BEGIN:VCARD +VERSION:3.0 +FN:Rossi\, Mario +EMAIL:mario@example.org +END:VCARD diff --git a/tests/fixtures/contact_extra.vcf b/tests/fixtures/contact_extra.vcf new file mode 100644 index 0000000..1f5223a --- /dev/null +++ b/tests/fixtures/contact_extra.vcf @@ -0,0 +1,9 @@ +BEGIN:VCARD +VERSION:3.0 +N:Extra;Fields;;; +FN:Extra Fields +ADR;TYPE=HOME:;;2 Example Rd;Exampleton;;11111;Examplestan +TEL;TYPE=CELL:+1-555-0199 +PHOTO;ENCODING=b;TYPE=JPEG:iVBORw0KGgo= +EMAIL:extra@example.org +END:VCARD diff --git a/tests/fixtures/contact_folded.vcf b/tests/fixtures/contact_folded.vcf new file mode 100644 index 0000000..8ef8729 --- /dev/null +++ b/tests/fixtures/contact_folded.vcf @@ -0,0 +1,7 @@ +BEGIN:VCARD +VERSION:3.0 +FN:Fold + ed Person +EMAIL:folded@exam + ple.org +END:VCARD diff --git a/tests/fixtures/contact_lowercase.vcf b/tests/fixtures/contact_lowercase.vcf new file mode 100644 index 0000000..9a2ce7b --- /dev/null +++ b/tests/fixtures/contact_lowercase.vcf @@ -0,0 +1,5 @@ +BEGIN:VCARD +VERSION:3.0 +fn:Carol Example +email:carol@example.org +END:VCARD diff --git a/tests/fixtures/contact_noemail.vcf b/tests/fixtures/contact_noemail.vcf new file mode 100644 index 0000000..6fbd458 --- /dev/null +++ b/tests/fixtures/contact_noemail.vcf @@ -0,0 +1,7 @@ +BEGIN:VCARD +VERSION:3.0 +FN:No Address +TEL;TYPE=CELL:+1-555-0100 +ADR;TYPE=HOME:;;1 Example St;Exampleville;;00000;Examplestan +PHOTO;ENCODING=b;TYPE=JPEG:iVBORw0KGgo= +END:VCARD diff --git a/tests/fixtures/contact_noname.vcf b/tests/fixtures/contact_noname.vcf new file mode 100644 index 0000000..c9bf087 --- /dev/null +++ b/tests/fixtures/contact_noname.vcf @@ -0,0 +1,4 @@ +BEGIN:VCARD +VERSION:3.0 +EMAIL:noname@example.org +END:VCARD diff --git a/tests/fixtures/contact_params.vcf b/tests/fixtures/contact_params.vcf new file mode 100644 index 0000000..f935ce5 --- /dev/null +++ b/tests/fixtures/contact_params.vcf @@ -0,0 +1,6 @@ +BEGIN:VCARD +VERSION:3.0 +FN:Bob Example +EMAIL;TYPE=WORK:bob@example.org +EMAIL;TYPE=INTERNET;LABEL="work: main":bob.work@example.org +END:VCARD diff --git a/tests/fixtures/contact_plain.vcf b/tests/fixtures/contact_plain.vcf new file mode 100644 index 0000000..7daaa01 --- /dev/null +++ b/tests/fixtures/contact_plain.vcf @@ -0,0 +1,6 @@ +BEGIN:VCARD +VERSION:3.0 +N:Example;Alice;;; +FN:Alice Example +EMAIL:alice@example.org +END:VCARD diff --git a/tests/fixtures/contact_twoemails.vcf b/tests/fixtures/contact_twoemails.vcf new file mode 100644 index 0000000..41325ed --- /dev/null +++ b/tests/fixtures/contact_twoemails.vcf @@ -0,0 +1,6 @@ +BEGIN:VCARD +VERSION:3.0 +FN:Dana Two +EMAIL:dana@example.org +EMAIL;TYPE=HOME:dana.home@example.org +END:VCARD diff --git a/tests/test_contactstore.cpp b/tests/test_contactstore.cpp new file mode 100644 index 0000000..b36f424 --- /dev/null +++ b/tests/test_contactstore.cpp @@ -0,0 +1,358 @@ +/* + * 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 <QtTest> +#include <QDir> +#include <QFile> +#include <QFileInfo> +#include <QTemporaryDir> + +#include "contactstore.h" + +namespace { + +QString readFixture(const QString &name) +{ + QFile file(QStringLiteral(FIXTURE_DIR) + QLatin1Char('/') + name); + if (!file.open(QIODevice::ReadOnly)) + return {}; + return QString::fromUtf8(file.readAll()); +} + +/// A minimal but complete vCard. Used by the loadDirectory cases, which build +/// their own tree in a QTemporaryDir rather than reaching for the shared +/// fixtures: a directory walk is about more than one card, and the flat +/// fixture directory has no subdirectories to walk into. +QString card(const QString &name, const QString &email) +{ + return QStringLiteral("BEGIN:VCARD\nVERSION:3.0\nFN:%1\nEMAIL:%2\nEND:VCARD\n") + .arg(name, email); +} + +void writeFile(const QString &path, const QString &content) +{ + QDir().mkpath(QFileInfo(path).absolutePath()); + QFile file(path); + QVERIFY2(file.open(QIODevice::WriteOnly | QIODevice::Truncate), + qPrintable(file.errorString())); + file.write(content.toUtf8()); +} + +} // namespace + +/// The vCard 3.0 parse behind the contact completion. +/// +/// Asserted on values, never on a rendered UI: ContactStore is a namespace of +/// free functions over values, which is what makes the parse testable without +/// a window at all. The loadDirectory cases each build a throwaway tree in a +/// QTemporaryDir, because the shared fixtures directory is flat. +class TestContactStore : public QObject +{ + Q_OBJECT +private slots: + void unfoldStripsTheFoldWhitespaceOnly(); + void unfoldJoinsAcrossSeveralFolds(); + void unfoldLeavesUnfoldedTextAlone(); + + void parsesNameAndAddress(); + void parsesAFoldedCard(); + void takesAContactPerEmailLine(); + void cardWithoutEmailYieldsNothing(); + void cardWithoutNameYieldsAnEmptyName(); + void ignoresPhotoAddressAndPhone(); + void acceptsParametersBeforeTheColon(); + void doesNotSplitOnAColonInsideAQuotedParameter(); + void propertyNamesAreCaseInsensitive(); + void unescapesTheFormattedName(); + void unescapesTheOtherTextEscapes(); + + void readsRecursively(); + void deduplicatesOnTheAddressCaseInsensitively(); + void sortsByNameThenAddress(); + void skipsFilesThatHoldNoCard(); + void skipsFilesThatAreNotVcf(); + void missingDirectoryIsEmptyAndQuiet(); + void emptyDirectoryIsEmptyAndQuiet(); +}; + +void TestContactStore::unfoldStripsTheFoldWhitespaceOnly() +{ + // The fold marker is CRLF plus ONE linear whitespace character. Removing + // the newline and that one character is the whole operation: the trailing + // space the writer left on the first line is content, not marker. + QCOMPARE(ContactStore::unfold(QStringLiteral("a \n b")), + QStringLiteral("a b")); + QCOMPARE(ContactStore::unfold(QStringLiteral("x\n\ty")), + QStringLiteral("xy")); +} + +void TestContactStore::unfoldJoinsAcrossSeveralFolds() +{ + // A fold may land anywhere, including mid-token, so every continuation + // line joins to the line that precedes it, not to the original. + QCOMPARE(ContactStore::unfold(QStringLiteral("EMAIL:folded@exam\n ple.org")), + QStringLiteral("EMAIL:folded@example.org")); + QCOMPARE(ContactStore::unfold(QStringLiteral("a\n b\n c")), + QStringLiteral("abc")); +} + +void TestContactStore::unfoldLeavesUnfoldedTextAlone() +{ + // No leading whitespace, no fold. The newlines are structure and survive. + QCOMPARE(ContactStore::unfold(QStringLiteral("FN:Alice\nEMAIL:a@example.org")), + QStringLiteral("FN:Alice\nEMAIL:a@example.org")); +} + +void TestContactStore::parsesNameAndAddress() +{ + const QList<Contact> contacts = + ContactStore::parseCard(readFixture(QStringLiteral("contact_plain.vcf"))); + + QCOMPARE(contacts.size(), 1); + QCOMPARE(contacts.at(0).name, QStringLiteral("Alice Example")); + QCOMPARE(contacts.at(0).email, QStringLiteral("alice@example.org")); +} + +void TestContactStore::parsesAFoldedCard() +{ + // The pipeline loadDirectory uses: unfold first, because a fold is only + // legal before any field is looked at, then parse. + const QList<Contact> contacts = ContactStore::parseCard( + ContactStore::unfold(readFixture(QStringLiteral("contact_folded.vcf")))); + + QCOMPARE(contacts.size(), 1); + QCOMPARE(contacts.at(0).name, QStringLiteral("Folded Person")); + QCOMPARE(contacts.at(0).email, QStringLiteral("folded@example.org")); +} + +void TestContactStore::takesAContactPerEmailLine() +{ + // A card with two addresses is two candidates, each carrying the one name. + const QList<Contact> contacts = ContactStore::parseCard( + readFixture(QStringLiteral("contact_twoemails.vcf"))); + + QCOMPARE(contacts.size(), 2); + QCOMPARE(contacts.at(0).name, QStringLiteral("Dana Two")); + QCOMPARE(contacts.at(0).email, QStringLiteral("dana@example.org")); + QCOMPARE(contacts.at(1).name, QStringLiteral("Dana Two")); + QCOMPARE(contacts.at(1).email, QStringLiteral("dana.home@example.org")); +} + +void TestContactStore::cardWithoutEmailYieldsNothing() +{ + // Measured on the real store: most cards carry no address. That is the + // ordinary case, not a failure, and it must not produce a candidate. + QVERIFY(ContactStore::parseCard( + readFixture(QStringLiteral("contact_noemail.vcf"))) + .isEmpty()); +} + +void TestContactStore::cardWithoutNameYieldsAnEmptyName() +{ + // An address with no FN is still a usable candidate, on the address alone. + const QList<Contact> contacts = ContactStore::parseCard( + readFixture(QStringLiteral("contact_noname.vcf"))); + + QCOMPARE(contacts.size(), 1); + QVERIFY(contacts.at(0).name.isEmpty()); + QCOMPARE(contacts.at(0).email, QStringLiteral("noname@example.org")); +} + +void TestContactStore::ignoresPhotoAddressAndPhone() +{ + // PHOTO, ADR and TEL are ignored entirely. They must not add candidates + // and must not disturb the one the EMAIL line produces. + const QList<Contact> contacts = ContactStore::parseCard( + readFixture(QStringLiteral("contact_extra.vcf"))); + + QCOMPARE(contacts.size(), 1); + QCOMPARE(contacts.at(0).name, QStringLiteral("Extra Fields")); + QCOMPARE(contacts.at(0).email, QStringLiteral("extra@example.org")); +} + +void TestContactStore::acceptsParametersBeforeTheColon() +{ + // EMAIL;TYPE=WORK:a@example.org. The parameters belong to the property + // name, so the value still parses. + const QList<Contact> contacts = ContactStore::parseCard( + readFixture(QStringLiteral("contact_params.vcf"))); + + QCOMPARE(contacts.size(), 2); + QCOMPARE(contacts.at(0).email, QStringLiteral("bob@example.org")); +} + +void TestContactStore::doesNotSplitOnAColonInsideAQuotedParameter() +{ + // EMAIL;TYPE=INTERNET;LABEL="work: main":bob.work@example.org. Splitting + // on the FIRST colon would cut inside the quoted parameter and hand the + // parser a mangled value, so the split tracks the quotes. + const QList<Contact> contacts = ContactStore::parseCard( + readFixture(QStringLiteral("contact_params.vcf"))); + + QCOMPARE(contacts.size(), 2); + QCOMPARE(contacts.at(1).email, QStringLiteral("bob.work@example.org")); +} + +void TestContactStore::propertyNamesAreCaseInsensitive() +{ + // RFC 2426 names are case-insensitive: `email:` is an EMAIL. + const QList<Contact> contacts = ContactStore::parseCard( + readFixture(QStringLiteral("contact_lowercase.vcf"))); + + QCOMPARE(contacts.size(), 1); + QCOMPARE(contacts.at(0).name, QStringLiteral("Carol Example")); + QCOMPARE(contacts.at(0).email, QStringLiteral("carol@example.org")); +} + +void TestContactStore::unescapesTheFormattedName() +{ + // FN is a TEXT value, so `\,` means a literal comma. Left escaped, the + // user sees the backslash in the completion popup. + const QList<Contact> contacts = ContactStore::parseCard( + readFixture(QStringLiteral("contact_escaped.vcf"))); + + QCOMPARE(contacts.size(), 1); + QCOMPARE(contacts.at(0).name, QStringLiteral("Rossi, Mario")); +} + +void TestContactStore::unescapesTheOtherTextEscapes() +{ + // The parser decodes the full TEXT escape set, so a name never reaches the + // UI half-decoded. The literal `\n` line is the one that would otherwise + // show two characters where the card described a line break. + const QList<Contact> contacts = ContactStore::parseCard(QStringLiteral( + "FN:Back\\\\slash\\; Semi\\nNext\nEMAIL:esc@example.org")); + + QCOMPARE(contacts.size(), 1); + QCOMPARE(contacts.at(0).name, + QStringLiteral("Back\\slash; Semi\nNext")); +} + +void TestContactStore::readsRecursively() +{ + // A vdir keeps one subdirectory per collection, so the walk has to + // descend. The tree lives in a QTemporaryDir, not the shared fixtures. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + writeFile(dir.path() + QStringLiteral("/collection-a/one.vcf"), + card(QStringLiteral("One"), QStringLiteral("one@example.org"))); + writeFile(dir.path() + QStringLiteral("/collection-b/two.vcf"), + card(QStringLiteral("Two"), QStringLiteral("two@example.org"))); + writeFile(dir.path() + QStringLiteral("/three.vcf"), + card(QStringLiteral("Three"), QStringLiteral("three@example.org"))); + + const QList<Contact> contacts = ContactStore::loadDirectory(dir.path()); + QCOMPARE(contacts.size(), 3); +} + +void TestContactStore::deduplicatesOnTheAddressCaseInsensitively() +{ + // Two collections holding the same person is the ordinary case, not an + // error. The address is the identity, and it is compared ignoring case. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + writeFile(dir.path() + QStringLiteral("/a.vcf"), + card(QStringLiteral("Dup"), QStringLiteral("dup@example.org"))); + writeFile(dir.path() + QStringLiteral("/b.vcf"), + card(QStringLiteral("Dup"), QStringLiteral("DUP@Example.org"))); + + const QList<Contact> contacts = ContactStore::loadDirectory(dir.path()); + QCOMPARE(contacts.size(), 1); +} + +void TestContactStore::sortsByNameThenAddress() +{ + // Name first, case-insensitively; the address breaks a name tie. The + // lowercase "bob" is what a case-sensitive sort would get wrong. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + writeFile(dir.path() + QStringLiteral("/c.vcf"), + card(QStringLiteral("Charlie"), QStringLiteral("charlie@example.org"))); + writeFile(dir.path() + QStringLiteral("/a.vcf"), + card(QStringLiteral("Alice"), QStringLiteral("alice@example.org"))); + writeFile(dir.path() + QStringLiteral("/b.vcf"), + card(QStringLiteral("bob"), QStringLiteral("bob@example.org"))); + writeFile(dir.path() + QStringLiteral("/s1.vcf"), + card(QStringLiteral("Same"), QStringLiteral("b@example.org"))); + writeFile(dir.path() + QStringLiteral("/s2.vcf"), + card(QStringLiteral("Same"), QStringLiteral("a@example.org"))); + + const QList<Contact> contacts = ContactStore::loadDirectory(dir.path()); + QCOMPARE(contacts.size(), 5); + QCOMPARE(contacts.at(0).name, QStringLiteral("Alice")); + QCOMPARE(contacts.at(1).name, QStringLiteral("bob")); + QCOMPARE(contacts.at(2).name, QStringLiteral("Charlie")); + QCOMPARE(contacts.at(3).name, QStringLiteral("Same")); + QCOMPARE(contacts.at(3).email, QStringLiteral("a@example.org")); + QCOMPARE(contacts.at(4).email, QStringLiteral("b@example.org")); +} + +void TestContactStore::skipsFilesThatHoldNoCard() +{ + // One bad card must not cost the other 116. A file with no EMAIL line + // contributes nothing and must not stop the walk. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + writeFile(dir.path() + QStringLiteral("/good.vcf"), + card(QStringLiteral("Good"), QStringLiteral("good@example.org"))); + writeFile(dir.path() + QStringLiteral("/bad.vcf"), + QStringLiteral("this is not a vcard\nnot a property either\n")); + + const QList<Contact> contacts = ContactStore::loadDirectory(dir.path()); + QCOMPARE(contacts.size(), 1); + QCOMPARE(contacts.at(0).email, QStringLiteral("good@example.org")); +} + +void TestContactStore::skipsFilesThatAreNotVcf() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + writeFile(dir.path() + QStringLiteral("/notes.txt"), + card(QStringLiteral("Ignored"), QStringLiteral("ignored@example.org"))); + writeFile(dir.path() + QStringLiteral("/keep.vcf"), + card(QStringLiteral("Kept"), QStringLiteral("kept@example.org"))); + + const QList<Contact> contacts = ContactStore::loadDirectory(dir.path()); + QCOMPARE(contacts.size(), 1); + QCOMPARE(contacts.at(0).email, QStringLiteral("kept@example.org")); +} + +void TestContactStore::missingDirectoryIsEmptyAndQuiet() +{ + // A machine with no vdir is the ordinary case for anyone who is not this + // user, so a missing path must not warn. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + QTest::failOnWarning(); + QVERIFY(ContactStore::loadDirectory( + dir.path() + QStringLiteral("/does-not-exist")) + .isEmpty()); +} + +void TestContactStore::emptyDirectoryIsEmptyAndQuiet() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + QDir(dir.path()).mkpath(QStringLiteral("empty")); + QTest::failOnWarning(); + QVERIFY(ContactStore::loadDirectory(dir.path() + QStringLiteral("/empty")) + .isEmpty()); +} + +QTEST_MAIN(TestContactStore) +#include "test_contactstore.moc" |
