Compare commits

...

2 Commits

Author SHA1 Message Date
Alejandro Martinez
6ab99506ad AppVeyor - run lupdate on Windows
For Linux and macOS this was part of before_build.sh
Also remove now unused site-packages from cache section
2026-01-30 21:19:20 -03:00
Joachim Kohlhammer
a36f52760b Copy & Paste for planned activities on calendar (#4819)
* Planned activities can be copied
* Pasting:
  * Month view: Target time is the same time as the source activity
  * Day + week view: Time at mouse pointer is used as target time
  * If the slot at target time is not long enough to take the full
    activity, the closest free slot is used
  * If no free slot could be found, the activity is pasted at a free
    start time (h:00, h:15, h:30, h:45)
  * Night hours (midnight until start hour + end hour until midnight)
    are considered blocked
2026-01-30 18:21:47 -03:00
10 changed files with 258 additions and 29 deletions

View File

@@ -94,7 +94,6 @@ cache:
- qwt -> qwt/qwtconfig.pri.in, appveyor.yml
- srmio -> appveyor.yml
- D2XX
- site-packages
- src\Python\SIP\release\sip_lib.lib -> src\Python\SIP\goldencheetah.sip
- src\Python\SIP\libsip_lib.a -> src\Python\SIP\goldencheetah.sip
@@ -160,9 +159,6 @@ install:
before_build:
# Windows
# Define GC version string, only for tagged builds
- cmd: if %APPVEYOR_REPO_TAG%==true echo DEFINES+=GC_VERSION=VERSION_STRING >> src\gcconfig.pri
# Enable CloudDB
- cmd: echo CloudDB=active >> src\gcconfig.pri
@@ -179,11 +175,18 @@ before_build:
# Avoid conflicts between Windows.h min/max macros and limits.h
- cmd: echo DEFINES+=NOMINMAX >> src\gcconfig.pri
# Linux / macOS
- sh: bash appveyor/$OS_NAME/before_build.sh
# Define GC version string, only for tagged builds
- sh: if $APPVEYOR_REPO_TAG; then echo DEFINES+=GC_VERSION=VERSION_STRING >> src/gcconfig.pri; fi
# For tagged builds, define GC version string
- ps: if ($env:APPVEYOR_REPO_TAG -eq "true") { Add-Content src/gcconfig.pri "DEFINES += GC_VERSION=VERSION_STRING" }
# Show config.
- ps: Get-Content src/gcconfig.pri
# update translations
- cmd: lupdate src\src.pro
- sh: lupdate src/src.pro
# Patch Secrets.h (Windows / Linux / macOS)
- ps: (Get-Content src\Core\Secrets.h) -replace '__GC_GOOGLE_CALENDAR_CLIENT_SECRET__', $env:GC_GOOGLE_CALENDAR_CLIENT_SECRET | Set-Content src\Core\Secrets.h

View File

@@ -40,8 +40,3 @@ sed -i "s|#GSL_LIBS =.*|GSL_LIBS = -lgsl -lgslcblas -lm|" src/gcconfig.pri
# TrainerDay Query API
echo DEFINES += GC_WANT_TRAINERDAY_API >> src/gcconfig.pri
echo DEFINES += GC_TRAINERDAY_API_PAGESIZE=25 >> src/gcconfig.pri
cat src/gcconfig.pri
# update translations
lupdate src/src.pro
exit

View File

@@ -75,10 +75,3 @@ echo DEFINES += GC_TRAINERDAY_API_PAGESIZE=25 >> src/gcconfig.pri
echo "QMAKE_CXXFLAGS += -mmacosx-version-min=10.7 -arch x86_64" >> src/gcconfig.pri
echo "QMAKE_CFLAGS_RELEASE += -mmacosx-version-min=10.7 -arch x86_64" >> src/gcconfig.pri
echo "QMAKE_MACOSX_DEPLOYMENT_TARGET = 10.15" >> src/gcconfig.pri
cat src/gcconfig.pri
# update translations
lupdate src/src.pro
exit

View File

@@ -359,6 +359,8 @@ CalendarWindow::CalendarWindow(Context *context)
this->context->mainWindow->selectAnalysis();
}
});
connect(calendar, &Calendar::copyPlannedActivity, this, &CalendarWindow::copyPlannedActivity);
connect(calendar, &Calendar::pastePlannedActivity, this, &CalendarWindow::pastePlannedActivity);
connect(calendar, &Calendar::addActivity, this, [this](bool plan, const QDate &day, const QTime &time) {
this->context->tab->setNoSwitch(true);
ManualActivityWizard wizard(this->context, plan, QDateTime(day, time));
@@ -1190,6 +1192,142 @@ CalendarWindow::getPrimary
}
QTime
CalendarWindow::findFreeSlot
(RideItem *sourceItem, QDate newDate, QTime targetTime)
{
if (sourceItem == nullptr) {
return QTime();
}
QList<std::pair<QTime, int>> busySlots;
busySlots.append(std::make_pair(QTime(0, 0), getStartHour() * 60 * 60));
for (RideItem *rideItem : context->athlete->rideCache->rides()) {
if (rideItem != nullptr && rideItem->planned == sourceItem->planned && rideItem->dateTime.date() == newDate) {
busySlots.append(std::make_pair(rideItem->dateTime.time(), static_cast<int>(rideItem->getForSymbol("workout_time"))));
}
}
busySlots.append(std::make_pair(QTime(getEndHour(), 0), (24 - getEndHour()) * 60 * 60 - 1));
if (! targetTime.isValid()) {
targetTime = sourceItem->dateTime.time();
}
QTime newTime = findFreeSlot(busySlots, targetTime, static_cast<int>(sourceItem->getForSymbol("workout_time")));
return newTime;
}
QTime
CalendarWindow::findFreeSlot
(QList<std::pair<QTime, int>> busySlots, QTime targetTime, int requiredDurationSeconds) const
{
std::sort(busySlots.begin(), busySlots.end(), [](const std::pair<QTime, int> &a, const std::pair<QTime, int> &b) {
return a.first < b.first;
});
QSet<QTime> forbiddenTimes;
for (const std::pair<QTime, int> &busySlot : busySlots) {
forbiddenTimes.insert(busySlot.first);
}
QTime bestSlot;
int bestDistance = 24 * 60 * 60;
bool foundFullSlot = false;
QTime dayStart = QTime(0, 0, 0);
QTime dayEnd = QTime(23, 59, 59);
QList<std::pair<QTime, QTime>> busyPeriods;
for (const std::pair<QTime, int> &busySlot : busySlots) {
QTime start = busySlot.first;
QTime end = busySlot.first.addSecs(busySlot.second);
if (busyPeriods.isEmpty()) {
busyPeriods.append({start, end});
} else {
std::pair<QTime, QTime> &last = busyPeriods.last();
if (start <= last.second) {
last.second = std::max(last.second, end);
} else {
busyPeriods.append({start, end});
}
}
}
QList<std::pair<QTime, QTime>> freeIntervals;
if (busyPeriods.isEmpty()) {
freeIntervals.append({dayStart, dayEnd});
} else {
if (busyPeriods.first().first > dayStart) {
freeIntervals.append({dayStart, busyPeriods.first().first.addSecs(-1)});
}
for (int i = 0; i < busyPeriods.size() - 1; ++i) {
QTime currentEnd = busyPeriods[i].second;
QTime nextStart = busyPeriods[i + 1].first;
if (currentEnd < nextStart) {
freeIntervals.append({currentEnd, nextStart.addSecs(-1)});
}
}
QTime lastEnd = busyPeriods.last().second;
if (lastEnd <= dayEnd) {
freeIntervals.append({lastEnd, dayEnd});
}
}
for (const std::pair<QTime, QTime> &freeInterval : freeIntervals) {
QTime intervalStart = freeInterval.first;
QTime intervalEnd = freeInterval.second;
int intervalDuration = intervalStart.secsTo(intervalEnd) + 1;
if (intervalDuration >= requiredDurationSeconds) {
QTime candidate = intervalStart;
if (! forbiddenTimes.contains(candidate)) {
int distance = qAbs(candidate.secsTo(targetTime));
if (distance < bestDistance) {
bestDistance = distance;
bestSlot = candidate;
foundFullSlot = true;
}
}
if (targetTime >= intervalStart && targetTime <= intervalEnd) {
if (targetTime.secsTo(intervalEnd) + 1 >= requiredDurationSeconds) {
if (! forbiddenTimes.contains(targetTime)) {
bestSlot = targetTime;
foundFullSlot = true;
break;
}
}
}
candidate = intervalEnd.addSecs(-(requiredDurationSeconds - 1));
if (candidate >= intervalStart && ! forbiddenTimes.contains(candidate)) {
int distance = std::abs(candidate.secsTo(targetTime));
if (distance < bestDistance) {
bestDistance = distance;
bestSlot = candidate;
foundFullSlot = true;
}
}
}
}
if (foundFullSlot) {
return bestSlot;
}
if (! forbiddenTimes.contains(targetTime)) {
return targetTime;
}
const int increment = 15 * 60;
int targetSecs = QTime(0, 0, 0).secsTo(targetTime);
int maxSecs = QTime(0, 0, 0).secsTo(QTime(23, 59, 59));
int maxSteps = (maxSecs / increment) + 1;
for (int step = 1; step <= maxSteps; ++step) {
int offset = step * increment;
if (targetSecs + offset <= maxSecs) {
QTime candidate = QTime(0, 0, 0).addSecs(targetSecs + offset);
if (! forbiddenTimes.contains(candidate)) {
return candidate;
}
}
if (targetSecs - offset >= 0) {
QTime candidate = QTime(0, 0, 0).addSecs(targetSecs - offset);
if (! forbiddenTimes.contains(candidate)) {
return candidate;
}
}
}
return QTime();
}
void
CalendarWindow::updateActivities
()
@@ -1405,6 +1543,66 @@ CalendarWindow::unlinkActivities
}
void
CalendarWindow::copyPlannedActivity
(CalendarEntry activity)
{
RideItem *rideItem = getRideItem(activity);
if (rideItem != nullptr) {
QClipboard *clipboard = QGuiApplication::clipboard();
QByteArray data;
QDataStream stream(&data, QIODevice::WriteOnly);
stream << activity.primary << activity.reference;
QMimeData *mimeData = new QMimeData();
mimeData->setData(PLANNED_MIME_TYPE, data);
clipboard->setMimeData(mimeData);
}
}
void
CalendarWindow::pastePlannedActivity
(QDate day, QTime time)
{
QClipboard *clipboard = QGuiApplication::clipboard();
const QMimeData *mimeData = clipboard->mimeData();
if (mimeData && mimeData->hasFormat(PLANNED_MIME_TYPE)) {
QByteArray data = mimeData->data(PLANNED_MIME_TYPE);
QDataStream stream(data);
QString primary;
QString reference;
stream >> primary >> reference;
RideItem *sourceItem = nullptr;
for (RideItem *rideItem : context->athlete->rideCache->rides()) {
if (rideItem != nullptr && rideItem->planned && rideItem->fileName == reference) {
sourceItem = rideItem;
break;
}
}
time = findFreeSlot(sourceItem, day, time);
RideCache::OperationPreCheck check = context->athlete->rideCache->checkCopyPlannedActivity(sourceItem, day, time);
if (check.canProceed) {
if (proceedDialog(context, check)) {
context->tab->setNoSwitch(true);
RideCache::OperationResult result = context->athlete->rideCache->copyPlannedActivity(sourceItem, day, time);
if (result.success) {
QString error;
context->athlete->rideCache->saveActivities(check.affectedItems, error);
// Context::rideDeleted is not always emitted, therefore forcing the update
updateActivities();
} else {
QMessageBox::warning(this, tr("Paste failed: %1").arg(primary), result.error);
}
context->tab->setNoSwitch(false);
}
} else {
QMessageBox::warning(this, tr("Paste failed: %1").arg(primary), check.blockingReason);
}
}
}
void
CalendarWindow::addEvent
(const QDate &date)

