2026-08-26 00:28:35 +02:00
|
|
|
#include "mainwindow.h"
|
|
|
|
|
|
|
|
|
|
#include "imageprep.h"
|
2026-09-26 10:17:13 +02:00
|
|
|
#include "photoitemwidget.h"
|
|
|
|
|
#include "uploadclient.h"
|
2026-08-26 00:28:35 +02:00
|
|
|
|
|
|
|
|
#include <QDateTime>
|
|
|
|
|
#include <QFileDialog>
|
|
|
|
|
#include <QFileInfo>
|
|
|
|
|
#include <QFormLayout>
|
2026-09-26 10:17:13 +02:00
|
|
|
#include <QFuture>
|
|
|
|
|
#include <QFutureWatcher>
|
2026-08-26 00:28:35 +02:00
|
|
|
#include <QLabel>
|
|
|
|
|
#include <QLineEdit>
|
|
|
|
|
#include <QPushButton>
|
2026-09-26 10:17:13 +02:00
|
|
|
#include <QScrollArea>
|
|
|
|
|
#include <QSpinBox>
|
2026-08-26 00:28:35 +02:00
|
|
|
#include <QStackedWidget>
|
|
|
|
|
#include <QStandardPaths>
|
2026-09-26 10:17:13 +02:00
|
|
|
#include <QUuid>
|
2026-08-26 00:28:35 +02:00
|
|
|
#include <QVBoxLayout>
|
2026-09-26 10:17:13 +02:00
|
|
|
#include <QtConcurrent>
|
2026-08-26 00:28:35 +02:00
|
|
|
|
|
|
|
|
MainWindow::MainWindow(QWidget *parent)
|
|
|
|
|
: QMainWindow(parent)
|
|
|
|
|
{
|
|
|
|
|
setWindowTitle(QStringLiteral("Photo Gallery Uploader"));
|
|
|
|
|
|
|
|
|
|
m_stack = new QStackedWidget(this);
|
|
|
|
|
m_stack->addWidget(buildSettingsPage()); // index 0
|
|
|
|
|
m_stack->addWidget(buildPostPage()); // index 1
|
|
|
|
|
setCentralWidget(m_stack);
|
|
|
|
|
|
|
|
|
|
if (m_settings.isConfigured())
|
|
|
|
|
showPost();
|
|
|
|
|
else
|
|
|
|
|
showSettings();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QWidget *MainWindow::buildSettingsPage()
|
|
|
|
|
{
|
|
|
|
|
auto *page = new QWidget;
|
|
|
|
|
auto *layout = new QVBoxLayout(page);
|
|
|
|
|
layout->setContentsMargins(24, 24, 24, 24);
|
|
|
|
|
|
|
|
|
|
auto *form = new QFormLayout;
|
|
|
|
|
m_baseUrl = new QLineEdit;
|
|
|
|
|
m_baseUrl->setPlaceholderText(QStringLiteral("https://photos.example.com"));
|
|
|
|
|
m_username = new QLineEdit;
|
|
|
|
|
m_password = new QLineEdit;
|
|
|
|
|
m_password->setEchoMode(QLineEdit::Password);
|
2026-09-26 10:17:13 +02:00
|
|
|
m_concurrency = new QSpinBox;
|
|
|
|
|
m_concurrency->setRange(1, Settings::kMaxConcurrency);
|
2026-08-26 00:28:35 +02:00
|
|
|
form->addRow(QStringLiteral("Base URL"), m_baseUrl);
|
|
|
|
|
form->addRow(QStringLiteral("Username"), m_username);
|
|
|
|
|
form->addRow(QStringLiteral("Password"), m_password);
|
2026-09-26 10:17:13 +02:00
|
|
|
form->addRow(QStringLiteral("Simultaneous uploads"), m_concurrency);
|
2026-08-26 00:28:35 +02:00
|
|
|
layout->addLayout(form);
|
|
|
|
|
|
|
|
|
|
m_saveButton = new QPushButton(QStringLiteral("Save && test connection"));
|
|
|
|
|
connect(m_saveButton, &QPushButton::clicked, this, &MainWindow::saveAndTest);
|
|
|
|
|
layout->addWidget(m_saveButton);
|
|
|
|
|
|
|
|
|
|
m_settingsStatus = new QLabel;
|
|
|
|
|
m_settingsStatus->setWordWrap(true);
|
|
|
|
|
layout->addWidget(m_settingsStatus);
|
|
|
|
|
|
|
|
|
|
m_settingsBack = new QPushButton(QStringLiteral("Back"));
|
|
|
|
|
connect(m_settingsBack, &QPushButton::clicked, this, &MainWindow::showPost);
|
|
|
|
|
layout->addWidget(m_settingsBack);
|
|
|
|
|
|
|
|
|
|
layout->addWidget(new QLabel(QStringLiteral(
|
|
|
|
|
"The password is stored in your system keychain, never in plain text. "
|
|
|
|
|
"https is required — Basic auth would otherwise send it in the clear.")));
|
|
|
|
|
layout->addStretch();
|
|
|
|
|
return page;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QWidget *MainWindow::buildPostPage()
|
|
|
|
|
{
|
|
|
|
|
auto *page = new QWidget;
|
2026-09-26 10:17:13 +02:00
|
|
|
auto *outer = new QVBoxLayout(page);
|
|
|
|
|
outer->setContentsMargins(24, 24, 24, 24);
|
2026-08-26 00:28:35 +02:00
|
|
|
|
2026-09-26 10:17:13 +02:00
|
|
|
auto *choose = new QPushButton(QStringLiteral("Choose photos…"));
|
|
|
|
|
connect(choose, &QPushButton::clicked, this, &MainWindow::chooseImages);
|
|
|
|
|
outer->addWidget(choose);
|
2026-08-26 00:28:35 +02:00
|
|
|
|
2026-09-26 10:17:13 +02:00
|
|
|
// Scrollable list of per-photo cards.
|
|
|
|
|
auto *scroll = new QScrollArea;
|
|
|
|
|
scroll->setWidgetResizable(true);
|
|
|
|
|
auto *container = new QWidget;
|
|
|
|
|
m_itemsLayout = new QVBoxLayout(container);
|
|
|
|
|
m_itemsLayout->setAlignment(Qt::AlignTop);
|
|
|
|
|
m_emptyHint = new QLabel(QStringLiteral("No photos selected"));
|
|
|
|
|
m_emptyHint->setAlignment(Qt::AlignCenter);
|
|
|
|
|
m_itemsLayout->addWidget(m_emptyHint);
|
|
|
|
|
scroll->setWidget(container);
|
|
|
|
|
outer->addWidget(scroll, 1);
|
2026-08-26 00:28:35 +02:00
|
|
|
|
|
|
|
|
m_postButton = new QPushButton(QStringLiteral("Post"));
|
|
|
|
|
m_postButton->setEnabled(false);
|
|
|
|
|
connect(m_postButton, &QPushButton::clicked, this, &MainWindow::submit);
|
2026-09-26 10:17:13 +02:00
|
|
|
outer->addWidget(m_postButton);
|
2026-08-26 00:28:35 +02:00
|
|
|
|
2026-09-26 10:17:13 +02:00
|
|
|
m_postSummary = new QLabel;
|
|
|
|
|
m_postSummary->setWordWrap(true);
|
|
|
|
|
outer->addWidget(m_postSummary);
|
2026-08-26 00:28:35 +02:00
|
|
|
|
|
|
|
|
m_lastPost = new QLabel;
|
|
|
|
|
m_lastPost->setOpenExternalLinks(true);
|
|
|
|
|
m_lastPost->setWordWrap(true);
|
2026-09-26 10:17:13 +02:00
|
|
|
outer->addWidget(m_lastPost);
|
2026-08-26 00:28:35 +02:00
|
|
|
|
|
|
|
|
auto *settingsButton = new QPushButton(QStringLiteral("Settings"));
|
|
|
|
|
connect(settingsButton, &QPushButton::clicked, this, &MainWindow::showSettings);
|
2026-09-26 10:17:13 +02:00
|
|
|
outer->addWidget(settingsButton);
|
2026-08-26 00:28:35 +02:00
|
|
|
|
|
|
|
|
return page;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MainWindow::showSettings()
|
|
|
|
|
{
|
|
|
|
|
m_baseUrl->setText(m_settings.baseUrl());
|
|
|
|
|
m_username->setText(m_settings.username());
|
|
|
|
|
m_password->setText(m_settings.readPassword());
|
2026-09-26 10:17:13 +02:00
|
|
|
m_concurrency->setValue(m_settings.concurrency());
|
2026-08-26 00:28:35 +02:00
|
|
|
m_settingsStatus->clear();
|
|
|
|
|
m_settingsBack->setVisible(m_settings.isConfigured());
|
|
|
|
|
m_stack->setCurrentIndex(0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MainWindow::showPost()
|
|
|
|
|
{
|
|
|
|
|
const Settings::LastPost last = m_settings.lastPost();
|
|
|
|
|
if (last.valid()) {
|
|
|
|
|
m_lastPost->setText(QStringLiteral("Last post: <a href=\"%1\">%2</a>")
|
|
|
|
|
.arg(last.url, last.caption.isEmpty() ? last.slug : last.caption));
|
|
|
|
|
} else {
|
|
|
|
|
m_lastPost->clear();
|
|
|
|
|
}
|
|
|
|
|
m_stack->setCurrentIndex(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MainWindow::saveAndTest()
|
|
|
|
|
{
|
2026-09-26 10:17:13 +02:00
|
|
|
// Concurrency is not a credential; persist it regardless of URL validity.
|
|
|
|
|
m_settings.setConcurrency(m_concurrency->value());
|
|
|
|
|
|
2026-08-26 00:28:35 +02:00
|
|
|
const QString urlError = Settings::validateBaseUrl(m_baseUrl->text());
|
|
|
|
|
if (!urlError.isEmpty()) {
|
|
|
|
|
m_settingsStatus->setText(urlError);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (m_username->text().trimmed().isEmpty() || m_password->text().isEmpty()) {
|
|
|
|
|
m_settingsStatus->setText(QStringLiteral("Username and password are required"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m_settings.setBaseUrl(m_baseUrl->text());
|
|
|
|
|
m_settings.setUsername(m_username->text());
|
|
|
|
|
m_settings.writePassword(m_password->text());
|
2026-09-26 10:17:13 +02:00
|
|
|
m_baseUrl->setText(m_settings.baseUrl());
|
2026-08-26 00:28:35 +02:00
|
|
|
|
|
|
|
|
m_saveButton->setEnabled(false);
|
|
|
|
|
m_settingsStatus->setText(QStringLiteral("Testing…"));
|
|
|
|
|
|
|
|
|
|
auto *client = new UploadClient(m_settings.baseUrl(), m_settings.username(),
|
|
|
|
|
m_settings.readPassword(), this);
|
|
|
|
|
connect(client, &UploadClient::testFinished, this, [this, client](const QString &error) {
|
|
|
|
|
m_saveButton->setEnabled(true);
|
|
|
|
|
m_settingsBack->setVisible(m_settings.isConfigured());
|
|
|
|
|
m_settingsStatus->setText(error.isEmpty()
|
|
|
|
|
? QStringLiteral("Saved — server reachable and credentials accepted")
|
|
|
|
|
: error);
|
|
|
|
|
client->deleteLater();
|
|
|
|
|
});
|
|
|
|
|
client->testConnection();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 10:17:13 +02:00
|
|
|
void MainWindow::chooseImages()
|
2026-08-26 00:28:35 +02:00
|
|
|
{
|
2026-09-26 10:17:13 +02:00
|
|
|
const QStringList paths = QFileDialog::getOpenFileNames(
|
|
|
|
|
this, QStringLiteral("Choose photos"), QString(),
|
2026-08-26 00:28:35 +02:00
|
|
|
QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.heif *.webp *.tif *.tiff *.bmp);;All files (*)"));
|
2026-09-26 10:17:13 +02:00
|
|
|
for (const QString &path : paths)
|
|
|
|
|
addItem(path);
|
|
|
|
|
refreshPosting();
|
|
|
|
|
}
|
2026-08-26 00:28:35 +02:00
|
|
|
|
2026-09-26 10:17:13 +02:00
|
|
|
void MainWindow::addItem(const QString &path)
|
|
|
|
|
{
|
|
|
|
|
m_emptyHint->hide();
|
|
|
|
|
auto *item = new PhotoItemWidget(path);
|
|
|
|
|
connect(item, &PhotoItemWidget::removeRequested, this, &MainWindow::removeItem);
|
|
|
|
|
m_items.append(item);
|
|
|
|
|
m_itemsLayout->addWidget(item);
|
|
|
|
|
}
|
2026-08-26 00:28:35 +02:00
|
|
|
|
2026-09-26 10:17:13 +02:00
|
|
|
void MainWindow::removeItem(PhotoItemWidget *item)
|
|
|
|
|
{
|
|
|
|
|
m_items.removeOne(item);
|
|
|
|
|
m_queue.removeOne(item); // may be waiting for a slot — drop it before deleting
|
|
|
|
|
item->deleteLater();
|
|
|
|
|
if (m_items.isEmpty())
|
|
|
|
|
m_emptyHint->show();
|
|
|
|
|
refreshPosting();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MainWindow::clearItems()
|
|
|
|
|
{
|
|
|
|
|
for (PhotoItemWidget *item : std::as_const(m_items))
|
|
|
|
|
item->deleteLater();
|
|
|
|
|
m_items.clear();
|
|
|
|
|
m_queue.clear();
|
|
|
|
|
m_emptyHint->show();
|
|
|
|
|
m_postSummary->clear();
|
|
|
|
|
refreshPosting();
|
2026-08-26 00:28:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MainWindow::submit()
|
|
|
|
|
{
|
2026-09-26 10:17:13 +02:00
|
|
|
if (m_items.isEmpty())
|
2026-08-26 00:28:35 +02:00
|
|
|
return;
|
|
|
|
|
if (!m_settings.isConfigured()) {
|
2026-09-26 10:17:13 +02:00
|
|
|
m_postSummary->setText(QStringLiteral("Set the server URL and credentials first"));
|
2026-08-26 00:28:35 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 10:17:13 +02:00
|
|
|
// Capture the batch's credentials once, then feed the queue through a
|
|
|
|
|
// concurrency cap rather than firing every photo at the server at once.
|
|
|
|
|
m_batchCacheDir = QStandardPaths::writableLocation(QStandardPaths::TempLocation);
|
|
|
|
|
m_batchBaseUrl = m_settings.baseUrl();
|
|
|
|
|
m_batchUsername = m_settings.username();
|
|
|
|
|
m_batchPassword = m_settings.readPassword();
|
2026-08-26 00:28:35 +02:00
|
|
|
|
2026-09-26 10:17:13 +02:00
|
|
|
m_queue.clear();
|
|
|
|
|
for (PhotoItemWidget *item : std::as_const(m_items)) {
|
|
|
|
|
if (item->status() != PhotoItemWidget::Status::Success)
|
|
|
|
|
m_queue.append(item); // re-posting retries only failures
|
2026-08-26 00:28:35 +02:00
|
|
|
}
|
2026-09-26 10:17:13 +02:00
|
|
|
if (m_queue.isEmpty())
|
|
|
|
|
return;
|
2026-08-26 00:28:35 +02:00
|
|
|
|
2026-09-26 10:17:13 +02:00
|
|
|
m_postSummary->clear();
|
|
|
|
|
pumpQueue();
|
|
|
|
|
refreshPosting();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int MainWindow::inFlightCount() const
|
|
|
|
|
{
|
|
|
|
|
int n = 0;
|
|
|
|
|
for (PhotoItemWidget *item : std::as_const(m_items)) {
|
|
|
|
|
const PhotoItemWidget::Status s = item->status();
|
|
|
|
|
if (s == PhotoItemWidget::Status::Preparing || s == PhotoItemWidget::Status::Uploading)
|
|
|
|
|
++n;
|
|
|
|
|
}
|
|
|
|
|
return n;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MainWindow::pumpQueue()
|
|
|
|
|
{
|
|
|
|
|
const int cap = m_settings.concurrency();
|
|
|
|
|
while (inFlightCount() < cap && !m_queue.isEmpty())
|
|
|
|
|
startItem(m_queue.takeFirst());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MainWindow::startItem(PhotoItemWidget *item)
|
|
|
|
|
{
|
|
|
|
|
item->setStatus(PhotoItemWidget::Status::Preparing);
|
|
|
|
|
|
|
|
|
|
// Slug from wall-clock time plus a per-item suffix so a batch posted in the
|
|
|
|
|
// same second cannot collide (server derives the stored name from the
|
|
|
|
|
// uploaded filename). The real capture date rides in the date field.
|
|
|
|
|
const QString slug = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd-HHmmss"))
|
|
|
|
|
+ QLatin1Char('-') + QUuid::createUuid().toString(QUuid::Id128).left(6);
|
|
|
|
|
const QString source = item->sourcePath();
|
|
|
|
|
const QString cacheDir = m_batchCacheDir;
|
|
|
|
|
const QString baseUrl = m_batchBaseUrl;
|
|
|
|
|
const QString username = m_batchUsername;
|
|
|
|
|
const QString password = m_batchPassword;
|
|
|
|
|
|
|
|
|
|
auto *watcher = new QFutureWatcher<ImagePrep::Prepared>(this);
|
|
|
|
|
connect(watcher, &QFutureWatcher<ImagePrep::Prepared>::finished, this,
|
|
|
|
|
[this, watcher, item, baseUrl, username, password]() {
|
|
|
|
|
const ImagePrep::Prepared prepared = watcher->result();
|
|
|
|
|
watcher->deleteLater();
|
|
|
|
|
|
|
|
|
|
if (!prepared.ok()) {
|
|
|
|
|
item->setStatus(PhotoItemWidget::Status::Error, prepared.error);
|
|
|
|
|
pumpQueue(); // free the slot for the next photo
|
|
|
|
|
refreshPosting();
|
|
|
|
|
return;
|
2026-08-26 00:28:35 +02:00
|
|
|
}
|
2026-09-26 10:17:13 +02:00
|
|
|
|
|
|
|
|
item->setStatus(PhotoItemWidget::Status::Uploading);
|
|
|
|
|
const QString caption = item->caption();
|
|
|
|
|
|
|
|
|
|
auto *client = new UploadClient(baseUrl, username, password, this);
|
|
|
|
|
connect(client, &UploadClient::uploadFinished, this,
|
|
|
|
|
[this, client, item, prepared, caption](UploadClient::Result result,
|
|
|
|
|
const QString &slug, const QString &url,
|
|
|
|
|
const QString &message) {
|
|
|
|
|
QFile::remove(prepared.filePath); // no retry queue on the desktop
|
|
|
|
|
if (result == UploadClient::Result::Success) {
|
|
|
|
|
m_settings.setLastPost({slug, url, caption, QDateTime::currentDateTime()});
|
|
|
|
|
item->setStatus(PhotoItemWidget::Status::Success, {}, url);
|
|
|
|
|
} else {
|
|
|
|
|
item->setStatus(PhotoItemWidget::Status::Error, message);
|
|
|
|
|
}
|
|
|
|
|
client->deleteLater();
|
|
|
|
|
pumpQueue(); // free the slot for the next photo
|
|
|
|
|
refreshPosting();
|
|
|
|
|
});
|
|
|
|
|
client->upload(prepared.filePath, QFileInfo(prepared.filePath).fileName(),
|
|
|
|
|
caption, item->date());
|
2026-08-26 00:28:35 +02:00
|
|
|
});
|
2026-09-26 10:17:13 +02:00
|
|
|
watcher->setFuture(QtConcurrent::run([source, cacheDir, slug]() {
|
|
|
|
|
return ImagePrep::prepare(source, cacheDir, slug);
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MainWindow::refreshPosting()
|
|
|
|
|
{
|
|
|
|
|
int pending = 0;
|
|
|
|
|
int inFlight = 0;
|
|
|
|
|
int succeeded = 0;
|
|
|
|
|
int failed = 0;
|
|
|
|
|
for (PhotoItemWidget *item : std::as_const(m_items)) {
|
|
|
|
|
switch (item->status()) {
|
|
|
|
|
case PhotoItemWidget::Status::Pending:
|
|
|
|
|
++pending;
|
|
|
|
|
break;
|
|
|
|
|
case PhotoItemWidget::Status::Preparing:
|
|
|
|
|
case PhotoItemWidget::Status::Uploading:
|
|
|
|
|
++inFlight;
|
|
|
|
|
break;
|
|
|
|
|
case PhotoItemWidget::Status::Success:
|
|
|
|
|
++succeeded;
|
|
|
|
|
break;
|
|
|
|
|
case PhotoItemWidget::Status::Error:
|
|
|
|
|
++failed;
|
|
|
|
|
++pending; // a failure is re-postable
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bool posting = inFlight > 0;
|
|
|
|
|
m_postButton->setEnabled(!m_items.isEmpty() && !posting && pending > 0);
|
|
|
|
|
m_postButton->setText(posting ? QStringLiteral("Posting… (%1/%2)").arg(succeeded).arg(m_items.size())
|
|
|
|
|
: pending == 1 ? QStringLiteral("Post 1 photo")
|
|
|
|
|
: pending > 1 ? QStringLiteral("Post %1 photos").arg(pending)
|
|
|
|
|
: QStringLiteral("All posted"));
|
|
|
|
|
|
|
|
|
|
if (!posting && (succeeded + failed) > 0) {
|
|
|
|
|
QString summary = QStringLiteral("%1 posted").arg(succeeded);
|
|
|
|
|
if (failed > 0)
|
|
|
|
|
summary += QStringLiteral(", %1 failed").arg(failed);
|
|
|
|
|
m_postSummary->setText(summary);
|
|
|
|
|
}
|
2026-08-26 00:28:35 +02:00
|
|
|
}
|