mirror of
https://github.com/GoldenCheetah/GoldenCheetah.git
synced 2026-02-13 16:18:42 +00:00
Added support to * filter workouts by multiple metrics * rate and tag workouts * view detailed information about the selected workout * Preserving user-content in trainDB on rescan for workouts Fixes #411
507 lines
17 KiB
C++
507 lines
17 KiB
C++
/*
|
|
* Copyright (c) 2017 Joern Rischmueller (joern.rm@gmail.com)
|
|
*
|
|
* This program is free software; you can redistribute it and/or modify it
|
|
* under the terms of the GNU General Public License as published by the Free
|
|
* Software Foundation; either version 2 of the License, or (at your option)
|
|
* any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful, but WITHOUT
|
|
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
* more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License along
|
|
* with this program; if not, write to the Free Software Foundation, Inc., 51
|
|
* Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|
*/
|
|
|
|
#include "TodaysPlanWorkoutDownload.h"
|
|
#include "MainWindow.h"
|
|
#include "TrainDB.h"
|
|
#include "HelpWhatsThis.h"
|
|
#include "TimeUtils.h"
|
|
|
|
TodaysPlanWorkoutDownload::TodaysPlanWorkoutDownload(Context *context) : QDialog(context->mainWindow), context(context)
|
|
{
|
|
|
|
nam = new QNetworkAccessManager(this);
|
|
connect(nam, SIGNAL(sslErrors(QNetworkReply*, const QList<QSslError> & )), this, SLOT(onSslErrors(QNetworkReply*, const QList<QSslError> & )));
|
|
|
|
setAttribute(Qt::WA_DeleteOnClose);
|
|
setWindowFlags(windowFlags() | Qt::WindowStaysOnTopHint);
|
|
setWindowTitle(tr("Download future workouts from Today's Plan"));
|
|
|
|
// help
|
|
HelpWhatsThis *help = new HelpWhatsThis(this);
|
|
this->setWhatsThis(help->getWhatsThisText(HelpWhatsThis::MenuBar_Tools_Download_TP));
|
|
|
|
// make the dialog a resonable size
|
|
setMinimumWidth(650);
|
|
setMinimumHeight(400);
|
|
|
|
QVBoxLayout *layout = new QVBoxLayout;
|
|
setLayout(layout);
|
|
|
|
// TopLine Fields and Layout
|
|
|
|
all = new QCheckBox(tr("check/uncheck all"), this);
|
|
all->setChecked(true);
|
|
|
|
QLabel *fromLabel = new QLabel(tr("From:"), this);
|
|
QLabel *toLabel = new QLabel(tr("To:"), this);
|
|
from = new QDateEdit(this);
|
|
from->setDate(QDate::currentDate());
|
|
from->setCalendarPopup(true);
|
|
to = new QDateEdit(this);
|
|
to->setDate(QDate::currentDate().addMonths(1));
|
|
to->setCalendarPopup(true);
|
|
|
|
refreshButton = new QPushButton(tr("Refresh List"), this);
|
|
|
|
QHBoxLayout *topline = new QHBoxLayout;
|
|
topline->addWidget(all);
|
|
topline->addStretch();
|
|
topline->addWidget(fromLabel);
|
|
topline->addWidget(from);
|
|
topline->addWidget(toLabel);
|
|
topline->addWidget(to);
|
|
topline->addStretch();
|
|
topline->addWidget(refreshButton);
|
|
|
|
// Filelist Widget
|
|
|
|
files = new QTreeWidget;
|
|
#ifdef Q_OS_MAC
|
|
files->setAttribute(Qt::WA_MacShowFocusRect, 0);
|
|
#endif
|
|
files->headerItem()->setText(0, tr(""));
|
|
files->headerItem()->setText(1, tr("Date"));
|
|
files->headerItem()->setText(2, tr("Name"));
|
|
files->headerItem()->setText(3, tr("T-Score"));
|
|
files->headerItem()->setText(4, tr("Duration"));
|
|
files->headerItem()->setText(5, tr("Action"));
|
|
|
|
files->setColumnCount(6);
|
|
files->setColumnWidth(0, 30*dpiXFactor); // selector
|
|
files->setColumnWidth(1, 100*dpiXFactor); // date
|
|
files->setColumnWidth(2, 220*dpiXFactor); // workout description
|
|
files->setColumnWidth(3, 70*dpiXFactor); // T-Score
|
|
files->setColumnWidth(4, 70*dpiXFactor); // duration
|
|
files->setSelectionMode(QAbstractItemView::SingleSelection);
|
|
files->setEditTriggers(QAbstractItemView::SelectedClicked); // allow edit
|
|
files->setUniformRowHeights(true);
|
|
files->setIndentation(0);
|
|
|
|
// BottomLine Fields and Layout
|
|
QHBoxLayout *bottomLine = new QHBoxLayout;
|
|
status = new QLabel("", this);
|
|
status->hide();
|
|
overwrite = new QCheckBox(tr("Overwrite existing workouts"), this);
|
|
close = new QPushButton(tr("Close"), this);
|
|
download = new QPushButton(tr("Download"), this);
|
|
bottomLine->addWidget(overwrite);
|
|
bottomLine->addWidget(status);
|
|
bottomLine->addStretch();
|
|
bottomLine->addWidget(close);
|
|
bottomLine->addWidget(download);
|
|
|
|
layout->addLayout(topline);
|
|
layout->addWidget(files);
|
|
layout->addLayout(bottomLine);
|
|
|
|
// connect signals and slots up..
|
|
connect(download, SIGNAL(clicked()), this, SLOT(downloadClicked()));
|
|
connect(all, SIGNAL(stateChanged(int)), this, SLOT(allClicked()));
|
|
connect(close, SIGNAL(clicked()), this, SLOT(cancelClicked()));
|
|
connect(refreshButton, SIGNAL(clicked()), this, SLOT(refreshClicked()));
|
|
|
|
// fill the data
|
|
refreshClicked();
|
|
|
|
}
|
|
|
|
void
|
|
TodaysPlanWorkoutDownload::allClicked()
|
|
{
|
|
// set/uncheck all rides according to the "all"
|
|
bool checked = all->isChecked();
|
|
|
|
for(int i=0; i<files->invisibleRootItem()->childCount(); i++) {
|
|
QTreeWidgetItem *current = files->invisibleRootItem()->child(i);
|
|
static_cast<QCheckBox*>(files->itemWidget(current,0))->setChecked(checked);
|
|
}
|
|
}
|
|
|
|
void
|
|
TodaysPlanWorkoutDownload::downloadClicked()
|
|
{
|
|
if (download->text() == tr("Download")) {
|
|
downloads = fails = 0;
|
|
aborted = false;
|
|
refreshButton->setEnabled(false);
|
|
overwrite->hide();
|
|
status->setText(tr("Download..."));
|
|
status->show();
|
|
close->hide();
|
|
download->setText(tr("Abort"));
|
|
downloadFiles();
|
|
status->setText(QString(tr("%1 workouts downloaded, %2 failed or skipped.")).arg(downloads).arg(fails));
|
|
download->setText(tr("Download"));
|
|
close->show();
|
|
overwrite->show();
|
|
refreshButton->setEnabled(true);
|
|
|
|
context->notifyWorkoutsChanged();
|
|
|
|
} else if (download->text() == tr("Abort")) {
|
|
aborted = true;
|
|
}
|
|
}
|
|
|
|
void
|
|
TodaysPlanWorkoutDownload::cancelClicked()
|
|
{
|
|
reject();
|
|
}
|
|
|
|
|
|
|
|
void
|
|
TodaysPlanWorkoutDownload::refreshClicked()
|
|
{
|
|
// reset download information
|
|
status->setText("");
|
|
downloads = fails = 0;
|
|
download->show();
|
|
close->show();
|
|
files->clear(); // delete existing entries
|
|
QString error;
|
|
|
|
// check period - download is only possible for future workouts !
|
|
if (from->date() < QDate::currentDate()) {
|
|
QMessageBox::warning(this, tr("Today's Plan Workout Download"), QString(tr("You can only download future workouts, 'From' will be adjusted to current date")));
|
|
from->setDate(QDate::currentDate());
|
|
}
|
|
if (from->date() > to->date()) {
|
|
QMessageBox::warning(this, tr("Today's Plan Workout Download"), QString(tr("Invalid date range, please check your input.")));
|
|
return;
|
|
}
|
|
|
|
QList<TodaysPlanWorkoutListEntry*> workouts = getFileList(error, from->dateTime(), to->dateTime());
|
|
if (error != "") {
|
|
QMessageBox::warning(this, tr("Today's Plan Workout Download"), QString(tr("The following error occured: %1").arg(error)));
|
|
return;
|
|
}
|
|
|
|
|
|
foreach(TodaysPlanWorkoutListEntry *item, workouts) {
|
|
|
|
QTreeWidgetItem *add = new QTreeWidgetItem(files->invisibleRootItem());
|
|
add->setFlags(add->flags() | Qt::ItemIsEditable);
|
|
|
|
// selector
|
|
QCheckBox *checkBox = new QCheckBox("", this);
|
|
checkBox->setChecked(true);
|
|
files->setItemWidget(add, 0, checkBox);
|
|
|
|
add->setText(1, item->planDate.toString(tr("dd MMM yyyy")));
|
|
add->setText(2, item->description);
|
|
add->setText(3, QString::number(item->tScore));
|
|
add->setText(4, time_to_string_minutes(item->duration));
|
|
|
|
// interval action
|
|
add->setText(5, tr("Download"));
|
|
|
|
// hide away the id
|
|
add->setText(6, QString("%1").arg(item->workoutId));
|
|
}
|
|
}
|
|
|
|
void
|
|
TodaysPlanWorkoutDownload::downloadFiles()
|
|
{
|
|
// where to place them
|
|
QString workoutDir = appsettings->value(this, GC_WORKOUTDIR).toString();
|
|
|
|
// for library updating, transactional for 10x performance
|
|
trainDB->startLUW();
|
|
|
|
// loop through the table and export all selected
|
|
for(int i=0; i<files->invisibleRootItem()->childCount(); i++) {
|
|
|
|
// give user a chance to abort..
|
|
QApplication::processEvents();
|
|
|
|
// did they?
|
|
if (aborted == true) {
|
|
trainDB->endLUW(); // need to commit whatever was copied.
|
|
return; // user aborted!
|
|
}
|
|
|
|
QTreeWidgetItem *current = files->invisibleRootItem()->child(i);
|
|
|
|
// is it selected
|
|
if (static_cast<QCheckBox*>(files->itemWidget(current,0))->isChecked()) {
|
|
|
|
files->setCurrentItem(current); QApplication::processEvents();
|
|
|
|
// this one then
|
|
current->setText(5, tr("Downloading...")); QApplication::processEvents();
|
|
|
|
// get the id
|
|
int id = current->text(6).toInt();
|
|
QByteArray content;
|
|
if (!readFile(&content, id)) {
|
|
current->setText(5,tr("Error downloading")); QApplication::processEvents();
|
|
fails++;
|
|
continue;
|
|
|
|
}
|
|
|
|
QString filename;
|
|
ErgFile *p = ErgFile::fromContent(content, context);
|
|
if (p->originalFilename() != "") {
|
|
filename = workoutDir + "/TP-" + p->originalFilename().replace("/", "-").simplified();
|
|
} else {
|
|
filename = workoutDir + "/TP-Workout-" + current->text(1).replace(" ", "_") + ".erg";
|
|
}
|
|
|
|
// open success?
|
|
if (p->isValid()) {
|
|
|
|
|
|
if (QFile(filename).exists()) {
|
|
|
|
if (overwrite->isChecked() == false) {
|
|
// skip existing files
|
|
current->setText(5,tr("Exists already")); QApplication::processEvents();
|
|
fails++;
|
|
delete p; // free memory!
|
|
continue;
|
|
|
|
} else {
|
|
|
|
// remove existing
|
|
QFile(filename).remove();
|
|
current->setText(5, tr("Removing...")); QApplication::processEvents();
|
|
}
|
|
|
|
}
|
|
|
|
QFile out(filename);
|
|
if (out.open(QIODevice::WriteOnly) == true) {
|
|
|
|
QTextStream output(&out);
|
|
output << content;
|
|
out.close();
|
|
|
|
downloads++;
|
|
current->setText(5, tr("Saved")); QApplication::processEvents();
|
|
trainDB->importWorkout(filename, *p); // add to library
|
|
|
|
} else {
|
|
|
|
fails++;
|
|
current->setText(5, tr("Write failed")); QApplication::processEvents();
|
|
}
|
|
|
|
delete p; // free memory!
|
|
|
|
// couldn't parse
|
|
} else {
|
|
|
|
delete p; // free memory!
|
|
fails++;
|
|
current->setText(5, tr("Invalid File")); QApplication::processEvents();
|
|
|
|
}
|
|
|
|
}
|
|
}
|
|
// for library updating, transactional for 10x performance
|
|
trainDB->endLUW();
|
|
}
|
|
|
|
QList<TodaysPlanWorkoutListEntry*>
|
|
TodaysPlanWorkoutDownload::getFileList(QString &error, QDateTime from, QDateTime to) {
|
|
|
|
QList<TodaysPlanWorkoutListEntry*> returning;
|
|
|
|
// do we have a token
|
|
QString token = appsettings->cvalue(context->athlete->cyclist, GC_TODAYSPLAN_TOKEN, "").toString();
|
|
if (token == "") {
|
|
error = tr("You must authorise with Today's Plan first");
|
|
return returning;
|
|
}
|
|
|
|
// Do Paginated Access to the Activities List
|
|
const int pageSize = 100;
|
|
int offset = 0;
|
|
int resultCount = INT_MAX;
|
|
|
|
while (offset < resultCount) {
|
|
|
|
QString url;
|
|
QString searchCommand;
|
|
if (offset == 0) {
|
|
// fist call
|
|
searchCommand = "search";
|
|
} else {
|
|
// subsequent pages
|
|
searchCommand = "page";
|
|
}
|
|
|
|
url = QString("%1/rest/users/activities/%2/%3/%4")
|
|
.arg(appsettings->cvalue(context->athlete->cyclist, GC_TODAYSPLAN_URL, "https://whats.todaysplan.com.au").toString())
|
|
.arg(searchCommand)
|
|
.arg(QString::number(offset))
|
|
.arg(QString::number(pageSize));;
|
|
|
|
// request using the bearer token
|
|
QNetworkRequest request(url);
|
|
QNetworkReply *reply;
|
|
request.setRawHeader("Authorization", (QString("Bearer %1").arg(token)).toLatin1());
|
|
if (offset == 0) {
|
|
|
|
// Prepare the Search Payload for First Call to Search
|
|
QString userId = appsettings->cvalue(context->athlete->cyclist, GC_TODAYSPLAN_ATHLETE_ID, "").toString();
|
|
// application/json
|
|
QString jsonString;
|
|
jsonString += "{\"criteria\": {";
|
|
if (userId.length()>0)
|
|
jsonString += "\"user\": "+ QString("%1").arg(userId) +", ";
|
|
jsonString += "\"fromTs\": \""+ QString("%1").arg(from.toMSecsSinceEpoch()) +"\", ";
|
|
jsonString += "\"toTs\": \"" + QString("%1").arg(to.addDays(1).addSecs(-1).toMSecsSinceEpoch()) + "\", ";
|
|
jsonString += "\"isNull\": [\"fileId\"]}, ";
|
|
jsonString += "\"order\": [ { \"field\" : \"startTs\",\"order\" : \"asc\" } ], ";
|
|
jsonString += "\"fields\": [\"startTs\", \"user.firstname\",\"name\",\"scheduled.workout\",\"scheduled.time\", \"scheduled.tscorepwr\", \"scheduled.durationSecs\" ], ";
|
|
jsonString += "\"opts\": 1 ";
|
|
jsonString += "}";
|
|
|
|
QByteArray jsonStringAsUTF8 = jsonString.toUtf8();
|
|
|
|
QByteArray jsonStringDataSize = QByteArray::number(jsonStringAsUTF8.size());
|
|
|
|
request.setHeader(QNetworkRequest::ContentTypeHeader,"application/json");
|
|
request.setRawHeader("Content-Length", jsonStringDataSize);
|
|
reply = nam->post(request, jsonStringAsUTF8);
|
|
} else {
|
|
// get further pages of the Search
|
|
reply = nam->get(request);
|
|
}
|
|
|
|
// blocking request
|
|
QEventLoop loop;
|
|
connect(reply, SIGNAL(finished()), &loop, SLOT(quit()));
|
|
loop.exec();
|
|
|
|
// did we get a good response ?
|
|
QByteArray r = reply->readAll();
|
|
|
|
QJsonParseError parseError;
|
|
QJsonDocument document = QJsonDocument::fromJson(r, &parseError);
|
|
|
|
// if path was returned all is good, lets set root
|
|
if (parseError.error == QJsonParseError::NoError) {
|
|
|
|
// number of Result Items
|
|
if (offset == 0) {
|
|
resultCount = document.object()["cnt"].toInt();
|
|
}
|
|
|
|
// results ?
|
|
QJsonObject result = document.object()["result"].toObject();
|
|
QJsonArray results = result["results"].toArray();
|
|
|
|
// lets look at that then
|
|
for(int i=0; i<results.size(); i++) {
|
|
QJsonObject each = results.at(i).toObject();
|
|
QJsonObject scheduled = each["scheduled"].toObject();
|
|
|
|
// skip "Rest" days
|
|
if (scheduled["workout"].toString() == "rest" && scheduled["tscorepwr"].toDouble() == 0.0) continue;
|
|
|
|
// workout details
|
|
TodaysPlanWorkoutListEntry *add = new TodaysPlanWorkoutListEntry();
|
|
add->workoutId = QString("%1").arg(each["workoutId"].toInt());
|
|
add->planDate = QDateTime::fromMSecsSinceEpoch(each["startTs"].toDouble()).date();
|
|
add->duration = scheduled["durationSecs"].toDouble();
|
|
add->tScore = scheduled["tscorepwr"].toDouble();
|
|
add->description = scheduled["_workout-display"].toString() + " : " + each["name"].toString();
|
|
|
|
returning << add;
|
|
}
|
|
// next page
|
|
offset += pageSize;
|
|
} else {
|
|
// we had a parsing error - so something is wrong - stop requesting more data by ending the loop
|
|
offset = INT_MAX;
|
|
}
|
|
}
|
|
|
|
// check, correct the sort order - TP Api does not consider the requested sort order and may provide
|
|
// the workout latest in future first - since this may change don't sort if order is ok
|
|
// if so, re-sort so that the most recent workout is top of the list
|
|
if (returning.count() > 1) {
|
|
if (returning.first()->planDate > returning.last()->planDate) {
|
|
// re-sort
|
|
int insertAt = returning.count()-1;
|
|
TodaysPlanWorkoutListEntry* entry;
|
|
|
|
while (insertAt > 0) {
|
|
entry = returning.takeFirst();
|
|
returning.insert(insertAt, entry);
|
|
insertAt--;
|
|
}
|
|
}
|
|
}
|
|
|
|
// all good ?
|
|
return returning;
|
|
}
|
|
|
|
bool
|
|
TodaysPlanWorkoutDownload::readFile(QByteArray *data, int workoutId)
|
|
{
|
|
// this must be performed asyncronously and call made
|
|
// to notifyReadComplete(QByteArray &data, QString remotename, QString message) when done
|
|
|
|
// do we have a token ?
|
|
QString token = appsettings->cvalue(context->athlete->cyclist, GC_TODAYSPLAN_TOKEN, "").toString();
|
|
if (token == "") return false;
|
|
|
|
// lets connect and get basic info on the root directory
|
|
QString url = QString("%1/rest/workouts/download/power/erg/%2")
|
|
.arg(appsettings->cvalue(context->athlete->cyclist, GC_TODAYSPLAN_URL, "https://whats.todaysplan.com.au").toString())
|
|
.arg(workoutId);
|
|
|
|
|
|
// request using the bearer token
|
|
QNetworkRequest request(url);
|
|
request.setRawHeader("Authorization", (QString("Bearer %1").arg(token)).toLatin1());
|
|
|
|
// put the file
|
|
QNetworkReply *reply = nam->get(request);
|
|
|
|
// blocking request
|
|
QEventLoop loop;
|
|
connect(reply, SIGNAL(finished()), &loop, SLOT(quit()));
|
|
loop.exec();
|
|
|
|
if (reply->error() != QNetworkReply::NoError) {
|
|
return false;
|
|
};
|
|
// did we get a good response ?
|
|
*data = reply->readAll();
|
|
return true;
|
|
}
|
|
|
|
|
|
void
|
|
TodaysPlanWorkoutDownload::onSslErrors(QNetworkReply *reply, const QList<QSslError>&)
|
|
{
|
|
reply->ignoreSslErrors();
|
|
}
|
|
|