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.
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
+51 -52
View File
@@ -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
+7 -7
View File
@@ -3,12 +3,12 @@
#include <QApplication>
#include <QCoreApplication>
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();
}
+228 -241
View File
@@ -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 ("<a href=\"%1\">%2</a>")
.arg (url.toString (QUrl::FullyEncoded), urlText.toHtmlEscaped());
}
QString makeLinkHtml(const QUrl &url) {
const QString urlText = url.toDisplayString();
return QStringLiteral("<a href=\"%1\">%2</a>")
.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:<br>%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:<br>%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);
}
+12 -12
View File
@@ -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
+30 -30
View File
@@ -2,39 +2,39 @@
#include <QTest>
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"
+41 -43
View File
@@ -3,50 +3,48 @@
#include <QCoreApplication>
#include <QDebug>
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](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](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;
}
+62 -61
View File
@@ -9,69 +9,70 @@
#include <QUrlQuery>
class YourlsClientTest : public QObject {
Q_OBJECT
Q_OBJECT
private slots:
void responses_data() {
QTest::addColumn<int> ("httpStatus");
QTest::addColumn<QString> ("status");
QTest::addColumn<QString> ("code");
QTest::addColumn<QString> ("originalUrl");
QTest::addColumn<QString> ("shortUrl");
QTest::addColumn<bool> ("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<int>("httpStatus");
QTest::addColumn<QString>("status");
QTest::addColumn<QString>("code");
QTest::addColumn<QString>("originalUrl");
QTest::addColumn<QString>("shortUrl");
QTest::addColumn<bool>("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"
+110 -119
View File
@@ -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);
});
}
+10 -10
View File
@@ -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