From b631c4ed72ddded0a71679c0827e23644dcb8a80 Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 9 Oct 2026 23:29:56 +0200 Subject: [PATCH] Add sequential member file uploads with destination locking --- tools/chatgui/src/member_files/browser.cpp | 163 ++++++++++++++++++--- tools/chatgui/src/member_files/browser.h | 13 +- 2 files changed, 156 insertions(+), 20 deletions(-) diff --git a/tools/chatgui/src/member_files/browser.cpp b/tools/chatgui/src/member_files/browser.cpp index 79c02397..dc80baa7 100644 --- a/tools/chatgui/src/member_files/browser.cpp +++ b/tools/chatgui/src/member_files/browser.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -54,10 +55,11 @@ MemberFileBrowser::MemberFileBrowser(DbManager* db, QWidget* parent) : QDialog(p m_quota->setObjectName("memberFilesQuota"); m_quota->setWordWrap(true); layout->addWidget(m_quota); auto* quotaTimer = new QTimer(this); quotaTimer->setInterval(2000); connect(quotaTimer, &QTimer::timeout, this, [this]() { if (isVisible()) requestQuota(); }); quotaTimer->start(); - auto* navigation = new QHBoxLayout; + m_navigation = new QWidget(this); + auto* navigation = new QHBoxLayout(m_navigation); navigation->setContentsMargins(0, 0, 0, 0); auto* up = new QPushButton(text("Up"), this); auto* reload = new QPushButton(text("Refresh"), this); m_location = new QLineEdit(this); m_location->setPlaceholderText(text("Path inside the member directory")); - navigation->addWidget(up); navigation->addWidget(m_location, 1); navigation->addWidget(reload); layout->addLayout(navigation); + navigation->addWidget(up); navigation->addWidget(m_location, 1); navigation->addWidget(reload); layout->addWidget(m_navigation); m_entries = new QTreeWidget(this); m_entries->setRootIsDecorated(false); m_entries->setHeaderLabels({text("Name"), text("Type"), text("Size"), text("Modified")}); m_entries->header()->setSectionResizeMode(0, QHeaderView::Stretch); @@ -68,6 +70,8 @@ MemberFileBrowser::MemberFileBrowser(DbManager* db, QWidget* parent) : QDialog(p m_remove = new QPushButton(text("Delete"), this); m_cancel = new QPushButton(text("Cancel transfer"), this); for (auto* button : {m_upload, m_download, m_mkdir, m_rename, m_remove, m_cancel}) actions->addWidget(button); layout->addLayout(actions); m_status = new QLabel(this); layout->addWidget(m_status); + m_uploadStatus = new QLabel(this); m_uploadStatus->setObjectName("memberFilesUploadStatus"); + m_uploadStatus->setWordWrap(true); layout->addWidget(m_uploadStatus); m_progress = new QProgressBar(this); m_progress->setRange(0, 1000); m_progress->hide(); layout->addWidget(m_progress); connect(reload, &QPushButton::clicked, this, [this]() { m_staleRetries = 0; refresh(); }); connect(up, &QPushButton::clicked, this, [this]() { @@ -107,6 +111,10 @@ MemberFileBrowser::~MemberFileBrowser() { } void MemberFileBrowser::setChannel(const QString& channel, quint64 selectedOwner) { + if (m_uploadBatchId || m_transferId) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: context change blocked during transfer channel=%s", qPrintable(channel)); + m_status->setText(text("Finish or cancel the current transfer first")); return; + } bool valid = false; quint64 group = channel.toULongLong(&valid); if (!valid || !group || !m_db) return; cancelRequest(m_listId); m_listId = 0; m_group = group; m_path.clear(); @@ -146,9 +154,11 @@ void MemberFileBrowser::updateActions() { auto* item = m_entries->currentItem(); bool own = owner() && owner() == m_db->myNodeId(); bool available = server() && gui_bridge_is_uasync_ready(); bool file = item && item->data(0, Qt::UserRole + 1).toInt() == MF_FILE; - m_upload->setEnabled(available && own && !m_transferId); m_mkdir->setEnabled(available && own); - m_rename->setEnabled(available && own && item); m_remove->setEnabled(available && own && item); - m_download->setEnabled(available && owner() && file && !m_transferId); m_cancel->setEnabled(m_transferId != 0); + bool busy = m_uploadBatchId || m_transferId; + m_servers->setEnabled(!busy); m_owners->setEnabled(!busy); m_navigation->setEnabled(!busy); m_entries->setEnabled(!busy); + m_upload->setEnabled(available && own && !busy); m_mkdir->setEnabled(available && own && !busy); + m_rename->setEnabled(available && own && item && !busy); m_remove->setEnabled(available && own && item && !busy); + m_download->setEnabled(available && owner() && file && !busy); m_cancel->setEnabled(busy); } bool MemberFileBrowser::post(mf_message& message) { @@ -230,6 +240,38 @@ void MemberFileBrowser::onResponse(const uint8_t* data, int length) { DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "member_files: GUI response id=%llu list_id=%llu cmd=%s status=%s bytes=%u owner=%llx/%llx path=%s", (unsigned long long)r.request_id, (unsigned long long)m_listId, mf_command_text(r.command), mf_status_text(r.status), r.data_len, (unsigned long long)r.owner, (unsigned long long)owner(), r.path); + if (m_uploadBatchId && r.request_id == m_uploadStatId) { + m_uploadStatId = 0; + QString local = m_uploadQueue.first(), name = QFileInfo(local).fileName(); + QString remote = m_path.isEmpty() ? name : m_path + '/' + name; + if (r.command != MF_STAT || r.owner != owner() || QString::fromUtf8(r.path) != remote) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: upload STAT context mismatch id=%llu", (unsigned long long)r.request_id); + finishUploads(text("Invalid directory response")); return; + } + if (r.status != MF_OK && r.status != MF_NOT_FOUND) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: upload STAT failed path=%s status=%s", qPrintable(remote), mf_status_text(r.status)); + finishUploads(text("Upload stopped: %1 — %2").arg(name, QString::fromUtf8(mf_status_text(r.status)))); return; + } + QByteArray version(16, '\0'); + if (r.status == MF_OK) { + if (r.type != MF_FILE) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: upload target is a directory path=%s", qPrintable(remote)); + finishUploads(text("Upload stopped: %1 — %2").arg(name, text("A folder with this name already exists"))); return; + } + version = QByteArray((const char*)r.version, 16); + quint64 batch = m_uploadBatchId; QPointer guard(this); + auto answer = QMessageBox::question(this, text("Replace file"), text("Replace %1?").arg(name), + QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel, QMessageBox::No); + if (!guard || m_uploadBatchId != batch) return; + if (answer == QMessageBox::Cancel) { cancel(); return; } + if (answer != QMessageBox::Yes) { + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file browser: upload skipped path=%s", qPrintable(remote)); + m_uploadSkipped++; m_uploadQueue.removeFirst(); nextUpload(); return; + } + } + if (!startTransfer(remote, local, true, version)) finishUploads(text("Invalid path or chat is stopped")); + return; + } if (r.command == MF_QUOTA) { if (r.request_id != m_quotaId || r.owner != owner()) { DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "member_files: GUI stale quota response id=%llu", (unsigned long long)r.request_id); @@ -317,17 +359,82 @@ void MemberFileBrowser::onResponse(const uint8_t* data, int length) { } void MemberFileBrowser::upload() { - if (m_transferId || !owner() || owner() != m_db->myNodeId()) return; - QString local = QFileDialog::getOpenFileName(this, text("Upload file")); if (local.isEmpty()) return; - QString name = QFileInfo(local).fileName(); QByteArray version(16, '\0'); - for (int i = 0; i < m_entries->topLevelItemCount(); i++) { - auto* item = m_entries->topLevelItem(i); - if (item->data(0, Qt::UserRole).toString() != name) continue; - if (item->data(0, Qt::UserRole + 1).toInt() != MF_FILE) { m_status->setText(text("A folder with this name already exists")); return; } - if (QMessageBox::question(this, text("Replace file"), text("Replace %1?").arg(name)) != QMessageBox::Yes) return; - version = item->data(0, Qt::UserRole + 2).toByteArray(); break; + QPointer guard(this); + QStringList paths = QFileDialog::getOpenFileNames(this, text("Upload file")); + if (guard && !paths.isEmpty()) beginUploads(paths, false); +} + +void MemberFileBrowser::uploadOwnFiles(const QString& channel, const QStringList& paths) { + if (m_uploadBatchId || m_transferId) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: drop rejected while busy files=%d", int(paths.size())); + QMessageBox::warning(this, text("Group files"), text("Finish or cancel the current transfer first")); return; } - startTransfer(m_path.isEmpty() ? name : m_path + '/' + name, local, true, version); + if (!channel.toULongLong() || !m_db || !m_db->myNodeId()) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: drop has no group or own node channel=%s", qPrintable(channel)); + QMessageBox::warning(this, text("Group files"), text("Select a group to upload files")); return; + } + setChannel(channel, m_db->myNodeId()); + beginUploads(paths, true); +} + +// Сервер выбирается до первого запроса; batch ID защищает вложенные циклы диалогов от отмены/закрытия окна. +void MemberFileBrowser::beginUploads(const QStringList& paths, bool chooseServer) { + if (paths.isEmpty()) return; + if (m_uploadBatchId || m_transferId || !server() || !owner() || owner() != m_db->myNodeId() || !gui_bridge_is_uasync_ready()) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: upload unavailable group=%llu server=%llu owner=%llu busy=%d ready=%d", + (unsigned long long)m_group, (unsigned long long)server(), (unsigned long long)owner(), + bool(m_uploadBatchId || m_transferId), gui_bridge_is_uasync_ready()); + QMessageBox::warning(this, text("Group files"), !server() ? text("No media server in this group") : text("Upload unavailable")); + return; + } + for (const auto& path : paths) { + QFileInfo info(path); QString remote = m_path.isEmpty() ? info.fileName() : m_path + '/' + info.fileName(); + QByteArray localBytes = path.toUtf8(), remoteBytes = remote.toUtf8(); + if (!info.isAbsolute() || info.fileName().isEmpty() || localBytes.contains('\0') || localBytes.size() > MF_PATH_MAX || + remoteBytes.contains('\0') || !mf_path_valid(remoteBytes.constData(), 0)) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: invalid upload path=%s", qPrintable(path)); + QMessageBox::warning(this, text("Group files"), text("Invalid upload path: %1").arg(path)); return; + } + } + m_uploadQueue = paths; m_uploadQueue.removeDuplicates(); m_uploadTotal = m_uploadQueue.size(); + m_uploadDone = m_uploadSkipped = 0; m_uploadBatchId = requestId(); updateActions(); + if (chooseServer) { + QStringList labels; for (int i = 0; i < m_servers->count(); i++) labels.append(m_servers->itemText(i)); + quint64 batch = m_uploadBatchId; QPointer guard(this); bool ok = false; + QString selected = QInputDialog::getItem(this, text("Upload files"), + text("Upload %1 file(s) to your root directory in group %2. Select media server:").arg(m_uploadTotal).arg(m_group), + labels, m_servers->currentIndex(), false, &ok); + if (!guard || m_uploadBatchId != batch) return; + if (!ok) { cancel(); return; } + m_servers->setCurrentIndex(labels.indexOf(selected)); + } + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file browser: upload queue started batch=%llu group=%llu server=%llu owner=%llu path=%s files=%d", + (unsigned long long)m_uploadBatchId, (unsigned long long)m_group, (unsigned long long)server(), + (unsigned long long)owner(), qPrintable(m_path), m_uploadTotal); + nextUpload(); +} + +void MemberFileBrowser::nextUpload() { + if (!m_uploadBatchId || m_transferId || m_uploadStatId) return; + if (m_uploadQueue.isEmpty()) { + finishUploads(text("Upload queue finished: %1 uploaded, %2 skipped").arg(m_uploadDone).arg(m_uploadSkipped)); + refresh(); return; + } + QString name = QFileInfo(m_uploadQueue.first()).fileName(); + m_uploadStatus->setText(text("Uploading %1 of %2: %3").arg(m_uploadDone + m_uploadSkipped + 1).arg(m_uploadTotal).arg(name)); + mf_message q{}; q.command = MF_STAT; q.owner = owner(); + if (!copyPath(q.path, m_path.isEmpty() ? name : m_path + '/' + name) || !post(q)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: upload STAT could not start name=%s", qPrintable(name)); + finishUploads(text("Unable to request directory")); return; + } + m_uploadStatId = q.request_id; +} + +void MemberFileBrowser::finishUploads(const QString& status) { + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file browser: upload queue finished batch=%llu uploaded=%d skipped=%d remaining=%d status=%s", + (unsigned long long)m_uploadBatchId, m_uploadDone, m_uploadSkipped, int(m_uploadQueue.size()), qPrintable(status)); + cancelRequest(m_uploadStatId); m_uploadStatId = 0; m_uploadBatchId = 0; m_uploadQueue.clear(); + m_uploadStatus->setText(status); updateActions(); } void MemberFileBrowser::download(bool open) { @@ -338,11 +445,13 @@ void MemberFileBrowser::download(bool open) { m_openAfter = open; startTransfer(selectedPath(), local, false, selectedVersion()); } -void MemberFileBrowser::startTransfer(const QString& remote, const QString& local, bool uploading, const QByteArray& version) { +bool MemberFileBrowser::startTransfer(const QString& remote, const QString& local, bool uploading, const QByteArray& version) { auto* q = (mf_transfer_request*)u_calloc(1, sizeof(mf_transfer_request)); - if (!q) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: transfer allocation failed"); return; } + if (!q) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: transfer allocation failed"); return false; } if (!copyPath(q->remote, remote) || !copyPath(q->local, local) || !mf_path_valid(q->remote, 0) || !gui_bridge_is_uasync_ready()) { - u_free(q); m_status->setText(text("Invalid path or chat is stopped")); return; + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: transfer rejected remote=%s local=%s ready=%d", + qPrintable(remote), qPrintable(local), gui_bridge_is_uasync_ready()); + u_free(q); m_status->setText(text("Invalid path or chat is stopped")); return false; } q->inst = gui_bridge_get_inst(); q->id = requestId(); q->group = m_group; q->server = server(); q->owner = owner(); q->upload = uploading; if (version.size() == 16) memcpy(q->version, version.constData(), 16); @@ -350,6 +459,7 @@ void MemberFileBrowser::startTransfer(const QString& remote, const QString& loca if (uploading) m_openAfter = false; m_progress->setRange(0, 0); m_progress->show(); m_status->setText(text("Preparing transfer…")); updateActions(); gui_bridge_post_uasync_fn(member_files_gui_transfer, q); + return true; } void MemberFileBrowser::onTransfer(const uint8_t* data, int length) { @@ -359,6 +469,18 @@ void MemberFileBrowser::onTransfer(const uint8_t* data, int length) { m_status->setText(text("Transferred %1 / %2 bytes").arg(done).arg(total)); if (data[50] != 2) return; m_transferId = 0; m_progress->hide(); updateActions(); + if (m_uploadBatchId) { + if (status) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: upload failed local=%s status=%s", qPrintable(m_local), mf_status_text(status)); + QString reason = status == MF_INVALID_PATH ? text("Only regular files with valid paths can be uploaded") : + QString::fromUtf8(mf_status_text(status)); + finishUploads(text("Upload stopped: %1 — %2").arg(QFileInfo(m_local).fileName(), reason)); + } else { + m_uploadDone++; m_uploadQueue.removeFirst(); nextUpload(); + } + if (!m_quotaId) requestQuota(); + return; + } if (status) m_status->setText(QString::fromUtf8(mf_status_text((int)status))); else { m_status->setText(text("Transfer completed")); @@ -391,4 +513,7 @@ void MemberFileBrowser::mutate(uint8_t command) { if (post(q)) m_actions.insert(q.request_id, command); } -void MemberFileBrowser::cancel() { cancelRequest(m_transferId); } +void MemberFileBrowser::cancel() { + if (m_uploadBatchId) finishUploads(text("Upload queue cancelled")); + cancelRequest(m_transferId); +} diff --git a/tools/chatgui/src/member_files/browser.h b/tools/chatgui/src/member_files/browser.h index fb87c0f5..0533bd2e 100644 --- a/tools/chatgui/src/member_files/browser.h +++ b/tools/chatgui/src/member_files/browser.h @@ -5,6 +5,7 @@ #include #include #include +#include #include class DbManager; class QComboBox; @@ -22,6 +23,8 @@ public: explicit MemberFileBrowser(DbManager* db, QWidget* parent = nullptr); ~MemberFileBrowser() override; void setChannel(const QString& channel, quint64 owner = 0); + // Drop открывает корень своей ноды и предлагает сервер; активная очередь сохраняет своё назначение. + void uploadOwnFiles(const QString& channel, const QStringList& paths); void onResponse(const uint8_t* data, int length); void onTransfer(const uint8_t* data, int length); private: @@ -36,8 +39,11 @@ private: QByteArray selectedVersion() const; bool post(struct mf_message& message); void upload(); + void beginUploads(const QStringList& paths, bool chooseServer); + void nextUpload(); + void finishUploads(const QString& status); void download(bool open); - void startTransfer(const QString& remote, const QString& local, bool upload, const QByteArray& version); + bool startTransfer(const QString& remote, const QString& local, bool upload, const QByteArray& version); void mutate(uint8_t command); void cancel(); void cancelRequest(quint64 id); @@ -47,14 +53,19 @@ private: quint64 m_transferGroup = 0, m_transferServer = 0; QString m_path, m_local; bool m_openAfter = false; + quint64 m_uploadBatchId = 0, m_uploadStatId = 0; + QStringList m_uploadQueue; + int m_uploadDone = 0, m_uploadSkipped = 0, m_uploadTotal = 0; int m_staleRetries = 0; QHash m_actions; QComboBox* m_servers; QComboBox* m_owners; QLineEdit* m_location; + QWidget* m_navigation; QTreeWidget* m_entries; QPushButton *m_upload, *m_download, *m_mkdir, *m_rename, *m_remove, *m_cancel; QLabel* m_status; + QLabel* m_uploadStatus; QLabel* m_quota; QProgressBar* m_progress; };