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:
@@ -5,12 +5,13 @@ set(CMAKE_CXX_STANDARD 17)
|
|||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
set(CMAKE_AUTOMOC ON)
|
set(CMAKE_AUTOMOC ON)
|
||||||
|
|
||||||
find_package(Qt6 REQUIRED COMPONENTS Widgets Network)
|
find_package(Qt6 REQUIRED COMPONENTS Widgets Network Concurrent)
|
||||||
find_package(Qt6Keychain REQUIRED)
|
find_package(Qt6Keychain REQUIRED)
|
||||||
|
|
||||||
add_executable(uploader
|
add_executable(uploader
|
||||||
main.cpp
|
main.cpp
|
||||||
mainwindow.cpp
|
mainwindow.cpp
|
||||||
|
photoitemwidget.cpp
|
||||||
settings.cpp
|
settings.cpp
|
||||||
uploadclient.cpp
|
uploadclient.cpp
|
||||||
imageprep.cpp
|
imageprep.cpp
|
||||||
@@ -19,5 +20,6 @@ add_executable(uploader
|
|||||||
target_link_libraries(uploader PRIVATE
|
target_link_libraries(uploader PRIVATE
|
||||||
Qt6::Widgets
|
Qt6::Widgets
|
||||||
Qt6::Network
|
Qt6::Network
|
||||||
|
Qt6::Concurrent
|
||||||
Qt6Keychain::Qt6Keychain
|
Qt6Keychain::Qt6Keychain
|
||||||
)
|
)
|
||||||
|
|||||||
+200
-96
@@ -1,27 +1,25 @@
|
|||||||
#include "mainwindow.h"
|
#include "mainwindow.h"
|
||||||
|
|
||||||
#include "imageprep.h"
|
#include "imageprep.h"
|
||||||
|
#include "photoitemwidget.h"
|
||||||
|
#include "uploadclient.h"
|
||||||
|
|
||||||
#include <QDateEdit>
|
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QDesktopServices>
|
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QFormLayout>
|
#include <QFormLayout>
|
||||||
#include <QFrame>
|
#include <QFuture>
|
||||||
#include <QImageReader>
|
#include <QFutureWatcher>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
#include <QPixmap>
|
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
|
#include <QScrollArea>
|
||||||
|
#include <QSpinBox>
|
||||||
#include <QStackedWidget>
|
#include <QStackedWidget>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
#include <QUrl>
|
#include <QUuid>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
#include <QtConcurrent>
|
||||||
namespace {
|
|
||||||
constexpr int kPreviewSize = 360;
|
|
||||||
}
|
|
||||||
|
|
||||||
MainWindow::MainWindow(QWidget *parent)
|
MainWindow::MainWindow(QWidget *parent)
|
||||||
: QMainWindow(parent)
|
: QMainWindow(parent)
|
||||||
@@ -33,7 +31,6 @@ MainWindow::MainWindow(QWidget *parent)
|
|||||||
m_stack->addWidget(buildPostPage()); // index 1
|
m_stack->addWidget(buildPostPage()); // index 1
|
||||||
setCentralWidget(m_stack);
|
setCentralWidget(m_stack);
|
||||||
|
|
||||||
// Land on settings until there is somewhere to post to.
|
|
||||||
if (m_settings.isConfigured())
|
if (m_settings.isConfigured())
|
||||||
showPost();
|
showPost();
|
||||||
else
|
else
|
||||||
@@ -52,9 +49,12 @@ QWidget *MainWindow::buildSettingsPage()
|
|||||||
m_username = new QLineEdit;
|
m_username = new QLineEdit;
|
||||||
m_password = new QLineEdit;
|
m_password = new QLineEdit;
|
||||||
m_password->setEchoMode(QLineEdit::Password);
|
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("Base URL"), m_baseUrl);
|
||||||
form->addRow(QStringLiteral("Username"), m_username);
|
form->addRow(QStringLiteral("Username"), m_username);
|
||||||
form->addRow(QStringLiteral("Password"), m_password);
|
form->addRow(QStringLiteral("Password"), m_password);
|
||||||
|
form->addRow(QStringLiteral("Simultaneous uploads"), m_concurrency);
|
||||||
layout->addLayout(form);
|
layout->addLayout(form);
|
||||||
|
|
||||||
m_saveButton = new QPushButton(QStringLiteral("Save && test connection"));
|
m_saveButton = new QPushButton(QStringLiteral("Save && test connection"));
|
||||||
@@ -79,47 +79,43 @@ QWidget *MainWindow::buildSettingsPage()
|
|||||||
QWidget *MainWindow::buildPostPage()
|
QWidget *MainWindow::buildPostPage()
|
||||||
{
|
{
|
||||||
auto *page = new QWidget;
|
auto *page = new QWidget;
|
||||||
auto *layout = new QVBoxLayout(page);
|
auto *outer = new QVBoxLayout(page);
|
||||||
layout->setContentsMargins(24, 24, 24, 24);
|
outer->setContentsMargins(24, 24, 24, 24);
|
||||||
|
|
||||||
m_preview = new QLabel(QStringLiteral("No photo selected"));
|
auto *choose = new QPushButton(QStringLiteral("Choose photos…"));
|
||||||
m_preview->setAlignment(Qt::AlignCenter);
|
connect(choose, &QPushButton::clicked, this, &MainWindow::chooseImages);
|
||||||
m_preview->setFixedSize(kPreviewSize, kPreviewSize);
|
outer->addWidget(choose);
|
||||||
m_preview->setFrameShape(QFrame::Box);
|
|
||||||
layout->addWidget(m_preview, 0, Qt::AlignHCenter);
|
|
||||||
|
|
||||||
m_chooseButton = new QPushButton(QStringLiteral("Choose a photo…"));
|
// Scrollable list of per-photo cards.
|
||||||
connect(m_chooseButton, &QPushButton::clicked, this, &MainWindow::chooseImage);
|
auto *scroll = new QScrollArea;
|
||||||
layout->addWidget(m_chooseButton);
|
scroll->setWidgetResizable(true);
|
||||||
|
auto *container = new QWidget;
|
||||||
auto *form = new QFormLayout;
|
m_itemsLayout = new QVBoxLayout(container);
|
||||||
m_caption = new QLineEdit;
|
m_itemsLayout->setAlignment(Qt::AlignTop);
|
||||||
m_date = new QDateEdit(QDate::currentDate());
|
m_emptyHint = new QLabel(QStringLiteral("No photos selected"));
|
||||||
m_date->setCalendarPopup(true);
|
m_emptyHint->setAlignment(Qt::AlignCenter);
|
||||||
m_date->setDisplayFormat(QStringLiteral("yyyy-MM-dd"));
|
m_itemsLayout->addWidget(m_emptyHint);
|
||||||
form->addRow(QStringLiteral("Caption"), m_caption);
|
scroll->setWidget(container);
|
||||||
form->addRow(QStringLiteral("Date"), m_date);
|
outer->addWidget(scroll, 1);
|
||||||
layout->addLayout(form);
|
|
||||||
|
|
||||||
m_postButton = new QPushButton(QStringLiteral("Post"));
|
m_postButton = new QPushButton(QStringLiteral("Post"));
|
||||||
m_postButton->setEnabled(false);
|
m_postButton->setEnabled(false);
|
||||||
connect(m_postButton, &QPushButton::clicked, this, &MainWindow::submit);
|
connect(m_postButton, &QPushButton::clicked, this, &MainWindow::submit);
|
||||||
layout->addWidget(m_postButton);
|
outer->addWidget(m_postButton);
|
||||||
|
|
||||||
m_postStatus = new QLabel;
|
m_postSummary = new QLabel;
|
||||||
m_postStatus->setWordWrap(true);
|
m_postSummary->setWordWrap(true);
|
||||||
layout->addWidget(m_postStatus);
|
outer->addWidget(m_postSummary);
|
||||||
|
|
||||||
m_lastPost = new QLabel;
|
m_lastPost = new QLabel;
|
||||||
m_lastPost->setOpenExternalLinks(true);
|
m_lastPost->setOpenExternalLinks(true);
|
||||||
m_lastPost->setWordWrap(true);
|
m_lastPost->setWordWrap(true);
|
||||||
layout->addWidget(m_lastPost);
|
outer->addWidget(m_lastPost);
|
||||||
|
|
||||||
auto *settingsButton = new QPushButton(QStringLiteral("Settings"));
|
auto *settingsButton = new QPushButton(QStringLiteral("Settings"));
|
||||||
connect(settingsButton, &QPushButton::clicked, this, &MainWindow::showSettings);
|
connect(settingsButton, &QPushButton::clicked, this, &MainWindow::showSettings);
|
||||||
layout->addWidget(settingsButton);
|
outer->addWidget(settingsButton);
|
||||||
|
|
||||||
layout->addStretch();
|
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +124,7 @@ void MainWindow::showSettings()
|
|||||||
m_baseUrl->setText(m_settings.baseUrl());
|
m_baseUrl->setText(m_settings.baseUrl());
|
||||||
m_username->setText(m_settings.username());
|
m_username->setText(m_settings.username());
|
||||||
m_password->setText(m_settings.readPassword());
|
m_password->setText(m_settings.readPassword());
|
||||||
|
m_concurrency->setValue(m_settings.concurrency());
|
||||||
m_settingsStatus->clear();
|
m_settingsStatus->clear();
|
||||||
m_settingsBack->setVisible(m_settings.isConfigured());
|
m_settingsBack->setVisible(m_settings.isConfigured());
|
||||||
m_stack->setCurrentIndex(0);
|
m_stack->setCurrentIndex(0);
|
||||||
@@ -147,6 +144,9 @@ void MainWindow::showPost()
|
|||||||
|
|
||||||
void MainWindow::saveAndTest()
|
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());
|
const QString urlError = Settings::validateBaseUrl(m_baseUrl->text());
|
||||||
if (!urlError.isEmpty()) {
|
if (!urlError.isEmpty()) {
|
||||||
m_settingsStatus->setText(urlError);
|
m_settingsStatus->setText(urlError);
|
||||||
@@ -160,7 +160,7 @@ void MainWindow::saveAndTest()
|
|||||||
m_settings.setBaseUrl(m_baseUrl->text());
|
m_settings.setBaseUrl(m_baseUrl->text());
|
||||||
m_settings.setUsername(m_username->text());
|
m_settings.setUsername(m_username->text());
|
||||||
m_settings.writePassword(m_password->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_saveButton->setEnabled(false);
|
||||||
m_settingsStatus->setText(QStringLiteral("Testing…"));
|
m_settingsStatus->setText(QStringLiteral("Testing…"));
|
||||||
@@ -178,80 +178,184 @@ void MainWindow::saveAndTest()
|
|||||||
client->testConnection();
|
client->testConnection();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::chooseImage()
|
void MainWindow::chooseImages()
|
||||||
{
|
{
|
||||||
const QString path = QFileDialog::getOpenFileName(
|
const QStringList paths = QFileDialog::getOpenFileNames(
|
||||||
this, QStringLiteral("Choose a photo"), QString(),
|
this, QStringLiteral("Choose photos"), QString(),
|
||||||
QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.heif *.webp *.tif *.tiff *.bmp);;All files (*)"));
|
QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.heif *.webp *.tif *.tiff *.bmp);;All files (*)"));
|
||||||
if (path.isEmpty())
|
for (const QString &path : paths)
|
||||||
return;
|
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);
|
void MainWindow::removeItem(PhotoItemWidget *item)
|
||||||
reader.setAutoTransform(true);
|
{
|
||||||
const QImage image = reader.read();
|
m_items.removeOne(item);
|
||||||
if (image.isNull()) {
|
m_queue.removeOne(item); // may be waiting for a slot — drop it before deleting
|
||||||
m_preview->setText(QStringLiteral("Could not preview this image"));
|
item->deleteLater();
|
||||||
} else {
|
if (m_items.isEmpty())
|
||||||
m_preview->setPixmap(QPixmap::fromImage(image).scaled(
|
m_emptyHint->show();
|
||||||
kPreviewSize, kPreviewSize, Qt::KeepAspectRatio, Qt::SmoothTransformation));
|
refreshPosting();
|
||||||
}
|
}
|
||||||
m_postStatus->clear();
|
|
||||||
m_postButton->setEnabled(true);
|
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()
|
void MainWindow::submit()
|
||||||
{
|
{
|
||||||
if (m_imagePath.isEmpty())
|
if (m_items.isEmpty())
|
||||||
return;
|
return;
|
||||||
if (!m_settings.isConfigured()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
m_postButton->setEnabled(false);
|
// Capture the batch's credentials once, then feed the queue through a
|
||||||
m_postStatus->setText(QStringLiteral("Preparing…"));
|
// 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
|
m_queue.clear();
|
||||||
// collide, and re-sending the same filename overwrites the same post. The
|
for (PhotoItemWidget *item : std::as_const(m_items)) {
|
||||||
// real capture date still travels in the date field.
|
if (item->status() != PhotoItemWidget::Status::Success)
|
||||||
const QString slug = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd-HHmmss"));
|
m_queue.append(item); // re-posting retries only failures
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
if (m_queue.isEmpty())
|
||||||
|
return;
|
||||||
|
|
||||||
m_postStatus->setText(QStringLiteral("Uploading…"));
|
m_postSummary->clear();
|
||||||
|
pumpQueue();
|
||||||
const QString caption = m_caption->text();
|
refreshPosting();
|
||||||
const QString date = m_date->date().toString(QStringLiteral("yyyy-MM-dd"));
|
}
|
||||||
const QString fileName = QFileInfo(prepared.filePath).fileName();
|
|
||||||
|
int MainWindow::inFlightCount() const
|
||||||
auto *client = new UploadClient(m_settings.baseUrl(), m_settings.username(),
|
{
|
||||||
m_settings.readPassword(), this);
|
int n = 0;
|
||||||
connect(client, &UploadClient::uploadFinished, this,
|
for (PhotoItemWidget *item : std::as_const(m_items)) {
|
||||||
[this, client, prepared, caption](UploadClient::Result result, const QString &slug,
|
const PhotoItemWidget::Status s = item->status();
|
||||||
const QString &url, const QString &message) {
|
if (s == PhotoItemWidget::Status::Preparing || s == PhotoItemWidget::Status::Uploading)
|
||||||
QFile::remove(prepared.filePath); // no retry queue in this MVP
|
++n;
|
||||||
m_postButton->setEnabled(true);
|
}
|
||||||
|
return n;
|
||||||
if (result == UploadClient::Result::Success) {
|
}
|
||||||
Settings::LastPost last{slug, url, caption, QDateTime::currentDateTime()};
|
|
||||||
m_settings.setLastPost(last);
|
void MainWindow::pumpQueue()
|
||||||
QString msg = QStringLiteral("Posted. <a href=\"%1\">View</a>").arg(url);
|
{
|
||||||
if (!prepared.note.isEmpty())
|
const int cap = m_settings.concurrency();
|
||||||
msg += QStringLiteral("<br><span>%1</span>").arg(prepared.note);
|
while (inFlightCount() < cap && !m_queue.isEmpty())
|
||||||
m_postStatus->setText(msg);
|
startItem(m_queue.takeFirst());
|
||||||
m_postStatus->setOpenExternalLinks(true);
|
}
|
||||||
} else {
|
|
||||||
m_postStatus->setText(message);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-8
@@ -1,15 +1,18 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <QList>
|
||||||
#include <QMainWindow>
|
#include <QMainWindow>
|
||||||
|
|
||||||
#include "settings.h"
|
#include "settings.h"
|
||||||
#include "uploadclient.h"
|
|
||||||
|
|
||||||
class QDateEdit;
|
class QDateEdit;
|
||||||
class QLabel;
|
class QLabel;
|
||||||
class QLineEdit;
|
class QLineEdit;
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
|
class QSpinBox;
|
||||||
class QStackedWidget;
|
class QStackedWidget;
|
||||||
|
class QVBoxLayout;
|
||||||
|
class PhotoItemWidget;
|
||||||
|
|
||||||
class MainWindow : public QMainWindow
|
class MainWindow : public QMainWindow
|
||||||
{
|
{
|
||||||
@@ -25,8 +28,15 @@ private:
|
|||||||
void showPost();
|
void showPost();
|
||||||
|
|
||||||
void saveAndTest();
|
void saveAndTest();
|
||||||
void chooseImage();
|
void chooseImages();
|
||||||
|
void addItem(const QString &path);
|
||||||
|
void removeItem(PhotoItemWidget *item);
|
||||||
|
void clearItems();
|
||||||
void submit();
|
void submit();
|
||||||
|
void pumpQueue(); // starts queued items up to the concurrency cap
|
||||||
|
void startItem(PhotoItemWidget *item);
|
||||||
|
int inFlightCount() const; // items currently preparing or uploading
|
||||||
|
void refreshPosting(); // updates the Post button + summary from item states
|
||||||
|
|
||||||
Settings m_settings;
|
Settings m_settings;
|
||||||
|
|
||||||
@@ -36,18 +46,24 @@ private:
|
|||||||
QLineEdit *m_baseUrl = nullptr;
|
QLineEdit *m_baseUrl = nullptr;
|
||||||
QLineEdit *m_username = nullptr;
|
QLineEdit *m_username = nullptr;
|
||||||
QLineEdit *m_password = nullptr;
|
QLineEdit *m_password = nullptr;
|
||||||
|
QSpinBox *m_concurrency = nullptr;
|
||||||
QPushButton *m_saveButton = nullptr;
|
QPushButton *m_saveButton = nullptr;
|
||||||
QPushButton *m_settingsBack = nullptr;
|
QPushButton *m_settingsBack = nullptr;
|
||||||
QLabel *m_settingsStatus = nullptr;
|
QLabel *m_settingsStatus = nullptr;
|
||||||
|
|
||||||
// Post page
|
// Post page
|
||||||
QLabel *m_preview = nullptr;
|
QVBoxLayout *m_itemsLayout = nullptr; // holds the PhotoItemWidgets
|
||||||
QLineEdit *m_caption = nullptr;
|
QList<PhotoItemWidget *> m_items;
|
||||||
QDateEdit *m_date = nullptr;
|
QLabel *m_emptyHint = nullptr;
|
||||||
QPushButton *m_chooseButton = nullptr;
|
|
||||||
QPushButton *m_postButton = nullptr;
|
QPushButton *m_postButton = nullptr;
|
||||||
QLabel *m_postStatus = nullptr;
|
QLabel *m_postSummary = nullptr;
|
||||||
QLabel *m_lastPost = nullptr;
|
QLabel *m_lastPost = nullptr;
|
||||||
|
|
||||||
QString m_imagePath; // currently selected source image
|
// Bulk-post state: items waiting for a free upload slot, plus the batch's
|
||||||
|
// credentials captured once at submit time.
|
||||||
|
QList<PhotoItemWidget *> m_queue;
|
||||||
|
QString m_batchBaseUrl;
|
||||||
|
QString m_batchUsername;
|
||||||
|
QString m_batchPassword;
|
||||||
|
QString m_batchCacheDir;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
#include "photoitemwidget.h"
|
||||||
|
|
||||||
|
#include <QDate>
|
||||||
|
#include <QDateEdit>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QImageReader>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QPixmap>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr int kThumbSize = 72;
|
||||||
|
}
|
||||||
|
|
||||||
|
PhotoItemWidget::PhotoItemWidget(QString sourcePath, QWidget *parent)
|
||||||
|
: QWidget(parent)
|
||||||
|
, m_sourcePath(std::move(sourcePath))
|
||||||
|
{
|
||||||
|
auto *row = new QHBoxLayout(this);
|
||||||
|
row->setContentsMargins(0, 4, 0, 4);
|
||||||
|
|
||||||
|
m_thumb = new QLabel;
|
||||||
|
m_thumb->setFixedSize(kThumbSize, kThumbSize);
|
||||||
|
m_thumb->setAlignment(Qt::AlignCenter);
|
||||||
|
m_thumb->setFrameShape(QFrame::Box);
|
||||||
|
{
|
||||||
|
QImageReader reader(m_sourcePath);
|
||||||
|
reader.setAutoTransform(true);
|
||||||
|
const QImage image = reader.read();
|
||||||
|
if (!image.isNull())
|
||||||
|
m_thumb->setPixmap(QPixmap::fromImage(image).scaled(
|
||||||
|
kThumbSize, kThumbSize, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation));
|
||||||
|
else
|
||||||
|
m_thumb->setText(QStringLiteral("?"));
|
||||||
|
}
|
||||||
|
row->addWidget(m_thumb);
|
||||||
|
|
||||||
|
auto *fields = new QVBoxLayout;
|
||||||
|
m_caption = new QLineEdit;
|
||||||
|
m_caption->setPlaceholderText(QStringLiteral("Caption (defaults to the filename)"));
|
||||||
|
fields->addWidget(m_caption);
|
||||||
|
|
||||||
|
m_date = new QDateEdit(QDate::currentDate());
|
||||||
|
m_date->setCalendarPopup(true);
|
||||||
|
m_date->setDisplayFormat(QStringLiteral("yyyy-MM-dd"));
|
||||||
|
fields->addWidget(m_date);
|
||||||
|
|
||||||
|
m_statusLabel = new QLabel;
|
||||||
|
m_statusLabel->setOpenExternalLinks(true);
|
||||||
|
m_statusLabel->setWordWrap(true);
|
||||||
|
fields->addWidget(m_statusLabel);
|
||||||
|
row->addLayout(fields, 1);
|
||||||
|
|
||||||
|
m_remove = new QPushButton(QStringLiteral("✕"));
|
||||||
|
m_remove->setFlat(true);
|
||||||
|
m_remove->setFixedWidth(32);
|
||||||
|
connect(m_remove, &QPushButton::clicked, this, [this]() { emit removeRequested(this); });
|
||||||
|
row->addWidget(m_remove, 0, Qt::AlignTop);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString PhotoItemWidget::caption() const
|
||||||
|
{
|
||||||
|
return m_caption->text();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString PhotoItemWidget::date() const
|
||||||
|
{
|
||||||
|
return m_date->date().toString(QStringLiteral("yyyy-MM-dd"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void PhotoItemWidget::setStatus(Status status, const QString &message, const QString &url)
|
||||||
|
{
|
||||||
|
m_status = status;
|
||||||
|
|
||||||
|
// Inputs are frozen once the photo is in flight; the remove button stays
|
||||||
|
// usable except mid-transfer.
|
||||||
|
const bool editable = status == Status::Pending || status == Status::Error;
|
||||||
|
m_caption->setEnabled(editable);
|
||||||
|
m_date->setEnabled(editable);
|
||||||
|
m_remove->setEnabled(status != Status::Preparing && status != Status::Uploading);
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case Status::Pending:
|
||||||
|
m_statusLabel->clear();
|
||||||
|
break;
|
||||||
|
case Status::Preparing:
|
||||||
|
m_statusLabel->setText(QStringLiteral("Preparing…"));
|
||||||
|
break;
|
||||||
|
case Status::Uploading:
|
||||||
|
m_statusLabel->setText(QStringLiteral("Uploading…"));
|
||||||
|
break;
|
||||||
|
case Status::Success:
|
||||||
|
m_statusLabel->setText(QStringLiteral("Posted — <a href=\"%1\">View</a>").arg(url));
|
||||||
|
break;
|
||||||
|
case Status::Error:
|
||||||
|
m_statusLabel->setText(QStringLiteral("<span style=\"color:#fb4934\">%1</span>").arg(message));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
class QDateEdit;
|
||||||
|
class QLabel;
|
||||||
|
class QLineEdit;
|
||||||
|
class QPushButton;
|
||||||
|
|
||||||
|
// One photo in the batch: thumbnail, caption, date, and its own upload status.
|
||||||
|
// A single-photo post is just a batch of one, so the window runs one code path.
|
||||||
|
class PhotoItemWidget : public QWidget
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
enum class Status { Pending, Preparing, Uploading, Success, Error };
|
||||||
|
|
||||||
|
explicit PhotoItemWidget(QString sourcePath, QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
QString sourcePath() const { return m_sourcePath; }
|
||||||
|
QString caption() const;
|
||||||
|
QString date() const; // yyyy-MM-dd
|
||||||
|
Status status() const { return m_status; }
|
||||||
|
|
||||||
|
void setStatus(Status status, const QString &message = {}, const QString &url = {});
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void removeRequested(PhotoItemWidget *self);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_sourcePath;
|
||||||
|
Status m_status = Status::Pending;
|
||||||
|
|
||||||
|
QLabel *m_thumb = nullptr;
|
||||||
|
QLineEdit *m_caption = nullptr;
|
||||||
|
QDateEdit *m_date = nullptr;
|
||||||
|
QLabel *m_statusLabel = nullptr;
|
||||||
|
QPushButton *m_remove = nullptr;
|
||||||
|
};
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
#include <QEventLoop>
|
#include <QEventLoop>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
#include <qt6keychain/keychain.h>
|
#include <qt6keychain/keychain.h>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -35,6 +37,17 @@ void Settings::setUsername(const QString &value)
|
|||||||
m_settings.setValue(QStringLiteral("username"), value.trimmed());
|
m_settings.setValue(QStringLiteral("username"), value.trimmed());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int Settings::concurrency() const
|
||||||
|
{
|
||||||
|
const int v = m_settings.value(QStringLiteral("concurrency"), kDefaultConcurrency).toInt();
|
||||||
|
return std::clamp(v, 1, kMaxConcurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Settings::setConcurrency(int value)
|
||||||
|
{
|
||||||
|
m_settings.setValue(QStringLiteral("concurrency"), std::clamp(value, 1, kMaxConcurrency));
|
||||||
|
}
|
||||||
|
|
||||||
QString Settings::readPassword() const
|
QString Settings::readPassword() const
|
||||||
{
|
{
|
||||||
QKeychain::ReadPasswordJob job(kService);
|
QKeychain::ReadPasswordJob job(kService);
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ public:
|
|||||||
QString username() const;
|
QString username() const;
|
||||||
void setUsername(const QString &value);
|
void setUsername(const QString &value);
|
||||||
|
|
||||||
|
// Max photos uploaded at once in a bulk post, clamped to [1, kMaxConcurrency].
|
||||||
|
static constexpr int kMaxConcurrency = 8;
|
||||||
|
static constexpr int kDefaultConcurrency = 3;
|
||||||
|
int concurrency() const;
|
||||||
|
void setConcurrency(int value);
|
||||||
|
|
||||||
// Keychain access is synchronous here (a local event loop): the app does
|
// Keychain access is synchronous here (a local event loop): the app does
|
||||||
// nothing until the credential is in hand, and there is no background retry
|
// nothing until the credential is in hand, and there is no background retry
|
||||||
// worker as on Android that would need it off the UI thread.
|
// worker as on Android that would need it off the UI thread.
|
||||||
|
|||||||
Reference in New Issue
Block a user