Compare commits

...
1 Commits
Author SHA1 Message Date
deeaitch 73d82e9562 applied clang format 2026-10-01 12:21:40 -04:00
10 changed files with 583 additions and 609 deletions

No files matched your search

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