aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-18 14:58:58 +0200
committerDanilo M. <danix@danix.xyz>2026-09-18 14:58:58 +0200
commit3303c7855cb22a6bb4f36aa1a17707187320f5d2 (patch)
tree6a2328cffaab820c8247f267785cc2782d77e154
parent7d4654536de5be4bd3555eb4b81a2e07c1d4148d (diff)
downloadqtmaildir-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.txt1
-rw-r--r--src/contactstore.cpp215
-rw-r--r--src/contactstore.h79
-rw-r--r--tests/CMakeLists.txt3
-rw-r--r--tests/fixtures/contact_escaped.vcf5
-rw-r--r--tests/fixtures/contact_extra.vcf9
-rw-r--r--tests/fixtures/contact_folded.vcf7
-rw-r--r--tests/fixtures/contact_lowercase.vcf5
-rw-r--r--tests/fixtures/contact_noemail.vcf7
-rw-r--r--tests/fixtures/contact_noname.vcf4
-rw-r--r--tests/fixtures/contact_params.vcf6
-rw-r--r--tests/fixtures/contact_plain.vcf6
-rw-r--r--tests/fixtures/contact_twoemails.vcf6
-rw-r--r--tests/test_contactstore.cpp358
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"