aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-24 19:54:27 +0200
committerDanilo M. <danix@danix.xyz>2026-08-24 19:54:27 +0200
commitbe2534ab60d362b2f685130265f727190ccf867a (patch)
treebe7e6f2bbdd12686a2c2ac71523e34a6f7030539
parent861628a75049002683f777baf837afe434d2f7db (diff)
downloadqtmaildir-be2534ab60d362b2f685130265f727190ccf867a.tar.gz
qtmaildir-be2534ab60d362b2f685130265f727190ccf867a.zip
feat(signatures): splice a signature into a buffer
Both placements over one implementation. above_quote inserts before the attribution rather than before the first quoted line: the attribution introduces the quote and belongs with it, and a signature between the two would read as part of the quoted message. A buffer with no quote makes above_quote identical to end, so a new message needs no branch of its own. Part of item 152. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
-rw-r--r--src/signatures.cpp72
-rw-r--r--tests/test_signatures.cpp62
2 files changed, 131 insertions, 3 deletions
diff --git a/src/signatures.cpp b/src/signatures.cpp
index 67bf491..ebf69f5 100644
--- a/src/signatures.cpp
+++ b/src/signatures.cpp
@@ -55,13 +55,79 @@ QString text(const QString &dir, const QString &name)
return QString::fromUtf8(file.readAll());
}
+namespace {
+
+/// The RFC 3676 signature separator: two hyphens, a space, end of line.
+///
+/// The trailing space is part of the standard and is what receiving clients
+/// match on to fold or strip a signature. It is also why `--` typed by hand
+/// does not collide: an editor does not add trailing whitespace on its own.
+const QLatin1String kDelimiter("-- ");
+
+bool isQuoted(const QString &line)
+{
+ return line.startsWith(QLatin1Char('>'));
+}
+
+/// The index of the first line of the quote, or -1 when the buffer has none.
+///
+/// The attribution line ("On Mon, someone wrote:") is deliberately NOT
+/// included: it introduces the quote and belongs with it, so a signature
+/// inserted above the quote goes above the attribution too. Returning the
+/// quoted line itself would strand the signature between the attribution and
+/// the text it introduces.
+int quoteStart(const QStringList &lines)
+{
+ for (int i = 0; i < lines.size(); ++i) {
+ if (!isQuoted(lines.at(i)))
+ continue;
+ // Walk back over the attribution and the blank line before it, so the
+ // signature lands above the whole block rather than inside it.
+ int start = i;
+ while (start > 0 && !lines.at(start - 1).trimmed().isEmpty()
+ && !isQuoted(lines.at(start - 1)))
+ --start;
+ return start;
+ }
+ return -1;
+}
+
+} // namespace
+
QString replace(const QString &buffer, const QString &signature,
const QStringList &known, Position position)
{
- Q_UNUSED(signature);
Q_UNUSED(known);
- Q_UNUSED(position);
- return buffer;
+
+ if (signature.isEmpty())
+ return buffer;
+
+ const QString block = QStringLiteral("\n") + kDelimiter
+ + QStringLiteral("\n") + signature;
+
+ QStringList lines = buffer.split(QLatin1Char('\n'));
+ const int quote =
+ position == Position::AboveQuote ? quoteStart(lines) : -1;
+
+ // No quote to sit above is not a special case: it is the End placement,
+ // which is why a New message needs no branch of its own.
+ if (quote < 0)
+ return buffer + block;
+
+ QStringList head = lines.mid(0, quote);
+ const QStringList tail = lines.mid(quote);
+ // The head ends in however many blank lines separated the reply from the
+ // attribution. Drop them all and let the block supply exactly one, so the
+ // spacing is the same whatever the quote was seeded with.
+ while (!head.isEmpty() && head.last().trimmed().isEmpty())
+ head.removeLast();
+
+ // head.join() has no trailing newline once trimmed, so the terminator for
+ // its last line is supplied here; `block` then opens with the blank line,
+ // which is the same shape as the End placement over a buffer ending in a
+ // newline.
+ return head.join(QLatin1Char('\n')) + QStringLiteral("\n") + block
+ + QStringLiteral("\n\n") + tail.join(QLatin1Char('\n'));
}
} // namespace Signatures
diff --git a/tests/test_signatures.cpp b/tests/test_signatures.cpp
index 2a75067..30085fb 100644
--- a/tests/test_signatures.cpp
+++ b/tests/test_signatures.cpp
@@ -31,6 +31,10 @@ private slots:
void aMissingDirectoryHasNoNames();
void textIsTheFileContent();
void textOfAnUnknownNameIsEmpty();
+ void insertingAtTheEndAppendsAfterADelimiter();
+ void insertingAboveTheQuotePutsItBeforeTheFirstQuotedLine();
+ void insertingAboveTheQuoteWithNoQuoteIsTheSameAsEnd();
+ void insertingNothingLeavesTheBufferAlone();
private:
/// Writes \p files as name -> content into a fresh temporary directory.
@@ -100,5 +104,63 @@ void TestSignatures::textOfAnUnknownNameIsEmpty()
QVERIFY(Signatures::text(dir.path(), QStringLiteral("absent")).isEmpty());
}
+void TestSignatures::insertingAtTheEndAppendsAfterADelimiter()
+{
+ const QString buffer = QStringLiteral("Hello.\n");
+
+ const QString result = Signatures::replace(
+ buffer, QStringLiteral("Jane Doe"), {}, Signatures::Position::End);
+
+ QCOMPARE(result, QStringLiteral("Hello.\n\n-- \nJane Doe"));
+}
+
+void TestSignatures::insertingAboveTheQuotePutsItBeforeTheFirstQuotedLine()
+{
+ const QString buffer = QStringLiteral(
+ "My reply.\n"
+ "\n"
+ "On Mon, someone wrote:\n"
+ "> the original\n"
+ "> second line\n");
+
+ const QString result = Signatures::replace(
+ buffer, QStringLiteral("Jane Doe"), {},
+ Signatures::Position::AboveQuote);
+
+ // Before the QUOTED lines, and the attribution stays with the quote it
+ // introduces: it is the line the quote hangs from, not part of the reply.
+ QCOMPARE(result, QStringLiteral(
+ "My reply.\n"
+ "\n"
+ "-- \n"
+ "Jane Doe\n"
+ "\n"
+ "On Mon, someone wrote:\n"
+ "> the original\n"
+ "> second line\n"));
+}
+
+void TestSignatures::insertingAboveTheQuoteWithNoQuoteIsTheSameAsEnd()
+{
+ const QString buffer = QStringLiteral("A new message.\n");
+
+ const QString above = Signatures::replace(
+ buffer, QStringLiteral("Jane Doe"), {},
+ Signatures::Position::AboveQuote);
+ const QString end = Signatures::replace(
+ buffer, QStringLiteral("Jane Doe"), {}, Signatures::Position::End);
+
+ QCOMPARE(above, end);
+}
+
+void TestSignatures::insertingNothingLeavesTheBufferAlone()
+{
+ const QString buffer = QStringLiteral("Hello.\n");
+
+ QCOMPARE(Signatures::replace(buffer, QString(), {},
+ Signatures::Position::End),
+ buffer);
+}
+
QTEST_MAIN(TestSignatures)
#include "test_signatures.moc"