View File

@@ -159,6 +159,8 @@ class CalendarWindow : public GcChartWindow
QHash<QDate, QList<CalendarEntry>> getPhasesEvents(const Season &season, const QDate &firstDay, const QDate &lastDay) const;
RideItem *getRideItem(const CalendarEntry &entry, bool linked = false);
QString getPrimary(RideItem const * const rideItem) const;
QTime findFreeSlot(RideItem *sourceItem, QDate newDate, QTime time);
QTime findFreeSlot(QList<std::pair<QTime, int>> busySlots, QTime targetTime, int requiredDurationSeconds) const;
private slots:
void updateActivities();
@@ -168,6 +170,8 @@ class CalendarWindow : public GcChartWindow
void shiftPlannedActivities(const QDate &destDay, int offset);
void linkActivities(const CalendarEntry &entry, bool autoLink);
bool unlinkActivities(const CalendarEntry &entry);
void copyPlannedActivity(CalendarEntry activity);
void pastePlannedActivity(QDate day, QTime time);
void addEvent(const QDate &date);
void editEvent(const CalendarEntry &entry);
void delEvent(const CalendarEntry &entry);

View File

@@ -1246,7 +1246,7 @@ RideCache::moveActivity
RideCache::OperationPreCheck
RideCache::checkCopyPlannedActivity
(RideItem *sourceItem, const QDate &newDate)
(RideItem *sourceItem, const QDate &newDate, QTime newTime)
{
OperationPreCheck check;
@@ -1260,8 +1260,12 @@ RideCache::checkCopyPlannedActivity
check.blockingReason = tr("Invalid date specified");
return check;
}
QTime time(sourceItem->dateTime.time());
if (newTime.isValid()) {
time = newTime;
}
QDateTime newDateTime(newDate, sourceItem->dateTime.time());
QDateTime newDateTime(newDate, time);
QFileInfo oldInfo(sourceItem->fileName);
QString newFileName = newDateTime.toString("yyyy_MM_dd_HH_mm_ss") + "." + oldInfo.suffix();
QString newPath = plannedDirectory.canonicalPath() + "/" + newFileName;
@@ -1277,12 +1281,16 @@ RideCache::checkCopyPlannedActivity
RideCache::OperationResult
RideCache::copyPlannedActivity
(RideItem *sourceItem, const QDate &newDate)
(RideItem *sourceItem, const QDate &newDate, QTime newTime)
{
OperationResult result;
QString error;
RideItem *newItem = copyPlannedRideFile(sourceItem, newDate, error);
QTime time(sourceItem->dateTime.time());
if (newTime.isValid()) {
time = newTime;
}
RideItem *newItem = copyPlannedRideFile(sourceItem, newDate, time, error);
if (! newItem) {
result.error = error;
@@ -1366,7 +1374,7 @@ RideCache::copyPlannedActivities
QStringList failedFiles;
for (const std::pair<RideItem*, QDate> &pair : sourceItemsAndTargets) {
QString error;
RideItem *newItem = copyPlannedRideFile(pair.first, pair.second, error);
RideItem *newItem = copyPlannedRideFile(pair.first, pair.second, QTime(), error);
if (newItem) {
newItems << newItem;
} else {
@@ -1806,9 +1814,9 @@ RideCache::isValidLink
RideItem*
RideCache::copyPlannedRideFile
(RideItem *sourceItem, const QDate &newDate, QString &error)
(RideItem *sourceItem, const QDate &newDate, const QTime &newTime, QString &error)
{
QDateTime newDateTime(newDate, sourceItem->dateTime.time());
QDateTime newDateTime(newDate, newTime);
QFileInfo oldInfo(sourceItem->fileName);
QString newFileName = newDateTime.toString("yyyy_MM_dd_HH_mm_ss") + "." + oldInfo.suffix();
QString newPath = plannedDirectory.canonicalPath() + "/" + newFileName;

View File

@@ -135,8 +135,8 @@ class RideCache : public QObject
OperationPreCheck checkMoveActivity(RideItem *item, const QDateTime &newDateTime);
OperationResult moveActivity(RideItem *item, const QDateTime &newDateTime);
OperationPreCheck checkCopyPlannedActivity(RideItem *sourceItem, const QDate &newDate);
OperationResult copyPlannedActivity(RideItem *sourceItem, const QDate &newDate);
OperationPreCheck checkCopyPlannedActivity(RideItem *sourceItem, const QDate &newDate, QTime newTime = QTime());
OperationResult copyPlannedActivity(RideItem *sourceItem, const QDate &newDate, QTime newTime = QTime());
OperationPreCheck checkCopyPlannedActivities(const QList<std::pair<RideItem*, QDate>> &sourceItemsAndTargets);
OperationResult copyPlannedActivities(const QList<std::pair<RideItem*, QDate>> &sourceItemsAndTargets);
@@ -220,7 +220,7 @@ class RideCache : public QObject
private:
bool renameRideFiles(const QString& oldFileName, const QString& newFileName, bool isPlanned, QString &error);
bool isValidLink(RideItem *item1, RideItem *item2, QString &error);
RideItem* copyPlannedRideFile(RideItem *sourceItem, const QDate &newDate, QString &error);
RideItem* copyPlannedRideFile(RideItem *sourceItem, const QDate &newDate, const QTime &newTime, QString &error);
};
class AthleteBest

View File

@@ -199,6 +199,7 @@ CalendarBaseTable::buildContextMenu
contextMenu->addAction(tr("View actual activity") % ellipsis, this, [this, entry]() { emit viewLinkedActivity(entry); });
}
contextMenu->addAction(tr("View planned activity") % ellipsis, this, [this, entry]() { emit viewActivity(entry); });
contextMenu->addAction(tr("Copy planned activity"), this, [this, entry]() { emit copyPlannedActivity(entry); });
contextMenu->addSeparator();
if (entry.linkedReference.isEmpty()) {
contextMenu->addAction(tr("Link to actual activity"), this, [this, entry]() { emit linkActivity(entry, true); });
@@ -264,6 +265,13 @@ CalendarBaseTable::buildContextMenu
}
emit addActivity(true, day.date, activityTime);
});
QClipboard *clipboard = QGuiApplication::clipboard();
const QMimeData *mimeData = clipboard->mimeData();
if (mimeData && mimeData->hasFormat(PLANNED_MIME_TYPE)) {
contextMenu->addAction(tr("Paste planned activity"), this, [this, day, time]() {
emit pastePlannedActivity(day.date, time);
});
}
}
if (canHavePhasesEvents) {
contextMenu->addSeparator();
@@ -1545,6 +1553,8 @@ CalendarDayView::CalendarDayView
connect(dayTable, &CalendarDayTable::unlinkActivity, this, &CalendarDayView::unlinkActivity);
connect(dayTable, &CalendarDayTable::viewActivity, this, &CalendarDayView::viewActivity);
connect(dayTable, &CalendarDayTable::viewLinkedActivity, this, &CalendarDayView::viewLinkedActivity);
connect(dayTable, &CalendarDayTable::copyPlannedActivity, this, &CalendarDayView::copyPlannedActivity);
connect(dayTable, &CalendarDayTable::pastePlannedActivity, this, &CalendarDayView::pastePlannedActivity);
connect(dayTable, &CalendarDayTable::addActivity, this, &CalendarDayView::addActivity);
connect(dayTable, &CalendarDayTable::showInTrainMode, this, &CalendarDayView::showInTrainMode);
connect(dayTable, &CalendarDayTable::filterSimilar, this, &CalendarDayView::filterSimilar);
@@ -1845,6 +1855,8 @@ CalendarWeekView::CalendarWeekView
connect(weekTable, &CalendarDayTable::unlinkActivity, this, &CalendarWeekView::unlinkActivity);
connect(weekTable, &CalendarDayTable::viewActivity, this, &CalendarWeekView::viewActivity);
connect(weekTable, &CalendarDayTable::viewLinkedActivity, this, &CalendarWeekView::viewLinkedActivity);
connect(weekTable, &CalendarDayTable::copyPlannedActivity, this, &CalendarWeekView::copyPlannedActivity);
connect(weekTable, &CalendarDayTable::pastePlannedActivity, this, &CalendarWeekView::pastePlannedActivity);
connect(weekTable, &CalendarDayTable::addActivity, this, &CalendarWeekView::addActivity);
connect(weekTable, &CalendarDayTable::showInTrainMode, this, &CalendarWeekView::showInTrainMode);
connect(weekTable, &CalendarDayTable::filterSimilar, this, &CalendarWeekView::filterSimilar);
@@ -2043,6 +2055,8 @@ Calendar::Calendar
connect(dayView, &CalendarDayView::unlinkActivity, this, &Calendar::unlinkActivity);
connect(dayView, &CalendarDayView::viewActivity, this, &Calendar::viewActivity);
connect(dayView, &CalendarDayView::viewLinkedActivity, this, &Calendar::viewLinkedActivity);
connect(dayView, &CalendarDayView::copyPlannedActivity, this, &Calendar::copyPlannedActivity);
connect(dayView, &CalendarDayView::pastePlannedActivity, this, &Calendar::pastePlannedActivity);
connect(dayView, &CalendarDayView::addActivity, this, &Calendar::addActivity);
connect(dayView, &CalendarDayView::showInTrainMode, this, &Calendar::showInTrainMode);
connect(dayView, &CalendarDayView::filterSimilar, this, &Calendar::filterSimilar);
@@ -2072,6 +2086,8 @@ Calendar::Calendar
connect(weekView, &CalendarWeekView::unlinkActivity, this, &Calendar::unlinkActivity);
connect(weekView, &CalendarWeekView::viewActivity, this, &Calendar::viewActivity);
connect(weekView, &CalendarWeekView::viewLinkedActivity, this, &Calendar::viewLinkedActivity);
connect(weekView, &CalendarWeekView::copyPlannedActivity, this, &Calendar::copyPlannedActivity);
connect(weekView, &CalendarWeekView::pastePlannedActivity, this, &Calendar::pastePlannedActivity);
connect(weekView, &CalendarWeekView::addActivity, this, &Calendar::addActivity);
connect(weekView, &CalendarWeekView::showInTrainMode, this, &Calendar::showInTrainMode);
connect(weekView, &CalendarWeekView::filterSimilar, this, &Calendar::filterSimilar);
@@ -2106,6 +2122,8 @@ Calendar::Calendar
connect(monthView, &CalendarMonthTable::unlinkActivity, this, &Calendar::unlinkActivity);
connect(monthView, &CalendarMonthTable::viewActivity, this, &Calendar::viewActivity);
connect(monthView, &CalendarMonthTable::viewLinkedActivity, this, &Calendar::viewLinkedActivity);
connect(monthView, &CalendarMonthTable::copyPlannedActivity, this, &Calendar::copyPlannedActivity);
connect(monthView, &CalendarMonthTable::pastePlannedActivity, this, &Calendar::pastePlannedActivity);
connect(monthView, &CalendarMonthTable::addActivity, this, &Calendar::addActivity);
connect(monthView, &CalendarMonthTable::repeatSchedule, this, &Calendar::repeatSchedule);
connect(monthView, &CalendarMonthTable::insertRestday, this, &Calendar::insertRestday);

View File

@@ -72,6 +72,8 @@ signals:
void linkActivity(CalendarEntry activity, bool autoLink);
void unlinkActivity(CalendarEntry activity);
void viewActivity(CalendarEntry activity);
void copyPlannedActivity(CalendarEntry activity);
void pastePlannedActivity(QDate day, QTime time);
void viewLinkedActivity(CalendarEntry activity);
void addActivity(bool plan, QDate day, QTime time);
void delActivity(CalendarEntry activity);
@@ -268,6 +270,8 @@ signals:
void unlinkActivity(CalendarEntry activity);
void viewActivity(CalendarEntry activity);
void viewLinkedActivity(CalendarEntry activity);
void copyPlannedActivity(CalendarEntry activity);
void pastePlannedActivity(QDate day, QTime time);
void addActivity(bool plan, QDate day, QTime time);
void delActivity(CalendarEntry activity);
void saveChanges(CalendarEntry activity);
@@ -322,6 +326,8 @@ signals:
void unlinkActivity(CalendarEntry activity);
void viewActivity(CalendarEntry activity);
void viewLinkedActivity(CalendarEntry activity);
void copyPlannedActivity(CalendarEntry activity);
void pastePlannedActivity(QDate day, QTime time);
void addActivity(bool plan, QDate day, QTime time);
void delActivity(CalendarEntry activity);
void saveChanges(CalendarEntry activity);
@@ -389,6 +395,8 @@ signals:
void unlinkActivity(CalendarEntry activity);
void viewActivity(CalendarEntry activity);
void viewLinkedActivity(CalendarEntry activity);
void copyPlannedActivity(CalendarEntry activity);
void pastePlannedActivity(QDate day, QTime time);
void addActivity(bool plan, QDate day, QTime time);
void delActivity(CalendarEntry activity);
void saveChanges(CalendarEntry activity);

View File

@@ -34,6 +34,8 @@
#define ENTRY_TYPE_PHASE 11
#define ENTRY_TYPE_OTHER 99
#define PLANNED_MIME_TYPE "application/x-goldencheetah-planned-activity"
struct AgendaEntry {
QString primary;