mirror of
https://github.com/GoldenCheetah/GoldenCheetah.git
synced 2026-02-13 08:08:42 +00:00
1883 lines
54 KiB
C++
1883 lines
54 KiB
C++
/*
|
|
* Copyright (c) 2014 Mark Liversedge (liversedge@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 "RideCache.h"
|
|
|
|
#include "Context.h"
|
|
#include "Athlete.h"
|
|
#include "RideFileCache.h"
|
|
#include "RideCacheModel.h"
|
|
#include "Specification.h"
|
|
#include "DataProcessor.h"
|
|
#include "Estimator.h"
|
|
|
|
#include "Route.h"
|
|
|
|
#include "Zones.h"
|
|
#include "HrZones.h"
|
|
#include "PaceZones.h"
|
|
|
|
#include "ErgFile.h"
|
|
|
|
#include "JsonRideFile.h" // for DATETIME_FORMAT
|
|
|
|
#ifdef SLOW_REFRESH
|
|
#include "unistd.h"
|
|
#endif
|
|
|
|
// we initialise the global user metrics
|
|
#include "RideMetric.h"
|
|
#include "UserMetricSettings.h"
|
|
#include "UserMetricParser.h"
|
|
#include "SpecialFields.h"
|
|
#include <QXmlInputSource>
|
|
#include <QXmlSimpleReader>
|
|
|
|
// for sorting
|
|
bool rideCacheGreaterThan(const RideItem *a, const RideItem *b) { return a->dateTime > b->dateTime; }
|
|
bool rideCacheLessThan(const RideItem *a, const RideItem *b) { return a->dateTime < b->dateTime; }
|
|
|
|
class RideCacheLoader : public QThread
|
|
{
|
|
public:
|
|
|
|
RideCacheLoader(RideCache *cache) : cache(cache) {}
|
|
void run() { cache->load(); }
|
|
|
|
private:
|
|
|
|
RideCache *cache;
|
|
};
|
|
|
|
RideCache::RideCache(Context *context) : context(context)
|
|
{
|
|
directory = context->athlete->home->activities();
|
|
plannedDirectory = context->athlete->home->planned();
|
|
|
|
progress_ = 100;
|
|
exiting = false;
|
|
estimator = new Estimator(context);
|
|
|
|
// initial load of user defined metrics - do once we have an initial context
|
|
// but before we refresh or check metrics for the first time
|
|
if (UserMetricSchemaVersion == 0) {
|
|
|
|
QString metrics = QString("%1/../usermetrics.xml").arg(context->athlete->home->root().absolutePath());
|
|
if (QFile(metrics).exists()) {
|
|
|
|
QFile metricfile(metrics);
|
|
QXmlInputSource source(&metricfile);
|
|
QXmlSimpleReader xmlReader;
|
|
UserMetricParser handler;
|
|
|
|
xmlReader.setContentHandler(&handler);
|
|
xmlReader.setErrorHandler(&handler);
|
|
|
|
// parse and get return values
|
|
xmlReader.parse(source);
|
|
_userMetrics = handler.getSettings();
|
|
UserMetric::addCompatibility(_userMetrics);
|
|
|
|
// reset schema version
|
|
UserMetricSchemaVersion = RideMetric::userMetricFingerprint(_userMetrics);
|
|
|
|
// now add initial metrics
|
|
foreach(UserMetricSettings m, _userMetrics) {
|
|
RideMetricFactory::instance().addMetric(UserMetric(context, m));
|
|
}
|
|
}
|
|
|
|
// reset special fields to take into account user metrics
|
|
SpecialFields::getInstance().reloadFields();
|
|
}
|
|
|
|
// set the list
|
|
// populate ride list
|
|
RideItem *last = NULL;
|
|
QStringListIterator i(RideFileFactory::instance().listRideFiles(directory));
|
|
while (i.hasNext()) {
|
|
QString name = i.next();
|
|
QDateTime dt;
|
|
if (RideFile::parseRideFileName(name, &dt)) {
|
|
last = new RideItem(directory.canonicalPath(), name, dt, context, false);
|
|
|
|
connect(last, SIGNAL(rideDataChanged()), this, SLOT(itemChanged()));
|
|
connect(last, SIGNAL(rideMetadataChanged()), this, SLOT(itemChanged()));
|
|
|
|
rides_ << last;
|
|
}
|
|
}
|
|
|
|
// set the list
|
|
// populate the planned ride list
|
|
QStringListIterator j(RideFileFactory::instance().listRideFiles(plannedDirectory));
|
|
while (j.hasNext()) {
|
|
QString name = j.next();
|
|
QDateTime dt;
|
|
if (RideFile::parseRideFileName(name, &dt)) {
|
|
last = new RideItem(plannedDirectory.canonicalPath(), name, dt, context, true);
|
|
|
|
connect(last, SIGNAL(rideDataChanged()), this, SLOT(itemChanged()));
|
|
connect(last, SIGNAL(rideMetadataChanged()), this, SLOT(itemChanged()));
|
|
|
|
rides_ << last;
|
|
}
|
|
}
|
|
|
|
// now sort it - we need to use find on it
|
|
std::sort(rides_.begin(), rides_.end(), rideCacheLessThan);
|
|
|
|
// load the store - will unstale once cache restored
|
|
RideCacheLoader *rideCacheLoader = new RideCacheLoader(this);
|
|
connect(rideCacheLoader, SIGNAL(finished()), this, SLOT(postLoad()));
|
|
connect(rideCacheLoader, SIGNAL(finished()), this, SIGNAL(loadComplete()));
|
|
rideCacheLoader->start();
|
|
}
|
|
|
|
void
|
|
RideCache::postLoad()
|
|
{
|
|
// set model once we have the basics
|
|
model_ = new RideCacheModel(context, this);
|
|
|
|
// after the first ridecache refresh we set initial pd estimates
|
|
first= true;
|
|
connect(context, SIGNAL(refreshEnd()), this, SLOT(initEstimates()));
|
|
|
|
// now refresh just in case.
|
|
refresh();
|
|
|
|
// do we have any stale items ?
|
|
connect(context, SIGNAL(configChanged(qint32)), this, SLOT(configChanged(qint32)));
|
|
|
|
}
|
|
|
|
struct comparerideitem { bool operator()(const RideItem *p1, const RideItem *p2) { return p1->dateTime < p2->dateTime; } };
|
|
|
|
int
|
|
RideCache::find(RideItem *dt)
|
|
{
|
|
// use lower_bound to binary search
|
|
QVector<RideItem*>::const_iterator i = std::lower_bound(rides_.begin(), rides_.end(), dt, comparerideitem());
|
|
int index = i - rides_.begin();
|
|
|
|
// did it find the right value?
|
|
if (index < 0 || index >= rides_.count() || rides_.at(index)->dateTime != dt->dateTime) return -1;
|
|
return index;
|
|
}
|
|
|
|
RideCache::~RideCache()
|
|
{
|
|
exiting = true;
|
|
|
|
// cancel any refresh that may be running
|
|
cancel();
|
|
|
|
// save to store
|
|
save();
|
|
}
|
|
|
|
void
|
|
RideCache::garbageCollect()
|
|
{
|
|
foreach(RideItem *item, delete_) {
|
|
if (item) item->deleteLater();
|
|
}
|
|
delete_.clear();
|
|
}
|
|
|
|
void
|
|
RideCache::initEstimates()
|
|
{
|
|
// kickoff first calculation
|
|
if (first) {
|
|
first = false;
|
|
estimator->calculate();
|
|
}
|
|
}
|
|
|
|
void
|
|
RideCache::configChanged(qint32 what)
|
|
{
|
|
|
|
// if the wbal formula changed invalidate all cached values
|
|
if (what & CONFIG_WBAL) {
|
|
foreach(RideItem *item, rides()) {
|
|
if (item->isOpen()) item->ride()->wstale=true;
|
|
}
|
|
}
|
|
|
|
// if metadata changed then recompute diary text
|
|
if (what & CONFIG_FIELDS) {
|
|
foreach(RideItem *item, rides()) {
|
|
item->metadata_.insert("Calendar Text", GlobalContext::context()->rideMetadata->calendarText(item));
|
|
}
|
|
}
|
|
|
|
// if zones or weight has changed refresh metrics
|
|
// will add more as they come
|
|
qint32 want = CONFIG_ATHLETE | CONFIG_ZONES | CONFIG_NOTECOLOR | CONFIG_DISCOVERY | CONFIG_GENERAL | CONFIG_USERMETRICS;
|
|
if (what & want) {
|
|
|
|
// restart !
|
|
cancel();
|
|
refresh();
|
|
}
|
|
}
|
|
|
|
void
|
|
RideCache::itemChanged()
|
|
{
|
|
// one of our kids changed, they grow up so fast.
|
|
// NOTE ONLY CONNECT THIS TO RIDEITEMS !!!
|
|
// BECAUSE IT IS ASSUMED BELOW THE SENDER IS A RIDEITEM
|
|
RideItem *item = static_cast<RideItem*>(QObject::sender());
|
|
|
|
// the model is particularly interested in ANY item that changes
|
|
emit itemChanged(item);
|
|
|
|
// current ride changed is more relevant for the charts lets notify
|
|
// them the ride they're showing has changed
|
|
if (item == context->currentRideItem()) {
|
|
|
|
context->notifyRideChanged(item);
|
|
}
|
|
}
|
|
|
|
// add a new ride
|
|
void
|
|
RideCache::addRide(QString name, bool dosignal, bool select, bool useTempActivities, bool planned)
|
|
{
|
|
RideItem *prior = context->ride;
|
|
|
|
// ignore malformed names
|
|
QDateTime dt;
|
|
if (!RideFile::parseRideFileName(name, &dt)) return;
|
|
|
|
// new ride item
|
|
RideItem *last;
|
|
if (useTempActivities)
|
|
last = new RideItem(context->athlete->home->tmpActivities().canonicalPath(), name, dt, context, false);
|
|
else if (planned)
|
|
last = new RideItem(plannedDirectory.canonicalPath(), name, dt, context, planned);
|
|
else
|
|
last = new RideItem(directory.canonicalPath(), name, dt, context, planned);
|
|
|
|
connect(last, SIGNAL(rideDataChanged()), this, SLOT(itemChanged()));
|
|
connect(last, SIGNAL(rideMetadataChanged()), this, SLOT(itemChanged()));
|
|
|
|
// now add to the list, or replace if already there
|
|
bool added = false;
|
|
for (int index=0; index < rides_.count(); index++) {
|
|
if (rides_[index]->fileName == last->fileName) {
|
|
rides_[index] = last;
|
|
added = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// add and sort, model needs to know !
|
|
if (!added) {
|
|
model_->beginReset();
|
|
rides_ << last;
|
|
std::sort(rides_.begin(), rides_.end(), rideCacheLessThan);
|
|
model_->endReset();
|
|
}
|
|
|
|
// refresh metrics for *this ride only*
|
|
last->refresh();
|
|
|
|
if (dosignal) context->notifyRideAdded(last); // here so emitted BEFORE rideSelected is emitted!
|
|
|
|
// free up memory from last one, which is no biggie when importing
|
|
// a single ride, but means we don't exhaust memory when we import
|
|
// hundreds/thousands of rides in a batch import.
|
|
if (prior) prior->close();
|
|
|
|
// notify everyone to select it
|
|
if (select) {
|
|
context->ride = last;
|
|
context->notifyRideSelected(last);
|
|
} else{
|
|
// notify everyone to select the one we were already on
|
|
context->notifyRideSelected(prior);
|
|
}
|
|
|
|
// model estimates (lazy refresh)
|
|
estimator->refresh();
|
|
}
|
|
|
|
bool
|
|
RideCache::removeCurrentRide() {
|
|
|
|
// if there is no current activity to delete then return
|
|
if (context->ride == NULL) return false;
|
|
|
|
// pass the current ride filename for deletion
|
|
return removeRide(context->ride->fileName);
|
|
}
|
|
|
|
bool
|
|
RideCache::removeRide(const QString& filenameToDelete) {
|
|
|
|
// if there is no file activity to delete then return
|
|
if (filenameToDelete.isEmpty()) return false;
|
|
|
|
RideItem* select = NULL; // ride to select once its gone
|
|
RideItem* todelete = NULL;
|
|
int index = 0; // index to wipe out
|
|
|
|
// find the filenameToDelete in the list and if it happens to be the
|
|
// the current ride then select another one immediately after it, but
|
|
// if it is the last one on the list select the one before
|
|
for (index = 0; index < rides_.count(); index++) {
|
|
|
|
RideItem* rideI = rides_[index];
|
|
|
|
if (rideI->fileName == filenameToDelete) {
|
|
|
|
// bingo!
|
|
todelete = rideI;
|
|
|
|
// if the ride to be deleted happens to be the current ride, then select another
|
|
if (context->ride == todelete) {
|
|
if (rides_.count() - index > 1) select = rides_[index + 1];
|
|
else if (index > 0) select = rides_[index - 1];
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// WTAF!?
|
|
if (!todelete) {
|
|
qDebug()<<"ERROR: delete not found.";
|
|
return false;
|
|
}
|
|
|
|
// If this activity is linked, unlink it first
|
|
if (todelete->hasLinkedActivity()) {
|
|
QString linkedFileName = todelete->getLinkedFileName();
|
|
RideItem *linkedItem = getLinkedActivity(todelete);
|
|
if (linkedItem) {
|
|
linkedItem->clearLinkedFileName();
|
|
QString error;
|
|
saveActivity(linkedItem, error);
|
|
}
|
|
}
|
|
|
|
// dataprocessor runs on "save" which is a short
|
|
// hand for add, update, delete
|
|
DataProcessorFactory::instance().autoProcess(todelete->ride(), "Save", "DELETE");
|
|
|
|
// remove from the cache, before deleting it this is so
|
|
// any aggregating functions no longer see it, when recalculating
|
|
// during aride deleted operation
|
|
// but model needs to know about this!
|
|
model_->startRemove(index);
|
|
rides_.remove(index, 1);
|
|
delete_<<todelete;
|
|
model_->endRemove(index);
|
|
|
|
// delete the file by renaming it
|
|
QFile file((todelete->planned ? plannedDirectory : directory).canonicalPath() + "/" + filenameToDelete);
|
|
|
|
// purposefully don't remove the old ext so the user wouldn't have to figure out what the old file type was
|
|
QString strNewName = filenameToDelete + ".bak";
|
|
|
|
// in case there was an existing bak file, delete it
|
|
// ignore errors since it probably isn't there.
|
|
QFile::remove(context->athlete->home->fileBackup().canonicalPath() + "/" + strNewName);
|
|
|
|
if (!file.rename(context->athlete->home->fileBackup().canonicalPath() + "/" + strNewName)) {
|
|
QMessageBox::critical(NULL, "Rename Error", tr("Can't rename %1 to %2 in %3")
|
|
.arg(filenameToDelete).arg(strNewName).arg(context->athlete->home->fileBackup().canonicalPath()));
|
|
}
|
|
|
|
// remove any other derived/additional files; notes, cpi etc (they can only exist in /cache )
|
|
QStringList extras;
|
|
extras << "notes" << "cpi" << "cpx";
|
|
foreach (QString extension, extras) {
|
|
|
|
QString deleteMe = QFileInfo(filenameToDelete).baseName() + "." + extension;
|
|
QFile::remove(context->athlete->home->cache().canonicalPath() + "/" + deleteMe);
|
|
}
|
|
|
|
if (select) {
|
|
|
|
// we don't want the whole delete, select next flicker
|
|
context->mainWindow->setUpdatesEnabled(false);
|
|
|
|
// select a different ride
|
|
context->ride = select;
|
|
|
|
// notify after removed from list
|
|
context->notifyRideDeleted(todelete);
|
|
|
|
// now we can update
|
|
context->mainWindow->setUpdatesEnabled(true);
|
|
QApplication::processEvents();
|
|
|
|
// now select another ride
|
|
context->notifyRideSelected(select);
|
|
|
|
} else {
|
|
// re-select the context ride (if it exists) when deleting a non current ride
|
|
context->notifyRideSelected(context->ride);
|
|
}
|
|
|
|
// model estimates (lazy refresh)
|
|
estimator->refresh();
|
|
|
|
return true;
|
|
}
|
|
|
|
// NOTE:
|
|
// We use a bison parser to reduce memory
|
|
// overhead and (believe it or not) simplicity
|
|
// RideCache::load() and save() -- see RideDB.y
|
|
|
|
// export metrics to csv, for users to play with R, Matlab, Excel etc
|
|
void
|
|
RideCache::writeAsCSV(QString filename)
|
|
{
|
|
const RideMetricFactory &factory = RideMetricFactory::instance();
|
|
QVector<const RideMetric *> indexed(factory.metricCount());
|
|
|
|
// get metrics indexed in same order as the array
|
|
foreach(QString name, factory.allMetrics()) {
|
|
|
|
const RideMetric *m = factory.rideMetric(name);
|
|
indexed[m->index()] = m;
|
|
}
|
|
|
|
// open file.. truncate if exists already
|
|
QFile file(filename);
|
|
if (!file.open(QFile::WriteOnly)) {
|
|
QMessageBox msgBox;
|
|
msgBox.setIcon(QMessageBox::Critical);
|
|
msgBox.setText(tr("Problem Saving Ride Cache"));
|
|
msgBox.setInformativeText(tr("File: %1 cannot be opened for 'Writing'. Please check file properties.").arg(filename));
|
|
msgBox.exec();
|
|
return;
|
|
};
|
|
file.resize(0);
|
|
QTextStream out(&file);
|
|
|
|
// write headings
|
|
out<<"date, time, filename";
|
|
foreach(const RideMetric *m, indexed) {
|
|
if (m->name().startsWith("BikeScore"))
|
|
out <<", BikeScore";
|
|
else
|
|
out <<", " <<m->name();
|
|
}
|
|
out<<"\n";
|
|
|
|
// write values
|
|
foreach(RideItem *item, rides()) {
|
|
|
|
// date, time, filename
|
|
out << item->dateTime.date().toString(Qt::ISODate);
|
|
out << "," << item->dateTime.time().toString("hh:mm:ss");
|
|
out << "," << item->fileName;
|
|
|
|
// values
|
|
foreach(double value, item->metrics()) {
|
|
out << "," << QString("%1").arg(value, 'f').simplified();
|
|
}
|
|
|
|
out<<"\n";
|
|
}
|
|
file.close();
|
|
}
|
|
|
|
int
|
|
RideCache::nextRefresh()
|
|
{
|
|
int returning=-1;
|
|
updateMutex.lock();
|
|
|
|
if (updates < 0) {
|
|
returning = -1; // force termination by returning -1
|
|
} else if (updates < reverse_.count()) {
|
|
returning = updates;
|
|
updates++;
|
|
progressing(returning);
|
|
}
|
|
updateMutex.unlock();
|
|
return(returning);
|
|
}
|
|
|
|
void
|
|
RideCache::threadCompleted(RideCacheRefreshThread*thread)
|
|
{
|
|
updateMutex.lock();
|
|
refreshThreads.removeOne(thread);
|
|
updateMutex.unlock();
|
|
|
|
if (refreshThreads.count() == 0) {
|
|
//fprintf(stderr,"refresh ended\n"); fflush(stderr);
|
|
context->notifyRefreshEnd();
|
|
garbageCollect();
|
|
save();
|
|
}
|
|
}
|
|
|
|
void
|
|
RideCache::progressing(int value)
|
|
{
|
|
// we're working away, notfy everyone where we got
|
|
progress_ = 100.0f * (double(value) / double(reverse_.count()));
|
|
|
|
// Avoid GUI event queue overflow- update every for every decile
|
|
if (reverse_.count() && (reverse_.count()/10) && (value == reverse_.count() || value % (reverse_.count()/10) == 1)) {
|
|
QDate here = reverse_.at(value-1)->dateTime.date();
|
|
context->notifyRefreshUpdate(here);
|
|
}
|
|
}
|
|
|
|
// cancel the refresh map, we're about to exit !
|
|
void
|
|
RideCache::cancel()
|
|
{
|
|
updateMutex.lock();
|
|
QVector<RideCacheRefreshThread*>current = refreshThreads;
|
|
updates=-1;
|
|
updateMutex.unlock();
|
|
|
|
// wait till threads are empty, but use our copy as the master
|
|
// is going to be changing as threads terminate and we need to be
|
|
// sure all our threads have stopped before returning.
|
|
foreach(RideCacheRefreshThread *thread, current) {
|
|
thread->wait();
|
|
}
|
|
}
|
|
|
|
// check if we need to refresh the metrics then start the thread if needed
|
|
void
|
|
RideCache::refresh()
|
|
{
|
|
// already on it !
|
|
if (refreshThreads.count()) return;
|
|
|
|
// how many need refreshing ?
|
|
int staleCount = 0;
|
|
|
|
foreach(RideItem *item, rides_) {
|
|
// ok set stale so we refresh
|
|
if (item->checkStale())
|
|
staleCount++;
|
|
}
|
|
|
|
// start if there is work to do
|
|
// and future watcher can notify of updates
|
|
if (staleCount) {
|
|
|
|
reverse_ = rides_;
|
|
std::sort(reverse_.begin(), reverse_.end(), rideCacheGreaterThan);
|
|
//future = QtConcurrent::map(reverse_, itemRefresh);
|
|
//watcher.setFuture(future);
|
|
|
|
// calculate number of threads and work per thread
|
|
int maxthreads = QThreadPool::globalInstance()->maxThreadCount();
|
|
int threads = maxthreads / 2;
|
|
if (threads==0) threads=1; // need at least one!
|
|
int n=0;
|
|
|
|
// refresh happenning
|
|
updates = 0;
|
|
context->notifyRefreshStart();
|
|
|
|
while(n++ < threads) {
|
|
|
|
// if goes past last make it the last
|
|
RideCacheRefreshThread *thread = new RideCacheRefreshThread(this);
|
|
refreshThreads << thread;
|
|
thread->start();
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// wait five seconds, so mainwindow can get up and running...
|
|
QTimer::singleShot(5000, context, SLOT(notifyRefreshEnd()));
|
|
}
|
|
}
|
|
|
|
QString
|
|
RideCache::getAggregate(QString name, Specification spec, bool useMetricUnits, bool nofmt)
|
|
{
|
|
// get the metric details, so we can convert etc
|
|
const RideMetric *metric = RideMetricFactory::instance().rideMetric(name);
|
|
if (!metric) {
|
|
qDebug()<<"unknown metric:"<<name;
|
|
return QString("%1 unknown").arg(name);
|
|
}
|
|
|
|
// what we will return
|
|
double rvalue = 0;
|
|
double rcount = 0; // using double to avoid rounding issues with int when dividing
|
|
|
|
// loop through and aggregate
|
|
foreach (RideItem *item, rides()) {
|
|
|
|
// skip filtered rides
|
|
if (!spec.pass(item)) continue;
|
|
|
|
// get this value
|
|
double value = item->getForSymbol(name);
|
|
double count = item->getCountForSymbol(name); // for averaging
|
|
|
|
// check values are bounded, just in case
|
|
if (std::isnan(value) || std::isinf(value)) value = 0;
|
|
|
|
// do we aggregate zero values ?
|
|
bool aggZero = metric->aggregateZero();
|
|
|
|
// set aggZero to false and value to zero if is temperature and -255
|
|
if (metric->symbol() == "average_temp" && value == RideFile::NA) {
|
|
value = 0;
|
|
aggZero = false;
|
|
}
|
|
|
|
switch (metric->type()) {
|
|
case RideMetric::RunningTotal:
|
|
case RideMetric::Total:
|
|
rvalue += value;
|
|
break;
|
|
default:
|
|
case RideMetric::Average:
|
|
{
|
|
// average should be calculated taking into account
|
|
// the duration of the ride, otherwise high value but
|
|
// short rides will skew the overall average
|
|
if (value || aggZero) {
|
|
rvalue += value*count;
|
|
rcount += count;
|
|
}
|
|
break;
|
|
}
|
|
case RideMetric::Low:
|
|
{
|
|
if (value < rvalue) rvalue = value;
|
|
break;
|
|
}
|
|
case RideMetric::Peak:
|
|
{
|
|
if (value > rvalue) rvalue = value;
|
|
break;
|
|
}
|
|
case RideMetric::MeanSquareRoot:
|
|
{
|
|
rvalue = sqrt((pow(rvalue, 2)*rcount + pow(value,2)*count)/(rcount + count));
|
|
rcount += count;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// now compute the average
|
|
if (metric->type() == RideMetric::Average) {
|
|
if (rcount) rvalue = rvalue / rcount;
|
|
}
|
|
|
|
const_cast<RideMetric*>(metric)->setValue(rvalue);
|
|
// Format appropriately
|
|
QString result;
|
|
if (metric->units(useMetricUnits) == "seconds" ||
|
|
metric->units(useMetricUnits) == tr("seconds")) {
|
|
if (nofmt) result = QString("%1").arg(rvalue);
|
|
else result = metric->toString(useMetricUnits);
|
|
|
|
} else result = metric->toString(useMetricUnits);
|
|
|
|
// 0 temp from aggregate means no values
|
|
if ((metric->symbol() == "average_temp" || metric->symbol() == "max_temp") && result == "0.0") result = "-";
|
|
return result;
|
|
}
|
|
|
|
bool rideCachesummaryBestGreaterThan(const AthleteBest &s1, const AthleteBest &s2)
|
|
{
|
|
return s1.nvalue > s2.nvalue;
|
|
}
|
|
|
|
bool rideCachesummaryBestLowerThan(const AthleteBest &s1, const AthleteBest &s2)
|
|
{
|
|
return s1.nvalue < s2.nvalue;
|
|
}
|
|
|
|
QList<AthleteBest>
|
|
RideCache::getBests(QString symbol, int n, Specification specification, bool useMetricUnits)
|
|
{
|
|
QList<AthleteBest> results;
|
|
|
|
// get the metric details, so we can convert etc
|
|
const RideMetric *metric = RideMetricFactory::instance().rideMetric(symbol);
|
|
if (!metric) return results;
|
|
|
|
// loop through and aggregate
|
|
foreach (RideItem *ride, rides_) {
|
|
|
|
// skip filtered rides
|
|
if (!specification.pass(ride)) continue;
|
|
|
|
// get this value
|
|
AthleteBest add;
|
|
add.nvalue = ride->getForSymbol(symbol, true);
|
|
add.date = ride->dateTime.date();
|
|
|
|
const_cast<RideMetric*>(metric)->setValue(add.nvalue);
|
|
add.value = metric->toString(useMetricUnits);
|
|
|
|
// nil values are not needed
|
|
if (add.nvalue < 0 || add.nvalue > 0) results << add;
|
|
}
|
|
|
|
// now sort
|
|
std::stable_sort(results.begin(), results.end(), metric->isLowerBetter() ?
|
|
rideCachesummaryBestLowerThan :
|
|
rideCachesummaryBestGreaterThan);
|
|
|
|
// truncate
|
|
if (results.count() > n) results.erase(results.begin()+n,results.end());
|
|
|
|
// return the array with the right number of entries in #1 - n order
|
|
return results;
|
|
}
|
|
|
|
QList<QDateTime>
|
|
RideCache::getAllDates()
|
|
{
|
|
QList<QDateTime> returning;
|
|
foreach(RideItem *item, rides()) {
|
|
returning << item->dateTime;
|
|
}
|
|
return returning;
|
|
}
|
|
|
|
QStringList
|
|
RideCache::getAllFilenames()
|
|
{
|
|
QStringList returning;
|
|
foreach(RideItem *item, rides()) {
|
|
returning << item->fileName;
|
|
}
|
|
return returning;
|
|
}
|
|
|
|
RideItem *
|
|
RideCache::getRide(QString filename)
|
|
{
|
|
foreach(RideItem *item, rides())
|
|
if (item->fileName == filename)
|
|
return item;
|
|
return NULL;
|
|
}
|
|
|
|
|
|
RideItem*
|
|
RideCache::getRide
|
|
(const QString &filename, bool planned)
|
|
{
|
|
for (RideItem *rideItem : rides()) {
|
|
if (rideItem != nullptr && rideItem->planned == planned && rideItem->fileName == filename) {
|
|
return rideItem;
|
|
}
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
|
|
RideItem *
|
|
RideCache::getRide(QDateTime dateTime)
|
|
{
|
|
foreach(RideItem *item, rides())
|
|
if (item->dateTime == dateTime)
|
|
return item;
|
|
return NULL;
|
|
}
|
|
|
|
|
|
|
|
QHash<QString,int>
|
|
RideCache::getRankedValues(QString field)
|
|
{
|
|
QHash<QString, int> returning;
|
|
foreach(RideItem *item, rides()) {
|
|
QString value = item->metadata().value(field, "");
|
|
if (value != "") {
|
|
int count = returning.value(value,0);
|
|
returning.insert(value,++count);
|
|
}
|
|
}
|
|
return returning;
|
|
}
|
|
|
|
class OrderedList {
|
|
public:
|
|
OrderedList(QString string, int rank) : string(string), rank(rank) {}
|
|
QString string;
|
|
int rank;
|
|
};
|
|
|
|
bool rideCacheOrderListGreaterThan(const OrderedList a, const OrderedList b) { return a.rank > b.rank; }
|
|
|
|
QStringList
|
|
RideCache::getDistinctValues(QString field)
|
|
{
|
|
QStringList returning;
|
|
|
|
// ranked
|
|
QHashIterator<QString,int> i(getRankedValues(field));
|
|
QList<OrderedList> ranked;
|
|
while(i.hasNext()) {
|
|
i.next();
|
|
ranked << OrderedList(i.key(), i.value());
|
|
}
|
|
|
|
// sort from big to small
|
|
std::sort(ranked.begin(), ranked.end(), rideCacheOrderListGreaterThan);
|
|
|
|
// extract ordered values
|
|
foreach(OrderedList x, ranked)
|
|
returning << x.string;
|
|
|
|
return returning;
|
|
}
|
|
|
|
void
|
|
RideCache::getRideTypeCounts(Specification specification, int& nActivities,
|
|
int& nRides, int& nRuns, int& nSwims, QString& sport)
|
|
{
|
|
nActivities = nRides = nRuns = nSwims = 0;
|
|
sport = "";
|
|
|
|
// loop through and aggregate
|
|
foreach (RideItem *ride, rides_) {
|
|
|
|
// skip filtered rides
|
|
if (!specification.pass(ride)) continue;
|
|
|
|
// sport is not empty only when all activities are from the same sport
|
|
if (nActivities == 0) sport = ride->sport;
|
|
else if (sport != ride-> sport) sport = "";
|
|
|
|
nActivities++;
|
|
if (ride->isSwim) nSwims++;
|
|
else if (ride->isRun) nRuns++;
|
|
else if (ride->isBike) nRides++;
|
|
}
|
|
}
|
|
|
|
bool
|
|
RideCache::isMetricRelevantForRides(Specification specification,
|
|
const RideMetric* metric,
|
|
SportRestriction sport)
|
|
{
|
|
// loop through and aggregate
|
|
foreach (RideItem *ride, rides_) {
|
|
|
|
// skip filtered rides
|
|
if (!specification.pass(ride)) continue;
|
|
|
|
// skip non selected sports when restriction supplied
|
|
if ((sport == OnlyRides) && !ride->isBike) continue;
|
|
if ((sport == OnlyRuns) && !ride->isRun) continue;
|
|
if ((sport == OnlySwims) && !ride->isSwim) continue;
|
|
if ((sport == OnlyXtrains) && !ride->isXtrain) continue;
|
|
|
|
if (metric->isRelevantForRide(ride)) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
|
|
RideCache::OperationPreCheck
|
|
RideCache::checkLinkActivities
|
|
(RideItem *item1, RideItem *item2)
|
|
{
|
|
OperationPreCheck check;
|
|
|
|
if (! isValidLink(item1, item2, check.blockingReason)) {
|
|
check.canProceed = false;
|
|
return check;
|
|
}
|
|
if (item1->hasLinkedActivity()) {
|
|
check.canProceed = false;
|
|
check.blockingReason = tr("%1 is already linked to %2").arg(item1->fileName).arg(item1->getLinkedFileName());
|
|
return check;
|
|
}
|
|
if (item2->hasLinkedActivity()) {
|
|
check.canProceed = false;
|
|
check.blockingReason = tr("%1 is already linked to %2").arg(item2->fileName).arg(item2->getLinkedFileName());
|
|
return check;
|
|
}
|
|
|
|
check.affectedItems << item1 << item2;
|
|
if (item1->isDirty()) {
|
|
check.dirtyItems << item1;
|
|
}
|
|
if (item2->isDirty()) {
|
|
check.dirtyItems << item2;
|
|
}
|
|
if (! check.dirtyItems.isEmpty()) {
|
|
check.requiresUserDecision = true;
|
|
QStringList dirtyNames;
|
|
for (RideItem *item : check.dirtyItems) {
|
|
dirtyNames << item->fileName;
|
|
}
|
|
check.warningMessage = tr(
|
|
"The following activities have unsaved changes:\n%1\n\n"
|
|
"Linking will modify both activities. You must save or discard changes first.")
|
|
.arg(dirtyNames.join("\n"));
|
|
}
|
|
|
|
return check;
|
|
}
|
|
|
|
|
|
RideCache::OperationResult
|
|
RideCache::linkActivities
|
|
(RideItem *item1, RideItem *item2)
|
|
{
|
|
OperationResult result;
|
|
|
|
item1->setLinkedFileName(item2->fileName);
|
|
item2->setLinkedFileName(item1->fileName);
|
|
|
|
result.success = true;
|
|
result.affectedCount = 2;
|
|
|
|
emit itemChanged(item1);
|
|
emit itemChanged(item2);
|
|
|
|
return result;
|
|
}
|
|
|
|
|
|
RideCache::OperationPreCheck
|
|
RideCache::checkUnlinkActivity
|
|
(RideItem *item)
|
|
{
|
|
OperationPreCheck check;
|
|
|
|
if (! item) {
|
|
check.canProceed = false;
|
|
check.blockingReason = tr("No activity given");
|
|
return check;
|
|
}
|
|
QString linkedFileName = item->getLinkedFileName();
|
|
if (linkedFileName.isEmpty()) {
|
|
check.canProceed = false;
|
|
check.blockingReason = tr("Activity is not linked");
|
|
return check;
|
|
}
|
|
RideItem *linkedItem = getLinkedActivity(item);
|
|
if (! linkedItem) {
|
|
check.canProceed = false;
|
|
check.blockingReason = tr("Linked activity not found: %1").arg(linkedFileName);
|
|
return check;
|
|
}
|
|
|
|
check.affectedItems << item << linkedItem;
|
|
if (item->isDirty()) {
|
|
check.dirtyItems << item;
|
|
}
|
|
if (linkedItem->isDirty()) {
|
|
check.dirtyItems << linkedItem;
|
|
}
|
|
if (! check.dirtyItems.isEmpty()) {
|
|
check.requiresUserDecision = true;
|
|
QStringList dirtyNames;
|
|
for (RideItem *item : check.dirtyItems) {
|
|
dirtyNames << item->fileName;
|
|
}
|
|
check.warningMessage = tr(
|
|
"The following activities have unsaved changes:\n%1\n\n"
|
|
"Unlinking will modify both activities. You must save or discard changes first.")
|
|
.arg(dirtyNames.join("\n"));
|
|
}
|
|
|
|
return check;
|
|
}
|
|
|
|
|
|
RideCache::OperationResult
|
|
RideCache::unlinkActivity
|
|
(RideItem *item)
|
|
{
|
|
OperationResult result;
|
|
|
|
RideItem *linkedItem = getLinkedActivity(item);
|
|
|
|
linkedItem->clearLinkedFileName();
|
|
item->clearLinkedFileName();
|
|
|
|
result.success = true;
|
|
result.affectedCount = 2;
|
|
|
|
emit itemChanged(item);
|
|
emit itemChanged(linkedItem);
|
|
|
|
return result;
|
|
}
|
|
|
|
|
|
RideCache::OperationPreCheck
|
|
RideCache::checkUnlinkActivities
|
|
(const QList<RideItem*> &items)
|
|
{
|
|
OperationPreCheck batchCheck;
|
|
|
|
if (items.isEmpty()) {
|
|
batchCheck.canProceed = false;
|
|
batchCheck.blockingReason = tr("No activities given");
|
|
return batchCheck;
|
|
}
|
|
|
|
QSet<RideItem*> processedItems;
|
|
for (RideItem *item : items) {
|
|
if (! item || processedItems.contains(item)) {
|
|
continue;
|
|
}
|
|
OperationPreCheck itemCheck = checkUnlinkActivity(item);
|
|
if (! itemCheck.canProceed) {
|
|
continue;
|
|
}
|
|
batchCheck.affectedItems.append(itemCheck.affectedItems);
|
|
batchCheck.dirtyItems.append(itemCheck.dirtyItems);
|
|
for (RideItem *affectedItem : itemCheck.affectedItems) {
|
|
processedItems.insert(affectedItem);
|
|
}
|
|
}
|
|
if (batchCheck.affectedItems.isEmpty()) {
|
|
batchCheck.canProceed = false;
|
|
batchCheck.blockingReason = tr("No valid linked activities to unlink");
|
|
return batchCheck;
|
|
}
|
|
if (! batchCheck.dirtyItems.isEmpty()) {
|
|
batchCheck.requiresUserDecision = true;
|
|
QStringList dirtyNames;
|
|
for (RideItem *item : batchCheck.dirtyItems) {
|
|
dirtyNames << item->fileName;
|
|
}
|
|
batchCheck.warningMessage = tr(
|
|
"The following activities have unsaved changes:\n%1\n\n"
|
|
"Unlinking will modify these activities. You must save or discard changes first.")
|
|
.arg(dirtyNames.join("\n"));
|
|
}
|
|
|
|
return batchCheck;
|
|
}
|
|
|
|
|
|
RideCache::OperationResult
|
|
RideCache::unlinkActivities
|
|
(const QList<RideItem*> &items)
|
|
{
|
|
OperationResult batchResult;
|
|
QSet<RideItem*> processedItems;
|
|
|
|
for (RideItem *item : items) {
|
|
if (! item || processedItems.contains(item)) {
|
|
continue;
|
|
}
|
|
RideItem *linkedItem = getLinkedActivity(item);
|
|
if (! linkedItem) {
|
|
continue;
|
|
}
|
|
if (processedItems.contains(linkedItem)) {
|
|
continue;
|
|
}
|
|
OperationResult itemResult = unlinkActivity(item);
|
|
if (itemResult.success) {
|
|
batchResult.affectedCount += itemResult.affectedCount;
|
|
processedItems.insert(item);
|
|
processedItems.insert(linkedItem);
|
|
}
|
|
}
|
|
batchResult.success = (batchResult.affectedCount > 0);
|
|
return batchResult;
|
|
}
|
|
|
|
|
|
RideCache::OperationPreCheck
|
|
RideCache::checkMoveActivity
|
|
(RideItem *item, const QDateTime &newDateTime)
|
|
{
|
|
OperationPreCheck check;
|
|
|
|
if (! item) {
|
|
check.canProceed = false;
|
|
check.blockingReason = tr("No activity given");
|
|
return check;
|
|
}
|
|
if (! newDateTime.isValid()) {
|
|
check.canProceed = false;
|
|
check.blockingReason = tr("Invalid date/time specified");
|
|
return check;
|
|
}
|
|
|
|
QFileInfo oldInfo(item->fileName);
|
|
QString newFileName = newDateTime.toString("yyyy_MM_dd_HH_mm_ss") + "." + oldInfo.suffix();
|
|
QString newPath = (item->planned ? plannedDirectory : directory).canonicalPath() + "/" + newFileName;
|
|
if (QFile::exists(newPath)) {
|
|
check.canProceed = false;
|
|
check.blockingReason = tr("Target file already exists: %1").arg(newFileName);
|
|
return check;
|
|
}
|
|
check.affectedItems << item;
|
|
if (item->isDirty()) {
|
|
check.dirtyItems << item;
|
|
}
|
|
|
|
RideItem *linkedItem = getLinkedActivity(item);
|
|
if (linkedItem) {
|
|
check.affectedItems << linkedItem;
|
|
if (linkedItem->isDirty()) {
|
|
check.dirtyItems << linkedItem;
|
|
}
|
|
}
|
|
if (! check.dirtyItems.isEmpty()) {
|
|
check.requiresUserDecision = true;
|
|
QStringList dirtyNames;
|
|
for (RideItem *dirtyItem : check.dirtyItems) {
|
|
dirtyNames << dirtyItem->fileName;
|
|
}
|
|
check.warningMessage = tr(
|
|
"The following activities have unsaved changes:\n%1\n\n"
|
|
"Moving will update the link reference. You must save or discard changes first.")
|
|
.arg(dirtyNames.join("\n"));
|
|
}
|
|
return check;
|
|
}
|
|
|
|
|
|
RideCache::OperationResult
|
|
RideCache::moveActivity
|
|
(RideItem *item, const QDateTime &newDateTime)
|
|
{
|
|
OperationResult result;
|
|
|
|
QString oldFileName = item->fileName;
|
|
QDateTime oldDateTime = item->dateTime;
|
|
|
|
QFileInfo oldInfo(oldFileName);
|
|
QString newFileName = newDateTime.toString("yyyy_MM_dd_HH_mm_ss") + "." + oldInfo.suffix();
|
|
|
|
RideFile *ride = item->ride(true);
|
|
if (! ride) {
|
|
result.error = tr("Failed to open activity file");
|
|
return result;
|
|
}
|
|
|
|
item->setStartTime(newDateTime);
|
|
ride->setTag("Year", newDateTime.toString("yyyy"));
|
|
ride->setTag("Month", newDateTime.toString("MMMM"));
|
|
ride->setTag("Weekday", newDateTime.toString("ddd"));
|
|
item->metadata_.insert("Calendar Text", GlobalContext::context()->rideMetadata->calendarText(item));
|
|
item->close();
|
|
|
|
QString renameError;
|
|
if (! renameRideFiles(oldFileName, newFileName, item->planned, renameError)) {
|
|
item->dateTime = oldDateTime;
|
|
item->fileName = oldFileName;
|
|
result.error = tr("Failed to rename files: %1").arg(renameError);
|
|
return result;
|
|
}
|
|
|
|
int index = rides_.indexOf(item);
|
|
if (index >= 0) {
|
|
model_->startRemove(index);
|
|
rides_.remove(index, 1);
|
|
model_->endRemove(index);
|
|
}
|
|
|
|
item->setFileName((item->planned ? plannedDirectory : directory).canonicalPath(), newFileName);
|
|
|
|
model_->beginReset();
|
|
rides_ << item;
|
|
std::sort(rides_.begin(), rides_.end(), rideCacheLessThan);
|
|
model_->endReset();
|
|
|
|
item->isstale = true;
|
|
|
|
RideItem *linkedItem = getLinkedActivity(item);
|
|
if (linkedItem) {
|
|
linkedItem->setLinkedFileName(newFileName);
|
|
emit itemChanged(linkedItem);
|
|
result.affectedCount = 2;
|
|
} else {
|
|
result.affectedCount = 1;
|
|
}
|
|
|
|
if (item->planned) {
|
|
updateFromWorkout(item, false);
|
|
}
|
|
|
|
item->refresh();
|
|
context->notifyRideChanged(item);
|
|
if (context->ride == item) {
|
|
context->notifyRideSelected(item);
|
|
}
|
|
estimator->refresh();
|
|
|
|
result.success = true;
|
|
|
|
return result;
|
|
}
|
|
|
|
|
|
RideCache::OperationPreCheck
|
|
RideCache::checkCopyPlannedActivity
|
|
(RideItem *sourceItem, const QDate &newDate)
|
|
{
|
|
OperationPreCheck check;
|
|
|
|
if (! sourceItem) {
|
|
check.canProceed = false;
|
|
check.blockingReason = tr("No activity given");
|
|
return check;
|
|
}
|
|
if (! newDate.isValid()) {
|
|
check.canProceed = false;
|
|
check.blockingReason = tr("Invalid date specified");
|
|
return check;
|
|
}
|
|
|
|
QDateTime newDateTime(newDate, sourceItem->dateTime.time());
|
|
QFileInfo oldInfo(sourceItem->fileName);
|
|
QString newFileName = newDateTime.toString("yyyy_MM_dd_HH_mm_ss") + "." + oldInfo.suffix();
|
|
QString newPath = plannedDirectory.canonicalPath() + "/" + newFileName;
|
|
if (QFile::exists(newPath)) {
|
|
check.canProceed = false;
|
|
check.blockingReason = tr("Target file already exists: %1").arg(newFileName);
|
|
return check;
|
|
}
|
|
|
|
return check;
|
|
}
|
|
|
|
|
|
RideCache::OperationResult
|
|
RideCache::copyPlannedActivity
|
|
(RideItem *sourceItem, const QDate &newDate)
|
|
{
|
|
OperationResult result;
|
|
|
|
QString error;
|
|
RideItem *newItem = copyPlannedRideFile(sourceItem, newDate, error);
|
|
|
|
if (! newItem) {
|
|
result.error = error;
|
|
return result;
|
|
}
|
|
|
|
model_->beginReset();
|
|
rides_ << newItem;
|
|
std::sort(rides_.begin(), rides_.end(), rideCacheLessThan);
|
|
model_->endReset();
|
|
|
|
newItem->refresh();
|
|
|
|
result.success = true;
|
|
result.affectedCount = 1;
|
|
|
|
return result;
|
|
}
|
|
|
|
|
|
RideCache::OperationPreCheck
|
|
RideCache::checkCopyPlannedActivities
|
|
(const QList<std::pair<RideItem*, QDate>> &sourceItemsAndTargets)
|
|
{
|
|
OperationPreCheck check;
|
|
|
|
if (sourceItemsAndTargets.isEmpty()) {
|
|
check.canProceed = false;
|
|
check.blockingReason = tr("No items specified");
|
|
return check;
|
|
}
|
|
|
|
for (const std::pair<RideItem*, QDate> &pair : sourceItemsAndTargets) {
|
|
RideItem *sourceItem = pair.first;
|
|
QDate targetDate = pair.second;
|
|
|
|
if (! sourceItem) {
|
|
check.canProceed = false;
|
|
check.blockingReason = tr("Invalid source item");
|
|
return check;
|
|
}
|
|
if (! sourceItem->planned) {
|
|
check.canProceed = false;
|
|
check.blockingReason = tr("Source item is not a planned activity: %1").arg(sourceItem->fileName);
|
|
return check;
|
|
}
|
|
if (! targetDate.isValid()) {
|
|
check.canProceed = false;
|
|
check.blockingReason = tr("Invalid target date for: %1").arg(sourceItem->fileName);
|
|
return check;
|
|
}
|
|
|
|
QDateTime newDateTime(targetDate, sourceItem->dateTime.time());
|
|
QFileInfo oldInfo(sourceItem->fileName);
|
|
QString newFileName = newDateTime.toString("yyyy_MM_dd_HH_mm_ss") + "." + oldInfo.suffix();
|
|
QString newPath = plannedDirectory.canonicalPath() + "/" + newFileName;
|
|
|
|
if (QFile::exists(newPath)) {
|
|
check.canProceed = false;
|
|
check.blockingReason = tr("Target file already exists: %1").arg(newFileName);
|
|
return check;
|
|
}
|
|
}
|
|
|
|
return check;
|
|
}
|
|
|
|
|
|
RideCache::OperationResult
|
|
RideCache::copyPlannedActivities
|
|
(const QList<std::pair<RideItem*, QDate>> &sourceItemsAndTargets)
|
|
{
|
|
OperationResult result;
|
|
|
|
if (sourceItemsAndTargets.isEmpty()) {
|
|
result.error = tr("No files specified");
|
|
return result;
|
|
}
|
|
|
|
QList<RideItem*> newItems;
|
|
QStringList failedFiles;
|
|
for (const std::pair<RideItem*, QDate> &pair : sourceItemsAndTargets) {
|
|
QString error;
|
|
RideItem *newItem = copyPlannedRideFile(pair.first, pair.second, error);
|
|
if (newItem) {
|
|
newItems << newItem;
|
|
} else {
|
|
failedFiles << pair.first->fileName;
|
|
}
|
|
}
|
|
|
|
if (! newItems.isEmpty()) {
|
|
model_->beginReset();
|
|
rides_ << newItems;
|
|
std::sort(rides_.begin(), rides_.end(), rideCacheLessThan);
|
|
model_->endReset();
|
|
foreach(RideItem *item, newItems) {
|
|
item->refresh();
|
|
}
|
|
refresh();
|
|
estimator->refresh();
|
|
}
|
|
if (! failedFiles.isEmpty()) {
|
|
result.error = tr("Failed to copy %1 of %2 activities: %3")
|
|
.arg(failedFiles.count())
|
|
.arg(sourceItemsAndTargets.count())
|
|
.arg(failedFiles.join(", "));
|
|
}
|
|
|
|
result.success = !newItems.isEmpty();
|
|
result.affectedCount = newItems.count();
|
|
|
|
return result;
|
|
}
|
|
|
|
|
|
RideCache::OperationPreCheck
|
|
RideCache::checkShiftPlannedActivities
|
|
(const QDate &fromDate, int dayOffset)
|
|
{
|
|
OperationPreCheck check;
|
|
|
|
if (! fromDate.isValid()) {
|
|
check.canProceed = false;
|
|
check.blockingReason = tr("Invalid from date specified");
|
|
return check;
|
|
}
|
|
if (dayOffset == 0) {
|
|
check.canProceed = true;
|
|
return check;
|
|
}
|
|
|
|
QList<RideItem*> itemsToShift;
|
|
for (RideItem *item : rides_) {
|
|
if (item->planned && item->dateTime.date() >= fromDate) {
|
|
itemsToShift.append(item);
|
|
check.affectedItems << item;
|
|
}
|
|
}
|
|
if (itemsToShift.isEmpty()) {
|
|
check.canProceed = true;
|
|
return check;
|
|
}
|
|
|
|
for (RideItem *item : itemsToShift) {
|
|
RideItem *linkedItem = getLinkedActivity(item);
|
|
if (linkedItem && ! linkedItem->planned) {
|
|
check.affectedItems << linkedItem;
|
|
}
|
|
}
|
|
for (RideItem *item : check.affectedItems) {
|
|
if (item->isDirty()) {
|
|
check.dirtyItems << item;
|
|
}
|
|
}
|
|
|
|
if (! check.dirtyItems.isEmpty()) {
|
|
check.requiresUserDecision = true;
|
|
|
|
QStringList plannedDirty;
|
|
QStringList actualDirty;
|
|
for (RideItem *item : check.dirtyItems) {
|
|
if (item->planned) {
|
|
plannedDirty << item->fileName;
|
|
} else {
|
|
actualDirty << item->fileName;
|
|
}
|
|
}
|
|
QString msg = tr("This operation will shift %1 planned activities.\n\n").arg(itemsToShift.count());
|
|
if (! plannedDirty.isEmpty()) {
|
|
msg += tr("Planned activities with unsaved changes:\n%1\n\n").arg(plannedDirty.join("\n"));
|
|
}
|
|
if (! actualDirty.isEmpty()) {
|
|
msg += tr("Linked actual activities with unsaved changes:\n%1\n\n").arg(actualDirty.join("\n"));
|
|
}
|
|
|
|
msg += tr("All affected activities must be saved or changes discarded before shifting.");
|
|
check.warningMessage = msg;
|
|
}
|
|
|
|
return check;
|
|
}
|
|
|
|
|
|
RideCache::OperationResult
|
|
RideCache::shiftPlannedActivities
|
|
(const QDate &fromDate, int dayOffset)
|
|
{
|
|
OperationResult result;
|
|
|
|
if (dayOffset == 0) {
|
|
result.success = true;
|
|
result.affectedCount = 0;
|
|
return result;
|
|
}
|
|
QList<RideItem*> itemsToShift;
|
|
for (RideItem *item : rides_) {
|
|
if (item->planned && item->dateTime.date() >= fromDate) {
|
|
itemsToShift.append(item);
|
|
}
|
|
}
|
|
if (itemsToShift.isEmpty()) {
|
|
result.success = true;
|
|
result.affectedCount = 0;
|
|
return result;
|
|
}
|
|
|
|
// prevent shifting any activity to before fromDate
|
|
int effectiveOffset = dayOffset;
|
|
if (dayOffset < 0) {
|
|
QDate earliestDate = itemsToShift[0]->dateTime.date();
|
|
for (RideItem *item : itemsToShift) {
|
|
if (item->dateTime.date() < earliestDate) {
|
|
earliestDate = item->dateTime.date();
|
|
}
|
|
}
|
|
int maxBackwardShift = fromDate.daysTo(earliestDate);
|
|
if (-dayOffset > maxBackwardShift) {
|
|
effectiveOffset = -maxBackwardShift;
|
|
}
|
|
if (effectiveOffset == 0) {
|
|
result.success = true;
|
|
result.affectedCount = 0;
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// avoid filename collisions: copy forward / backward, depending on offset
|
|
if (effectiveOffset > 0) {
|
|
std::sort(itemsToShift.begin(), itemsToShift.end(), [](RideItem *a, RideItem *b) { return a->dateTime > b->dateTime; });
|
|
} else {
|
|
std::sort(itemsToShift.begin(), itemsToShift.end(), [](RideItem *a, RideItem *b) { return a->dateTime < b->dateTime; });
|
|
}
|
|
|
|
QStringList failedFiles;
|
|
int successCount = 0;
|
|
for (RideItem *item : itemsToShift) {
|
|
QString oldFileName = item->fileName;
|
|
QDate newDate = item->dateTime.date().addDays(effectiveOffset);
|
|
QDateTime newDateTime(newDate, item->dateTime.time());
|
|
|
|
QFileInfo oldInfo(oldFileName);
|
|
QString newFileName = newDateTime.toString("yyyy_MM_dd_HH_mm_ss") + "." + oldInfo.suffix();
|
|
|
|
RideFile *ride = item->ride(true);
|
|
if (! ride) {
|
|
failedFiles << oldFileName;
|
|
continue;
|
|
}
|
|
|
|
item->setStartTime(newDateTime);
|
|
ride->setTag("Year", newDateTime.toString("yyyy"));
|
|
ride->setTag("Month", newDateTime.toString("MMMM"));
|
|
ride->setTag("Weekday", newDateTime.toString("ddd"));
|
|
item->metadata_.insert("Calendar Text", GlobalContext::context()->rideMetadata->calendarText(item));
|
|
item->close();
|
|
|
|
QString renameError;
|
|
if (! renameRideFiles(oldFileName, newFileName, true, renameError)) {
|
|
failedFiles << oldFileName;
|
|
continue;
|
|
}
|
|
item->setFileName(plannedDirectory.canonicalPath(), newFileName);
|
|
updateFromWorkout(item, true);
|
|
item->isstale = true;
|
|
|
|
RideItem *linkedItem = getLinkedActivity(item);
|
|
if (linkedItem) {
|
|
linkedItem->setLinkedFileName(item->fileName);
|
|
emit itemChanged(linkedItem);
|
|
}
|
|
|
|
successCount++;
|
|
}
|
|
|
|
if (successCount > 0) {
|
|
model_->beginReset();
|
|
std::sort(rides_.begin(), rides_.end(), rideCacheLessThan);
|
|
model_->endReset();
|
|
|
|
refresh();
|
|
estimator->refresh();
|
|
}
|
|
|
|
if (! failedFiles.isEmpty()) {
|
|
result.error = tr("Failed to shift %1 of %2 activities: %3")
|
|
.arg(failedFiles.count())
|
|
.arg(itemsToShift.count())
|
|
.arg(failedFiles.join(", "));
|
|
}
|
|
|
|
result.success = true;
|
|
result.affectedCount = successCount;
|
|
|
|
return result;
|
|
}
|
|
|
|
|
|
bool
|
|
RideCache::saveActivity
|
|
(RideItem *item, QString &error)
|
|
{
|
|
error = "";
|
|
if (! item) {
|
|
error = tr("No activity given");
|
|
return false;
|
|
}
|
|
if (item->isDirty()) {
|
|
context->mainWindow->saveSilent(context, item);
|
|
item->setDirty(false);
|
|
emit itemSaved(item);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
|
|
bool
|
|
RideCache::saveActivities
|
|
(QList<RideItem*> items, QString &error)
|
|
{
|
|
QStringList failed;
|
|
|
|
for (RideItem *item : items) {
|
|
QString itemError;
|
|
if (! saveActivity(item, itemError)) {
|
|
failed << item->fileName;
|
|
}
|
|
}
|
|
if (! failed.isEmpty()) {
|
|
error = tr("Failed to save: %1").arg(failed.join(", "));
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
bool
|
|
RideCache::renameRideFiles
|
|
(const QString &oldFileName, const QString &newFileName, bool isPlanned, QString &error)
|
|
{
|
|
QFileInfo oldInfo(oldFileName);
|
|
QFileInfo newInfo(newFileName);
|
|
|
|
QDir activeDir = isPlanned ? plannedDirectory : directory;
|
|
|
|
QString oldPath = activeDir.canonicalPath() + "/" + oldFileName;
|
|
QString newPath = activeDir.canonicalPath() + "/" + newFileName;
|
|
|
|
if (! QFile::rename(oldPath, newPath)) {
|
|
error = tr("Failed to rename activity file from %1 to %2").arg(oldFileName).arg(newFileName);
|
|
return false;
|
|
}
|
|
|
|
QStringList extensions;
|
|
extensions << "notes" << "cpi" << "cpx";
|
|
for (const QString &ext : extensions) {
|
|
QString oldExtPath = context->athlete->home->cache().canonicalPath() + "/" + oldInfo.baseName() + "." + ext;
|
|
QString newExtPath = context->athlete->home->cache().canonicalPath() + "/" + newInfo.baseName() + "." + ext;
|
|
if (QFile::exists(oldExtPath)) {
|
|
QFile::rename(oldExtPath, newExtPath);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
RideItem*
|
|
RideCache::getLinkedActivity
|
|
(RideItem *item)
|
|
{
|
|
if (! item) {
|
|
return nullptr;
|
|
}
|
|
QString linkedFileName = item->getLinkedFileName();
|
|
if (linkedFileName.isEmpty()) {
|
|
return nullptr;
|
|
}
|
|
return getRide(linkedFileName, ! item->planned);
|
|
}
|
|
|
|
|
|
RideItem*
|
|
RideCache::findSuggestion
|
|
(RideItem *rideItem)
|
|
{
|
|
RideItem *closest = nullptr;
|
|
for (RideItem *o: this->context->athlete->rideCache->rides()) {
|
|
if ( o != nullptr
|
|
&& o->planned == ! rideItem->planned
|
|
&& o->dateTime.date() == rideItem->dateTime.date()
|
|
&& o->sport == rideItem->sport) {
|
|
if (closest == nullptr) {
|
|
closest = o;
|
|
} else if (std::abs(rideItem->dateTime.time().secsTo(o->dateTime.time())) < std::abs(rideItem->dateTime.time().secsTo(closest->dateTime.time()))) {
|
|
closest = o;
|
|
}
|
|
}
|
|
if (o->dateTime.date() > rideItem->dateTime.date()) {
|
|
break;
|
|
}
|
|
}
|
|
return closest;
|
|
}
|
|
|
|
|
|
bool
|
|
RideCache::updateFromWorkout
|
|
(RideItem *item, bool autoSave)
|
|
{
|
|
if (item == nullptr || ! item->planned) {
|
|
return false;
|
|
}
|
|
QString workoutFilename = item->getText("WorkoutFilename", item->ride()->getTag("WorkoutFilename", "")).trimmed();
|
|
if (workoutFilename.isEmpty()) {
|
|
return false;
|
|
}
|
|
ErgFile ergFile(workoutFilename, ErgFileFormat::unknown, context, item->dateTime.date());
|
|
if (! ergFile.hasRelativeWatts()) {
|
|
return false;
|
|
}
|
|
bool changed = false;
|
|
for (const QString &name : item->overrides_) {
|
|
int value = static_cast<int>(item->getForSymbol(name));
|
|
// Operate only on the values overridden by ManualActivityWizard
|
|
if (name == "average_power") {
|
|
if (value != std::round(ergFile.AP())) {
|
|
QMap<QString, QString> values;
|
|
values.insert("value", QString::number(std::round(ergFile.AP())));
|
|
item->ride()->metricOverrides.insert(name, values);
|
|
changed = true;
|
|
}
|
|
} else if (name == "coggan_np") {
|
|
if (value != std::round(ergFile.IsoPower())) {
|
|
QMap<QString, QString> values;
|
|
values.insert("value", QString::number(std::round(ergFile.IsoPower())));
|
|
item->ride()->metricOverrides.insert(name, values);
|
|
changed = true;
|
|
}
|
|
} else if (name == "coggan_tss") {
|
|
if (value != std::round(ergFile.bikeStress())) {
|
|
QMap<QString, QString> values;
|
|
values.insert("value", QString::number(std::round(ergFile.bikeStress())));
|
|
item->ride()->metricOverrides.insert(name, values);
|
|
changed = true;
|
|
}
|
|
} else if (name == "skiba_bike_score") {
|
|
if (value != std::round(ergFile.BS())) {
|
|
QMap<QString, QString> values;
|
|
values.insert("value", QString::number(std::round(ergFile.BS())));
|
|
item->ride()->metricOverrides.insert(name, values);
|
|
changed = true;
|
|
}
|
|
} else if (name == "skiba_xpower") {
|
|
if (value != std::round(ergFile.XP())) {
|
|
QMap<QString, QString> values;
|
|
values.insert("value", QString::number(std::round(ergFile.XP())));
|
|
item->ride()->metricOverrides.insert(name, values);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
if (changed) {
|
|
item->setDirty(true);
|
|
item->isstale = true;
|
|
if (autoSave) {
|
|
QString error;
|
|
saveActivity(item, error);
|
|
}
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
|
|
bool
|
|
RideCache::updateFromWorkoutAfter
|
|
(const QDate &when, bool autoSave)
|
|
{
|
|
QList<RideItem*> changedItems;
|
|
for (RideItem *item : context->athlete->rideCache->rides()) {
|
|
if (item->planned && item->dateTime.date() >= when) {
|
|
if (context->athlete->rideCache->updateFromWorkout(item, false)) {
|
|
changedItems << item;
|
|
}
|
|
}
|
|
}
|
|
if (changedItems.count() > 0) {
|
|
if (autoSave) {
|
|
QString error;
|
|
saveActivities(changedItems, error);
|
|
}
|
|
cancel();
|
|
refresh();
|
|
estimator->refresh();
|
|
}
|
|
return changedItems.count() > 0;
|
|
}
|
|
|
|
|
|
bool
|
|
RideCache::isValidLink
|
|
(RideItem *item1, RideItem *item2, QString &error)
|
|
{
|
|
error = "";
|
|
if (! item1 || ! item2) {
|
|
error = tr("Invalid activities for linking");
|
|
return false;
|
|
}
|
|
if (item1 == item2) {
|
|
error = tr("Can't link to self");
|
|
return false;
|
|
}
|
|
if (item1->planned == item2->planned) {
|
|
error = tr("Cannot link two activities of the same type. One must be planned, one actual.");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
|
|
RideItem*
|
|
RideCache::copyPlannedRideFile
|
|
(RideItem *sourceItem, const QDate &newDate, QString &error)
|
|
{
|
|
QDateTime newDateTime(newDate, sourceItem->dateTime.time());
|
|
QFileInfo oldInfo(sourceItem->fileName);
|
|
QString newFileName = newDateTime.toString("yyyy_MM_dd_HH_mm_ss") + "." + oldInfo.suffix();
|
|
QString newPath = plannedDirectory.canonicalPath() + "/" + newFileName;
|
|
QString sourcePath = plannedDirectory.canonicalPath() + "/" + sourceItem->fileName;
|
|
|
|
if (! QFile::copy(sourcePath, newPath)) {
|
|
error = tr("Failed to copy file");
|
|
return nullptr;
|
|
}
|
|
|
|
QFile file(newPath);
|
|
QStringList errors;
|
|
RideFile *newRide = RideFileFactory::instance().openRideFile(context, file, errors);
|
|
if (! newRide) {
|
|
QFile::remove(newPath);
|
|
error = tr("Failed to open copied file");
|
|
return nullptr;
|
|
}
|
|
|
|
newRide->setStartTime(QDateTime(newDate, sourceItem->dateTime.time()));
|
|
newRide->setTag("Year", newDateTime.toString("yyyy"));
|
|
newRide->setTag("Month", newDateTime.toString("MMMM"));
|
|
newRide->setTag("Weekday", newDateTime.toString("ddd"));
|
|
|
|
if (! newRide->getTag("Linked Filename", "").isEmpty()) {
|
|
newRide->removeTag("Linked Filename");
|
|
}
|
|
|
|
QFile outFile(newPath);
|
|
if (! RideFileFactory::instance().writeRideFile(context, newRide, outFile, oldInfo.suffix())) {
|
|
error = tr("Failed to write modified file");
|
|
delete newRide;
|
|
QFile::remove(newPath);
|
|
return nullptr;
|
|
}
|
|
delete newRide;
|
|
|
|
RideItem *newItem = new RideItem(plannedDirectory.canonicalPath(), newFileName, newDateTime, context, true);
|
|
updateFromWorkout(newItem, true);
|
|
newItem->isstale = true;
|
|
|
|
return newItem;
|
|
}
|
|
|
|
|
|
// refresh metrics
|
|
void RideCacheRefreshThread::run()
|
|
{
|
|
//fprintf(stderr, "worker thread starts!\n"); fflush(stderr);
|
|
while (1) {
|
|
|
|
int n = cache->nextRefresh();
|
|
//fprintf(stderr, "refreshing %d of %d\n", n+1, cache->reverse_.count()); fflush(stderr);
|
|
if (n<0) {
|
|
//fprintf(stderr, "worker thread exits!\n"); fflush(stderr);
|
|
goto exitthread;
|
|
}
|
|
|
|
// we have one to do
|
|
RideItem *item = cache->reverse_[n];
|
|
if(item->isstale) {
|
|
item->refresh();
|
|
if (item == item->context->currentRideItem())
|
|
item->context->notifyRideChanged(item);
|
|
}
|
|
}
|
|
|
|
exitthread:
|
|
cache->threadCompleted(this);
|
|
return;
|
|
}
|