gui: Options, Scheduler, Speed Limiter, Batch, Grabber, and the tray icon

Continues the build order past the Add-URL/File-Info/Progress dialog flow.

- OptionsDialog: General/Save To/Connection/Downloads/Proxy/Sounds tabs, every
  control bound to a real settings.* key (contracts/schema/types/Settings.
  schema.json). The spec's File Types and Site Logins tabs have no settings.*
  backing (categories go through category.upsert, credentials through the
  Secret Service) so they don't exist here — a tab either binds to a real key
  or isn't shipped. diffChanged() sends only what actually changed, matching
  settings.set's "changed[] names exactly what took effect" contract.
- SchedulerDialog: per-queue schedule (schedule.get/set) plus maxConcurrent/
  onComplete (queue.upsert), Start Now/Stop. Queue.schema.json already carries
  the schedule so queue.list alone seeds the window.
- SpeedLimiterDialog: the live global limiter (limiter.get/set) — a different
  thing from Options' downloads.speedLimit* default. buildParams() enforces
  the schema's "0 with enabled true must not be offered".
- BatchDialog: clipboard-blob and {start..end}-wildcard tabs sharing one
  category/queue/start-mode footer into download.addBatch.
- GrabberWizard: 4-step QWizard (project label -> start URL/depth/filters ->
  file-type filter -> review), grabber.start feeding a poll+event.grabber.
  progress-driven review page, Finish = grabber.harvest for the checked files.
- TrayIcon: active-count tooltip, Show/Add URL/Pause All/Resume All/Speed
  Limiter submenu/Quit. Quit only closes the GUI — there is no RPC to stop
  veloxd itself, filed as a new gap in daemon-requests-m1.md. MainWindow now
  also hides to tray instead of closing when general.minimizeToTray is set.

Every dialog's non-widget logic (diffChanged, buildSchedule, buildParams,
parseUrlBlob/expandWildcard/buildAddBatchParams, buildFileTypes/
buildStartParams/buildHarvestParams) is a static pure function with its own
test, same shape as FileInfoDialog::buildSpec from the previous round.

