From 73d82e956229f8834b511aca7c748668033aa710 Mon Sep 17 00:00:00 2001 From: deeaitch Date: Thu, 1 Oct 2026 12:21:40 -0400 Subject: [PATCH] applied clang format --- clipboardresultdialog.h | 66 ++-- clipboardurl.h | 103 +++--- main.cpp | 14 +- mainwindow.cpp | 469 +++++++++++++-------------- mainwindow.h | 24 +- tests/clipboardresultdialog_test.cpp | 60 ++-- tests/clipboardurl_test.cpp | 84 +++-- tests/yourlsclient_test.cpp | 123 +++---- yourlsclient.cpp | 229 +++++++------ yourlsclient.h | 20 +- 10 files changed, 583 insertions(+), 609 deletions(-) diff --git a/clipboardresultdialog.h b/clipboardresultdialog.h index b799239..6e88f2c 100644 --- a/clipboardresultdialog.h +++ b/clipboardresultdialog.h @@ -15,48 +15,46 @@ // Keep the result window focused while taking ownership of the clipboard. class ClipboardResultDialog : public QDialog { public: - ClipboardResultDialog (const QUrl &url, bool requiresFocus, QWidget *parent = nullptr) - : QDialog (parent), m_text (url.toString (QUrl::FullyEncoded)), - m_requiresFocus (requiresFocus), m_status (new QLabel (this)) { - m_status->setText (QStringLiteral ("Activate this window to copy the short URL.")); - m_status->setWordWrap (true); - } + ClipboardResultDialog(const QUrl &url, bool requiresFocus, QWidget *parent = nullptr) + : QDialog(parent), m_text(url.toString(QUrl::FullyEncoded)), + m_requiresFocus(requiresFocus), m_status(new QLabel(this)) { + m_status->setText(QStringLiteral("Activate this window to copy the short URL.")); + m_status->setWordWrap(true); + } - QLabel *copyStatusLabel() const { return m_status; } + QLabel *copyStatusLabel() const { return m_status; } - bool copyShortUrl() { - if (m_requiresFocus && !isActiveWindow()) - return false; - auto *clipboard = QGuiApplication::clipboard(); - clipboard->setText (m_text, QClipboard::Clipboard); - const bool copied = clipboard->text (QClipboard::Clipboard) == m_text - && (!QGuiApplication::platformName().startsWith (QStringLiteral ("wayland")) - || clipboard->ownsClipboard()); - m_status->setText (copied ? QStringLiteral ("Short URL copied to clipboard.") - : QStringLiteral ("Could not copy the short URL. Click OK to retry.")); + bool copyShortUrl() { + if (m_requiresFocus && !isActiveWindow()) + return false; + auto *clipboard = QGuiApplication::clipboard(); + clipboard->setText(m_text, QClipboard::Clipboard); + const bool copied = clipboard->text(QClipboard::Clipboard) == m_text && (!QGuiApplication::platformName().startsWith(QStringLiteral("wayland")) || clipboard->ownsClipboard()); + m_status->setText(copied ? QStringLiteral("Short URL copied to clipboard.") + : QStringLiteral("Could not copy the short URL. Click OK to retry.")); #ifndef QT_NO_DEBUG - qDebug() << "[clipboard] short URL write, active:" << isActiveWindow() - << "owns clipboard:" << clipboard->ownsClipboard() << "verified:" << copied; + qDebug() << "[clipboard] short URL write, active:" << isActiveWindow() + << "owns clipboard:" << clipboard->ownsClipboard() << "verified:" << copied; #endif - return copied; - } + return copied; + } protected: - void changeEvent (QEvent *event) override { - QDialog::changeEvent (event); - if (event->type() == QEvent::ActivationChange && isActiveWindow()) { - // Let Qt finish processing the focus event and its Wayland input serial. - QTimer::singleShot (0, this, [this]() { - if (isActiveWindow()) - copyShortUrl(); - }); + void changeEvent(QEvent *event) override { + QDialog::changeEvent(event); + if (event->type() == QEvent::ActivationChange && isActiveWindow()) { + // Let Qt finish processing the focus event and its Wayland input serial. + QTimer::singleShot(0, this, [this]() { + if (isActiveWindow()) + copyShortUrl(); + }); + } } - } private: - QString m_text; - bool m_requiresFocus; - QLabel *m_status; + QString m_text; + bool m_requiresFocus; + QLabel *m_status; }; -#endif // CLIPBOARDRESULTDIALOG_H +#endif// CLIPBOARDRESULTDIALOG_H diff --git a/clipboardurl.h b/clipboardurl.h index eb3a704..35fe635 100644 --- a/clipboardurl.h +++ b/clipboardurl.h @@ -10,64 +10,63 @@ namespace ClipboardUrl { -inline QUrl parseText (const QString &text) { - QString urlText = text.trimmed(); + inline QUrl parseText(const QString &text) { + QString urlText = text.trimmed(); #ifndef QT_NO_DEBUG - qDebug() << "[clipboard] raw text:" << text - << "UTF-8 hex:" << text.toUtf8().toHex(); + qDebug() << "[clipboard] raw text:" << text + << "UTF-8 hex:" << text.toUtf8().toHex(); #endif - if (urlText.size() >= 2) { - const QChar first = urlText.front(); - const QChar last = urlText.back(); - if ((first == '\'' && last == '\'') || (first == '"' && last == '"') - || (first == '<' && last == '>')) - urlText = urlText.mid (1, urlText.size() - 2).trimmed(); - } + if (urlText.size() >= 2) { + const QChar first = urlText.front(); + const QChar last = urlText.back(); + if ((first == '\'' && last == '\'') || (first == '"' && last == '"') || (first == '<' && last == '>')) + urlText = urlText.mid(1, urlText.size() - 2).trimmed(); + } - static const QRegularExpression markdownLink ( - QStringLiteral ("\\A\\[[^\\r\\n]*\\]\\((https?://[^\\r\\n]+)\\)\\z"), - QRegularExpression::CaseInsensitiveOption); - const auto match = markdownLink.match (urlText); - if (match.hasMatch()) - urlText = match.captured (1); + static const QRegularExpression markdownLink( + QStringLiteral("\\A\\[[^\\r\\n]*\\]\\((https?://[^\\r\\n]+)\\)\\z"), + QRegularExpression::CaseInsensitiveOption); + const auto match = markdownLink.match(urlText); + if (match.hasMatch()) + urlText = match.captured(1); - const QUrl url = QUrl::fromUserInput (urlText); + const QUrl url = QUrl::fromUserInput(urlText); #ifndef QT_NO_DEBUG - qDebug() << "[clipboard] normalized text:" << urlText - << "parsed URL:" << url << "valid:" << url.isValid() - << "scheme:" << url.scheme() << "host:" << url.host() - << "parse error:" << url.errorString(); + qDebug() << "[clipboard] normalized text:" << urlText + << "parsed URL:" << url << "valid:" << url.isValid() + << "scheme:" << url.scheme() << "host:" << url.host() + << "parse error:" << url.errorString(); #endif - if (!url.isValid() || url.scheme().isEmpty() || url.host().isEmpty()) - return QUrl(); - return url; -} - -inline QUrl fromMimeData (const QMimeData *mimeData) { - if (mimeData == nullptr) { -#ifndef QT_NO_DEBUG - qDebug() << "[clipboard] MIME data unavailable"; -#endif - return QUrl(); - } - - const auto urls = mimeData->urls(); -#ifndef QT_NO_DEBUG - qDebug() << "[clipboard] MIME formats:" << mimeData->formats() - << "URL entries:" << urls; -#endif - if (urls.size() == 1) { - const QUrl url = urls.first(); - if (url.isValid() && !url.scheme().isEmpty() && !url.host().isEmpty()) { -#ifndef QT_NO_DEBUG - qDebug() << "[clipboard] using MIME URL:" << url; -#endif - return url; + if (!url.isValid() || url.scheme().isEmpty() || url.host().isEmpty()) + return QUrl(); + return url; } - } - return parseText (mimeData->text()); -} -} // namespace ClipboardUrl + inline QUrl fromMimeData(const QMimeData *mimeData) { + if (mimeData == nullptr) { +#ifndef QT_NO_DEBUG + qDebug() << "[clipboard] MIME data unavailable"; +#endif + return QUrl(); + } -#endif // CLIPBOARDURL_H + const auto urls = mimeData->urls(); +#ifndef QT_NO_DEBUG + qDebug() << "[clipboard] MIME formats:" << mimeData->formats() + << "URL entries:" << urls; +#endif + if (urls.size() == 1) { + const QUrl url = urls.first(); + if (url.isValid() && !url.scheme().isEmpty() && !url.host().isEmpty()) { +#ifndef QT_NO_DEBUG + qDebug() << "[clipboard] using MIME URL:" << url; +#endif + return url; + } + } + return parseText(mimeData->text()); + } + +}// namespace ClipboardUrl + +#endif// CLIPBOARDURL_H diff --git a/main.cpp b/main.cpp index 2388e7f..54c4889 100644 --- a/main.cpp +++ b/main.cpp @@ -3,12 +3,12 @@ #include #include -int main (int argc, char *argv[]) { - QApplication application (argc, argv); - QCoreApplication::setOrganizationName (QStringLiteral ("deeaitch")); - QCoreApplication::setApplicationName (QStringLiteral ("yourls-ui")); +int main(int argc, char *argv[]) { + QApplication application(argc, argv); + QCoreApplication::setOrganizationName(QStringLiteral("deeaitch")); + QCoreApplication::setApplicationName(QStringLiteral("yourls-ui")); - MainWindow window; - window.show(); - return application.exec(); + MainWindow window; + window.show(); + return application.exec(); } diff --git a/mainwindow.cpp b/mainwindow.cpp index 312f920..822aead 100644 --- a/mainwindow.cpp +++ b/mainwindow.cpp @@ -1,6 +1,6 @@ #include "mainwindow.h" -#include "clipboardurl.h" #include "clipboardresultdialog.h" +#include "clipboardurl.h" #include "ui_mainwindow.h" #include "yourlsclient.h" @@ -24,139 +24,130 @@ namespace { - /** + /** * @brief Builds a clickable HTML link for dialogs. * @param url URL to render. * @return Rich text string. */ - QString makeLinkHtml (const QUrl &url) { - const QString urlText = url.toDisplayString(); - return QStringLiteral ("%2") - .arg (url.toString (QUrl::FullyEncoded), urlText.toHtmlEscaped()); - } + QString makeLinkHtml(const QUrl &url) { + const QString urlText = url.toDisplayString(); + return QStringLiteral("%2") + .arg(url.toString(QUrl::FullyEncoded), urlText.toHtmlEscaped()); + } - /** + /** * @brief Returns the organization name used by QSettings. * @return Organization string. */ - QString settingsOrganization() { - return QStringLiteral ("deeaitch"); - } + QString settingsOrganization() { + return QStringLiteral("deeaitch"); + } - /** + /** * @brief Returns the application name used by QSettings. * @return Application string. */ - QString settingsApplication() { - return QStringLiteral ("yourls-ui"); - } + QString settingsApplication() { + return QStringLiteral("yourls-ui"); + } -} // namespace +}// namespace /** * @brief Constructs the main window. * @param parent Parent widget. */ -MainWindow::MainWindow (QWidget *parent) - : QMainWindow (parent) - , ui (new Ui::MainWindow) - , m_trayIcon (nullptr) - , m_trayMenu (nullptr) - , m_cutUrlAction (nullptr) - , m_settingsAction (nullptr) - , m_helpAction (nullptr) - , m_aboutAction (nullptr) - , m_exitAction (nullptr) - , m_yourlsClient (new YourlsClient (this)) { - ui->setupUi (this); +MainWindow::MainWindow(QWidget *parent) + : QMainWindow(parent), ui(new Ui::MainWindow), m_trayIcon(nullptr), m_trayMenu(nullptr), m_cutUrlAction(nullptr), m_settingsAction(nullptr), m_helpAction(nullptr), m_aboutAction(nullptr), m_exitAction(nullptr), m_yourlsClient(new YourlsClient(this)) { + ui->setupUi(this); #ifndef QT_NO_DEBUG - qDebug() << "[yourls-ui] debug build, executable:" << QCoreApplication::applicationFilePath() - << "Qt:" << qVersion() << "platform:" << QGuiApplication::platformName(); + qDebug() << "[yourls-ui] debug build, executable:" << QCoreApplication::applicationFilePath() + << "Qt:" << qVersion() << "platform:" << QGuiApplication::platformName(); #endif - setWindowTitle (QStringLiteral ("yourls-ui settings")); - ui->apiKeyLineEdit->setEchoMode (QLineEdit::Password); - statusBar()->showMessage (QStringLiteral ("Configure YOURLS API settings and save.")); + setWindowTitle(QStringLiteral("yourls-ui settings")); + ui->apiKeyLineEdit->setEchoMode(QLineEdit::Password); + statusBar()->showMessage(QStringLiteral("Configure YOURLS API settings and save.")); - setupMenuActions(); - setupTray(); - setupConnections(); - loadSettings(); + setupMenuActions(); + setupTray(); + setupConnections(); + loadSettings(); - connect (m_yourlsClient, &YourlsClient::shortenSucceeded, this, &MainWindow::onShortenSucceeded); - connect (m_yourlsClient, &YourlsClient::shortenFailed, this, &MainWindow::onShortenFailed); + connect(m_yourlsClient, &YourlsClient::shortenSucceeded, this, &MainWindow::onShortenSucceeded); + connect(m_yourlsClient, &YourlsClient::shortenFailed, this, &MainWindow::onShortenFailed); - if (hasValidSettings()) { - if (m_trayIcon != nullptr) - m_trayIcon->show(); - QTimer::singleShot (0, this, &MainWindow::hideToTray); - } + if (hasValidSettings()) { + if (m_trayIcon != nullptr) + m_trayIcon->show(); + QTimer::singleShot(0, this, &MainWindow::hideToTray); + } } /** * @brief Destroys the main window. */ MainWindow::~MainWindow() { - delete ui; + delete ui; } /** * @brief Handles close requests. * @param event Close event. */ -void MainWindow::closeEvent (QCloseEvent *event) { - if (hasValidSettings() && m_trayIcon != nullptr && m_trayIcon->isVisible()) { - hideToTray(); - event->ignore(); - return; - } +void MainWindow::closeEvent(QCloseEvent *event) { + if (hasValidSettings() && m_trayIcon != nullptr && m_trayIcon->isVisible()) { + hideToTray(); + event->ignore(); + return; + } - QMainWindow::closeEvent (event); + QMainWindow::closeEvent(event); } /** * @brief Saves settings from the UI. */ void MainWindow::saveSettings() { - const QString apiUrl = ui->urlLineEdit->text().trimmed(); - const QString apiKey = ui->apiKeyLineEdit->text().trimmed(); + const QString apiUrl = ui->urlLineEdit->text().trimmed(); + const QString apiKey = ui->apiKeyLineEdit->text().trimmed(); - if (apiUrl.isEmpty()) { - QMessageBox::warning (this, QStringLiteral ("Missing API URL"), QStringLiteral ("Please enter the YOURLS API URL.")); - return; - } + if (apiUrl.isEmpty()) { + QMessageBox::warning(this, QStringLiteral("Missing API URL"), QStringLiteral("Please enter the YOURLS API URL.")); + return; + } - if (apiKey.isEmpty()) { - QMessageBox::warning (this, QStringLiteral ("Missing API key"), QStringLiteral ("Please enter the YOURLS API signature.")); - return; - } + if (apiKey.isEmpty()) { + QMessageBox::warning(this, QStringLiteral("Missing API key"), QStringLiteral("Please enter the YOURLS API signature.")); + return; + } - const QUrl parsedApiUrl (apiUrl); - if (!parsedApiUrl.isValid() || parsedApiUrl.scheme().isEmpty() || parsedApiUrl.host().isEmpty()) { - QMessageBox::warning (this, - QStringLiteral ("Invalid API URL"), - QStringLiteral ("The configured API URL does not look valid.")); - return; - } + const QUrl parsedApiUrl(apiUrl); + if (!parsedApiUrl.isValid() || parsedApiUrl.scheme().isEmpty() || parsedApiUrl.host().isEmpty()) { + QMessageBox::warning(this, + QStringLiteral("Invalid API URL"), + QStringLiteral("The configured API URL does not look valid.")); + return; + } - QSettings settings (settingsOrganization(), settingsApplication()); - settings.setValue (QStringLiteral ("api/url"), apiUrl); - settings.setValue (QStringLiteral ("api/signature"), apiKey); - settings.sync(); + QSettings settings(settingsOrganization(), settingsApplication()); + settings.setValue(QStringLiteral("api/url"), apiUrl); + settings.setValue(QStringLiteral("api/signature"), apiKey); + settings.sync(); - if (m_trayIcon != nullptr) - m_trayIcon->show(); + if (m_trayIcon != nullptr) + m_trayIcon->show(); - statusBar()->showMessage (QStringLiteral ("Settings saved."), 2000); - hideToTray(); + statusBar()->showMessage(QStringLiteral("Settings saved."), 2000); + hideToTray(); } /** * @brief Shows About text. */ void MainWindow::showAbout() { - QMessageBox::about (this, - QStringLiteral ("About yourls-ui"), - QStringLiteral ("yourls-ui\n\n" + QMessageBox::about(this, + QStringLiteral("About yourls-ui"), + QStringLiteral("yourls-ui\n\n" "Small tray utility for shortening clipboard URLs with a YOURLS API.\n\n" "Creator: deeaitch\n" "Created: 2026-03-16")); @@ -166,210 +157,207 @@ void MainWindow::showAbout() { * @brief Shows Help text. */ void MainWindow::showHelp() { - QMessageBox::information (this, - QStringLiteral ("Help"), - QStringLiteral ("1. Open Settings and enter the YOURLS API URL and signature.\n" - "2. Save the settings. The application will stay in the tray.\n" - "3. Copy a full URL to the clipboard.\n" - "4. Right-click the tray icon and choose \"Cut URL\".\n" - "5. The short URL will be copied back to the clipboard and shown in a popup.\n\n" - "If something fails, the program shows detailed error information.")); + QMessageBox::information(this, + QStringLiteral("Help"), + QStringLiteral("1. Open Settings and enter the YOURLS API URL and signature.\n" + "2. Save the settings. The application will stay in the tray.\n" + "3. Copy a full URL to the clipboard.\n" + "4. Right-click the tray icon and choose \"Cut URL\".\n" + "5. The short URL will be copied back to the clipboard and shown in a popup.\n\n" + "If something fails, the program shows detailed error information.")); } /** * @brief Shows the settings window. */ void MainWindow::showSettingsWindow() { - showNormal(); - raise(); - activateWindow(); + showNormal(); + raise(); + activateWindow(); } /** * @brief Hides the settings window to tray when possible. */ void MainWindow::hideToTray() { - if (!hasValidSettings() || m_trayIcon == nullptr - || !QSystemTrayIcon::isSystemTrayAvailable() || !m_trayIcon->isVisible()) { - showSettingsWindow(); - return; - } + if (!hasValidSettings() || m_trayIcon == nullptr || !QSystemTrayIcon::isSystemTrayAvailable() || !m_trayIcon->isVisible()) { + showSettingsWindow(); + return; + } - hide(); - if (m_trayIcon != nullptr && m_trayIcon->isVisible()) { - m_trayIcon->showMessage (QStringLiteral ("yourls-ui"), - QStringLiteral ("The application is running in the system tray."), - QSystemTrayIcon::Information, - 2000); - } + hide(); + if (m_trayIcon != nullptr && m_trayIcon->isVisible()) { + m_trayIcon->showMessage(QStringLiteral("yourls-ui"), + QStringLiteral("The application is running in the system tray."), + QSystemTrayIcon::Information, + 2000); + } } /** * @brief Exits the application. */ void MainWindow::exitApplication() { - QApplication::quit(); + QApplication::quit(); } /** * @brief Handles tray icon activation. * @param reason Activation reason. */ -void MainWindow::onTrayIconActivated (QSystemTrayIcon::ActivationReason reason) { - if (reason == QSystemTrayIcon::DoubleClick || reason == QSystemTrayIcon::Trigger) - showSettingsWindow(); +void MainWindow::onTrayIconActivated(QSystemTrayIcon::ActivationReason reason) { + if (reason == QSystemTrayIcon::DoubleClick || reason == QSystemTrayIcon::Trigger) + showSettingsWindow(); } /** * @brief Reads a URL from the clipboard and shortens it. */ void MainWindow::cutUrlFromClipboard() { - if (!hasValidSettings()) { - QMessageBox::warning (this, - QStringLiteral ("Settings required"), - QStringLiteral ("Please configure the API URL and signature first.")); - showSettingsWindow(); - return; - } - - QUrl longUrl; - if (QGuiApplication::platformName().startsWith (QStringLiteral ("wayland")) - && !isActiveWindow()) { - // Wayland delivers clipboard offers to clients with keyboard focus. - // A tray menu belongs to the shell and does not focus our application. - QDialog clipboardDialog (this); - clipboardDialog.setWindowTitle (QStringLiteral ("Read clipboard URL")); - auto *layout = new QVBoxLayout (&clipboardDialog); - layout->addWidget (new QLabel ( - QStringLiteral ("Activate this window to read the URL from the clipboard."), &clipboardDialog)); - auto *buttons = new QDialogButtonBox (QDialogButtonBox::Cancel, &clipboardDialog); - auto *readButton = buttons->addButton (QStringLiteral ("Read clipboard"), QDialogButtonBox::AcceptRole); - layout->addWidget (buttons); - connect (buttons, &QDialogButtonBox::rejected, &clipboardDialog, &QDialog::reject); - - const auto readClipboard = [&]() { - longUrl = ClipboardUrl::fromMimeData (QGuiApplication::clipboard()->mimeData()); - clipboardDialog.accept(); - }; - connect (readButton, &QPushButton::clicked, &clipboardDialog, readClipboard); - - // Wait for focus and the clipboard offer, without blocking the event loop. - QTimer pollTimer (&clipboardDialog); - int activePolls = 0; - connect (&pollTimer, &QTimer::timeout, &clipboardDialog, [&]() { - if (!clipboardDialog.isActiveWindow()) + if (!hasValidSettings()) { + QMessageBox::warning(this, + QStringLiteral("Settings required"), + QStringLiteral("Please configure the API URL and signature first.")); + showSettingsWindow(); return; - const auto *mimeData = QGuiApplication::clipboard()->mimeData(); - if ((mimeData != nullptr && !mimeData->formats().isEmpty()) - || ++activePolls >= 20) - readClipboard(); - }); -#ifndef QT_NO_DEBUG - qDebug() << "[clipboard] Wayland: waiting for focused window and clipboard offer"; -#endif - pollTimer.start (100); - if (clipboardDialog.exec() != QDialog::Accepted) - return; - pollTimer.stop(); - } else { - longUrl = ClipboardUrl::fromMimeData (QGuiApplication::clipboard()->mimeData()); - } -#ifndef QT_NO_DEBUG - qDebug() << "[clipboard] final URL:" << longUrl << "valid:" << longUrl.isValid(); -#endif - if (!longUrl.isValid()) { -#ifndef QT_NO_DEBUG - qDebug() << "[clipboard] rejected locally; no YOURLS request sent"; -#endif - QMessageBox::warning (this, - QStringLiteral ("Invalid clipboard content"), - QStringLiteral ("The clipboard does not contain a valid URL.")); - return; - } + } - YourlsSettings settings; - settings.apiUrl = ui->urlLineEdit->text().trimmed(); - settings.signature = ui->apiKeyLineEdit->text().trimmed(); + QUrl longUrl; + if (QGuiApplication::platformName().startsWith(QStringLiteral("wayland")) && !isActiveWindow()) { + // Wayland delivers clipboard offers to clients with keyboard focus. + // A tray menu belongs to the shell and does not focus our application. + QDialog clipboardDialog(this); + clipboardDialog.setWindowTitle(QStringLiteral("Read clipboard URL")); + auto *layout = new QVBoxLayout(&clipboardDialog); + layout->addWidget(new QLabel( + QStringLiteral("Activate this window to read the URL from the clipboard."), &clipboardDialog)); + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Cancel, &clipboardDialog); + auto *readButton = buttons->addButton(QStringLiteral("Read clipboard"), QDialogButtonBox::AcceptRole); + layout->addWidget(buttons); + connect(buttons, &QDialogButtonBox::rejected, &clipboardDialog, &QDialog::reject); - if (m_trayIcon != nullptr) { - m_trayIcon->showMessage (QStringLiteral ("yourls-ui"), - QStringLiteral ("Requesting short URL..."), - QSystemTrayIcon::Information, - 1200); - } + const auto readClipboard = [&]() { + longUrl = ClipboardUrl::fromMimeData(QGuiApplication::clipboard()->mimeData()); + clipboardDialog.accept(); + }; + connect(readButton, &QPushButton::clicked, &clipboardDialog, readClipboard); - m_yourlsClient->shortenUrl (settings, longUrl); + // Wait for focus and the clipboard offer, without blocking the event loop. + QTimer pollTimer(&clipboardDialog); + int activePolls = 0; + connect(&pollTimer, &QTimer::timeout, &clipboardDialog, [&]() { + if (!clipboardDialog.isActiveWindow()) + return; + const auto *mimeData = QGuiApplication::clipboard()->mimeData(); + if ((mimeData != nullptr && !mimeData->formats().isEmpty()) || ++activePolls >= 20) + readClipboard(); + }); +#ifndef QT_NO_DEBUG + qDebug() << "[clipboard] Wayland: waiting for focused window and clipboard offer"; +#endif + pollTimer.start(100); + if (clipboardDialog.exec() != QDialog::Accepted) + return; + pollTimer.stop(); + } else { + longUrl = ClipboardUrl::fromMimeData(QGuiApplication::clipboard()->mimeData()); + } +#ifndef QT_NO_DEBUG + qDebug() << "[clipboard] final URL:" << longUrl << "valid:" << longUrl.isValid(); +#endif + if (!longUrl.isValid()) { +#ifndef QT_NO_DEBUG + qDebug() << "[clipboard] rejected locally; no YOURLS request sent"; +#endif + QMessageBox::warning(this, + QStringLiteral("Invalid clipboard content"), + QStringLiteral("The clipboard does not contain a valid URL.")); + return; + } + + YourlsSettings settings; + settings.apiUrl = ui->urlLineEdit->text().trimmed(); + settings.signature = ui->apiKeyLineEdit->text().trimmed(); + + if (m_trayIcon != nullptr) { + m_trayIcon->showMessage(QStringLiteral("yourls-ui"), + QStringLiteral("Requesting short URL..."), + QSystemTrayIcon::Information, + 1200); + } + + m_yourlsClient->shortenUrl(settings, longUrl); } /** * @brief Handles successful shortening. * @param shortUrl Generated short URL. */ -void MainWindow::onShortenSucceeded (const QUrl &shortUrl) { - showShortUrlPopup (shortUrl); +void MainWindow::onShortenSucceeded(const QUrl &shortUrl) { + showShortUrlPopup(shortUrl); } /** * @brief Handles shortening failure. * @param details Detailed error text. */ -void MainWindow::onShortenFailed (const QString &details) { - QMessageBox::critical (this, QStringLiteral ("Failed to create short URL"), details); +void MainWindow::onShortenFailed(const QString &details) { + QMessageBox::critical(this, QStringLiteral("Failed to create short URL"), details); } /** * @brief Creates tray icon and tray menu. */ void MainWindow::setupTray() { - if (!QSystemTrayIcon::isSystemTrayAvailable()) { - QMessageBox::warning (this, - QStringLiteral ("System tray unavailable"), - QStringLiteral ("No system tray was detected. The application will stay as a normal window.")); - return; - } + if (!QSystemTrayIcon::isSystemTrayAvailable()) { + QMessageBox::warning(this, + QStringLiteral("System tray unavailable"), + QStringLiteral("No system tray was detected. The application will stay as a normal window.")); + return; + } - m_trayIcon = new QSystemTrayIcon (this); - m_trayIcon->setIcon (style()->standardIcon (QStyle::SP_DialogApplyButton)); + m_trayIcon = new QSystemTrayIcon(this); + m_trayIcon->setIcon(style()->standardIcon(QStyle::SP_DialogApplyButton)); - m_trayMenu = new QMenu (this); - m_trayMenu->addAction (m_cutUrlAction); - m_trayMenu->addSeparator(); - m_trayMenu->addAction (m_settingsAction); - m_trayMenu->addAction (m_helpAction); - m_trayMenu->addAction (m_aboutAction); - m_trayMenu->addSeparator(); - m_trayMenu->addAction (m_exitAction); + m_trayMenu = new QMenu(this); + m_trayMenu->addAction(m_cutUrlAction); + m_trayMenu->addSeparator(); + m_trayMenu->addAction(m_settingsAction); + m_trayMenu->addAction(m_helpAction); + m_trayMenu->addAction(m_aboutAction); + m_trayMenu->addSeparator(); + m_trayMenu->addAction(m_exitAction); - m_trayIcon->setContextMenu (m_trayMenu); + m_trayIcon->setContextMenu(m_trayMenu); } /** * @brief Connects UI signals to slots. */ void MainWindow::setupConnections() { - connect (ui->saveBtn, &QPushButton::clicked, this, &MainWindow::saveSettings); + connect(ui->saveBtn, &QPushButton::clicked, this, &MainWindow::saveSettings); - connect (ui->actionHelp, &QAction::triggered, this, &MainWindow::showHelp); - connect (ui->actionAbout, &QAction::triggered, this, &MainWindow::showAbout); - connect (ui->actionExit, &QAction::triggered, this, &MainWindow::exitApplication); + connect(ui->actionHelp, &QAction::triggered, this, &MainWindow::showHelp); + connect(ui->actionAbout, &QAction::triggered, this, &MainWindow::showAbout); + connect(ui->actionExit, &QAction::triggered, this, &MainWindow::exitApplication); - connect (m_cutUrlAction, &QAction::triggered, this, &MainWindow::cutUrlFromClipboard); - connect (m_settingsAction, &QAction::triggered, this, &MainWindow::showSettingsWindow); - connect (m_helpAction, &QAction::triggered, this, &MainWindow::showHelp); - connect (m_aboutAction, &QAction::triggered, this, &MainWindow::showAbout); - connect (m_exitAction, &QAction::triggered, this, &MainWindow::exitApplication); + connect(m_cutUrlAction, &QAction::triggered, this, &MainWindow::cutUrlFromClipboard); + connect(m_settingsAction, &QAction::triggered, this, &MainWindow::showSettingsWindow); + connect(m_helpAction, &QAction::triggered, this, &MainWindow::showHelp); + connect(m_aboutAction, &QAction::triggered, this, &MainWindow::showAbout); + connect(m_exitAction, &QAction::triggered, this, &MainWindow::exitApplication); - if (m_trayIcon != nullptr) - connect (m_trayIcon, &QSystemTrayIcon::activated, this, &MainWindow::onTrayIconActivated); + if (m_trayIcon != nullptr) + connect(m_trayIcon, &QSystemTrayIcon::activated, this, &MainWindow::onTrayIconActivated); } /** * @brief Loads persisted settings into the UI. */ void MainWindow::loadSettings() { - QSettings settings (settingsOrganization(), settingsApplication()); - ui->urlLineEdit->setText (settings.value (QStringLiteral ("api/url")).toString()); - ui->apiKeyLineEdit->setText (settings.value (QStringLiteral ("api/signature")).toString()); + QSettings settings(settingsOrganization(), settingsApplication()); + ui->urlLineEdit->setText(settings.value(QStringLiteral("api/url")).toString()); + ui->apiKeyLineEdit->setText(settings.value(QStringLiteral("api/signature")).toString()); } /** @@ -377,47 +365,46 @@ void MainWindow::loadSettings() { * @return True when the application can work from the tray. */ bool MainWindow::hasValidSettings() const { - return !ui->urlLineEdit->text().trimmed().isEmpty() - && !ui->apiKeyLineEdit->text().trimmed().isEmpty(); + return !ui->urlLineEdit->text().trimmed().isEmpty() && !ui->apiKeyLineEdit->text().trimmed().isEmpty(); } /** * @brief Shows a popup with the generated short URL. * @param shortUrl Generated short URL. */ -void MainWindow::showShortUrlPopup (const QUrl &shortUrl) { - const bool wayland = QGuiApplication::platformName().startsWith (QStringLiteral ("wayland")); - ClipboardResultDialog dialog (shortUrl, wayland, this); - dialog.setWindowTitle (QStringLiteral ("Short URL created")); +void MainWindow::showShortUrlPopup(const QUrl &shortUrl) { + const bool wayland = QGuiApplication::platformName().startsWith(QStringLiteral("wayland")); + ClipboardResultDialog dialog(shortUrl, wayland, this); + dialog.setWindowTitle(QStringLiteral("Short URL created")); - auto *layout = new QVBoxLayout (&dialog); - auto *label = new QLabel (QStringLiteral ("Generated short URL:
%1").arg (makeLinkHtml (shortUrl)), &dialog); - label->setTextFormat (Qt::RichText); - label->setTextInteractionFlags (Qt::TextBrowserInteraction); - label->setOpenExternalLinks (true); - layout->addWidget (label); - layout->addWidget (dialog.copyStatusLabel()); + auto *layout = new QVBoxLayout(&dialog); + auto *label = new QLabel(QStringLiteral("Generated short URL:
%1").arg(makeLinkHtml(shortUrl)), &dialog); + label->setTextFormat(Qt::RichText); + label->setTextInteractionFlags(Qt::TextBrowserInteraction); + label->setOpenExternalLinks(true); + layout->addWidget(label); + layout->addWidget(dialog.copyStatusLabel()); - auto *buttons = new QDialogButtonBox (QDialogButtonBox::Ok, &dialog); - QObject::connect (buttons, &QDialogButtonBox::accepted, &dialog, [&dialog]() { - // An OK click supplies focus/input even when automatic activation was denied. - if (dialog.copyShortUrl()) - dialog.accept(); - }); - layout->addWidget (buttons); + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok, &dialog); + QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, [&dialog]() { + // An OK click supplies focus/input even when automatic activation was denied. + if (dialog.copyShortUrl()) + dialog.accept(); + }); + layout->addWidget(buttons); - if (!wayland) - dialog.copyShortUrl(); - dialog.exec(); + if (!wayland) + dialog.copyShortUrl(); + dialog.exec(); } /** * @brief Creates tray/menu actions. */ void MainWindow::setupMenuActions() { - m_cutUrlAction = new QAction (QStringLiteral ("Cut URL"), this); - m_settingsAction = new QAction (QStringLiteral ("Settings"), this); - m_helpAction = new QAction (QStringLiteral ("Help"), this); - m_aboutAction = new QAction (QStringLiteral ("About"), this); - m_exitAction = new QAction (QStringLiteral ("Exit"), this); + m_cutUrlAction = new QAction(QStringLiteral("Cut URL"), this); + m_settingsAction = new QAction(QStringLiteral("Settings"), this); + m_helpAction = new QAction(QStringLiteral("Help"), this); + m_aboutAction = new QAction(QStringLiteral("About"), this); + m_exitAction = new QAction(QStringLiteral("Exit"), this); } diff --git a/mainwindow.h b/mainwindow.h index 4633857..0f8971d 100644 --- a/mainwindow.h +++ b/mainwindow.h @@ -7,7 +7,7 @@ QT_BEGIN_NAMESPACE namespace Ui { - class MainWindow; + class MainWindow; } QT_END_NAMESPACE @@ -26,26 +26,26 @@ class YourlsClient; class MainWindow : public QMainWindow { Q_OBJECT - public: +public: /** * @brief Constructs the main window. * @param parent Parent widget. */ - explicit MainWindow (QWidget *parent = nullptr); + explicit MainWindow(QWidget *parent = nullptr); /** * @brief Destroys the main window. */ ~MainWindow() override; - protected: +protected: /** * @brief Handles close requests. * @param event Close event. */ - void closeEvent (QCloseEvent *event) override; + void closeEvent(QCloseEvent *event) override; - private slots: +private slots: /** * @brief Saves settings from the UI. */ @@ -80,7 +80,7 @@ class MainWindow : public QMainWindow { * @brief Handles tray icon activation. * @param reason Activation reason. */ - void onTrayIconActivated (QSystemTrayIcon::ActivationReason reason); + void onTrayIconActivated(QSystemTrayIcon::ActivationReason reason); /** * @brief Reads a URL from the clipboard and shortens it. @@ -91,15 +91,15 @@ class MainWindow : public QMainWindow { * @brief Handles successful shortening. * @param shortUrl Generated short URL. */ - void onShortenSucceeded (const QUrl &shortUrl); + void onShortenSucceeded(const QUrl &shortUrl); /** * @brief Handles shortening failure. * @param details Detailed error text. */ - void onShortenFailed (const QString &details); + void onShortenFailed(const QString &details); - private: +private: /** * @brief Creates tray icon and tray menu. */ @@ -125,7 +125,7 @@ class MainWindow : public QMainWindow { * @brief Shows a popup with the generated short URL. * @param shortUrl Generated short URL. */ - void showShortUrlPopup (const QUrl &shortUrl); + void showShortUrlPopup(const QUrl &shortUrl); /** * @brief Creates tray/menu actions. @@ -143,4 +143,4 @@ class MainWindow : public QMainWindow { YourlsClient *m_yourlsClient; }; -#endif // MAINWINDOW_H +#endif// MAINWINDOW_H diff --git a/tests/clipboardresultdialog_test.cpp b/tests/clipboardresultdialog_test.cpp index 3ec164d..9d93bb7 100644 --- a/tests/clipboardresultdialog_test.cpp +++ b/tests/clipboardresultdialog_test.cpp @@ -2,39 +2,39 @@ #include class ClipboardResultDialogTest : public QObject { - Q_OBJECT + Q_OBJECT private slots: - void inactiveWindowDoesNotOverwriteClipboard() { - auto *clipboard = QGuiApplication::clipboard(); - clipboard->setText (QStringLiteral ("original")); - ClipboardResultDialog dialog (QUrl (QStringLiteral ("https://cut.himro.ca/h")), true); - QVERIFY (!dialog.copyShortUrl()); - QCOMPARE (clipboard->text(), QStringLiteral ("original")); - } + void inactiveWindowDoesNotOverwriteClipboard() { + auto *clipboard = QGuiApplication::clipboard(); + clipboard->setText(QStringLiteral("original")); + ClipboardResultDialog dialog(QUrl(QStringLiteral("https://cut.himro.ca/h")), true); + QVERIFY(!dialog.copyShortUrl()); + QCOMPARE(clipboard->text(), QStringLiteral("original")); + } - void focusCopiesAndRetryRestoresShortUrl() { - const QUrl shortUrl (QStringLiteral ("https://cut.himro.ca/h?value=a%26b#section")); - auto *clipboard = QGuiApplication::clipboard(); - clipboard->setText (QStringLiteral ("original")); - ClipboardResultDialog dialog (shortUrl, true); - dialog.show(); - dialog.activateWindow(); - QTRY_VERIFY (dialog.isActiveWindow()); - QTRY_COMPARE (clipboard->text(), shortUrl.toString (QUrl::FullyEncoded)); - QVERIFY (dialog.copyStatusLabel()->text().contains (QStringLiteral ("copied"))); - clipboard->setText (QStringLiteral ("other text")); - QVERIFY (dialog.copyShortUrl()); - QCOMPARE (clipboard->text(), shortUrl.toString (QUrl::FullyEncoded)); - dialog.accept(); - QCOMPARE (clipboard->text(), shortUrl.toString (QUrl::FullyEncoded)); - } + void focusCopiesAndRetryRestoresShortUrl() { + const QUrl shortUrl(QStringLiteral("https://cut.himro.ca/h?value=a%26b#section")); + auto *clipboard = QGuiApplication::clipboard(); + clipboard->setText(QStringLiteral("original")); + ClipboardResultDialog dialog(shortUrl, true); + dialog.show(); + dialog.activateWindow(); + QTRY_VERIFY(dialog.isActiveWindow()); + QTRY_COMPARE(clipboard->text(), shortUrl.toString(QUrl::FullyEncoded)); + QVERIFY(dialog.copyStatusLabel()->text().contains(QStringLiteral("copied"))); + clipboard->setText(QStringLiteral("other text")); + QVERIFY(dialog.copyShortUrl()); + QCOMPARE(clipboard->text(), shortUrl.toString(QUrl::FullyEncoded)); + dialog.accept(); + QCOMPARE(clipboard->text(), shortUrl.toString(QUrl::FullyEncoded)); + } - void platformsWithoutFocusRequirementCopyImmediately() { - ClipboardResultDialog dialog (QUrl (QStringLiteral ("https://cut.himro.ca/h")), false); - QVERIFY (dialog.copyShortUrl()); - QCOMPARE (QGuiApplication::clipboard()->text(), QStringLiteral ("https://cut.himro.ca/h")); - } + void platformsWithoutFocusRequirementCopyImmediately() { + ClipboardResultDialog dialog(QUrl(QStringLiteral("https://cut.himro.ca/h")), false); + QVERIFY(dialog.copyShortUrl()); + QCOMPARE(QGuiApplication::clipboard()->text(), QStringLiteral("https://cut.himro.ca/h")); + } }; -QTEST_MAIN (ClipboardResultDialogTest) +QTEST_MAIN(ClipboardResultDialogTest) #include "clipboardresultdialog_test.moc" diff --git a/tests/clipboardurl_test.cpp b/tests/clipboardurl_test.cpp index 24d0b4f..a8eee29 100644 --- a/tests/clipboardurl_test.cpp +++ b/tests/clipboardurl_test.cpp @@ -3,50 +3,48 @@ #include #include -int main (int argc, char **argv) { - QCoreApplication application (argc, argv); - const QUrl expected (QStringLiteral ("https://git.himro.ca/explore/repos")); - int failures = 0; - const auto check = [&failures] (bool condition, const char *description) { - if (!condition) { - qCritical() << description; - ++failures; +int main(int argc, char **argv) { + QCoreApplication application(argc, argv); + const QUrl expected(QStringLiteral("https://git.himro.ca/explore/repos")); + int failures = 0; + const auto check = [&failures](bool condition, const char *description) { + if (!condition) { + qCritical() << description; + ++failures; + } + }; + + for (const auto &text: { + "https://git.himro.ca/explore/repos", + " \nhttps://git.himro.ca/explore/repos\n ", + "'https://git.himro.ca/explore/repos'", + "\"https://git.himro.ca/explore/repos\"", + "", + "[https://git.himro.ca/explore/repos](https://git.himro.ca/explore/repos)", + "'[Repositories](https://git.himro.ca/explore/repos)'"}) { + check(ClipboardUrl::parseText(QString::fromUtf8(text)) == expected, text); } - }; + check(ClipboardUrl::parseText(QStringLiteral( + "[Other label](https://git.himro.ca/explore/repos?filter=a%26b#section)")) == QUrl(QStringLiteral("https://git.himro.ca/explore/repos?filter=a%26b#section")), + "Markdown must use the destination and preserve query and fragment"); + check(ClipboardUrl::parseText(QStringLiteral("[Link](https://example.com/a_(b))")) == QUrl(QStringLiteral("https://example.com/a_(b)")), + "Parentheses in URL paths must be preserved"); + for (const auto &text: {"", "not a URL", "https://", "[Link](https://)"}) + check(!ClipboardUrl::parseText(QString::fromUtf8(text)).isValid(), text); - for (const auto &text : { - "https://git.himro.ca/explore/repos", - " \nhttps://git.himro.ca/explore/repos\n ", - "'https://git.himro.ca/explore/repos'", - "\"https://git.himro.ca/explore/repos\"", - "", - "[https://git.himro.ca/explore/repos](https://git.himro.ca/explore/repos)", - "'[Repositories](https://git.himro.ca/explore/repos)'"}) { - check (ClipboardUrl::parseText (QString::fromUtf8 (text)) == expected, text); - } - check (ClipboardUrl::parseText (QStringLiteral ( - "[Other label](https://git.himro.ca/explore/repos?filter=a%26b#section)")) - == QUrl (QStringLiteral ("https://git.himro.ca/explore/repos?filter=a%26b#section")), - "Markdown must use the destination and preserve query and fragment"); - check (ClipboardUrl::parseText (QStringLiteral ("[Link](https://example.com/a_(b))")) - == QUrl (QStringLiteral ("https://example.com/a_(b)")), - "Parentheses in URL paths must be preserved"); - for (const auto &text : {"", "not a URL", "https://", "[Link](https://)"}) - check (!ClipboardUrl::parseText (QString::fromUtf8 (text)).isValid(), text); + QMimeData mimeData; + mimeData.setText(QStringLiteral("Repositories")); + mimeData.setUrls({expected}); + check(ClipboardUrl::fromMimeData(&mimeData) == expected, + "Browser URL MIME data must take precedence over a link label"); + mimeData.clear(); + mimeData.setText(expected.toString()); + check(ClipboardUrl::fromMimeData(&mimeData) == expected, "Plain text fallback"); + mimeData.setUrls({QUrl(QStringLiteral("file:///tmp/example"))}); + check(ClipboardUrl::fromMimeData(&mimeData) == expected, "Unusable MIME URL fallback"); + mimeData.clear(); + check(!ClipboardUrl::fromMimeData(&mimeData).isValid(), "Empty clipboard"); + check(!ClipboardUrl::fromMimeData(nullptr).isValid(), "Unavailable clipboard"); - QMimeData mimeData; - mimeData.setText (QStringLiteral ("Repositories")); - mimeData.setUrls ({expected}); - check (ClipboardUrl::fromMimeData (&mimeData) == expected, - "Browser URL MIME data must take precedence over a link label"); - mimeData.clear(); - mimeData.setText (expected.toString()); - check (ClipboardUrl::fromMimeData (&mimeData) == expected, "Plain text fallback"); - mimeData.setUrls ({QUrl (QStringLiteral ("file:///tmp/example"))}); - check (ClipboardUrl::fromMimeData (&mimeData) == expected, "Unusable MIME URL fallback"); - mimeData.clear(); - check (!ClipboardUrl::fromMimeData (&mimeData).isValid(), "Empty clipboard"); - check (!ClipboardUrl::fromMimeData (nullptr).isValid(), "Unavailable clipboard"); - - return failures == 0 ? 0 : 1; + return failures == 0 ? 0 : 1; } diff --git a/tests/yourlsclient_test.cpp b/tests/yourlsclient_test.cpp index bb5c1f3..85160f6 100644 --- a/tests/yourlsclient_test.cpp +++ b/tests/yourlsclient_test.cpp @@ -9,69 +9,70 @@ #include class YourlsClientTest : public QObject { - Q_OBJECT + Q_OBJECT private slots: - void responses_data() { - QTest::addColumn ("httpStatus"); - QTest::addColumn ("status"); - QTest::addColumn ("code"); - QTest::addColumn ("originalUrl"); - QTest::addColumn ("shortUrl"); - QTest::addColumn ("success"); - const QString source = QStringLiteral ("https://git.himro.ca/explore/repos?q=a%26b&next=x+y#section"); - const QString shortened = QStringLiteral ("https://cut.himro.ca/h"); - QTest::newRow ("created") << 200 << "success" << "" << source << shortened << true; - QTest::newRow ("duplicate") << 400 << "fail" << "error:url" << source << shortened << true; - QTest::newRow ("different-url") << 400 << "fail" << "error:url" << "https://example.com" << shortened << false; - QTest::newRow ("missing-short-url") << 400 << "fail" << "error:url" << source << "" << false; - QTest::newRow ("relative-short-url") << 400 << "fail" << "error:url" << source << "/h" << false; - QTest::newRow ("other-error") << 400 << "fail" << "error:auth" << source << shortened << false; - QTest::newRow ("server-error") << 500 << "fail" << "error:url" << source << shortened << false; - } + void responses_data() { + QTest::addColumn("httpStatus"); + QTest::addColumn("status"); + QTest::addColumn("code"); + QTest::addColumn("originalUrl"); + QTest::addColumn("shortUrl"); + QTest::addColumn("success"); + const QString source = QStringLiteral("https://git.himro.ca/explore/repos?q=a%26b&next=x+y#section"); + const QString shortened = QStringLiteral("https://cut.himro.ca/h"); + QTest::newRow("created") << 200 << "success" << "" << source << shortened << true; + QTest::newRow("duplicate") << 400 << "fail" << "error:url" << source << shortened << true; + QTest::newRow("different-url") << 400 << "fail" << "error:url" << "https://example.com" << shortened << false; + QTest::newRow("missing-short-url") << 400 << "fail" << "error:url" << source << "" << false; + QTest::newRow("relative-short-url") << 400 << "fail" << "error:url" << source << "/h" << false; + QTest::newRow("other-error") << 400 << "fail" << "error:auth" << source << shortened << false; + QTest::newRow("server-error") << 500 << "fail" << "error:url" << source << shortened << false; + } - void responses() { - QFETCH (int, httpStatus); - QFETCH (QString, status); - QFETCH (QString, code); - QFETCH (QString, originalUrl); - QFETCH (QString, shortUrl); - QFETCH (bool, success); - QTcpServer server; - QVERIFY (server.listen (QHostAddress::LocalHost)); - QByteArray requestData; - const QByteArray body = QJsonDocument (QJsonObject { - {"status", status}, {"code", code}, {"shorturl", shortUrl}, - {"url", QJsonObject {{"url", originalUrl}}} - }).toJson (QJsonDocument::Compact); - connect (&server, &QTcpServer::newConnection, this, [&]() { - auto *socket = server.nextPendingConnection(); - connect (socket, &QTcpSocket::readyRead, &server, [&, socket]() { - requestData += socket->readAll(); - if (!requestData.contains ("\r\n\r\n")) - return; - socket->write ("HTTP/1.1 " + QByteArray::number (httpStatus) - + " Result\r\nContent-Type: application/json\r\nContent-Length: " - + QByteArray::number (body.size()) + "\r\nConnection: close\r\n\r\n" + body); - socket->disconnectFromHost(); - }); - }); - YourlsClient client; - QSignalSpy succeeded (&client, &YourlsClient::shortenSucceeded); - QSignalSpy failed (&client, &YourlsClient::shortenFailed); - const QUrl source (QStringLiteral ("https://git.himro.ca/explore/repos?q=a%26b&next=x+y#section")); - client.shortenUrl ({QStringLiteral ("http://127.0.0.1:%1/api").arg (server.serverPort()), - QStringLiteral ("test-signature")}, source); - QTRY_COMPARE (succeeded.count() + failed.count(), 1); - QCOMPARE (succeeded.count(), success ? 1 : 0); - if (success) - QCOMPARE (succeeded.first().first().toUrl(), QUrl (shortUrl)); - const QByteArray target = requestData.split (' ').value (1); - const QUrlQuery query (QUrl::fromEncoded (target)); - // Decode the HTTP query once, as the server does, preserving URL escapes. - const QString encodedValue = query.queryItemValue (QStringLiteral ("url"), QUrl::FullyEncoded); - QCOMPARE (QUrl::fromPercentEncoding (encodedValue.toLatin1()), source.toString (QUrl::FullyEncoded)); - } + void responses() { + QFETCH(int, httpStatus); + QFETCH(QString, status); + QFETCH(QString, code); + QFETCH(QString, originalUrl); + QFETCH(QString, shortUrl); + QFETCH(bool, success); + QTcpServer server; + QVERIFY(server.listen(QHostAddress::LocalHost)); + QByteArray requestData; + const QByteArray body = QJsonDocument(QJsonObject{ + {"status", status}, + {"code", code}, + {"shorturl", shortUrl}, + {"url", QJsonObject{{"url", originalUrl}}}}) + .toJson(QJsonDocument::Compact); + connect(&server, &QTcpServer::newConnection, this, [&]() { + auto *socket = server.nextPendingConnection(); + connect(socket, &QTcpSocket::readyRead, &server, [&, socket]() { + requestData += socket->readAll(); + if (!requestData.contains("\r\n\r\n")) + return; + socket->write("HTTP/1.1 " + QByteArray::number(httpStatus) + " Result\r\nContent-Type: application/json\r\nContent-Length: " + QByteArray::number(body.size()) + "\r\nConnection: close\r\n\r\n" + body); + socket->disconnectFromHost(); + }); + }); + YourlsClient client; + QSignalSpy succeeded(&client, &YourlsClient::shortenSucceeded); + QSignalSpy failed(&client, &YourlsClient::shortenFailed); + const QUrl source(QStringLiteral("https://git.himro.ca/explore/repos?q=a%26b&next=x+y#section")); + client.shortenUrl({QStringLiteral("http://127.0.0.1:%1/api").arg(server.serverPort()), + QStringLiteral("test-signature")}, + source); + QTRY_COMPARE(succeeded.count() + failed.count(), 1); + QCOMPARE(succeeded.count(), success ? 1 : 0); + if (success) + QCOMPARE(succeeded.first().first().toUrl(), QUrl(shortUrl)); + const QByteArray target = requestData.split(' ').value(1); + const QUrlQuery query(QUrl::fromEncoded(target)); + // Decode the HTTP query once, as the server does, preserving URL escapes. + const QString encodedValue = query.queryItemValue(QStringLiteral("url"), QUrl::FullyEncoded); + QCOMPARE(QUrl::fromPercentEncoding(encodedValue.toLatin1()), source.toString(QUrl::FullyEncoded)); + } }; -QTEST_GUILESS_MAIN (YourlsClientTest) +QTEST_GUILESS_MAIN(YourlsClientTest) #include "yourlsclient_test.moc" diff --git a/yourlsclient.cpp b/yourlsclient.cpp index 5e839c2..ffc098c 100644 --- a/yourlsclient.cpp +++ b/yourlsclient.cpp @@ -14,9 +14,8 @@ * @brief Constructs the client. * @param parent QObject parent. */ -YourlsClient::YourlsClient (QObject *parent) - : QObject (parent) - , m_networkAccessManager (new QNetworkAccessManager (this)) { +YourlsClient::YourlsClient(QObject *parent) + : QObject(parent), m_networkAccessManager(new QNetworkAccessManager(this)) { } /** @@ -24,134 +23,126 @@ YourlsClient::YourlsClient (QObject *parent) * @param settings API endpoint and signature. * @param longUrl Source URL to shorten. */ -void YourlsClient::shortenUrl (const YourlsSettings &settings, const QUrl &longUrl) { +void YourlsClient::shortenUrl(const YourlsSettings &settings, const QUrl &longUrl) { #ifndef QT_NO_DEBUG - qDebug() << "[yourls] source URL:" << longUrl << "valid:" << longUrl.isValid() - << "scheme:" << longUrl.scheme() << "host:" << longUrl.host(); + qDebug() << "[yourls] source URL:" << longUrl << "valid:" << longUrl.isValid() + << "scheme:" << longUrl.scheme() << "host:" << longUrl.host(); #endif - if (settings.apiUrl.trimmed().isEmpty()) { - emit shortenFailed (QStringLiteral ("API URL is empty.")); - return; - } - - if (settings.signature.trimmed().isEmpty()) { - emit shortenFailed (QStringLiteral ("API signature is empty.")); - return; - } - - if (!longUrl.isValid() || longUrl.scheme().isEmpty() || longUrl.host().isEmpty()) { - emit shortenFailed (QStringLiteral ("Clipboard does not contain a valid URL.")); - return; - } - - QUrl requestUrl (settings.apiUrl); - if (!requestUrl.isValid()) { - emit shortenFailed (QStringLiteral ("Configured API URL is invalid: %1").arg (settings.apiUrl)); - return; - } - - QUrlQuery query; - query.addQueryItem (QStringLiteral ("signature"), settings.signature); - query.addQueryItem (QStringLiteral ("action"), QStringLiteral ("shorturl")); - query.addQueryItem (QStringLiteral ("format"), QStringLiteral ("json")); - query.addQueryItem (QStringLiteral ("url"), - QString::fromLatin1 (QUrl::toPercentEncoding (longUrl.toString (QUrl::FullyEncoded)))); - requestUrl.setQuery (query); - - QNetworkRequest request (requestUrl); - request.setHeader (QNetworkRequest::UserAgentHeader, QStringLiteral ("yourls-ui/1.0")); - -#ifndef QT_NO_DEBUG - // Never log the request query: it contains the API signature. - qDebug() << "[yourls] sending GET, endpoint:" - << requestUrl.adjusted (QUrl::RemoveQuery | QUrl::RemoveFragment | QUrl::RemoveUserInfo) - << "source URL:" << longUrl; -#endif - QNetworkReply *reply = m_networkAccessManager->get (request); - - connect (reply, &QNetworkReply::finished, this, [this, reply, longUrl]() { - const QByteArray body = reply->readAll(); -#ifndef QT_NO_DEBUG - qDebug() << "[yourls] response HTTP status:" - << reply->attribute (QNetworkRequest::HttpStatusCodeAttribute) - << "network error code:" << reply->error() - << "response bytes:" << body.size(); -#endif - - QJsonParseError parseError; - const QJsonDocument jsonDocument = QJsonDocument::fromJson (body, &parseError); - const QJsonObject object = jsonDocument.object(); - const QUrl existingShortUrl (object.value (QStringLiteral ("shorturl")).toString()); - const QUrl existingLongUrl (object.value (QStringLiteral ("url")).toObject() - .value (QStringLiteral ("url")).toString()); - // YOURLS reports duplicate URLs as HTTP 400, but returns the existing link. - if (reply->attribute (QNetworkRequest::HttpStatusCodeAttribute).toInt() == 400 - && object.value (QStringLiteral ("status")).toString() == QStringLiteral ("fail") - && object.value (QStringLiteral ("code")).toString() == QStringLiteral ("error:url") - && existingLongUrl == longUrl && existingShortUrl.isValid() - && (existingShortUrl.scheme() == QStringLiteral ("https") - || existingShortUrl.scheme() == QStringLiteral ("http")) - && !existingShortUrl.host().isEmpty()) { -#ifndef QT_NO_DEBUG - qDebug() << "[yourls] reusing existing short URL:" << existingShortUrl; -#endif - reply->deleteLater(); - emit shortenSucceeded (existingShortUrl); - return; + if (settings.apiUrl.trimmed().isEmpty()) { + emit shortenFailed(QStringLiteral("API URL is empty.")); + return; } - if (reply->error() != QNetworkReply::NoError) { - const QString details = QStringLiteral ("Network error: %1\nHTTP status: %2\nResponse body:\n%3") - .arg (reply->errorString(), - reply->attribute (QNetworkRequest::HttpStatusCodeAttribute).toString(), - QString::fromUtf8 (body)); - reply->deleteLater(); - emit shortenFailed (details); - return; + if (settings.signature.trimmed().isEmpty()) { + emit shortenFailed(QStringLiteral("API signature is empty.")); + return; } + if (!longUrl.isValid() || longUrl.scheme().isEmpty() || longUrl.host().isEmpty()) { + emit shortenFailed(QStringLiteral("Clipboard does not contain a valid URL.")); + return; + } + + QUrl requestUrl(settings.apiUrl); + if (!requestUrl.isValid()) { + emit shortenFailed(QStringLiteral("Configured API URL is invalid: %1").arg(settings.apiUrl)); + return; + } + + QUrlQuery query; + query.addQueryItem(QStringLiteral("signature"), settings.signature); + query.addQueryItem(QStringLiteral("action"), QStringLiteral("shorturl")); + query.addQueryItem(QStringLiteral("format"), QStringLiteral("json")); + query.addQueryItem(QStringLiteral("url"), + QString::fromLatin1(QUrl::toPercentEncoding(longUrl.toString(QUrl::FullyEncoded)))); + requestUrl.setQuery(query); + + QNetworkRequest request(requestUrl); + request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("yourls-ui/1.0")); + #ifndef QT_NO_DEBUG - qDebug() << "[yourls] JSON parse error:" << parseError.errorString() - << "offset:" << parseError.offset << "is object:" << jsonDocument.isObject(); + // Never log the request query: it contains the API signature. + qDebug() << "[yourls] sending GET, endpoint:" + << requestUrl.adjusted(QUrl::RemoveQuery | QUrl::RemoveFragment | QUrl::RemoveUserInfo) + << "source URL:" << longUrl; #endif - if (parseError.error != QJsonParseError::NoError || !jsonDocument.isObject()) { - const QString details = QStringLiteral ("Failed to parse YOURLS response as JSON: %1\nResponse body:\n%2") - .arg (parseError.errorString(), QString::fromUtf8 (body)); - reply->deleteLater(); - emit shortenFailed (details); - return; - } + QNetworkReply *reply = m_networkAccessManager->get(request); - const QString status = object.value (QStringLiteral ("status")).toString(); - const QString shortUrlText = object.value (QStringLiteral ("shorturl")).toString(); + connect(reply, &QNetworkReply::finished, this, [this, reply, longUrl]() { + const QByteArray body = reply->readAll(); #ifndef QT_NO_DEBUG - qDebug() << "[yourls] API status:" << status - << "code:" << object.value (QStringLiteral ("code")).toVariant() - << "short URL:" << shortUrlText; + qDebug() << "[yourls] response HTTP status:" + << reply->attribute(QNetworkRequest::HttpStatusCodeAttribute) + << "network error code:" << reply->error() + << "response bytes:" << body.size(); #endif - if (status.compare (QStringLiteral ("success"), Qt::CaseInsensitive) != 0 || shortUrlText.isEmpty()) { - QStringList details; - details << QStringLiteral ("YOURLS returned an error."); - if (object.contains (QStringLiteral ("message"))) - details << QStringLiteral ("Message: %1").arg (object.value (QStringLiteral ("message")).toString()); - if (object.contains (QStringLiteral ("code"))) - details << QStringLiteral ("Code: %1").arg (object.value (QStringLiteral ("code")).toVariant().toString()); - details << QStringLiteral ("Raw response:\n%1").arg (QString::fromUtf8 (body)); - reply->deleteLater(); - emit shortenFailed (details.join (QStringLiteral ("\n"))); - return; - } + QJsonParseError parseError; + const QJsonDocument jsonDocument = QJsonDocument::fromJson(body, &parseError); + const QJsonObject object = jsonDocument.object(); + const QUrl existingShortUrl(object.value(QStringLiteral("shorturl")).toString()); + const QUrl existingLongUrl(object.value(QStringLiteral("url")).toObject().value(QStringLiteral("url")).toString()); + // YOURLS reports duplicate URLs as HTTP 400, but returns the existing link. + if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 400 && object.value(QStringLiteral("status")).toString() == QStringLiteral("fail") && object.value(QStringLiteral("code")).toString() == QStringLiteral("error:url") && existingLongUrl == longUrl && existingShortUrl.isValid() && (existingShortUrl.scheme() == QStringLiteral("https") || existingShortUrl.scheme() == QStringLiteral("http")) && !existingShortUrl.host().isEmpty()) { +#ifndef QT_NO_DEBUG + qDebug() << "[yourls] reusing existing short URL:" << existingShortUrl; +#endif + reply->deleteLater(); + emit shortenSucceeded(existingShortUrl); + return; + } - const QUrl shortUrl (shortUrlText); - if (!shortUrl.isValid() || shortUrl.host().isEmpty() - || (shortUrl.scheme() != QStringLiteral ("https") && shortUrl.scheme() != QStringLiteral ("http"))) { - reply->deleteLater(); - emit shortenFailed (QStringLiteral ("YOURLS returned an invalid short URL: %1").arg (shortUrlText)); - return; - } + if (reply->error() != QNetworkReply::NoError) { + const QString details = QStringLiteral("Network error: %1\nHTTP status: %2\nResponse body:\n%3") + .arg(reply->errorString(), + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toString(), + QString::fromUtf8(body)); + reply->deleteLater(); + emit shortenFailed(details); + return; + } - reply->deleteLater(); - emit shortenSucceeded (shortUrl); - }); +#ifndef QT_NO_DEBUG + qDebug() << "[yourls] JSON parse error:" << parseError.errorString() + << "offset:" << parseError.offset << "is object:" << jsonDocument.isObject(); +#endif + if (parseError.error != QJsonParseError::NoError || !jsonDocument.isObject()) { + const QString details = QStringLiteral("Failed to parse YOURLS response as JSON: %1\nResponse body:\n%2") + .arg(parseError.errorString(), QString::fromUtf8(body)); + reply->deleteLater(); + emit shortenFailed(details); + return; + } + + const QString status = object.value(QStringLiteral("status")).toString(); + const QString shortUrlText = object.value(QStringLiteral("shorturl")).toString(); +#ifndef QT_NO_DEBUG + qDebug() << "[yourls] API status:" << status + << "code:" << object.value(QStringLiteral("code")).toVariant() + << "short URL:" << shortUrlText; +#endif + + if (status.compare(QStringLiteral("success"), Qt::CaseInsensitive) != 0 || shortUrlText.isEmpty()) { + QStringList details; + details << QStringLiteral("YOURLS returned an error."); + if (object.contains(QStringLiteral("message"))) + details << QStringLiteral("Message: %1").arg(object.value(QStringLiteral("message")).toString()); + if (object.contains(QStringLiteral("code"))) + details << QStringLiteral("Code: %1").arg(object.value(QStringLiteral("code")).toVariant().toString()); + details << QStringLiteral("Raw response:\n%1").arg(QString::fromUtf8(body)); + reply->deleteLater(); + emit shortenFailed(details.join(QStringLiteral("\n"))); + return; + } + + const QUrl shortUrl(shortUrlText); + if (!shortUrl.isValid() || shortUrl.host().isEmpty() || (shortUrl.scheme() != QStringLiteral("https") && shortUrl.scheme() != QStringLiteral("http"))) { + reply->deleteLater(); + emit shortenFailed(QStringLiteral("YOURLS returned an invalid short URL: %1").arg(shortUrlText)); + return; + } + + reply->deleteLater(); + emit shortenSucceeded(shortUrl); + }); } diff --git a/yourlsclient.h b/yourlsclient.h index 68c4ad8..6e499b1 100644 --- a/yourlsclient.h +++ b/yourlsclient.h @@ -10,8 +10,8 @@ class QNetworkAccessManager; * @brief Small value object describing YOURLS API settings. */ struct YourlsSettings { - QString apiUrl; - QString signature; + QString apiUrl; + QString signature; }; /** @@ -24,35 +24,35 @@ struct YourlsSettings { class YourlsClient : public QObject { Q_OBJECT - public: +public: /** * @brief Constructs the client. * @param parent QObject parent. */ - explicit YourlsClient (QObject *parent = nullptr); + explicit YourlsClient(QObject *parent = nullptr); /** * @brief Requests a short URL from the YOURLS API. * @param settings API endpoint and signature. * @param longUrl Source URL to shorten. */ - void shortenUrl (const YourlsSettings &settings, const QUrl &longUrl); + void shortenUrl(const YourlsSettings &settings, const QUrl &longUrl); - signals: +signals: /** * @brief Emitted when the short URL has been created successfully. * @param shortUrl Generated short URL. */ - void shortenSucceeded (const QUrl &shortUrl); + void shortenSucceeded(const QUrl &shortUrl); /** * @brief Emitted when the request fails. * @param details Human-readable error details. */ - void shortenFailed (const QString &details); + void shortenFailed(const QString &details); - private: +private: QNetworkAccessManager *m_networkAccessManager; }; -#endif // YOURLSCLIENT_H +#endif// YOURLSCLIENT_H