From 504b96ed310431063d48de80496fe0124f044aa9 Mon Sep 17 00:00:00 2001 From: puttaalu Date: Wed, 4 Nov 2026 22:04:06 +0100 Subject: [PATCH] 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/, 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. --- CLAUDE.md | 2 +- desktop/CMakeLists.txt | 2 + desktop/mainwindow.cpp | 242 ++++++++++++++++++++++++++++++++++- desktop/mainwindow.h | 28 ++++ desktop/managepostwidget.cpp | 92 +++++++++++++ desktop/managepostwidget.h | 48 +++++++ desktop/photoitemwidget.cpp | 9 ++ desktop/photoitemwidget.h | 5 + desktop/tagcompleter.cpp | 42 ++++++ desktop/tagcompleter.h | 26 ++++ desktop/uploadclient.cpp | 153 ++++++++++++++++++++++ desktop/uploadclient.h | 30 +++++ 12 files changed, 677 insertions(+), 2 deletions(-) create mode 100644 desktop/managepostwidget.cpp create mode 100644 desktop/managepostwidget.h create mode 100644 desktop/tagcompleter.cpp create mode 100644 desktop/tagcompleter.h diff --git a/CLAUDE.md b/CLAUDE.md index ee657fe..2ba2f32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/desktop/CMakeLists.txt b/desktop/CMakeLists.txt index 04363f1..6cc937f 100644 --- a/desktop/CMakeLists.txt +++ b/desktop/CMakeLists.txt @@ -13,6 +13,8 @@ add_executable(uploader main.cpp mainwindow.cpp photoitemwidget.cpp + managepostwidget.cpp + tagcompleter.cpp settings.cpp uploadclient.cpp imageprep.cpp diff --git a/desktop/mainwindow.cpp b/desktop/mainwindow.cpp index 7fbd453..d90e2fd 100644 --- a/desktop/mainwindow.cpp +++ b/desktop/mainwindow.cpp @@ -1,17 +1,25 @@ #include "mainwindow.h" #include "imageprep.h" +#include "managepostwidget.h" #include "photoitemwidget.h" +#include "tagcompleter.h" #include "uploadclient.h" +#include +#include #include +#include +#include #include #include #include #include #include +#include #include #include +#include #include #include #include @@ -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("Manage posts")); + 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 &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()); +} diff --git a/desktop/mainwindow.h b/desktop/mainwindow.h index fab575c..2a30487 100644 --- a/desktop/mainwindow.h +++ b/desktop/mainwindow.h @@ -2,6 +2,7 @@ #include #include +#include #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 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; }; diff --git a/desktop/managepostwidget.cpp b/desktop/managepostwidget.cpp new file mode 100644 index 0000000..2d80545 --- /dev/null +++ b/desktop/managepostwidget.cpp @@ -0,0 +1,92 @@ +#include "managepostwidget.h" + +#include +#include +#include +#include +#include +#include + +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("%2").arg(m_url, shown.toHtmlEscaped())); + + QString meta = m_date; + if (!m_tags.isEmpty()) + meta += QStringLiteral(" · ") + m_tags.join(QStringLiteral(", ")); + m_metaLabel->setText(meta); +} diff --git a/desktop/managepostwidget.h b/desktop/managepostwidget.h new file mode 100644 index 0000000..c0be62c --- /dev/null +++ b/desktop/managepostwidget.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include + +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; +}; diff --git a/desktop/photoitemwidget.cpp b/desktop/photoitemwidget.cpp index ab7230d..3726804 100644 --- a/desktop/photoitemwidget.cpp +++ b/desktop/photoitemwidget.cpp @@ -1,5 +1,7 @@ #include "photoitemwidget.h" +#include "tagcompleter.h" + #include #include #include @@ -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; diff --git a/desktop/photoitemwidget.h b/desktop/photoitemwidget.h index da6a007..e473f7d 100644 --- a/desktop/photoitemwidget.h +++ b/desktop/photoitemwidget.h @@ -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; }; diff --git a/desktop/tagcompleter.cpp b/desktop/tagcompleter.cpp new file mode 100644 index 0000000..64914d4 --- /dev/null +++ b/desktop/tagcompleter.cpp @@ -0,0 +1,42 @@ +#include "tagcompleter.h" + +#include +#include + +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(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(", "); +} diff --git a/desktop/tagcompleter.h b/desktop/tagcompleter.h new file mode 100644 index 0000000..c8ecc86 --- /dev/null +++ b/desktop/tagcompleter.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +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; +}; diff --git a/desktop/uploadclient.cpp b/desktop/uploadclient.cpp index 118460b..c04ff40 100644 --- a/desktop/uploadclient.cpp +++ b/desktop/uploadclient.cpp @@ -4,9 +4,13 @@ #include #include #include +#include +#include +#include #include #include #include +#include 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 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")); + }); +} diff --git a/desktop/uploadclient.h b/desktop/uploadclient.h index 8bd3247..724675e 100644 --- a/desktop/uploadclient.h +++ b/desktop/uploadclient.h @@ -1,10 +1,23 @@ #pragma once +#include +#include #include #include +#include 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/ (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 &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;