Files
GoldenCheetah/src/Cloud/CloudDBChart.cpp
Alejandro Martinez 9bf7a3cbb8 Revert "Key the CloudDB cache by hostname and add a button to clear the cache (#4794)"
This reverts commit b25b3e3c61.

While this is useful for development and testing we don't need it
for release versions and it invalidates existing caches causing
additional traffic on CloudDB servers.
2026-01-17 18:26:15 -03:00

1445 lines
48 KiB
C++

/*
* Copyright (c) 2015 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 "CloudDBChart.h"
#include "CloudDBCommon.h"
#include "CloudDBStatus.h"
#include "CloudService.h"
#include "LTMChartParser.h"
#include "GcUpgrade.h"
#include "Colors.h"
#ifdef GC_WANT_R
#include <RTool.h>
#endif
#ifdef GC_WANT_PYTHON
#include "PythonEmbed.h"
#endif
#include <QtGlobal>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QMessageBox>
#include <QJsonParseError>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QXmlInputSource>
#include <QXmlSimpleReader>
#include <QRegularExpressionValidator>
CloudDBChartClient::CloudDBChartClient()
{
g_nam = new QNetworkAccessManager(this);
QDir cacheDir(QStandardPaths::standardLocations(QStandardPaths::AppLocalDataLocation).at(0));
cacheDir.cdUp();
g_cacheDir = QString(cacheDir.absolutePath()+"/GoldenCheetahCloudDB");
QDir newCacheDir(g_cacheDir);
if (!newCacheDir.exists()) {
cacheDir.mkdir("GoldenCheetahCloudDB");
}
// general handling for sslErrors
connect(g_nam, SIGNAL(sslErrors(QNetworkReply*,QList<QSslError>)), this,
SLOT(sslErrors(QNetworkReply*,QList<QSslError>)));
// common definitions used
g_chart_url_base = g_chart_url_header = g_chartcuration_url_base = g_chartdownloadincr_url_base = CloudDBCommon::cloudDBBaseURL;
g_chart_url_base.append("gchart/");
g_chart_url_header.append("gchartheader");
g_chartcuration_url_base.append("gchartcuration/");
g_chartdownloadincr_url_base.append("gchartuse/");
}
CloudDBChartClient::~CloudDBChartClient() {
delete g_nam;
}
bool
CloudDBChartClient::postChart(ChartAPIv1 chart) {
// check if Athlete ID is filled
if (chart.Header.CreatorId.isEmpty()) return CloudDBCommon::APIresponseOthers;
// default where it may be necessary
if (chart.Header.Language.isEmpty()) chart.Header.Language = "en";
// first create the JSON object
// only a subset of fields is required for POST
QJsonObject json_header;
CloudDBCommon::marshallAPIHeaderV1Object(json_header, chart.Header);
QJsonObject json;
json["header"] = json_header;
json["chartSport"] = chart.ChartSport;
json["chartType"] = chart.ChartType;
json["chartView"] = chart.ChartView;
json["chartDef"] = chart.ChartDef;
QString image;
image.append(chart.Image.toBase64());
json["image"] = image;
json["creatornick"] = chart.CreatorNick;
json["creatoremail"] = chart.CreatorEmail;
QJsonDocument document;
document.setObject(json);
QNetworkRequest request;
CloudDBCommon::prepareRequest(request, g_chart_url_base);
g_reply = g_nam->post(request, document.toJson());
// wait for reply (synchronously) and process error codes as necessary
if (!CloudDBCommon::replyReceivedAndOk(g_reply)) return false;
CloudDBHeader::setChartHeaderStale(true);
return true;
}
bool
CloudDBChartClient::putChart(ChartAPIv1 chart) {
// we assume all field are filled properly / not further check or modification
// first create the JSON object / all fields are required for PUT / only LastChanged i
QJsonObject json_header;
CloudDBCommon::marshallAPIHeaderV1Object(json_header, chart.Header);
QJsonObject json;
json["header"] = json_header;
json["chartSport"] = chart.ChartSport;
json["chartType"] = chart.ChartType;
json["chartView"] = chart.ChartView;
json["chartDef"] = chart.ChartDef;
QString image;
image.append(chart.Image.toBase64());
json["image"] = image;
json["creatornick"] = chart.CreatorNick;
json["creatoremail"] = chart.CreatorEmail;
QJsonDocument document;
document.setObject(json);
QNetworkRequest request;
CloudDBCommon::prepareRequest(request, g_chart_url_base);
g_reply = g_nam->put(request, document.toJson());
// wait for reply (synchronously) and process error codes as necessary
if (!CloudDBCommon::replyReceivedAndOk(g_reply)) return false;
// cache is stale
deleteChartCache(chart.Header.Id);
CloudDBHeader::setChartHeaderStale(true);
return true;
}
bool
CloudDBChartClient::getChartByID(qint64 id, ChartAPIv1 *chart) {
// read from Cache first
if (readChartCache(id, chart)) return CloudDBCommon::APIresponseOk;
// now from GAE
QNetworkRequest request;
CloudDBCommon::prepareRequest(request, g_chart_url_base+QString::number(id, 10));
g_reply = g_nam->get(request);
// wait for reply (synchronously) and process error codes as necessary
if (!CloudDBCommon::replyReceivedAndOk(g_reply)) return false;
// call successfull
QByteArray result = g_reply->readAll();
QList<ChartAPIv1>* charts = new QList<ChartAPIv1>;
unmarshallAPIv1(result, charts);
if (charts->size() > 0) {
*chart = charts->value(0);
writeChartCache(chart);
charts->clear();
delete charts;
return true;
}
delete charts;
return false;
}
bool
CloudDBChartClient::deleteChartByID(qint64 id) {
QNetworkRequest request;
CloudDBCommon::prepareRequest(request, g_chart_url_base+QString::number(id, 10));
g_reply = g_nam->deleteResource(request);
// wait for reply (synchronously) and process error codes as necessary
if (!CloudDBCommon::replyReceivedAndOk(g_reply)) return false;
deleteChartCache(id);
return true;
}
bool
CloudDBChartClient::curateChartByID(qint64 id, bool newStatus) {
QUrlQuery query;
query.addQueryItem("newStatus", (newStatus ? "true": "false"));
QNetworkRequest request;
CloudDBCommon::prepareRequest(request, g_chartcuration_url_base+QString::number(id, 10), &query);
// add a payload to "PUT" even though it's not processed
g_reply = g_nam->put(request, "{ \"id\": \"dummy\" }");
// wait for reply (synchronously) and process error codes as necessary
if (!CloudDBCommon::replyReceivedAndOk(g_reply)) return false;
deleteChartCache(id);
return true;
}
void
CloudDBChartClient::incrementDownloadCounterByID(qint64 id) {
QNetworkRequest request;
CloudDBCommon::prepareRequest(request, g_chartdownloadincr_url_base+QString::number(id, 10));
g_reply = g_nam->put(request, "{ \"id\": \"dummy\" }");
// ignore any errors or reply - user does not need to be informed in case of problems
}
bool
CloudDBChartClient::getAllChartHeader(QList<CommonAPIHeaderV1>* header) {
bool request_ok = CloudDBHeader::getAllCachedHeader(header, CloudDBHeader::CloudDB_Chart, g_cacheDir, g_chart_url_header, g_nam, g_reply);
if (request_ok && header->size()>0) {
cleanChartCache(header);
}
return request_ok;
}
//
// Trap SSL errors
//
void
CloudDBChartClient::sslErrors(QNetworkReply* reply ,QList<QSslError> errors)
{
CloudService::sslErrors(nullptr, reply, errors);
}
// Internal Methods
bool
CloudDBChartClient::writeChartCache(ChartAPIv1 * chart) {
// make sure the subdir exists
QDir cacheDir(g_cacheDir);
if (cacheDir.exists()) {
cacheDir.mkdir("charts");
} else {
return false;
}
QFile file(g_cacheDir+"/charts/"+QString::number(chart->Header.Id)+".dat");
if (file.exists()) {
// overwrite data
file.resize(0);
}
if (!file.open(QIODevice::WriteOnly)) return false;
QDataStream out(&file);
out.setVersion(QDataStream::Qt_4_6);
// track a version to be able change data structure
out << chart_magic_string;
out << chart_cache_version;
out << chart->Header.LastChanged; // start
out << chart->Header.CreatorId;
out << chart->Header.Curated;
out << chart->Header.Deleted;
out << chart->Header.Description;
out << chart->Header.GcVersion;
out << chart->Header.Id;
out << chart->Header.Language;
out << chart->Header.Name;
out << chart->ChartSport;
out << chart->ChartType;
out << chart->ChartView;
out << chart->ChartDef;
out << chart->CreatorEmail;
out << chart->CreatorNick;
out << chart->Image;
file.close();
return true;
}
bool CloudDBChartClient::readChartCache(qint64 id, ChartAPIv1 *chart) {
QFile file(g_cacheDir+"/charts/"+QString::number(id)+".dat");
if (!file.open(QIODevice::ReadOnly)) return false;
QDataStream in(&file);
in.setVersion(QDataStream::Qt_4_6);
// track a version to be able change data structure
int magic_string;
int version;
in >> magic_string;
if (magic_string != chart_magic_string) {
// wrong file format / close and exit
file.close();
return false;
}
in >> version;
if (version != chart_cache_version) {
// change of version, delete old cache entry
file.remove();
return false;
}
in >> chart->Header.LastChanged; // start
in >> chart->Header.CreatorId;
in >> chart->Header.Curated;
in >> chart->Header.Deleted;
in >> chart->Header.Description;
in >> chart->Header.GcVersion;
in >> chart->Header.Id;
in >> chart->Header.Language;
in >> chart->Header.Name;
in >> chart->ChartSport;
in >> chart->ChartType;
in >> chart->ChartView;
in >> chart->ChartDef;
in >> chart->CreatorEmail;
in >> chart->CreatorNick;
in >> chart->Image;
file.close();
return true;
}
void
CloudDBChartClient::deleteChartCache(qint64 id) {
QFile file(g_cacheDir+"/charts/"+QString::number(id)+".dat");
if (file.exists()) {
file.remove();
}
}
void
CloudDBChartClient::cleanChartCache(QList<CommonAPIHeaderV1> *objectHeader) {
// remove Deleted Entries from Cache
int deleted = 0;
QMutableListIterator<CommonAPIHeaderV1> it(*objectHeader);
while (it.hasNext()) {
CommonAPIHeaderV1 header = it.next();
if (header.Deleted) {
deleteChartCache(header.Id);
it.remove();
deleted ++;
}
}
if (deleted > 0) {
// store cache for next time
CloudDBHeader::writeHeaderCache(objectHeader, CloudDBHeader::CloudDB_Chart, g_cacheDir);
}
}
bool
CloudDBChartClient::unmarshallAPIv1(QByteArray json, QList<ChartAPIv1> *charts) {
QJsonParseError parseError;
QJsonDocument document = QJsonDocument::fromJson(json, &parseError);
// all these things should not happen and we have not valid object to return
if (parseError.error != QJsonParseError::NoError || document.isEmpty() || document.isNull()) {
return false;
}
// do we have a single object or an array ?
if (document.isObject()) {
ChartAPIv1 chart;
QJsonObject object = document.object();
unmarshallAPIv1Object(&object, &chart);
QJsonObject header = object["header"].toObject();
CloudDBCommon::unmarshallAPIHeaderV1Object(&header, &chart.Header);
charts->append(chart);
} else if (document.isArray()) {
QJsonArray array(document.array());
for (int i = 0; i< array.size(); i++) {
QJsonValue value = array.at(i);
if (value.isObject()) {
ChartAPIv1 chart;
QJsonObject object = value.toObject();
unmarshallAPIv1Object(&object, &chart);
QJsonObject header = object["header"].toObject();
CloudDBCommon::unmarshallAPIHeaderV1Object(&header, &chart.Header);
charts->append(chart);
}
}
}
return true;
}
void
CloudDBChartClient::unmarshallAPIv1Object(QJsonObject* object, ChartAPIv1* chart) {
chart->ChartSport = object->value("chartSport").toString();
chart->ChartType = object->value("chartType").toString();
chart->ChartView = object->value("chartView").toString();
chart->ChartDef = object->value("chartDef").toString();
QString imageString = object->value("image").toString();
QByteArray ba;
ba.append(imageString.toUtf8());
chart->Image = QByteArray::fromBase64(ba);
chart->CreatorNick = object->value("creatorNick").toString();
chart->CreatorEmail = object->value("creatorEmail").toString();
}
//------------------------------------------------------------------------------------------------------------
// Dialog to show and retrieve Shared Charts
//------------------------------------------------------------------------------------------------------------
// define size if image preview at one place
static const int chartImageWidth = 320;
static const int chartImageHeight = 240;
CloudDBChartListDialog::CloudDBChartListDialog() : const_stepSize(5)
{
g_client = new CloudDBChartClient();
g_currentHeaderList = new QList<CommonAPIHeaderV1>;
g_fullHeaderList = new QList<CommonAPIHeaderV1>;
CloudDBHeader::setChartHeaderStale(true); //Force Headers to be loaded
g_currentPresets = new QList<ChartWorkingStructure>;
g_textFilterActive = false;
g_networkrequestactive = false; // don't allow Dialog to close while we are retrieving data
showing = new QLabel;
showingTextTemplate = tr("Showing %1 to %2 of %3 charts for %4 / Total uploaded %5");
resetToStart = new QPushButton(tr("First"));
nextSet = new QPushButton(tr("Next %1").arg(QString::number(const_stepSize)));
prevSet = new QPushButton(tr("Prev %1").arg(QString::number(const_stepSize)));
resetToStart->setEnabled(true);
nextSet->setDefault(true);
nextSet->setEnabled(true);
prevSet->setEnabled(true);
connect(resetToStart, SIGNAL(clicked()), this, SLOT(resetToStartClicked()));
connect(nextSet, SIGNAL(clicked()), this, SLOT(nextSetClicked()));
connect(prevSet, SIGNAL(clicked()), this, SLOT(prevSetClicked()));
showingLayout = new QHBoxLayout;
showingLayout->addWidget(showing);
showingLayout->addStretch();
showingLayout->addWidget(resetToStart);
showingLayout->addWidget(prevSet);
showingLayout->addWidget(nextSet);
ownChartsOnly = new QCheckBox(tr("My Charts"));
ownChartsOnly->setChecked(false);
connect(ownChartsOnly, SIGNAL(toggled(bool)), this, SLOT(ownChartsToggled(bool)));
curationStateCombo = new QComboBox();
curationStateCombo->addItem(tr("All"));
curationStateCombo->addItem(tr("Curated Only"));
curationStateCombo->addItem(tr("Uncurated Only"));
// default to curated charts only
curationStateCombo->setCurrentIndex(1);
connect(curationStateCombo, SIGNAL(currentIndexChanged(int)), this, SLOT(curationStateFilterChanged(int)));
sportCombo = new QComboBox();
sportCombo->addItem(tr("Any Sport"));
foreach (QString sport, CloudDBCommon::cloudDBSports) {
sportCombo->addItem(sport);
}
connect(sportCombo, SIGNAL(currentIndexChanged(int)), this, SLOT(sportComboFilterChanged(int)));
langCombo = new QComboBox();
langCombo->addItem(tr("Any Language"));
foreach (QString lang, CloudDBCommon::cloudDBLangs) {
langCombo->addItem(lang);
}
textFilterApply = new QPushButton(tr("Search Keyword"));
textFilterApply->setFixedWidth(180 *dpiXFactor); // To allow proper translation
textFilter = new QLineEdit;
g_textFilterActive = false;
connect(textFilter, SIGNAL(editingFinished()), this, SLOT(textFilterEditingFinished()));
connect(langCombo, SIGNAL(currentIndexChanged(int)), this, SLOT(languageFilterChanged(int)));
connect(textFilterApply, SIGNAL(clicked()), this, SLOT(toggleTextFilterApply()));
filterLayout = new QHBoxLayout;
filterLayout->addWidget(ownChartsOnly);
filterLayout->addStretch();
filterLayout->addWidget(curationStateCombo);
filterLayout->addStretch();
filterLayout->addWidget(sportCombo);
filterLayout->addStretch();
filterLayout->addWidget(langCombo);
filterLayout->addStretch();
filterLayout->addWidget(textFilterApply);
filterLayout->addWidget(textFilter);
textFilter->setVisible(true);
tableWidget = new QTableWidget(0, 2);
tableWidget->setContentsMargins(0,0,0,0);
tableWidget->horizontalHeader()->setStretchLastSection(true);
tableWidget->horizontalHeader()->setVisible(false);
tableWidget->verticalHeader()->setVisible(false);
tableWidget->setShowGrid(true);
// the preview shall have a dedicated size
tableWidget->setColumnWidth(0, chartImageWidth);
connect(tableWidget, SIGNAL(cellDoubleClicked(int,int)), this, SLOT(cellDoubleClicked(int,int)));
// UserGet Role
addAndCloseUserGetButton = new QPushButton(tr("Download selected chart(s)"));
closeUserGetButton = new QPushButton(tr("Close"));
addAndCloseUserGetButton->setEnabled(true);
closeUserGetButton->setEnabled(true);
closeUserGetButton->setDefault(true);
connect(addAndCloseUserGetButton, SIGNAL(clicked()), this, SLOT(addAndCloseClicked()));
connect(closeUserGetButton, SIGNAL(clicked()), this, SLOT(closeClicked()));
buttonUserGetLayout = new QHBoxLayout;
buttonUserGetLayout->addStretch();
buttonUserGetLayout->addWidget(addAndCloseUserGetButton);
buttonUserGetLayout->addWidget(closeUserGetButton);
// UserEdit Role
deleteUserEditButton = new QPushButton(tr("Delete selected chart"));
editUserEditButton = new QPushButton(tr("Edit selected chart"));
closeUserEditButton = new QPushButton(tr("Close"));
connect(deleteUserEditButton, SIGNAL(clicked()), this, SLOT(deleteUserEdit()));
connect(editUserEditButton, SIGNAL(clicked()), this, SLOT(editUserEdit()));
connect(closeUserEditButton, SIGNAL(clicked()), this, SLOT(closeClicked()));
buttonUserEditLayout = new QHBoxLayout;
buttonUserEditLayout->addStretch();
buttonUserEditLayout->addWidget(deleteUserEditButton);
buttonUserEditLayout->addWidget(editUserEditButton);
buttonUserEditLayout->addWidget(closeUserEditButton);
// CuratorEdit Role
curateCuratorEditButton = new QPushButton(tr("Set selected chart 'Curated'"));
editCuratorEditButton = new QPushButton(tr("Edit selected chart"));
deleteCuratorEditButton = new QPushButton(tr("Delete selected chart"));
closeCuratorButton = new QPushButton(tr("Close"));
connect(curateCuratorEditButton, SIGNAL(clicked()), this, SLOT(curateCuratorEdit()));
connect(editCuratorEditButton, SIGNAL(clicked()), this, SLOT(editCuratorEdit()));
connect(deleteCuratorEditButton, SIGNAL(clicked()), this, SLOT(deleteCuratorEdit()));
connect(closeCuratorButton, SIGNAL(clicked()), this, SLOT(closeClicked()));
buttonCuratorEditLayout = new QHBoxLayout;
buttonCuratorEditLayout->addStretch();
buttonCuratorEditLayout->addWidget(curateCuratorEditButton);
buttonCuratorEditLayout->addWidget(editCuratorEditButton);
buttonCuratorEditLayout->addWidget(deleteCuratorEditButton);
buttonCuratorEditLayout->addWidget(closeCuratorButton);
// prepare the main layouts - with different buttons layouts
mainLayout = new QVBoxLayout;
mainLayout->addLayout(showingLayout);
mainLayout->addLayout(filterLayout);
mainLayout->addWidget(tableWidget);
mainLayout->addLayout(buttonUserGetLayout);
mainLayout->addLayout(buttonUserEditLayout);
mainLayout->addLayout(buttonCuratorEditLayout);
setMinimumHeight(500 *dpiYFactor);
setMinimumWidth(700 *dpiXFactor);
setLayout(mainLayout);
}
CloudDBChartListDialog::~CloudDBChartListDialog() {
delete g_client;
delete g_currentHeaderList;
delete g_fullHeaderList;
delete g_currentPresets;
}
// block DialogWindow close while networkrequest is processed
void
CloudDBChartListDialog::closeEvent(QCloseEvent* event) {
if (g_networkrequestactive) {
event->ignore();
return;
}
QDialog::closeEvent(event);
}
bool
CloudDBChartListDialog::prepareData(QString athlete, CloudDBCommon::UserRole role, int chartView) {
g_role = role;
g_chartView = chartView;
// and now initialize the dialog
setVisibleButtonsForRole();
if (g_role == CloudDBCommon::UserEdit) {
ownChartsOnly->setChecked(true);
ownChartsOnly->setEnabled(false);
setWindowTitle(tr("Chart maintenance - Edit or Delete your Charts"));
tableWidget->setSelectionMode(QAbstractItemView::SingleSelection);
tableWidget->setSelectionBehavior(QAbstractItemView::SelectRows);
} else if (role == CloudDBCommon::CuratorEdit) {
ownChartsOnly->setChecked(false);
curationStateCombo->setCurrentIndex(2); // start with uncurated
setWindowTitle(tr("Curator chart maintenance - Curate, Edit or Delete Charts"));
tableWidget->setSelectionMode(QAbstractItemView::SingleSelection);
tableWidget->setSelectionBehavior(QAbstractItemView::SelectRows);
} else {
setWindowTitle(tr("Select charts to download"));
tableWidget->setSelectionMode(QAbstractItemView::ExtendedSelection);
tableWidget->setSelectionBehavior(QAbstractItemView::SelectRows);
}
if (CloudDBHeader::isStaleChartHeader()) {
if (!refreshStaleChartHeader()) return false;
CloudDBHeader::setChartHeaderStale(false);
}
g_currentAthleteId = appsettings->cvalue(athlete, GC_ATHLETE_ID, "").toString();
applyAllFilters();
return true;
}
void
CloudDBChartListDialog::updateCurrentPresets(int index, int count) {
// while getting the data (which may take of few seconds), disable the UI
resetToStart->setEnabled(false);
nextSet->setEnabled(false);
prevSet->setEnabled(false);
closeUserGetButton->setEnabled(false);
addAndCloseUserGetButton->setEnabled(false);
curationStateCombo->setEnabled(false);
ownChartsOnly->setEnabled(false);
textFilterApply->setEnabled(false);
sportCombo->setEnabled(false);
langCombo->setEnabled(false);
deleteUserEditButton->setEnabled(false);
editUserEditButton->setEnabled(false);
closeUserEditButton->setEnabled(false);
curateCuratorEditButton->setEnabled(false);
editCuratorEditButton->setEnabled(false);
deleteCuratorEditButton->setEnabled(false);
closeCuratorButton->setEnabled(false);
// now get the presets
g_currentPresets->clear();
ChartAPIv1* chart = new ChartAPIv1;
g_networkrequestactive = true;
bool noError = true;
for (int i = index; i< index+count && i<g_currentHeaderList->count() && noError; i++) {
if (g_client->getChartByID(g_currentHeaderList->at(i).Id, chart)) {
ChartWorkingStructure preset;
preset.id = chart->Header.Id;
preset.name = chart->Header.Name;
preset.description = chart->Header.Description;
preset.language = chart->Header.Language;
preset.createdAt = chart->Header.LastChanged;
preset.image.loadFromData(chart->Image);
preset.gchartType = chart->ChartType;
preset.gchartView = chart->ChartView;
preset.gchartDef = chart->ChartDef;
preset.creatorNick = chart->CreatorNick;
preset.gchartSport = chart->ChartSport;
g_currentPresets->append(preset);
} else {
noError = false;
}
}
g_networkrequestactive = false;
delete chart;
// update table with current list
tableWidget->clearContents();
tableWidget->setRowCount(0);
int chartCount = (g_currentHeaderList->size() == 0) ? 0 : g_currentIndex+1;
int lastIndex = (g_currentIndex+const_stepSize > g_currentHeaderList->size()) ? g_currentHeaderList->size() : g_currentIndex+const_stepSize;
QString view = tr("unknown");
if (g_role == CloudDBCommon::UserImport ) {
switch(g_chartView) {
case 0 : view = tr("Trends"); break;
case 1 : view = tr("Activities"); break;
case 2 : view = tr("Plan"); break;
case 3 : view = tr("Train"); break;
}
} else
{
view = tr("All Views");
}
showing->setText(QString(showingTextTemplate)
.arg(QString::number(chartCount))
.arg(QString::number(lastIndex))
.arg(QString::number(g_currentHeaderList->size()))
.arg(view)
.arg(QString::number(g_fullHeaderList->size())));
for (int i = 0; i< g_currentPresets->size(); i++ ) {
tableWidget->insertRow(i);
ChartWorkingStructure preset = g_currentPresets->at(i);
QTableWidgetItem *newPxItem = new QTableWidgetItem("");
newPxItem->setData(Qt::DecorationRole, QVariant(preset.image.scaled(chartImageWidth, chartImageHeight, Qt::KeepAspectRatio, Qt::SmoothTransformation)));
newPxItem->setSizeHint(QSize(chartImageWidth, chartImageHeight));
newPxItem->setFlags(newPxItem->flags() & ~Qt::ItemIsEditable);
tableWidget->setItem(i, 0, newPxItem);
tableWidget->item(i,0)->setBackground(Qt::darkGray);
tableWidget->setRowHeight(i, chartImageHeight+20);
QString cellText = QString(tr("<h3>%1</h3><h4>Last Edited At: %2 - Creator: %3</h4>%4"))
.arg(CloudDBCommon::encodeHTML(preset.name))
.arg(preset.createdAt.date().toString(Qt::ISODate))
.arg(CloudDBCommon::encodeHTML(preset.creatorNick))
.arg(CloudDBCommon::encodeHTML(preset.description));
QTextEdit *formattedText = new QTextEdit;
formattedText->setHtml(cellText);
formattedText->setReadOnly(true);
tableWidget->setCellWidget(i,1, formattedText);
}
// enable UI again
resetToStart->setEnabled(true);
nextSet->setEnabled(true);
prevSet->setEnabled(true);
closeUserGetButton->setEnabled(true);
addAndCloseUserGetButton->setEnabled(true);
curationStateCombo->setEnabled(true);
ownChartsOnly->setEnabled(true);
textFilterApply->setEnabled(true);
sportCombo->setEnabled(true);
langCombo->setEnabled(true);
deleteUserEditButton->setEnabled(true);
editUserEditButton->setEnabled(true);
closeUserEditButton->setEnabled(true);
curateCuratorEditButton->setEnabled(true);
editCuratorEditButton->setEnabled(true);
deleteCuratorEditButton->setEnabled(true);
closeCuratorButton->setEnabled(true);
// role dependent UI settings
if (g_role == CloudDBCommon::UserEdit) {
ownChartsOnly->setEnabled(false);
}
}
void
CloudDBChartListDialog::setVisibleButtonsForRole() {
if (g_role == CloudDBCommon::UserEdit) {
deleteUserEditButton->setVisible(true);
editUserEditButton->setVisible(true);
closeUserEditButton->setVisible(true);
curateCuratorEditButton->setVisible(false);
editCuratorEditButton->setVisible(false);
deleteCuratorEditButton->setVisible(false);
closeCuratorButton->setVisible(false);
closeUserGetButton->setVisible(false);
addAndCloseUserGetButton->setVisible(false);
} else if (g_role == CloudDBCommon::CuratorEdit) {
deleteUserEditButton->setVisible(false);
editUserEditButton->setVisible(false);
closeUserEditButton->setVisible(false);
curateCuratorEditButton->setVisible(true);
editCuratorEditButton->setVisible(true);
deleteCuratorEditButton->setVisible(true);
closeCuratorButton->setVisible(true);
closeUserGetButton->setVisible(false);
addAndCloseUserGetButton->setVisible(false);
} else {
deleteUserEditButton->setVisible(false);
editUserEditButton->setVisible(false);
closeUserEditButton->setVisible(false);
curateCuratorEditButton->setVisible(false);
editCuratorEditButton->setVisible(false);
deleteCuratorEditButton->setVisible(false);
closeCuratorButton->setVisible(false);
closeUserGetButton->setVisible(true);
addAndCloseUserGetButton->setVisible(true);
}
}
bool
CloudDBChartListDialog::refreshStaleChartHeader() {
g_fullHeaderList->clear();
g_currentHeaderList->clear();
g_currentIndex = 0;
g_networkrequestactive = true;
if (!g_client->getAllChartHeader(g_fullHeaderList)) {
g_networkrequestactive = false;
return false;
}
g_networkrequestactive = false;
return true;
}
void
CloudDBChartListDialog::addAndCloseClicked() {
// check if an item of the table is selected
QList<QTableWidgetItem*> selected = tableWidget->selectedItems();
if (selected.count()>0)
{
g_selected.clear();
// the selectionMode allows multiple selection so get them all
for (int i = 0; i< selected.count(); i++) {
QTableWidgetItem* s = selected.at(i);
if (s->row() >= 0 && s->row() <= g_currentPresets->count()) {
#if defined(GC_WANT_R) || defined (GC_WANT_PYTHON)
int chartType = g_currentPresets->at(s->row()).gchartType.toInt();
#ifdef GC_WANT_R
if (chartType == GcWindowTypes::RConsole ||
chartType == GcWindowTypes::RConsoleSeason ) {
if (rtool == NULL) {
QMessageBox::information(0, tr("Chart requires 'R'"), tr("The chart your are downloading requires 'R' to be installed \
and activated for GoldenCheetah to show any graphics. Either 'R' is not activated \
in the preferences, or not even installed.<br><br> Please ensure 'R' \
is installed and activated to be able to use this chart."));
}
}
#endif
#ifdef GC_WANT_PYTHON
if (chartType == GcWindowTypes::Python ||
chartType == GcWindowTypes::PythonSeason ) {
if (python == NULL) {
QMessageBox::information(0, tr("Chart requires 'Python'"), tr("The chart your are downloading requires 'Python' to be installed \
and activated for GoldenCheetah to show any graphics. Either 'Python' is not activated \
in the preferences, or not even installed.<br><br> Please ensure 'Python' \
is installed and activated to be able to use this chart."));
}
}
#endif
#endif
g_selected << g_currentPresets->at(s->row()).gchartDef;
// increment download counter
g_client->incrementDownloadCounterByID(g_currentPresets->at(s->row()).id);
}
}
accept();
}
}
void
CloudDBChartListDialog::closeClicked() {
reject();
}
void
CloudDBChartListDialog::resetToStartClicked() {
if (g_currentIndex == 0) return;
g_currentIndex = 0;
updateCurrentPresets(g_currentIndex, const_stepSize);
}
void
CloudDBChartListDialog::nextSetClicked() {
g_currentIndex += const_stepSize;
if (g_currentIndex >= g_currentHeaderList->size()) {
g_currentIndex = g_currentHeaderList->size() - const_stepSize;
}
if (g_currentIndex < 0) g_currentIndex = 0;
updateCurrentPresets(g_currentIndex, const_stepSize);
}
void
CloudDBChartListDialog::prevSetClicked() {
g_currentIndex -= const_stepSize;
if (g_currentIndex < 0) g_currentIndex = 0;
updateCurrentPresets(g_currentIndex, const_stepSize);
}
void
CloudDBChartListDialog::curateCuratorEdit() {
if (tableWidget->selectedItems().size()>0)
{
// the selectionMode allows only 1 item to be selected at a time
QTableWidgetItem* s = tableWidget->selectedItems().at(0);
if (s->row() >= 0 && s->row() <= g_currentPresets->count()) {
qint64 id = g_currentPresets->at(s->row()).id;
if (!g_client->curateChartByID(id, true)) {
return;
}
// refresh header buffer
refreshStaleChartHeader();
// curated chart appears on top of the list / and needs to be filtered
applyAllFilters();
}
}
}
void
CloudDBChartListDialog::deleteCuratorEdit(){
// currently same like User
deleteUserEdit();
}
void
CloudDBChartListDialog::editCuratorEdit(){
// currently same like User
editUserEdit();
}
void
CloudDBChartListDialog::deleteUserEdit(){
if (tableWidget->selectedItems().size()>0)
{
// chart selected for deletion - but ask again to be sure
if (QMessageBox::question(0, tr("Chart Maintenance"), QString(tr("Do you really want to delete this chart definition ?"))) != QMessageBox::Yes) return;
// the selectionMode allows only 1 item to be selected at a time
QTableWidgetItem* s = tableWidget->selectedItems().at(0);
if (s->row() >= 0 && s->row() <= g_currentPresets->count()) {
qint64 id = g_currentPresets->at(s->row()).id;
if (!g_client->deleteChartByID(id)) {
return;
}
// set stale for subsequent list dialog calls
CloudDBHeader::setChartHeaderStale(true);
// remove deleted chart from both lists
QMutableListIterator<CommonAPIHeaderV1> it1(*g_currentHeaderList);
while (it1.hasNext()) {
if (it1.next().Id == id) {
it1.remove();
break; // there is just one equal entry
}
}
QMutableListIterator<CommonAPIHeaderV1> it2(*g_fullHeaderList);
while (it2.hasNext()) {
if (it2.next().Id == id) {
it2.remove();
break; // there is just one equal entry
}
}
if (g_currentIndex >= g_currentHeaderList->size()) {
g_currentIndex = g_currentHeaderList->size() - const_stepSize;
}
if (g_currentIndex < 0) g_currentIndex = 0;
updateCurrentPresets(g_currentIndex, const_stepSize);
}
}
}
void
CloudDBChartListDialog::editUserEdit(){
if (tableWidget->selectedItems().size()>0)
{
// the selectionMode allows only 1 item to be selected at a time
QTableWidgetItem* s = tableWidget->selectedItems().at(0);
if (s->row() >= 0 && s->row() <= g_currentPresets->count()) {
qint64 id = g_currentPresets->at(s->row()).id;
ChartAPIv1 chart;
if (!g_client->getChartByID(id, &chart)) {
return;
}
// now complete the chart with for the user manually added fields
CloudDBChartObjectDialog dialog(chart, "", true);
if (dialog.exec() == QDialog::Accepted) {
if (g_client->putChart(dialog.getChart())) {
refreshStaleChartHeader();
} else {
return;
}
} else {
return;
}
}
// updated chart appears on top of the list / and needs to be filtered
applyAllFilters();
}
}
void
CloudDBChartListDialog::curationStateFilterChanged(int) {
applyAllFilters();
}
void
CloudDBChartListDialog::sportComboFilterChanged(int) {
applyAllFilters();
}
void
CloudDBChartListDialog::ownChartsToggled(bool) {
applyAllFilters();
}
void
CloudDBChartListDialog::toggleTextFilterApply() {
if (g_textFilterActive) {
g_textFilterActive = false;
textFilterApply->setText(tr("Search Keyword"));
applyAllFilters();
} else {
g_textFilterActive = true;
textFilterApply->setText(tr("Reset Search"));
applyAllFilters();
}
}
void
CloudDBChartListDialog::languageFilterChanged(int) {
applyAllFilters();
}
void
CloudDBChartListDialog::applyAllFilters() {
// setup to only show charts that are relevant to the current view
unsigned int mask=0;
switch(g_chartView) {
case 0 : mask = VIEW_TRENDS; break;
default:
case 1 : mask = VIEW_ANALYSIS; break;
case 2 : mask = VIEW_PLAN; break;
case 3 : mask = VIEW_TRAIN; break;
}
QStringList searchList;
g_currentHeaderList->clear();
if (!textFilter->text().isEmpty()) {
// split by any whitespace
searchList = textFilter->text().split(QRegularExpression("\\s+"), Qt::SkipEmptyParts);
}
foreach (CommonAPIHeaderV1 chart, *g_fullHeaderList) {
// list does not contain any deleted chart id's
// first filter based on the current view (Home, Analysis, Plan) - but only on Imp
bool chartOkForView = false;
if (g_role == CloudDBCommon::UserImport) {
int winId = chart.ChartType.toInt();
for (int i = 0; GcWindows[i].relevance; i++) {
if (GcWindows[i].id == winId && (GcWindows[i].relevance & mask)) {
chartOkForView = true;
break;
}
}
} else {
// for editing and curation all charts are shown
chartOkForView = true;
}
int curationState = curationStateCombo->currentIndex();
if (chartOkForView) {
// check curated first
if (curationState == 0 ||
(curationState == 1 && chart.Curated) ||
(curationState == 2 && !chart.Curated ) ) {
//check own chart only
if (!ownChartsOnly->isChecked() ||
(ownChartsOnly->isChecked() && chart.CreatorId == g_currentAthleteId)) {
// then check sport (observe that we have an additional entry "all" in the combo !
if (sportCombo->currentIndex() == 0 ||
(sportCombo->currentIndex() > 0 && CloudDBCommon::cloudDBSportIds[sportCombo->currentIndex()-1] == chart.ChartSport )) {
// then check language (observe that we have an additional entry "all" in the combo !
if (langCombo->currentIndex() == 0 ||
(langCombo->currentIndex() > 0 && CloudDBCommon::cloudDBLangsIds[langCombo->currentIndex()-1] == chart.Language )) {
// at last check text filter
if (g_textFilterActive) {
// search with filter Keywords
QString chartInfo = chart.Name + " " + chart.Description;
foreach (QString search, searchList) {
if (chartInfo.contains(search, Qt::CaseInsensitive)) {
g_currentHeaderList->append(chart);
break;
}
}
} else {
g_currentHeaderList->append(chart);
}
}
}
}
}
}
}
g_currentIndex = 0;
// now get the data
updateCurrentPresets(g_currentIndex, const_stepSize);
}
void
CloudDBChartListDialog::textFilterEditingFinished() {
if (g_textFilterActive) {
applyAllFilters();
}
}
void
CloudDBChartListDialog::cellDoubleClicked(int row, int /*column */) {
ChartAPIv1* chart = new ChartAPIv1;
if (row >= 0 && row < g_currentHeaderList->size() ) {
g_networkrequestactive = true;
if (g_client->getChartByID(g_currentHeaderList->at(g_currentIndex+row).Id, chart)) {
CloudDBChartShowPictureDialog showImage(chart->Image);
showImage.exec();
}
}
g_networkrequestactive = false;
delete chart;
}
CloudDBChartShowPictureDialog::CloudDBChartShowPictureDialog(QByteArray imageData) : imageData(imageData) {
QRect rec = QApplication::primaryScreen()->geometry();
imageLabel = new QLabel();
imageLabel->setMinimumSize(150,100); // not scaled to hi-dpi screens (cloud db constraint not device)
chartImage.loadFromData(imageData);
QSize size = chartImage.size();
int w = rec.width()/2;
int h = (qreal)size.width()*w/size.height();
imageLabel->setPixmap(chartImage.scaled(w, h, Qt::KeepAspectRatio, Qt::SmoothTransformation));
okButton = new QPushButton(tr("Close"));
QHBoxLayout *buttonLayout = new QHBoxLayout;
buttonLayout->addStretch();
buttonLayout->addWidget(okButton);
connect(okButton, SIGNAL(clicked()), this, SLOT(okClicked()));
QVBoxLayout *layout = new QVBoxLayout;
layout->addWidget(imageLabel);
layout->addLayout(buttonLayout);
setLayout(layout);
}
CloudDBChartShowPictureDialog::~CloudDBChartShowPictureDialog() {
delete imageLabel;
delete okButton;
}
void
CloudDBChartShowPictureDialog::okClicked() {
accept();
}
void
CloudDBChartShowPictureDialog::resizeEvent(QResizeEvent *) {
int w = imageLabel->width();
int h = imageLabel->height();
// set a scaled pixmap to a w x h window keeping its aspect ratio
imageLabel->setPixmap(chartImage.scaled(w,h,Qt::KeepAspectRatio, Qt::SmoothTransformation));
}
//------------------------------------------------------------------------------------------------------------
// Dialog for publishing Chart Details
//------------------------------------------------------------------------------------------------------------
CloudDBChartObjectDialog::CloudDBChartObjectDialog(ChartAPIv1 data, QString athlete, bool update) : data(data), athlete(athlete), update(update) {
QLabel *chartName = new QLabel(tr("Chart Name"));
name = new QLineEdit();
nameDefault = tr("<Chart Name>");
name->setMaxLength(50);
if (update) {
name->setText(data.Header.Name);
nameOk = true; // no need to re-check
} else {
name->setText(nameDefault);
nameOk = false;
}
QRegularExpression name_rx("^.{5,50}$");
QValidator *name_validator = new QRegularExpressionValidator(name_rx, this);
name->setValidator(name_validator);
QLabel* sportLabel = new QLabel(tr("Sport"));
sportCombo = new QComboBox();
foreach (QString sport, CloudDBCommon::cloudDBSports) {
sportCombo->addItem(sport);
}
if (update) {
int index = CloudDBCommon::cloudDBSportIds.indexOf(data.ChartSport);
sportCombo->setCurrentIndex( index<0 ? 0 : index);
}
QLabel* langLabel = new QLabel(tr("Language"));
langCombo = new QComboBox();
foreach (QString lang, CloudDBCommon::cloudDBLangs) {
langCombo->addItem(lang);
}
if (update) {
int index = CloudDBCommon::cloudDBLangsIds.indexOf(data.Header.Language);
langCombo->setCurrentIndex( index<0 ? 0 : index);
}
connect(name, SIGNAL(textChanged(QString)), this, SLOT(nameTextChanged(QString)));
connect(name, SIGNAL(editingFinished()), this, SLOT(nameEditingFinished()));
QLabel *nickLabel = new QLabel(tr("Nickname"));
nickName = new QLineEdit();
if (update) {
nickName->setText(data.CreatorNick);
} else {
nickName->setText(appsettings->cvalue(athlete, GC_NICKNAME, "").toString());
}
QLabel *emailLabel = new QLabel(tr("E-Mail"));
email = new QLineEdit();
email->setMaxLength(100);
if (update) {
email->setText(data.CreatorEmail);
} else {
email->setText(appsettings->cvalue(athlete, GC_CLOUDDB_EMAIL, "").toString());
}
// regexp: simple e-mail validation / also allow long domain types & subdomains
QRegularExpression email_rx("^.+@([a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,10}$");
QValidator *email_validator = new QRegularExpressionValidator(email_rx, this);
email->setValidator(email_validator);
emailOk = !email->text().isEmpty(); // email from properties is ok when loaded
connect(email, SIGNAL(textChanged(QString)), this, SLOT(emailTextChanged(QString)));
connect(email, SIGNAL(editingFinished()), this, SLOT(emailEditingFinished()));
QLabel* gcVersionLabel = new QLabel(tr("Version Details"));
QString versionString = VERSION_STRING;
versionString.append(" : " + data.Header.GcVersion);
gcVersionString = new QLabel(versionString);
QLabel* creatorIdLabel = new QLabel(tr("Creator UUid"));
creatorId = new QLabel(data.Header.CreatorId);
QGridLayout *detailsLayout = new QGridLayout;
detailsLayout->addWidget(chartName, 0, 0, Qt::AlignLeft);
detailsLayout->addWidget(name, 0, 1);
detailsLayout->addWidget(sportLabel, 1, 0, Qt::AlignLeft);
detailsLayout->addWidget(sportCombo, 1, 1, Qt::AlignLeft);
detailsLayout->addWidget(langLabel, 1, 2, Qt::AlignLeft);
detailsLayout->addWidget(langCombo, 1, 3, Qt::AlignLeft);
detailsLayout->addWidget(nickLabel, 2, 0, Qt::AlignLeft);
detailsLayout->addWidget(nickName, 2, 1);
detailsLayout->addWidget(emailLabel, 2, 2, Qt::AlignLeft);
detailsLayout->addWidget(email, 2, 3);
detailsLayout->addWidget(gcVersionLabel, 3, 0, Qt::AlignLeft);
detailsLayout->addWidget(gcVersionString, 3, 1);
detailsLayout->addWidget(creatorIdLabel, 3, 2, Qt::AlignLeft);
detailsLayout->addWidget(creatorId, 3, 3);
description = new QTextEdit();
description->setAcceptRichText(false);
descriptionDefault = tr("<Enter the description of the chart here>");
description->setText(descriptionDefault);
if (update) {
description->setText(data.Header.Description);
} else {
description->setText(descriptionDefault);
}
QPixmap *chartImage = new QPixmap();
chartImage->loadFromData(data.Image);
image = new QLabel();
image->setPixmap(chartImage->scaled(chartImageWidth*2, chartImageHeight*2, Qt::KeepAspectRatio, Qt::SmoothTransformation));
publishButton = new QPushButton(tr("Upload"), this);
cancelButton = new QPushButton(tr("Cancel"), this);
publishButton->setEnabled(true);
connect(publishButton, SIGNAL(clicked()), this, SLOT(publishClicked()));
connect(cancelButton, SIGNAL(clicked()), this, SLOT(cancelClicked()));
QHBoxLayout *buttonLayout = new QHBoxLayout;
buttonLayout->addStretch();
buttonLayout->addWidget(publishButton);
buttonLayout->addWidget(cancelButton);
QVBoxLayout *mainLayout = new QVBoxLayout(this);
mainLayout->addLayout(detailsLayout);
mainLayout->addWidget(description);
mainLayout->addWidget(image);
mainLayout->addLayout(buttonLayout);
setLayout(mainLayout);
}
CloudDBChartObjectDialog::~CloudDBChartObjectDialog() {
}
void
CloudDBChartObjectDialog::publishClicked() {
// check data consistency
if (name->text().isEmpty() || name->text() == nameDefault || !nameOk) {
QMessageBox::warning(0, tr("Upload Chart"), QString(tr("Please enter a valid chart name with min. 5 characters length.")));
return;
}
if (nickName->text().isEmpty()) {
QMessageBox::warning(0, tr("Upload Chart"), QString(tr("Please enter a nickname for this athlete.")));
return;
}
if (email->text().isEmpty() || !emailOk) {
QMessageBox::warning(0, tr("Upload Chart"), QString(tr("Please enter a valid e-mail address.")));
return;
}
if (description->toPlainText().isEmpty() || description->toPlainText() == descriptionDefault) {
QMessageBox::warning(0, tr("Upload Chart"), QString(tr("Please enter a sensible chart description.")));
return;
}
if (QMessageBox::question(0, tr("Cloud Upload"), QString(tr("Do you want to upload this chart definition ?"))) != QMessageBox::Yes) return;
data.Header.Name = name->text();
data.Header.Description = description->toPlainText();
data.CreatorEmail = email->text();
data.CreatorNick = nickName->text();
data.Header.Language = CloudDBCommon::cloudDBLangsIds.at(langCombo->currentIndex());
data.ChartSport = CloudDBCommon::cloudDBSportIds.at(sportCombo->currentIndex());
if (!update) {
appsettings->setCValue(athlete, GC_NICKNAME, data.CreatorNick);
appsettings->setCValue(athlete, GC_CLOUDDB_EMAIL, data.CreatorEmail);
}
accept();
}
void
CloudDBChartObjectDialog::cancelClicked() {
reject();
}
void
CloudDBChartObjectDialog::emailTextChanged(QString text) {
if (text.isEmpty()) {
QMessageBox::warning(0, tr("Upload Chart"), QString(tr("Please enter a valid e-mail address.")));
} else {
emailOk = false;
}
}
void
CloudDBChartObjectDialog::emailEditingFinished() {
// validator check passed
emailOk = true;
}
void
CloudDBChartObjectDialog::nameTextChanged(QString text) {
if (text.isEmpty()) {
QMessageBox::warning(0, tr("Upload Chart"), QString(tr("Please enter a valid chart name with min. 5 characters length.")));
} else {
nameOk = false;
}
}
void
CloudDBChartObjectDialog::nameEditingFinished() {
// validator check passed
nameOk = true;
}