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:
@@ -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
|
||||
Reference in New Issue
Block a user