Qt desktop: add manage (edit/delete) and tag autocomplete

Brings the Qt uploader to parity with the web /manage page and the Android
app. Adds a stacked Manage page (reached from the post page) that lists
posts via GET /api/photos with thumbnails from GET /api/thumb/<slug>, edits
caption/date/tags in a QDialog via POST /api/edit, and deletes to trash via
POST /api/delete with a QMessageBox confirm. As a non-browser client it sends
no Origin and so passes the server's same-origin CSRF check.

Tag autocomplete now backs every tags field (post cards and the edit dialog)
via a per-token TagCompleter (QCompleter subclass, MatchContains) fed from
the unguarded GET /api/tags.json.

New files: managepostwidget.{h,cpp}, tagcompleter.{h,cpp}; network methods
and a RemotePhoto struct added to uploadclient. Built and linked clean; not
yet runtime-tested on a real display.
This commit is contained in:
puttaalu committed 2026-11-04 22:04:06 +01:00
1 parent bb8c7109fa
commit 504b96ed31
12 files changed
+677 -2

No files matched your search

+1 -1
View File
@@ -266,7 +266,7 @@ nix build # succeeds
## What's not done yet
- ActivityPub federation (planned later — start with RSS/Atom)
- Edit/delete in the **Qt desktop** app (web `/manage` and the Android app have it; the `/api/` endpoints are there for Qt to adopt). Restoring from trash is still web-only on all clients.
- Restoring from trash is still web-only: the `/manage` page has it, but neither the Android nor the Qt desktop app exposes `/api/restore` or `/api/trash` yet. (Edit/delete and tag autocomplete now exist in all three clients.)
- Any auth beyond the app's htpasswd Basic auth (no sessions, no per-user scopes)
- The Android app has never been run on real hardware
- Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part
+2
View File
@@ -13,6 +13,8 @@ add_executable(uploader
main.cpp
mainwindow.cpp
photoitemwidget.cpp
managepostwidget.cpp
tagcompleter.cpp
settings.cpp
uploadclient.cpp
imageprep.cpp
+241 -1
View File
@@ -1,17 +1,25 @@
#include "mainwindow.h"
#include "imageprep.h"
#include "managepostwidget.h"
#include "photoitemwidget.h"
#include "tagcompleter.h"
#include "uploadclient.h"
#include <QDate>
#include <QDateEdit>
#include <QDateTime>
#include <QDialog>
#include <QDialogButtonBox>
#include <QFileDialog>
#include <QFileInfo>
#include <QFormLayout>
#include <QFuture>
#include <QFutureWatcher>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QScrollArea>
#include <QSpinBox>
@@ -29,6 +37,7 @@ MainWindow::MainWindow(QWidget *parent)
m_stack = new QStackedWidget(this);
m_stack->addWidget(buildSettingsPage()); // index 0
m_stack->addWidget(buildPostPage()); // index 1
m_stack->addWidget(buildManagePage()); // index 2
setCentralWidget(m_stack);
if (m_settings.isConfigured())
@@ -112,9 +121,51 @@ QWidget *MainWindow::buildPostPage()
m_lastPost->setWordWrap(true);
outer->addWidget(m_lastPost);
auto *bottomRow = new QHBoxLayout;
auto *manageButton = new QPushButton(QStringLiteral("Manage"));
connect(manageButton, &QPushButton::clicked, this, &MainWindow::showManage);
bottomRow->addWidget(manageButton);
auto *settingsButton = new QPushButton(QStringLiteral("Settings"));
connect(settingsButton, &QPushButton::clicked, this, &MainWindow::showSettings);
outer->addWidget(settingsButton);
bottomRow->addWidget(settingsButton);
outer->addLayout(bottomRow);
return page;
}
QWidget *MainWindow::buildManagePage()
{
auto *page = new QWidget;
auto *outer = new QVBoxLayout(page);
outer->setContentsMargins(24, 24, 24, 24);
auto *header = new QHBoxLayout;
auto *title = new QLabel(QStringLiteral("<b>Manage posts</b>"));
header->addWidget(title);
header->addStretch();
m_manageRefresh = new QPushButton(QStringLiteral("Refresh"));
connect(m_manageRefresh, &QPushButton::clicked, this, &MainWindow::loadManage);
header->addWidget(m_manageRefresh);
auto *back = new QPushButton(QStringLiteral("Back"));
connect(back, &QPushButton::clicked, this, &MainWindow::showPost);
header->addWidget(back);
outer->addLayout(header);
m_manageStatus = new QLabel;
m_manageStatus->setWordWrap(true);
outer->addWidget(m_manageStatus);
auto *scroll = new QScrollArea;
scroll->setWidgetResizable(true);
auto *container = new QWidget;
m_manageLayout = new QVBoxLayout(container);
m_manageLayout->setAlignment(Qt::AlignTop);
m_manageEmptyHint = new QLabel(QStringLiteral("No posts."));
m_manageEmptyHint->setAlignment(Qt::AlignCenter);
m_manageLayout->addWidget(m_manageEmptyHint);
scroll->setWidget(container);
outer->addWidget(scroll, 1);
return page;
}
@@ -139,9 +190,21 @@ void MainWindow::showPost()
} else {
m_lastPost->clear();
}
refreshTags();
m_stack->setCurrentIndex(1);
}
void MainWindow::showManage()
{
if (!m_settings.isConfigured()) {
showSettings();
return;
}
m_stack->setCurrentIndex(2);
refreshTags();
loadManage();
}
void MainWindow::saveAndTest()
{
// Concurrency is not a credential; persist it regardless of URL validity.
@@ -162,6 +225,13 @@ void MainWindow::saveAndTest()
m_settings.writePassword(m_password->text());
m_baseUrl->setText(m_settings.baseUrl());
// The manage client caches the credentials it was built with; drop it so
// the next manage action rebuilds with the ones just saved.
if (m_manageClient) {
m_manageClient->deleteLater();
m_manageClient = nullptr;
}
m_saveButton->setEnabled(false);
m_settingsStatus->setText(QStringLiteral("Testing…"));
@@ -192,6 +262,7 @@ void MainWindow::addItem(const QString &path)
{
m_emptyHint->hide();
auto *item = new PhotoItemWidget(path);
item->setTagSuggestions(m_tagSuggestions);
connect(item, &PhotoItemWidget::removeRequested, this, &MainWindow::removeItem);
m_items.append(item);
m_itemsLayout->addWidget(item);
@@ -359,3 +430,172 @@ void MainWindow::refreshPosting()
m_postSummary->setText(summary);
}
}
// --- Manage page -----------------------------------------------------------
UploadClient *MainWindow::manageClient()
{
if (!m_manageClient) {
m_manageClient = new UploadClient(m_settings.baseUrl(), m_settings.username(),
m_settings.readPassword(), this);
// Thumbnails arrive one per request; route each to its row by slug.
connect(m_manageClient, &UploadClient::thumbnailReady, this,
[this](const QString &slug, const QImage &image) {
for (ManagePostWidget *w : std::as_const(m_manageItems))
if (w->slug() == slug)
w->setThumbnail(image);
});
// The tag list feeds both the post cards and the edit dialog.
connect(m_manageClient, &UploadClient::tagsFetched, this,
[this](const QStringList &tags) {
if (tags.isEmpty())
return; // best-effort; keep whatever we had
m_tagSuggestions = tags;
applyTagSuggestions();
});
}
return m_manageClient;
}
void MainWindow::refreshTags()
{
if (m_settings.isConfigured())
manageClient()->fetchTags();
}
void MainWindow::applyTagSuggestions()
{
for (PhotoItemWidget *item : std::as_const(m_items))
item->setTagSuggestions(m_tagSuggestions);
}
void MainWindow::clearManageItems()
{
for (ManagePostWidget *item : std::as_const(m_manageItems))
item->deleteLater();
m_manageItems.clear();
m_manageEmptyHint->show();
}
void MainWindow::loadManage()
{
if (!m_settings.isConfigured()) {
m_manageStatus->setText(QStringLiteral("Set the server URL and credentials first"));
return;
}
m_manageStatus->setText(QStringLiteral("Loading…"));
m_manageRefresh->setEnabled(false);
connect(
manageClient(), &UploadClient::photosListed, this,
[this](const QList<RemotePhoto> &photos, const QString &error) {
m_manageRefresh->setEnabled(true);
clearManageItems();
if (!error.isEmpty()) {
m_manageStatus->setText(error);
return;
}
m_manageStatus->clear();
if (photos.isEmpty())
return;
m_manageEmptyHint->hide();
for (const RemotePhoto &p : photos) {
auto *item = new ManagePostWidget(p.slug, p.caption, p.date, p.tags,
m_settings.baseUrl() + p.url);
connect(item, &ManagePostWidget::editRequested, this, &MainWindow::editItem);
connect(item, &ManagePostWidget::deleteRequested, this, &MainWindow::deleteItem);
m_manageItems.append(item);
m_manageLayout->addWidget(item);
manageClient()->fetchThumbnail(p.slug);
}
},
Qt::SingleShotConnection);
manageClient()->listPhotos();
}
void MainWindow::editItem(ManagePostWidget *item)
{
QDialog dialog(this);
dialog.setWindowTitle(QStringLiteral("Edit %1").arg(item->slug()));
auto *layout = new QVBoxLayout(&dialog);
auto *form = new QFormLayout;
auto *caption = new QLineEdit(item->caption());
auto *date = new QDateEdit(QDate::fromString(item->date(), QStringLiteral("yyyy-MM-dd")));
date->setCalendarPopup(true);
date->setDisplayFormat(QStringLiteral("yyyy-MM-dd"));
if (!date->date().isValid())
date->setDate(QDate::currentDate());
auto *tags = new QLineEdit(item->tags().join(QStringLiteral(", ")));
auto *completer = new TagCompleter(&dialog);
completer->setSuggestions(m_tagSuggestions);
tags->setCompleter(completer);
form->addRow(QStringLiteral("Caption"), caption);
form->addRow(QStringLiteral("Date"), date);
form->addRow(QStringLiteral("Tags"), tags);
layout->addLayout(form);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel);
connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
layout->addWidget(buttons);
if (dialog.exec() != QDialog::Accepted)
return;
const QString newCaption = caption->text();
const QString newDate = date->date().toString(QStringLiteral("yyyy-MM-dd"));
const QString newTags = tags->text();
item->setBusy(true);
connect(
manageClient(), &UploadClient::editFinished, this,
[this, item, newCaption, newDate, newTags](const QString &slug, const QString &error) {
item->setBusy(false);
if (!error.isEmpty()) {
m_manageStatus->setText(QStringLiteral("Edit failed: %1").arg(error));
return;
}
QStringList tagList;
const auto parts = newTags.split(QLatin1Char(','), Qt::SkipEmptyParts);
for (const QString &t : parts) {
const QString trimmed = t.trimmed();
if (!trimmed.isEmpty())
tagList << trimmed;
}
item->applyEdit(newCaption, newDate, tagList);
m_manageStatus->setText(QStringLiteral("Saved %1").arg(slug));
},
Qt::SingleShotConnection);
manageClient()->editPost(item->slug(), newCaption, newDate, newTags);
}
void MainWindow::deleteItem(ManagePostWidget *item)
{
const QString name = item->caption().isEmpty() ? item->slug() : item->caption();
const auto choice = QMessageBox::question(
this, QStringLiteral("Move to trash?"),
QStringLiteral("\"%1\" will be moved to the server's trash.").arg(name));
if (choice != QMessageBox::Yes)
return;
item->setBusy(true);
connect(
manageClient(), &UploadClient::deleteFinished, this,
[this, item](const QString &, const QString &error) {
if (!error.isEmpty()) {
item->setBusy(false);
m_manageStatus->setText(QStringLiteral("Delete failed: %1").arg(error));
return;
}
m_manageItems.removeOne(item);
item->deleteLater();
m_manageStatus->setText(QStringLiteral("Moved to trash"));
if (m_manageItems.isEmpty())
m_manageEmptyHint->show();
},
Qt::SingleShotConnection);
manageClient()->deletePost(item->slug());
}
+28
View File
@@ -2,6 +2,7 @@
#include <QList>
#include <QMainWindow>
#include <QStringList>
#include "settings.h"
@@ -13,6 +14,8 @@ class QSpinBox;
class QStackedWidget;
class QVBoxLayout;
class PhotoItemWidget;
class ManagePostWidget;
class UploadClient;
class MainWindow : public QMainWindow
{
@@ -23,9 +26,11 @@ public:
private:
QWidget *buildSettingsPage();
QWidget *buildPostPage();
QWidget *buildManagePage();
void showSettings();
void showPost();
void showManage();
void saveAndTest();
void chooseImages();
@@ -38,6 +43,17 @@ private:
int inFlightCount() const; // items currently preparing or uploading
void refreshPosting(); // updates the Post button + summary from item states
// Manage page.
UploadClient *manageClient(); // lazily-built, lives as long as the window
void loadManage(); // fetches the post list and thumbnails
void clearManageItems();
void editItem(ManagePostWidget *item);
void deleteItem(ManagePostWidget *item);
// Tag autocomplete.
void refreshTags(); // fetches /api/tags.json
void applyTagSuggestions(); // pushes the list onto every post card
Settings m_settings;
QStackedWidget *m_stack = nullptr;
@@ -66,4 +82,16 @@ private:
QString m_batchUsername;
QString m_batchPassword;
QString m_batchCacheDir;
// Manage page
QVBoxLayout *m_manageLayout = nullptr; // holds the ManagePostWidgets
QList<ManagePostWidget *> m_manageItems;
QLabel *m_manageEmptyHint = nullptr;
QLabel *m_manageStatus = nullptr;
QPushButton *m_manageRefresh = nullptr;
UploadClient *m_manageClient = nullptr; // lazily created, reused for manage ops
// Known tag names for autocomplete, shared by the post cards and the edit
// dialog. Best-effort: empty until /api/tags.json arrives.
QStringList m_tagSuggestions;
};
+92
View File
@@ -0,0 +1,92 @@
#include "managepostwidget.h"
#include <QHBoxLayout>
#include <QImage>
#include <QLabel>
#include <QPixmap>
#include <QPushButton>
#include <QVBoxLayout>
namespace {
constexpr int kThumbSize = 72;
}
ManagePostWidget::ManagePostWidget(const QString &slug, const QString &caption,
const QString &date, const QStringList &tags,
const QString &url, QWidget *parent)
: QWidget(parent)
, m_slug(slug)
, m_caption(caption)
, m_date(date)
, m_tags(tags)
, m_url(url)
{
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);
row->addWidget(m_thumb, 0, Qt::AlignTop);
auto *fields = new QVBoxLayout;
m_captionLabel = new QLabel;
m_captionLabel->setTextFormat(Qt::RichText);
m_captionLabel->setOpenExternalLinks(true);
m_captionLabel->setWordWrap(true);
fields->addWidget(m_captionLabel);
m_metaLabel = new QLabel;
m_metaLabel->setWordWrap(true);
m_metaLabel->setStyleSheet(QStringLiteral("color:#a89984;"));
fields->addWidget(m_metaLabel);
row->addLayout(fields, 1);
m_edit = new QPushButton(QStringLiteral("Edit"));
connect(m_edit, &QPushButton::clicked, this, [this]() { emit editRequested(this); });
row->addWidget(m_edit, 0, Qt::AlignTop);
m_delete = new QPushButton(QStringLiteral("Delete"));
connect(m_delete, &QPushButton::clicked, this, [this]() { emit deleteRequested(this); });
row->addWidget(m_delete, 0, Qt::AlignTop);
refreshSummary();
}
void ManagePostWidget::setThumbnail(const QImage &image)
{
if (image.isNull()) {
m_thumb->setText(QStringLiteral("?"));
return;
}
m_thumb->setPixmap(QPixmap::fromImage(image).scaled(
kThumbSize, kThumbSize, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation));
}
void ManagePostWidget::applyEdit(const QString &caption, const QString &date,
const QStringList &tags)
{
m_caption = caption;
m_date = date;
m_tags = tags;
refreshSummary();
}
void ManagePostWidget::setBusy(bool busy)
{
m_edit->setEnabled(!busy);
m_delete->setEnabled(!busy);
}
void ManagePostWidget::refreshSummary()
{
const QString shown = m_caption.isEmpty() ? m_slug : m_caption;
m_captionLabel->setText(
QStringLiteral("<a href=\"%1\">%2</a>").arg(m_url, shown.toHtmlEscaped()));
QString meta = m_date;
if (!m_tags.isEmpty())
meta += QStringLiteral(" · ") + m_tags.join(QStringLiteral(", "));
m_metaLabel->setText(meta);
}
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include <QString>
#include <QStringList>
#include <QWidget>
class QImage;
class QLabel;
class QPushButton;
// One published post on the Manage page: thumbnail, caption/date/tags summary,
// and Edit / Delete buttons. Mirrors a row of the web /manage list and the
// Android manage screen.
class ManagePostWidget : public QWidget
{
Q_OBJECT
public:
ManagePostWidget(const QString &slug, const QString &caption, const QString &date,
const QStringList &tags, const QString &url, QWidget *parent = nullptr);
QString slug() const { return m_slug; }
QString caption() const { return m_caption; }
QString date() const { return m_date; }
QStringList tags() const { return m_tags; }
void setThumbnail(const QImage &image);
void applyEdit(const QString &caption, const QString &date, const QStringList &tags);
void setBusy(bool busy); // a save/delete is in flight
signals:
void editRequested(ManagePostWidget *self);
void deleteRequested(ManagePostWidget *self);
private:
void refreshSummary();
QString m_slug;
QString m_caption;
QString m_date;
QStringList m_tags;
QString m_url;
QLabel *m_thumb = nullptr;
QLabel *m_captionLabel = nullptr;
QLabel *m_metaLabel = nullptr;
QPushButton *m_edit = nullptr;
QPushButton *m_delete = nullptr;
};
+9
View File
@@ -1,5 +1,7 @@
#include "photoitemwidget.h"
#include "tagcompleter.h"
#include <QDate>
#include <QDateEdit>
#include <QHBoxLayout>
@@ -49,6 +51,8 @@ PhotoItemWidget::PhotoItemWidget(QString sourcePath, QWidget *parent)
m_tags = new QLineEdit;
m_tags->setPlaceholderText(QStringLiteral("tags (comma-separated)"));
m_tagCompleter = new TagCompleter(this);
m_tags->setCompleter(m_tagCompleter);
fields->addWidget(m_tags);
m_statusLabel = new QLabel;
@@ -79,6 +83,11 @@ QString PhotoItemWidget::tags() const
return m_tags->text();
}
void PhotoItemWidget::setTagSuggestions(const QStringList &tags)
{
m_tagCompleter->setSuggestions(tags);
}
void PhotoItemWidget::setStatus(Status status, const QString &message, const QString &url)
{
m_status = status;
+5
View File
@@ -7,6 +7,7 @@ class QDateEdit;
class QLabel;
class QLineEdit;
class QPushButton;
class TagCompleter;
// 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.
@@ -26,6 +27,9 @@ public:
void setStatus(Status status, const QString &message = {}, const QString &url = {});
// Fills the tags field's autocomplete with the known tag names.
void setTagSuggestions(const QStringList &tags);
signals:
void removeRequested(PhotoItemWidget *self);
@@ -39,4 +43,5 @@ private:
QLineEdit *m_tags = nullptr;
QLabel *m_statusLabel = nullptr;
QPushButton *m_remove = nullptr;
TagCompleter *m_tagCompleter = nullptr;
};
+42
View File
@@ -0,0 +1,42 @@
#include "tagcompleter.h"
#include <QLineEdit>
#include <QStringListModel>
TagCompleter::TagCompleter(QObject *parent)
: QCompleter(parent)
, m_model(new QStringListModel(this))
{
setModel(m_model);
setCaseSensitivity(Qt::CaseInsensitive);
setFilterMode(Qt::MatchContains); // match anywhere in the tag, like Android
setCompletionMode(QCompleter::PopupCompletion);
}
void TagCompleter::setSuggestions(const QStringList &tags)
{
m_model->setStringList(tags);
}
// Complete against the token after the last comma, ignoring the tags already
// entered before it.
QStringList TagCompleter::splitPath(const QString &path) const
{
return QStringList(path.section(QLatin1Char(','), -1).trimmed());
}
// Rebuild the full field value: everything up to and including the last comma,
// then the chosen tag and a trailing ", " ready for the next one.
QString TagCompleter::pathFromIndex(const QModelIndex &index) const
{
const QString completion = QCompleter::pathFromIndex(index);
const auto *edit = qobject_cast<const QLineEdit *>(widget());
if (!edit)
return completion;
const QString text = edit->text();
const int lastComma = text.lastIndexOf(QLatin1Char(','));
const QString head =
lastComma >= 0 ? text.left(lastComma + 1) + QLatin1Char(' ') : QString();
return head + completion + QStringLiteral(", ");
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <QCompleter>
#include <QStringList>
class QStringListModel;
// A QCompleter for a comma-separated tags line edit. It completes only the
// token currently being typed (the text after the last comma) and, on
// selection, splices the chosen tag back in place of that token — the same
// per-token behaviour as the web and Android tag fields. Attach with
// QLineEdit::setCompleter; call setSuggestions() to (re)fill the tag list.
class TagCompleter : public QCompleter
{
public:
explicit TagCompleter(QObject *parent = nullptr);
void setSuggestions(const QStringList &tags);
protected:
QStringList splitPath(const QString &path) const override;
QString pathFromIndex(const QModelIndex &index) const override;
private:
QStringListModel *m_model;
};
+153
View File
@@ -4,9 +4,13 @@
#include <QFileInfo>
#include <QHttpMultiPart>
#include <QHttpPart>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrlQuery>
UploadClient::UploadClient(QString baseUrl, QString username, QString password, QObject *parent)
: QObject(parent)
@@ -125,3 +129,152 @@ void UploadClient::testConnection()
emit testFinished(QStringLiteral("Server returned %1").arg(status));
});
}
// --- Manage API ------------------------------------------------------------
namespace {
QString httpError(QNetworkReply *reply, int status)
{
if (status == 401 || status == 403)
return QStringLiteral("Wrong username or password");
if (status == 0)
return reply->errorString();
const QString body = QString::fromUtf8(reply->readAll()).trimmed();
return body.isEmpty() ? QStringLiteral("Server returned %1").arg(status) : body;
}
} // namespace
void UploadClient::listPhotos()
{
QNetworkRequest request(QUrl(m_base + QStringLiteral("/api/photos")));
request.setRawHeader("Authorization", authHeader());
QNetworkReply *reply = m_net->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError || status < 200 || status >= 300) {
emit photosListed({}, httpError(reply, status));
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
if (!doc.isArray()) {
emit photosListed({}, QStringLiteral("Could not read the server's response"));
return;
}
QList<RemotePhoto> photos;
const QJsonArray arr = doc.array();
for (const QJsonValue &v : arr) {
const QJsonObject o = v.toObject();
RemotePhoto p;
p.slug = o.value(QStringLiteral("slug")).toString();
p.caption = o.value(QStringLiteral("caption")).toString();
p.date = o.value(QStringLiteral("date")).toString();
p.thumb = o.value(QStringLiteral("thumb")).toString();
p.url = o.value(QStringLiteral("url")).toString();
const QJsonArray tags = o.value(QStringLiteral("tags")).toArray();
for (const QJsonValue &t : tags)
p.tags << t.toString();
photos << p;
}
emit photosListed(photos, QString());
});
}
void UploadClient::fetchTags()
{
QNetworkRequest request(QUrl(m_base + QStringLiteral("/api/tags.json")));
request.setRawHeader("Authorization", authHeader());
QNetworkReply *reply = m_net->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError || status < 200 || status >= 300) {
emit tagsFetched({}); // best-effort; autocomplete is a convenience
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
QStringList names;
for (const QJsonValue &v : doc.array()) {
const QString name = v.toObject().value(QStringLiteral("tag")).toString();
if (!name.isEmpty())
names << name;
}
emit tagsFetched(names);
});
}
void UploadClient::fetchThumbnail(const QString &slug)
{
QNetworkRequest request(QUrl(m_base + QStringLiteral("/api/thumb/") + slug));
request.setRawHeader("Authorization", authHeader());
QNetworkReply *reply = m_net->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply, slug]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError || status < 200 || status >= 300) {
emit thumbnailReady(slug, QImage()); // null image on any failure
return;
}
QImage image;
image.loadFromData(reply->readAll());
emit thumbnailReady(slug, image);
});
}
void UploadClient::editPost(const QString &slug, const QString &caption,
const QString &date, const QString &tags)
{
QUrlQuery form;
form.addQueryItem(QStringLiteral("slug"), slug);
form.addQueryItem(QStringLiteral("caption"), caption);
form.addQueryItem(QStringLiteral("date"), date);
form.addQueryItem(QStringLiteral("tags"), tags);
QNetworkRequest request(QUrl(m_base + QStringLiteral("/api/edit")));
request.setRawHeader("Authorization", authHeader());
request.setHeader(QNetworkRequest::ContentTypeHeader,
QStringLiteral("application/x-www-form-urlencoded"));
QNetworkReply *reply =
m_net->post(request, form.toString(QUrl::FullyEncoded).toUtf8());
connect(reply, &QNetworkReply::finished, this, [this, reply, slug]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() == QNetworkReply::NoError && status >= 200 && status < 300)
emit editFinished(slug, QString());
else
emit editFinished(slug, httpError(reply, status));
});
}
void UploadClient::deletePost(const QString &slug)
{
QUrlQuery form;
form.addQueryItem(QStringLiteral("slug"), slug);
QNetworkRequest request(QUrl(m_base + QStringLiteral("/api/delete")));
request.setRawHeader("Authorization", authHeader());
request.setHeader(QNetworkRequest::ContentTypeHeader,
QStringLiteral("application/x-www-form-urlencoded"));
QNetworkReply *reply =
m_net->post(request, form.toString(QUrl::FullyEncoded).toUtf8());
connect(reply, &QNetworkReply::finished, this, [this, reply, slug]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError || status < 200 || status >= 300) {
emit deleteFinished(slug, httpError(reply, status));
return;
}
// The server reports how many it moved; 0 means the slug wasn't found.
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
const int deleted = doc.object().value(QStringLiteral("deleted")).toInt(0);
if (deleted > 0)
emit deleteFinished(slug, QString());
else
emit deleteFinished(slug, QStringLiteral("Nothing deleted — not found on the server"));
});
}
+30
View File
@@ -1,10 +1,23 @@
#pragma once
#include <QImage>
#include <QList>
#include <QObject>
#include <QString>
#include <QStringList>
class QNetworkAccessManager;
// One published post, as returned by GET /api/photos.
struct RemotePhoto {
QString slug;
QString caption;
QString date; // yyyy-MM-dd
QStringList tags;
QString thumb; // site-relative, e.g. "/thumbs/foo.jpg"
QString url; // site-relative, e.g. "/photo/foo/"
};
// Talks to the gallery's POST /upload endpoint, which sits behind nginx HTTP
// Basic auth. The credentials go out preemptively in the Authorization header
// on every request (never via QNetworkAccessManager's authenticationRequired
@@ -35,12 +48,29 @@ public:
// /health is not usable: nginx only proxies location /upload.
void testConnection();
// Manage API, mirroring the web /manage and the Android app. The desktop
// app is a non-browser client: it sends no Origin/Sec-Fetch-Site header, so
// the state-changing posts pass the server's same-origin (CSRF) check.
void listPhotos(); // GET /api/photos
void fetchTags(); // GET /api/tags.json (unguarded)
void fetchThumbnail(const QString &slug); // GET /api/thumb/<slug> (unguarded)
void editPost(const QString &slug, const QString &caption,
const QString &date, const QString &tags); // POST /api/edit
void deletePost(const QString &slug); // POST /api/delete (moves to trash)
signals:
void uploadFinished(UploadClient::Result result, const QString &slug,
const QString &url, const QString &message);
// errorMessage is empty on success.
void testFinished(const QString &errorMessage);
// Manage signals. The error strings are empty on success.
void photosListed(const QList<RemotePhoto> &photos, const QString &error);
void tagsFetched(const QStringList &tags);
void thumbnailReady(const QString &slug, const QImage &image);
void editFinished(const QString &slug, const QString &error);
void deleteFinished(const QString &slug, const QString &error);
private:
QByteArray authHeader() const;