Verified end-to-end against a running mockd under ASan+UBSan: all five
surfaces render real data (settings.get values, queue.list's two seeded
queues, limiter.get, a live grabber.start/status crawl returning 3 files) with
no sanitizer reports. gui-check (non-ASan) and dev (ASan+UBSan) presets both
build the whole repo clean; all gui-labeled ctest targets pass.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NSCdCWFXBTSBBK3MzWtJiC
This commit is contained in:
2026-09-11 17:17:43 +04:00
co-authored by Claude Sonnet 5
parent 39c69f3871
commit de83ee3cce
22 changed files with 2678 additions and 13 deletions
+321
View File
@@ -0,0 +1,321 @@
#include "dialogs/SchedulerDialog.hpp"
#include <QCheckBox>
#include <QComboBox>
#include <QDateEdit>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QJsonArray>
#include <QLabel>
#include <QListWidget>
#include <QPointer>
#include <QPushButton>
#include <QSpinBox>
#include <QTimeEdit>
#include <QVBoxLayout>
#include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp"
namespace velox::gui {
namespace {
// 0 = Sunday, matching Schedule.schema.json's daysOfWeek.
constexpr const char *kDayLabels[7] = {
QT_TR_NOOP("Sun"), QT_TR_NOOP("Mon"), QT_TR_NOOP("Tue"), QT_TR_NOOP("Wed"),
QT_TR_NOOP("Thu"), QT_TR_NOOP("Fri"), QT_TR_NOOP("Sat"),
};
} // namespace
QJsonValue SchedulerDialog::buildSchedule(bool useSchedule, const QString &mode,
const QString &startTime, bool runUntilDrains,
const QString &stopTime, const QList<int> &daysOfWeek,
const QString &onceDate) {
if (!useSchedule) {
return QJsonValue(QJsonValue::Null);
}
QJsonObject s{
{"enabled", true},
{"mode", mode},
{"startTime", startTime},
{"stopTime", runUntilDrains ? QJsonValue(QJsonValue::Null) : QJsonValue(stopTime)},
};
if (mode == QLatin1String("periodic")) {
QJsonArray days;
for (int d : daysOfWeek) {
days.append(d);
}
s["daysOfWeek"] = days;
} else {
s["onceDate"] = onceDate;
}
return s;
}
SchedulerDialog::SchedulerDialog(rpc::RpcClient *client, QWidget *parent)
: QDialog(parent),
client_(client),
queueList_(new QListWidget(this)),
useSchedule_(new QCheckBox(tr("Run this queue on a schedule"), this)),
mode_(new QComboBox(this)),
startTime_(new QTimeEdit(this)),
runUntilDrains_(new QCheckBox(tr("Run until the queue drains"), this)),
stopTime_(new QTimeEdit(this)),
onceDate_(new QDateEdit(this)),
maxConcurrent_(new QSpinBox(this)),
onComplete_(new QComboBox(this)),
pauseRunningOnStop_(new QCheckBox(tr("Pause running tasks too"), this)),
statusLabel_(new QLabel(this)) {
setWindowTitle(tr("Scheduler"));
resize(640, 420);
queueList_->setMaximumWidth(200);
mode_->addItem(tr("Once"), QStringLiteral("once"));
mode_->addItem(tr("Periodic (weekly)"), QStringLiteral("periodic"));
startTime_->setDisplayFormat(QStringLiteral("HH:mm"));
stopTime_->setDisplayFormat(QStringLiteral("HH:mm"));
onceDate_->setCalendarPopup(true);
maxConcurrent_->setRange(1, 32);
onComplete_->addItem(tr("Do nothing"), QStringLiteral("nothing"));
onComplete_->addItem(tr("Exit Velox"), QStringLiteral("exit"));
onComplete_->addItem(tr("Shut down"), QStringLiteral("shutdown"));
onComplete_->addItem(tr("Hang up (disconnect network)"), QStringLiteral("hangup"));
auto *daysRow = new QWidget(this);
auto *daysLayout = new QHBoxLayout(daysRow);
daysLayout->setContentsMargins(0, 0, 0, 0);
for (int i = 0; i < 7; ++i) {
dayChecks_[i] = new QCheckBox(tr(kDayLabels[i]), daysRow);
daysLayout->addWidget(dayChecks_[i]);
}
auto *stopRow = new QWidget(this);
auto *stopLayout = new QHBoxLayout(stopRow);
stopLayout->setContentsMargins(0, 0, 0, 0);
stopLayout->addWidget(stopTime_);
stopLayout->addWidget(runUntilDrains_);
connect(runUntilDrains_, &QCheckBox::toggled, stopTime_, &QWidget::setDisabled);
const auto updateModeVisibility = [this, daysRow] {
const bool periodic = mode_->currentData().toString() == QLatin1String("periodic");
daysRow->setVisible(periodic);
onceDate_->setVisible(!periodic);
};
connect(mode_, &QComboBox::currentIndexChanged, this, updateModeVisibility);
updateModeVisibility();
form_ = new QWidget(this);
auto *form = new QFormLayout(form_);
form->addRow(useSchedule_);
form->addRow(tr("Mode:"), mode_);
form->addRow(tr("Start time:"), startTime_);
form->addRow(tr("Stop time:"), stopRow);
form->addRow(tr("Days:"), daysRow);
form->addRow(tr("Date:"), onceDate_);
form->addRow(tr("Max concurrent:"), maxConcurrent_);
form->addRow(tr("When the queue finishes:"), onComplete_);
connect(useSchedule_, &QCheckBox::toggled, this, [this](bool on) {
for (QWidget *w :
{static_cast<QWidget *>(mode_), static_cast<QWidget *>(startTime_),
static_cast<QWidget *>(stopTime_), static_cast<QWidget *>(runUntilDrains_),
static_cast<QWidget *>(onceDate_)}) {
w->setEnabled(on);
}
});
statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
statusLabel_->hide();
form_->setEnabled(false); // no queue selected yet
auto *startButton = new QPushButton(tr("Start Now"), this);
auto *stopButton = new QPushButton(tr("Stop"), this);
auto *saveButton = new QPushButton(tr("Save"), this);
saveButton->setDefault(true);
connect(startButton, &QPushButton::clicked, this, &SchedulerDialog::startNow);
connect(stopButton, &QPushButton::clicked, this, &SchedulerDialog::stopNow);
connect(saveButton, &QPushButton::clicked, this, &SchedulerDialog::save);
auto *actionRow = new QHBoxLayout;
actionRow->addWidget(startButton);
actionRow->addWidget(stopButton);
actionRow->addWidget(pauseRunningOnStop_);
actionRow->addStretch();
actionRow->addWidget(saveButton);
auto *closeButtons = new QDialogButtonBox(QDialogButtonBox::Close, this);
connect(closeButtons, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(closeButtons, &QDialogButtonBox::accepted, this, &QDialog::accept);
auto *rightLayout = new QVBoxLayout;
rightLayout->addWidget(form_, 1);
rightLayout->addWidget(statusLabel_);
rightLayout->addLayout(actionRow);
rightLayout->addWidget(closeButtons);
auto *split = new QHBoxLayout(this);
split->addWidget(queueList_);
split->addLayout(rightLayout, 1);
connect(queueList_, &QListWidget::currentItemChanged, this, &SchedulerDialog::onQueueSelected);
QPointer<SchedulerDialog> self(this);
client_->call(QString::fromLatin1(rpc::method::kQueueList), {},
[self](const rpc::RpcReply &reply) {
if (!self || !reply.ok()) {
return;
}
self->onQueuesLoaded(reply.result.toObject().value("items").toArray());
});
}
void SchedulerDialog::onQueuesLoaded(const QJsonArray &items) {
queues_ = items;
queueList_->clear();
for (const QJsonValue &v : items) {
auto *item = new QListWidgetItem(v.toObject().value("name").toString(), queueList_);
item->setData(Qt::UserRole, v.toObject().value("queueId").toString());
}
if (queueList_->count() > 0) {
queueList_->setCurrentRow(0);
}
}
QJsonObject SchedulerDialog::selectedQueue() const {
if (!queueList_->currentItem()) {
return {};
}
const QString id = queueList_->currentItem()->data(Qt::UserRole).toString();
for (const QJsonValue &v : queues_) {
if (v.toObject().value("queueId").toString() == id) {
return v.toObject();
}
}
return {};
}
void SchedulerDialog::onQueueSelected(QListWidgetItem *item, QListWidgetItem * /*previous*/) {
if (!item) {
form_->setEnabled(false);
return;
}
populateForm(selectedQueue());
form_->setEnabled(true);
}
void SchedulerDialog::populateForm(const QJsonObject &queue) {
maxConcurrent_->setValue(queue.value("maxConcurrent").toInt(1));
const int completeIdx = onComplete_->findData(queue.value("onComplete").toString("nothing"));
onComplete_->setCurrentIndex(completeIdx >= 0 ? completeIdx : 0);
const QJsonValue schedule = queue.value("schedule");
const bool has = schedule.isObject();
useSchedule_->setChecked(has);
for (QWidget *w : {static_cast<QWidget *>(mode_), static_cast<QWidget *>(startTime_),
static_cast<QWidget *>(stopTime_), static_cast<QWidget *>(runUntilDrains_),
static_cast<QWidget *>(onceDate_)}) {
w->setEnabled(has);
}
if (!has) {
return;
}
const QJsonObject sched = schedule.toObject();
const int modeIdx = mode_->findData(sched.value("mode").toString("once"));
mode_->setCurrentIndex(modeIdx >= 0 ? modeIdx : 0);
startTime_->setTime(QTime::fromString(sched.value("startTime").toString("00:00"), "HH:mm"));
const bool stopIsNull = sched.value("stopTime").isNull();
runUntilDrains_->setChecked(stopIsNull);
if (!stopIsNull) {
stopTime_->setTime(QTime::fromString(sched.value("stopTime").toString(), "HH:mm"));
}
for (int i = 0; i < 7; ++i) {
dayChecks_[i]->setChecked(false);
}
for (const QJsonValue &d : sched.value("daysOfWeek").toArray()) {
const int idx = d.toInt();
if (idx >= 0 && idx < 7) {
dayChecks_[idx]->setChecked(true);
}
}
if (!sched.value("onceDate").isNull()) {
onceDate_->setDate(QDate::fromString(sched.value("onceDate").toString(), Qt::ISODate));
}
}
void SchedulerDialog::save() {
const QJsonObject queue = selectedQueue();
const QString queueId = queue.value("queueId").toString();
if (queueId.isEmpty()) {
return;
}
QList<int> days;
for (int i = 0; i < 7; ++i) {
if (dayChecks_[i]->isChecked()) {
days << i;
}
}
const QJsonValue schedule = buildSchedule(
useSchedule_->isChecked(), mode_->currentData().toString(),
startTime_->time().toString(QStringLiteral("HH:mm")), runUntilDrains_->isChecked(),
stopTime_->time().toString(QStringLiteral("HH:mm")), days,
onceDate_->date().toString(Qt::ISODate));
QPointer<SchedulerDialog> self(this);
client_->call(QString::fromLatin1(rpc::method::kScheduleSet),
QJsonObject{{"queueId", queueId}, {"schedule", schedule}},
[self, queueId](const rpc::RpcReply &reply) {
if (!self) {
return;
}
if (!reply.ok()) {
self->statusLabel_->setText(
tr("Could not save the schedule: %1").arg(reply.error.message));
self->statusLabel_->show();
return;
}
self->statusLabel_->hide();
});
// Queue-level fields (maxConcurrent/onComplete) go through queue.upsert; taskIds is
// ignored by the daemon so re-sending the cached membership cannot drop a task.
QJsonObject updatedQueue = queue;
updatedQueue["maxConcurrent"] = maxConcurrent_->value();
updatedQueue["onComplete"] = onComplete_->currentData().toString();
client_->call(QString::fromLatin1(rpc::method::kQueueUpsert),
QJsonObject{{"queue", updatedQueue}}, [self](const rpc::RpcReply &reply) {
if (!self || !reply.ok()) {
return;
}
const QJsonObject updated = reply.result.toObject().value("queue").toObject();
for (int i = 0; i < self->queues_.size(); ++i) {
if (self->queues_[i].toObject().value("queueId").toString() ==
updated.value("queueId").toString()) {
self->queues_[i] = updated;
break;
}
}
});
}
void SchedulerDialog::startNow() {
const QString queueId = selectedQueue().value("queueId").toString();
if (queueId.isEmpty()) {
return;
}
client_->call(QString::fromLatin1(rpc::method::kQueueStart), QJsonObject{{"queueId", queueId}});
}
void SchedulerDialog::stopNow() {
const QString queueId = selectedQueue().value("queueId").toString();
if (queueId.isEmpty()) {
return;
}
client_->call(
QString::fromLatin1(rpc::method::kQueueStop),
QJsonObject{{"queueId", queueId}, {"pauseRunning", pauseRunningOnStop_->isChecked()}});
}
} // namespace velox::gui