Add bulk multi-photo upload to the desktop app

Mirror the Android bulk feature in the Qt uploader: choose several photos
at once, each a card with its own caption, date, and upload status, and
post the batch.

- Per-photo cards (PhotoItemWidget) in a scrollable list; retry re-posts
  only the failures.
- One upload per photo with a per-item UUID slug suffix so a batch posted
  in the same second cannot collide.
- Image prep runs off the UI thread via QtConcurrent so the window stays
  responsive.
- A "Simultaneous uploads" setting (1–8, default 3) caps how many photos
  are prepared/uploaded at once; the rest queue and fill freed slots.
This commit is contained in:
2026-09-26 10:17:18 +02:00
parent 1d81ec4511
commit a7783671f2
7 changed files with 387 additions and 105 deletions
+200 -96
View File
@@ -1,27 +1,25 @@
#include "mainwindow.h"
#include "imageprep.h"
#include "photoitemwidget.h"
#include "uploadclient.h"
#include <QDateEdit>
#include <QDateTime>
#include <QDesktopServices>
#include <QFileDialog>
#include <QFileInfo>
#include <QFormLayout>
#include <QFrame>
#include <QImageReader>
#include <QFuture>
#include <QFutureWatcher>
#include <QLabel>
#include <QLineEdit>
#include <QPixmap>
#include <QPushButton>
#include <QScrollArea>
#include <QSpinBox>
#include <QStackedWidget>
#include <QStandardPaths>
#include <QUrl>
#include <QUuid>
#include <QVBoxLayout>
namespace {
constexpr int kPreviewSize = 360;
}
#include <QtConcurrent>
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
@@ -33,7 +31,6 @@ MainWindow::MainWindow(QWidget *parent)
m_stack->addWidget(buildPostPage()); // index 1
setCentralWidget(m_stack);
// Land on settings until there is somewhere to post to.
if (m_settings.isConfigured())
showPost();
else
@@ -52,9 +49,12 @@ QWidget *MainWindow::buildSettingsPage()
m_username = new QLineEdit;
m_password = new QLineEdit;
m_password->setEchoMode(QLineEdit::Password);
m_concurrency = new QSpinBox;
m_concurrency->setRange(1, Settings::kMaxConcurrency);
form->addRow(QStringLiteral("Base URL"), m_baseUrl);
form->addRow(QStringLiteral("Username"), m_username);
form->addRow(QStringLiteral("Password"), m_password);
form->addRow(QStringLiteral("Simultaneous uploads"), m_concurrency);
layout->addLayout(form);
m_saveButton = new QPushButton(QStringLiteral("Save && test connection"));
@@ -79,47 +79,43 @@ QWidget *MainWindow::buildSettingsPage()
QWidget *MainWindow::buildPostPage()
{
auto *page = new QWidget;
auto *layout = new QVBoxLayout(page);
layout->setContentsMargins(24, 24, 24, 24);
auto *outer = new QVBoxLayout(page);
outer->setContentsMargins(24, 24, 24, 24);
m_preview = new QLabel(QStringLiteral("No photo selected"));
m_preview->setAlignment(Qt::AlignCenter);
m_preview->setFixedSize(kPreviewSize, kPreviewSize);
m_preview->setFrameShape(QFrame::Box);
layout->addWidget(m_preview, 0, Qt::AlignHCenter);
auto *choose = new QPushButton(QStringLiteral("Choose photos…"));
connect(choose, &QPushButton::clicked, this, &MainWindow::chooseImages);
outer->addWidget(choose);
m_chooseButton = new QPushButton(QStringLiteral("Choose a photo…"));
connect(m_chooseButton, &QPushButton::clicked, this, &MainWindow::chooseImage);
layout->addWidget(m_chooseButton);
auto *form = new QFormLayout;
m_caption = new QLineEdit;
m_date = new QDateEdit(QDate::currentDate());
m_date->setCalendarPopup(true);
m_date->setDisplayFormat(QStringLiteral("yyyy-MM-dd"));
form->addRow(QStringLiteral("Caption"), m_caption);
form->addRow(QStringLiteral("Date"), m_date);
layout->addLayout(form);
// 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);
m_postButton = new QPushButton(QStringLiteral("Post"));
m_postButton->setEnabled(false);
connect(m_postButton, &QPushButton::clicked, this, &MainWindow::submit);
layout->addWidget(m_postButton);
outer->addWidget(m_postButton);
m_postStatus = new QLabel;
m_postStatus->setWordWrap(true);
layout->addWidget(m_postStatus);
m_postSummary = new QLabel;
m_postSummary->setWordWrap(true);
outer->addWidget(m_postSummary);
m_lastPost = new QLabel;
m_lastPost->setOpenExternalLinks(true);
m_lastPost->setWordWrap(true);
layout->addWidget(m_lastPost);
outer->addWidget(m_lastPost);
auto *settingsButton = new QPushButton(QStringLiteral("Settings"));
connect(settingsButton, &QPushButton::clicked, this, &MainWindow::showSettings);
layout->addWidget(settingsButton);
outer->addWidget(settingsButton);
layout->addStretch();
return page;
}
@@ -128,6 +124,7 @@ void MainWindow::showSettings()
m_baseUrl->setText(m_settings.baseUrl());
m_username->setText(m_settings.username());
m_password->setText(m_settings.readPassword());
m_concurrency->setValue(m_settings.concurrency());
m_settingsStatus->clear();
m_settingsBack->setVisible(m_settings.isConfigured());
m_stack->setCurrentIndex(0);
@@ -147,6 +144,9 @@ void MainWindow::showPost()
void MainWindow::saveAndTest()
{
// Concurrency is not a credential; persist it regardless of URL validity.
m_settings.setConcurrency(m_concurrency->value());
const QString urlError = Settings::validateBaseUrl(m_baseUrl->text());
if (!urlError.isEmpty()) {
m_settingsStatus->setText(urlError);
@@ -160,7 +160,7 @@ void MainWindow::saveAndTest()
m_settings.setBaseUrl(m_baseUrl->text());
m_settings.setUsername(m_username->text());
m_settings.writePassword(m_password->text());
m_baseUrl->setText(m_settings.baseUrl()); // show normalised value
m_baseUrl->setText(m_settings.baseUrl());
m_saveButton->setEnabled(false);
m_settingsStatus->setText(QStringLiteral("Testing…"));
@@ -178,80 +178,184 @@ void MainWindow::saveAndTest()
client->testConnection();
}
void MainWindow::chooseImage()
void MainWindow::chooseImages()
{
const QString path = QFileDialog::getOpenFileName(
this, QStringLiteral("Choose a photo"), QString(),
const QStringList paths = QFileDialog::getOpenFileNames(
this, QStringLiteral("Choose photos"), QString(),
QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.heif *.webp *.tif *.tiff *.bmp);;All files (*)"));
if (path.isEmpty())
return;
for (const QString &path : paths)
addItem(path);
refreshPosting();
}
m_imagePath = path;
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);
}
QImageReader reader(path);
reader.setAutoTransform(true);
const QImage image = reader.read();
if (image.isNull()) {
m_preview->setText(QStringLiteral("Could not preview this image"));
} else {
m_preview->setPixmap(QPixmap::fromImage(image).scaled(
kPreviewSize, kPreviewSize, Qt::KeepAspectRatio, Qt::SmoothTransformation));
}
m_postStatus->clear();
m_postButton->setEnabled(true);
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();
}
void MainWindow::submit()
{
if (m_imagePath.isEmpty())
if (m_items.isEmpty())
return;
if (!m_settings.isConfigured()) {
m_postStatus->setText(QStringLiteral("Set the server URL and credentials first"));
m_postSummary->setText(QStringLiteral("Set the server URL and credentials first"));
return;
}
m_postButton->setEnabled(false);
m_postStatus->setText(QStringLiteral("Preparing…"));
// 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();
// Slug from wall-clock time: posts are made one at a time, so this cannot
// collide, and re-sending the same filename overwrites the same post. The
// real capture date still travels in the date field.
const QString slug = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd-HHmmss"));
const QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::TempLocation);
const ImagePrep::Prepared prepared = ImagePrep::prepare(m_imagePath, cacheDir, slug);
if (!prepared.ok()) {
m_postStatus->setText(prepared.error);
m_postButton->setEnabled(true);
return;
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
}
if (m_queue.isEmpty())
return;
m_postStatus->setText(QStringLiteral("Uploading…"));
const QString caption = m_caption->text();
const QString date = m_date->date().toString(QStringLiteral("yyyy-MM-dd"));
const QString fileName = QFileInfo(prepared.filePath).fileName();
auto *client = new UploadClient(m_settings.baseUrl(), m_settings.username(),
m_settings.readPassword(), this);
connect(client, &UploadClient::uploadFinished, this,
[this, client, prepared, caption](UploadClient::Result result, const QString &slug,
const QString &url, const QString &message) {
QFile::remove(prepared.filePath); // no retry queue in this MVP
m_postButton->setEnabled(true);
if (result == UploadClient::Result::Success) {
Settings::LastPost last{slug, url, caption, QDateTime::currentDateTime()};
m_settings.setLastPost(last);
QString msg = QStringLiteral("Posted. <a href=\"%1\">View</a>").arg(url);
if (!prepared.note.isEmpty())
msg += QStringLiteral("<br><span>%1</span>").arg(prepared.note);
m_postStatus->setText(msg);
m_postStatus->setOpenExternalLinks(true);
} else {
m_postStatus->setText(message);
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;
}
client->deleteLater();
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());
});
client->upload(prepared.filePath, fileName, caption, date);
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);
}
}