diff --git a/companion/src/CMakeLists.txt b/companion/src/CMakeLists.txt index e0db4adf4..0e4dc92f5 100644 --- a/companion/src/CMakeLists.txt +++ b/companion/src/CMakeLists.txt @@ -246,6 +246,7 @@ set(companion_SRCS wizarddata.cpp wizarddialog.cpp styleeditdialog.cpp + dialogs/filesyncdialog.cpp ) set(companion_MOC_HDRS @@ -279,6 +280,7 @@ set(companion_MOC_HDRS modelslist.h styleeditdialog.h helpers_html.h + dialogs/filesyncdialog.h ) set(companion_UIS diff --git a/companion/src/dialogs/filesyncdialog.cpp b/companion/src/dialogs/filesyncdialog.cpp new file mode 100644 index 000000000..3927a3ffd --- /dev/null +++ b/companion/src/dialogs/filesyncdialog.cpp @@ -0,0 +1,502 @@ +/* + * Copyright (C) OpenTX + * + * Based on code named + * th9x - http://code.google.com/p/th9x + * er9x - http://code.google.com/p/er9x + * gruvin9x - http://code.google.com/p/gruvin9x + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * 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. + */ + +#include "filesyncdialog.h" + +#include "autobitsetcheckbox.h" +#include "autocheckbox.h" +#include "autocombobox.h" +#include "autolineedit.h" +#include "constants.h" +#include "helpers.h" +#include "progresswidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +FileSyncDialog::FileSyncDialog(QWidget * parent, const SyncProcess::SyncOptions & syncOptions) : + QDialog(parent), + m_syncOptions(syncOptions), + m_running(false) +{ + setWindowTitle(tr("Synchronize Files")); + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + setSizeGripEnabled(true); + + setupUi(); + toggleExtraOptions(false); + validate(); + updateRunningState(); +} + +void FileSyncDialog::reject() +{ + // override close event via window system close or esc. key + if (m_running) { + if (QMessageBox::question(this, CPN_STR_APP_NAME, tr("Are you sure you wish to abort the sync?")) == QMessageBox::No) + return; + emit stopSync(); + // Force termination if the sync hasn't finished within 5s + QElapsedTimer tim; + tim.start(); + while (!tim.hasExpired(5000) && m_running) + QApplication::processEvents(QEventLoop::ExcludeUserInputEvents, 10); + if (m_running) + qWarning() << "Terminating thread while Sync process was still running."; + } + QDialog::reject(); +} + +// Create a widget with a line edit and folder select button and handles all interactions. Features autosuggest +// path hints while typing, invalid paths shown in red. +// This should probably be moved some place more reusable, esp. the QFileSystemModel. +static QWidget * folderSelectorWidget(QLineEdit * le, QWidget * parent) +{ + static QFileSystemModel fileModel; + static bool init = false; + if (!init) { + init = true; + fileModel.setFilter(QDir::Dirs); + fileModel.setRootPath("/"); + } + + QWidget * fsw = new QWidget(parent); + //le = new QLineEdit(parent); + QCompleter * fsc = new QCompleter(fsw); + fsc->setModel(&fileModel); + //fsc->setCompletionMode(QCompleter::InlineCompletion); + le->setCompleter(fsc); + + QToolButton * btn = new QToolButton(fsw); + btn->setIcon(CompanionIcon("open.png")); + QHBoxLayout * l = new QHBoxLayout(fsw); + l->setContentsMargins(0,0,0,0); + l->setSpacing(3); + l->addWidget(le); + l->addWidget(btn); + + QObject::connect(btn, &QToolButton::clicked, [=]() { + QString dir = QFileDialog::getExistingDirectory(parent, fsw->property("fileDialogTitle").toString(), le->text(), 0); + if (!dir.isEmpty()) { + le->setText(QDir::toNativeSeparators(dir)); + le->setFocus(); + } + }); + + QObject::connect(le, &QLineEdit::textChanged, [=](const QString & text) { + if (QFile::exists(text)) + le->setStyleSheet(""); + else + le->setStyleSheet("QLineEdit {color: red;}"); + }); + + return fsw; +} + +void FileSyncDialog::setupUi() +{ + const QString srcArw = CPN_STR_SW_INDICATOR_UP % " "; + const QString dstArw = CPN_STR_SW_INDICATOR_DN % " "; + int row = 0; + + CompanionIcon playIcon("play.png"); + playIcon.addImage("stop.png", QIcon::Normal, QIcon::On); + + QLabel * lblSrc = new QLabel(tr("Source Folder:"), this); + AutoLineEdit * leSrc = new AutoLineEdit(this, true); + leSrc->setField(m_syncOptions.folderA); + leSrc->setText(QDir::toNativeSeparators(m_syncOptions.folderA)); + QWidget * wdgSrc = folderSelectorWidget(leSrc, this); + + QLabel * lblDst = new QLabel(tr("Destination Folder:"), this); + AutoLineEdit * leDst = new AutoLineEdit(this, true); + leDst->setField(m_syncOptions.folderB); + leDst->setText(QDir::toNativeSeparators(m_syncOptions.folderB)); + QWidget * wdgDst = folderSelectorWidget(leDst, this); + + AutoComboBox * syncDir = new AutoComboBox(this); + syncDir->addItem(tr("%1%2 Both directions, to destination folder first").arg(dstArw, srcArw), SyncProcess::SYNC_A2B_B2A); + syncDir->addItem(tr("%1%2 Both directions, to source folder first").arg(srcArw, dstArw), SyncProcess::SYNC_B2A_A2B); + syncDir->addItem(tr(" %1 Only from source folder to destination folder").arg(dstArw), SyncProcess::SYNC_A2B); + syncDir->addItem(tr(" %1 Only from destination folder to source folder").arg(srcArw), SyncProcess::SYNC_B2A); + syncDir->setCurrentIndex(-1); // we set the default option later + + AutoComboBox * copyMode = new AutoComboBox(this); + copyMode->setToolTip(tr("How to handle overwriting files which already exist in the destination folder.")); + copyMode->addItem(tr("Copy only if newer and different (compare contents)"), SyncProcess::OVERWR_NEWER_IF_DIFF); + copyMode->addItem(tr("Copy only if newer (do not compare contents)"), SyncProcess::OVERWR_NEWER_ALWAYS); + copyMode->addItem(tr("Copy only if different (ignore file time stamps)"), SyncProcess::OVERWR_IF_DIFF); + copyMode->addItem(tr("Always copy (force overwite existing files)"), SyncProcess::OVERWR_ALWAYS); + copyMode->setField(m_syncOptions.compareType); + + QSpinBox * maxSize = new QSpinBox(this); + maxSize->setRange(0, 100 * 1024); + maxSize->setAccelerated(true); + maxSize->setSpecialValueText(tr("Any size")); + maxSize->setToolTip(tr("Skip files larger than this size. Enter zero for unlimited.")); + maxSize->setGroupSeparatorShown(true); + maxSize->setValue(0); // we set the real default value later + + AutoComboBox * logLevel = new AutoComboBox(this); + logLevel->setToolTip(tr("Minimum reporting level. Events of this type and of higher importance are shown.\n" \ + "WARNING: High log rates may make the user interface temporarily unresponsive.")); + logLevel->addItem(tr("Skipped"), QtDebugMsg); + logLevel->addItem(tr("Created"), QtInfoMsg); + logLevel->addItem(tr("Updated"), QtWarningMsg); + //logLevel->addItem(tr("Item Deleted"), QtCriticalMsg); // unused + logLevel->addItem(tr("Errors Only"), QtFatalMsg); + logLevel->setField(m_syncOptions.logLevel); + + AutoBitsetCheckBox * testRun = new AutoBitsetCheckBox(m_syncOptions.flags, SyncProcess::OPT_DRY_RUN, tr("Test-run only"), this); + testRun->setToolTip(tr("Run as normal but do not actually copy anything. Useful for verifying results before real sync.")); + + // layout to hold size spinbox and checkbox option(s) + QHBoxLayout * hlay1 = new QHBoxLayout(); + hlay1->addWidget(maxSize, 1); + hlay1->addWidget(testRun); + hlay1->addWidget(new QLabel(tr("Log Level:"), this)); + hlay1->addWidget(logLevel); + + // "extra" options + + QLabel * lbFilter = new QLabel(tr("Filters:"), this); + lbFilter->setToolTip(tr("The \"Include\" filter will only copy files which match the pattern(s).\n" \ + "The \"Exclude\" filter will skip files matching the filter pattern(s).\n" \ + "The Include filter is evaluated first.")); + + AutoLineEdit * leFiltExc = new AutoLineEdit(this, true); + leFiltExc->setField(m_syncOptions.excludeFilter); + leFiltExc->setToolTip(tr("One or more file pattern(s) to exclude, separated by commas.\n" "Blank means exclude none. ?, *, and [...] wildcards accepted.")); + + AutoLineEdit * leFiltInc = new AutoLineEdit(this, true); + leFiltInc->setField(m_syncOptions.includeFilter); + leFiltInc->setToolTip(tr("One or more file pattern(s) to include, separated by commas.\n" "Blank means include all. ?, *, and [...] wildcards accepted.")); + + // layout to hold filter options + QHBoxLayout * filtLo = new QHBoxLayout(); + filtLo->addWidget(new QLabel(tr("Include:"), this)); + filtLo->addWidget(leFiltInc, 1); + filtLo->addWidget(new QLabel(tr("Exclude:"), this)); + filtLo->addWidget(leFiltExc, 1); + + // file options + QHBoxLayout * filOptsLo = new QHBoxLayout(); + filOptsLo->addWidget(new AutoBitsetCheckBox(m_syncOptions.dirFilterFlags, QDir::CaseSensitive, tr("Case sensitive"), this)); + filOptsLo->addWidget(new AutoBitsetCheckBox(m_syncOptions.dirFilterFlags, QDir::NoSymLinks, true, tr("Follow links"), this)); + filOptsLo->addWidget(new AutoBitsetCheckBox(m_syncOptions.dirFilterFlags, QDir::Hidden, tr("Include hidden"), this)); + + // folder options + QHBoxLayout * dirOptsLo = new QHBoxLayout(); + dirOptsLo->addWidget(new AutoBitsetCheckBox(m_syncOptions.flags, SyncProcess::OPT_RECURSIVE, tr("Recursive"), this)); + dirOptsLo->addWidget(new AutoBitsetCheckBox(m_syncOptions.flags, SyncProcess::OPT_SKIP_DIR_LINKS, true, tr("Follow links"), this)); + dirOptsLo->addWidget(new AutoBitsetCheckBox(m_syncOptions.dirFilterFlags, QDir::Dirs, true, tr("Skip empty"), this)); + dirOptsLo->addWidget(new AutoBitsetCheckBox(m_syncOptions.dirFilterFlags, QDir::AllDirs, true, tr("Apply filters"), this)); + + // extra options container widget and layout + ui_extraOptions = new QWidget(this); + ui_extraOptions->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum); + + QGridLayout * xOptsLay = new QGridLayout(ui_extraOptions); + xOptsLay->setContentsMargins(0, 0, 0, 0); + xOptsLay->addWidget(lbFilter, row, 0); + xOptsLay->addLayout(filtLo, row++, 1); + xOptsLay->addWidget(new QLabel(tr("Filter Options:"), this), row, 0); + xOptsLay->addLayout(filOptsLo, row++, 1); + xOptsLay->addWidget(new QLabel(tr("Folder Options:"), this), row, 0); + xOptsLay->addLayout(dirOptsLo, row++, 1); + + // status label + ui_statusLabel = new QLabel(this); + // progress widget + ui_progress = new ProgressWidget(this); + ui_progress->setMinimumWidth(0); + ui_progress->setVisible(false); + + // dialog buttons & menu + + QMenu * optionsMenu = new QMenu(tr("Options")); + QAction * xtrasAct = optionsMenu->addAction(CompanionIcon("fuses.png"), tr("Show extra options"), this, &FileSyncDialog::toggleExtraOptions); + xtrasAct->setCheckable(true); + connect(this, &FileSyncDialog::extraOptionsToggled, xtrasAct, &QAction::setChecked); + optionsMenu->addAction(CompanionIcon("paintbrush.png"), tr("Reset to defaults"), this, &FileSyncDialog::resetOptions); + + QToolButton * btnOpts = new QToolButton(this); + btnOpts->setDefaultAction(xtrasAct); + btnOpts->setMenu(optionsMenu); + btnOpts->setToolButtonStyle(Qt::ToolButtonIconOnly); + btnOpts->setPopupMode(QToolButton::MenuButtonPopup); + btnOpts->setToolTip(xtrasAct->text()); + + ui_btnStartStop = new QToolButton(this); + ui_btnStartStop->setCheckable(true); + ui_btnStartStop->setIcon(playIcon); + ui_btnStartStop->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + + ui_btnClose = new QToolButton(this); + ui_btnClose->setText(tr("Close")); + ui_btnClose->setToolButtonStyle(Qt::ToolButtonTextOnly); + ui_btnClose->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Expanding); + + ui_buttonBox = new QDialogButtonBox(this); + ui_buttonBox->addButton(btnOpts, QDialogButtonBox::ActionRole); + ui_buttonBox->addButton(ui_btnStartStop, QDialogButtonBox::ActionRole); + ui_buttonBox->addButton(ui_btnClose, QDialogButtonBox::RejectRole); + + // The options group box. + ui_optionsPanel = new QGroupBox(tr("Options"), this); + QGridLayout * optsLay = new QGridLayout(ui_optionsPanel); + row = 0; + optsLay->addWidget(lblSrc, row, 0); + optsLay->addWidget(wdgSrc, row++, 1); + optsLay->addWidget(lblDst, row, 0); + optsLay->addWidget(wdgDst, row++, 1); + optsLay->addWidget(new QLabel(tr("Sync. Direction:"), this), row, 0); + optsLay->addWidget(syncDir, row++, 1); + optsLay->addWidget(new QLabel(tr("Existing Files:"), this), row, 0); + optsLay->addWidget(copyMode, row++, 1); + optsLay->addWidget(new QLabel(tr("Max. File Size:"), this), row, 0); + optsLay->addLayout(hlay1, row++, 1); + optsLay->addWidget(ui_extraOptions, row, 0, 1, 2); + + // layout to hold warning label and button box + QHBoxLayout * hlay2 = new QHBoxLayout(); + hlay2->addWidget(ui_statusLabel, 1); + hlay2->addWidget(ui_buttonBox); + + // Create main layout and add options, progress, buttons. + QVBoxLayout * dlgL = new QVBoxLayout(this); + dlgL->addWidget(ui_optionsPanel, 0, Qt::AlignTop); + dlgL->addLayout(hlay2); + dlgL->addWidget(ui_progress, 1); + + // Connect all signal handlers + + // source path + connect(leSrc, &AutoLineEdit::textChanged, this, &FileSyncDialog::validate); + // destination path + connect(leDst, &AutoLineEdit::textChanged, this, &FileSyncDialog::validate); + + // function to dis/enable the OVERWR_ALWAYS option depending on sync direction + connect(syncDir, &AutoComboBox::currentDataChanged, [=](int dir) { + int idx = copyMode->findData(SyncProcess::OVERWR_ALWAYS); + int flg = (dir == SyncProcess::SYNC_A2B || dir == SyncProcess::SYNC_B2A) ? 33 : 0; + if (!flg && idx == copyMode->currentIndex()) + copyMode->setCurrentIndex(copyMode->findData(SyncProcess::OVERWR_NEWER_IF_DIFF)); + copyMode->setItemData(idx, flg, Qt::UserRole - 1); + }); + + // function to set magnitude of file size spinbox, KB or MB + connect(maxSize, static_cast(&QSpinBox::valueChanged), [=](int value) { + const QSignalBlocker blocker(maxSize); + int multi = maxSize->property("multi").isValid() ? maxSize->property("multi").toInt() : 0; + if (value >= 10 * 1024 && multi != 1024 * 1024) { + // KB -> MB + multi = 1024 * 1024; + maxSize->setValue(value / 1024); + maxSize->setMaximum(100); + maxSize->setSingleStep(1); + maxSize->setSuffix(tr(" MB")); + } + else if ((value < 10 && multi != 1024) || !multi) { + // MB -> KB + if (multi) + value *= 1024; + multi = 1024; + if (value == 9 * 1024) + value += 1024 - 32; // avoid large jump when stepping from 10MB to 10,208KB + maxSize->setMaximum(100 * 1024); + maxSize->setValue(value); + maxSize->setSingleStep(32); + maxSize->setSuffix(tr(" KB")); + } + maxSize->setProperty("multi", multi); + m_syncOptions.maxFileSize = value * multi; + }); + + // we set these after connections are made because we want the signal processors to run + syncDir->setField(m_syncOptions.direction); + maxSize->setValue(m_syncOptions.maxFileSize / 1024); + + // connect Reset action to all fields + for (AutoLineEdit * a : ui_optionsPanel->findChildren()) + connect(this, &FileSyncDialog::optionsWereReset, a, &AutoLineEdit::updateValue); + for (AutoComboBox * a : ui_optionsPanel->findChildren()) + connect(this, &FileSyncDialog::optionsWereReset, a, &AutoComboBox::updateValue); + for (AutoBitsetCheckBox * a : ui_optionsPanel->findChildren()) + connect(this, &FileSyncDialog::optionsWereReset, a, &AutoBitsetCheckBox::updateValue); + connect(this, &FileSyncDialog::optionsWereReset, [=]() { maxSize->setValue(m_syncOptions.maxFileSize / 1024); }); + + // React to name changes for source and destination path labels + connect(this, &FileSyncDialog::folderNameAChanged, [=](const QString &text) { + lblSrc->setText(text % ":"); + wdgSrc->setProperty("fileDialogTitle", text); + }); + connect(this, &FileSyncDialog::folderNameBChanged, [=](const QString &text) { + lblDst->setText(text % ":"); + wdgDst->setProperty("fileDialogTitle", text); + }); + + // button actions + connect(ui_btnStartStop, &QPushButton::clicked, this, &FileSyncDialog::toggleSync); + connect(ui_buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(ui_buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(ui_progress, &ProgressWidget::detailsToggled, this, &FileSyncDialog::adjustSizeDelayed); +} + +void FileSyncDialog::toggleSync(bool) +{ + if (m_running) { + setCursor(Qt::WaitCursor); + emit stopSync(); + return; + } + + ui_progress->clearDetails(); + ui_progress->setInfo(""); + SyncProcess * syncProcess = new SyncProcess(m_syncOptions); + + // move sync process to separate thread, we only use signals/slots from here on... + QThread * syncThread = new QThread(this); + syncProcess->moveToThread(syncThread); + + // ...and quite a few of them! + connect(this, &FileSyncDialog::startSync, syncProcess, &SyncProcess::run); + connect(this, &FileSyncDialog::stopSync, syncProcess, &SyncProcess::stop); + connect(syncProcess, &SyncProcess::started, this, &FileSyncDialog::onSyncStarted); + connect(syncProcess, &SyncProcess::finished, this, &FileSyncDialog::onSyncFinished); + connect(syncProcess, &SyncProcess::statusMessage, this, &FileSyncDialog::setStatusText); + connect(syncProcess, &SyncProcess::statusUpdate, this, &FileSyncDialog::onStatusUpdate); + connect(syncProcess, &SyncProcess::fileCountChanged, ui_progress, &ProgressWidget::setMaximum); + connect(syncProcess, &SyncProcess::progressMessage, ui_progress, &ProgressWidget::addMessage); + connect(syncProcess, &SyncProcess::finished, syncThread, &QThread::quit); + connect(syncThread, &QThread::finished, syncThread, &QThread::deleteLater); + connect(syncThread, &QThread::destroyed, syncProcess, &SyncProcess::deleteLater); + + // go + syncThread->start(QThread::LowPriority); + emit startSync(); +} + +void FileSyncDialog::onSyncStarted() +{ + m_running = true; + updateRunningState(); +} + +void FileSyncDialog::onSyncFinished() +{ + m_running = false; + updateRunningState(); + if (parentWidget()) + QApplication::alert(parentWidget()); +} + +void FileSyncDialog::updateRunningState() +{ + ui_btnStartStop->setChecked(m_running); + ui_optionsPanel->setEnabled(!m_running); + ui_btnClose->setEnabled(!m_running); + ui_btnStartStop->setText(m_running ? tr("Abort") : tr("Start")); + if (m_running) { + ui_progress->setVisible(true); // don't hide after run + } + else { + setCursor(Qt::ArrowCursor); // in case we set a wait cursor upon abort request + } +} + +void FileSyncDialog::onStatusUpdate(const SyncProcess::SyncStatus & stat) +{ + static const QString reportTemplate = tr("Total: %1; Created: %2; Updated: %3; Skipped: %4; Errors: %5;"); + ui_progress->setInfo((stat.index < stat.count ? tr("Current: %1 of ").arg(stat.index) : "") % reportTemplate.arg(stat.count).arg(stat.created).arg(stat.updated).arg(stat.skipped).arg(stat.errored).arg(stat.errored ? "red" : "black")); + ui_progress->setValue(stat.index); +} + +void FileSyncDialog::setSyncOptions(const SyncProcess::SyncOptions & syncOptions) +{ + m_syncOptions = syncOptions; + emit optionsWereReset(); +} + +void FileSyncDialog::resetOptions() +{ + m_syncOptions.reset(); + emit optionsWereReset(); +} + +void FileSyncDialog::toggleExtraOptions(bool show) +{ + ui_extraOptions->setVisible(show); + if (!ui_progress->detailsVisible()) + adjustSizeDelayed(); + m_extraOptionsVisible = show; + emit extraOptionsToggled(show); +} + +void FileSyncDialog::setFolderNameA(const QString & name) +{ + if (m_folderNameA != name) { + m_folderNameA = name; + emit folderNameAChanged(name); + } +} + +void FileSyncDialog::setFolderNameB(const QString & name) +{ + if (m_folderNameB != name) { + m_folderNameB = name; + emit folderNameBChanged(name); + } +} + +void FileSyncDialog::setStatusText(const QString & text, int type) +{ + ui_statusLabel->setText(text); + ui_statusLabel->setStyleSheet(type != QtInfoMsg && type >= QtWarningMsg ? "QLabel { color: red; }:" : ""); +} + +bool FileSyncDialog::validate() +{ + QString msg; + if (m_syncOptions.folderA.isEmpty() || !QFileInfo(m_syncOptions.folderA).exists()) + msg = tr("Source folder not found."); + else if (m_syncOptions.folderB.isEmpty() || !QFileInfo(m_syncOptions.folderB).exists()) + msg = tr("Destination folder not found."); + else if (QFileInfo(m_syncOptions.folderA).canonicalFilePath() == QFileInfo(m_syncOptions.folderB).canonicalFilePath()) + msg = tr("Source and destination folders are the same."); + + setStatusText(msg, QtCriticalMsg); + ui_btnStartStop->setEnabled(msg.isEmpty()); + return msg.isEmpty(); +} + +void FileSyncDialog::adjustSizeDelayed() +{ + QTimer::singleShot(0, [=]() { adjustSize(); }); +} diff --git a/companion/src/dialogs/filesyncdialog.h b/companion/src/dialogs/filesyncdialog.h new file mode 100644 index 000000000..6375baa5b --- /dev/null +++ b/companion/src/dialogs/filesyncdialog.h @@ -0,0 +1,96 @@ +/* + * Copyright (C) OpenTX + * + * Based on code named + * th9x - http://code.google.com/p/th9x + * er9x - http://code.google.com/p/er9x + * gruvin9x - http://code.google.com/p/gruvin9x + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * 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. + */ + +#ifndef FILESYNCDIALOG_H +#define FILESYNCDIALOG_H + +#include "process_sync.h" + +#include +#include +#include +#include +#include + +class ProgressWidget; + +class FileSyncDialog : public QDialog +{ + Q_OBJECT + Q_PROPERTY(SyncProcess::SyncOptions syncOptions READ syncOptions WRITE setSyncOptions RESET resetOptions NOTIFY optionsWereReset) + Q_PROPERTY(bool extraOptionsVisible READ extraOptionsVisible WRITE toggleExtraOptions NOTIFY extraOptionsToggled) + Q_PROPERTY(QString folderNameA READ folderNameA WRITE setFolderNameA NOTIFY folderNameAChanged) + Q_PROPERTY(QString folderNameB READ folderNameB WRITE setFolderNameB NOTIFY folderNameBChanged) + + public: + FileSyncDialog(QWidget * parent = nullptr, const SyncProcess::SyncOptions & syncOptions = SyncProcess::SyncOptions()); + + inline SyncProcess::SyncOptions syncOptions() const { return m_syncOptions; } + void setSyncOptions(const SyncProcess::SyncOptions & syncOptions); + void resetOptions(); + + inline bool extraOptionsVisible() const { return m_extraOptionsVisible; } + void toggleExtraOptions(bool show); + + inline QString folderNameA() const { return m_folderNameA; } + void setFolderNameA(const QString & name); + + inline QString folderNameB() const { return m_folderNameB; } + void setFolderNameB(const QString & name); + + public slots: + virtual void reject() override; + void setStatusText(const QString & text, int type = QtWarningMsg); + bool validate(); + + signals: + void startSync(); + void stopSync(); + void folderNameAChanged(const QString & name); + void folderNameBChanged(const QString & name); + void extraOptionsToggled(bool show); + void optionsWereReset(); + + private slots: + void setupUi(); + void toggleSync(bool); + void onSyncStarted(); + void onSyncFinished(); + void updateRunningState(); + void onStatusUpdate(const SyncProcess::SyncStatus & stat); + void adjustSizeDelayed(); + + private: + SyncProcess::SyncOptions m_syncOptions; + QString m_folderNameA; + QString m_folderNameB; + bool m_running; + bool m_extraOptionsVisible; + + QGroupBox * ui_optionsPanel; + QWidget * ui_extraOptions; + ProgressWidget * ui_progress; + QDialogButtonBox * ui_buttonBox; + QToolButton * ui_btnStartStop; + QToolButton * ui_btnClose; + QLabel * ui_statusLabel; +}; + +#endif // FILESYNCDIALOG_H diff --git a/companion/src/mainwindow.cpp b/companion/src/mainwindow.cpp index d99ea5a34..d3a224c6c 100644 --- a/companion/src/mainwindow.cpp +++ b/companion/src/mainwindow.cpp @@ -48,6 +48,8 @@ #include "storage.h" #include "translations.h" +#include "dialogs/filesyncdialog.h" + #include #include #include @@ -56,12 +58,12 @@ #define OPENTX_DOWNLOADS_PAGE_URL "http://www.open-tx.org/downloads" #define DONATE_STR "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=QUZ48K4SEXDP2" -#ifdef __APPLE__ +#ifdef Q_OS_MACOS #define COMPANION_STAMP "companion-macosx.stamp" #define COMPANION_INSTALLER "macosx/opentx-companion-%1.dmg" #define COMPANION_FILEMASK QT_TRANSLATE_NOOP("MainWindow", "Diskimage (*.dmg)") #define COMPANION_INSTALL_QUESTION QT_TRANSLATE_NOOP("MainWindow", "Would you like to open the disk image to install the new version?") -#elif WIN32 +#elif defined(Q_OS_WIN) #define COMPANION_STAMP "companion-windows.stamp" #define COMPANION_INSTALLER "windows/companion-windows-%1.exe" #define COMPANION_FILEMASK QT_TRANSLATE_NOOP("MainWindow", "Executable (*.exe)") @@ -797,254 +799,41 @@ void MainWindow::contributors() dialog->deleteLater(); } -// Create a widget with a line edit and folder select button and handles all interactions. Features autosuggest -// path hints while typing, invalid paths shown in red. The label string is only for dialog title, not a QLabel. -// This should probably be moved some place more reusable, esp. the QFileSystemModel. -QWidget * folderSelectorWidget(QString * path, const QString & label, QWidget * parent) -{ - static QFileSystemModel fileModel; - static bool init = false; - if (!init) { - init = true; - fileModel.setFilter(QDir::Dirs); - fileModel.setRootPath("/"); - } - - QWidget * fsw = new QWidget(parent); - QLineEdit * le = new QLineEdit(parent); - QCompleter * fsc = new QCompleter(fsw); - fsc->setModel(&fileModel); - //fsc->setCompletionMode(QCompleter::InlineCompletion); - le->setCompleter(fsc); - - QToolButton * btn = new QToolButton(fsw); - btn->setIcon(CompanionIcon("open.png")); - QHBoxLayout * l = new QHBoxLayout(fsw); - l->setContentsMargins(0,0,0,0); - l->setSpacing(3); - l->addWidget(le); - l->addWidget(btn); - - QObject::connect(btn, &QToolButton::clicked, [=]() { - QString dir = QFileDialog::getExistingDirectory(parent, label, le->text(), 0); - if (!dir.isEmpty()) { - le->setText(QDir::toNativeSeparators(dir)); - le->setFocus(); - } - }); - - QObject::connect(le, &QLineEdit::textChanged, [=](const QString & text) { - *path = text; - if (QFile::exists(text)) - le->setStyleSheet(""); - else - le->setStyleSheet("QLineEdit {color: red;}"); - }); - le->setText(QDir::toNativeSeparators(*path)); - - return fsw; -} - void MainWindow::sdsync() { - const QString dlgTtl = tr("Synchronize SD"); - const QIcon dlgIcn = CompanionIcon("sdsync.png"); - const QString srcArw = CPN_STR_SW_INDICATOR_UP % " "; - const QString dstArw = CPN_STR_SW_INDICATOR_DN % " "; + // remember user-selectable options for duration of session TODO: save to settings + static SyncProcess::SyncOptions syncOpts; + static bool showExtraOptions = false; QStringList errorMsgs; - // remember user-selectable options for duration of session - static QString sourcePath; - static QString destPath; - static int syncDirection = SyncProcess::SYNC_A2B_B2A; - static int compareType = SyncProcess::OVERWR_NEWER_IF_DIFF; - static int maxFileSize = 2 * 1024 * 1024; // Bytes - static bool dryRun = false; + if (syncOpts.folderA.isEmpty()) + syncOpts.folderA = g.profile[g.id()].sdPath(); + if (syncOpts.folderB.isEmpty()) + syncOpts.folderB = findMassstoragePath("SOUNDS", true); - if (sourcePath.isEmpty()) - sourcePath = g.profile[g.id()].sdPath(); - if (destPath.isEmpty()) - destPath = findMassstoragePath("SOUNDS").replace(QRegExp("[/\\\\]?SOUNDS"), ""); - - if (sourcePath.isEmpty()) + if (syncOpts.folderA.isEmpty()) errorMsgs << tr("No local SD structure path configured!"); - if (destPath.isEmpty()) + if (syncOpts.folderB.isEmpty()) errorMsgs << tr("No Radio or SD card detected!"); - QDialog dlg(this); - dlg.setWindowTitle(dlgTtl % tr(" :: Options")); - dlg.setWindowIcon(dlgIcn); - dlg.setSizeGripEnabled(true); - dlg.setWindowFlags(dlg.windowFlags() & ~Qt::WindowContextHelpButtonHint); + QPointer dlg = new FileSyncDialog(this, syncOpts); + dlg->setAttribute(Qt::WA_DeleteOnClose, true); + dlg->setWindowFlags(dlg->windowFlags() | Qt::WindowStaysOnTopHint); + dlg->setWindowTitle(tr("Synchronize SD")); + dlg->setWindowIcon(CompanionIcon("sdsync.png")); + dlg->setFolderNameA(tr("Local Folder")); + dlg->setFolderNameB(tr("Radio Folder")); + dlg->toggleExtraOptions(showExtraOptions); + if (errorMsgs.size()) + dlg->setStatusText(errorMsgs.join('\n'), QtWarningMsg); + dlg->show(); - QLabel * lblSrc = new QLabel(tr("Local Folder:"), &dlg); - QWidget * wdgSrc = folderSelectorWidget(&sourcePath, lblSrc->text(), &dlg); - - QLabel * lblDst = new QLabel(tr("Radio Folder:"), &dlg); - QWidget * wdgDst = folderSelectorWidget(&destPath, lblDst->text(), &dlg); - - QLabel * lbDir = new QLabel(tr("Sync. Direction:"), &dlg); - QComboBox * syncDir = new QComboBox(&dlg); - syncDir->addItem(tr("%1%2 Both directions, to radio folder first").arg(dstArw, srcArw), SyncProcess::SYNC_A2B_B2A); - syncDir->addItem(tr("%1%2 Both directions, to local folder first").arg(srcArw, dstArw), SyncProcess::SYNC_B2A_A2B); - syncDir->addItem(tr(" %1 Only from local folder to radio folder").arg(dstArw), SyncProcess::SYNC_A2B); - syncDir->addItem(tr(" %1 Only from radio folder to local folder").arg(srcArw), SyncProcess::SYNC_B2A); - syncDir->setCurrentIndex(-1); // we set the default option later - - QLabel * lbMode = new QLabel(tr("Existing Files:"), &dlg); - QComboBox * copyMode = new QComboBox(&dlg); - copyMode->setToolTip(tr("How to handle overwriting files which already exist in the destination folder.")); - copyMode->addItem(tr("Copy only if newer and different (compare contents)"), SyncProcess::OVERWR_NEWER_IF_DIFF); - copyMode->addItem(tr("Copy only if newer (do not compare contents)"), SyncProcess::OVERWR_NEWER_ALWAYS); - copyMode->addItem(tr("Copy only if different (ignore file time stamps)"), SyncProcess::OVERWR_IF_DIFF); - copyMode->addItem(tr("Always copy (force overwite existing files)"), SyncProcess::OVERWR_ALWAYS); - - QLabel * lbSize = new QLabel(tr("Max. File Size:"), &dlg); - QSpinBox * maxSize = new QSpinBox(&dlg); - maxSize->setRange(0, 100 * 1024); - maxSize->setAccelerated(true); - maxSize->setSpecialValueText(tr("Any size")); - maxSize->setToolTip(tr("Skip files larger than this size. Enter zero for unlimited.")); -#if (QT_VERSION >= QT_VERSION_CHECK(5, 3, 0)) - maxSize->setGroupSeparatorShown(true); -#endif - - QCheckBox * testRun = new QCheckBox(tr("Test-run only"), &dlg); - testRun->setToolTip(tr("Run as normal but do not actually copy anything. Useful for verifying results before real sync.")); - connect(testRun, &QCheckBox::toggled, [=](bool on) { dryRun = on; }); - - // layout to hold size spinbox and checkbox option(s) - QHBoxLayout * hlay1 = new QHBoxLayout(); - hlay1->addWidget(maxSize, 1); - hlay1->addWidget(testRun); - - // dialog OK/Cancel buttons - QDialogButtonBox * bb = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dlg); - - // Create main layout and add everything - QGridLayout * dlgL = new QGridLayout(&dlg); - dlgL->setSizeConstraint(QLayout::SetFixedSize); - int row = 0; - if (errorMsgs.size()) { - QLabel * lblWarn = new QLabel(QString(errorMsgs.join('\n')), &dlg); - lblWarn->setStyleSheet("QLabel { color: red; }"); - dlgL->addWidget(lblWarn, row++, 0, 1, 2); - } - dlgL->addWidget(lblSrc, row, 0); - dlgL->addWidget(wdgSrc, row++, 1); - dlgL->addWidget(lblDst, row, 0); - dlgL->addWidget(wdgDst, row++, 1); - dlgL->addWidget(lbDir, row, 0); - dlgL->addWidget(syncDir, row++, 1); - dlgL->addWidget(lbMode, row, 0); - dlgL->addWidget(copyMode, row++, 1); - dlgL->addWidget(lbSize, row, 0); - dlgL->addLayout(hlay1, row++, 1); - dlgL->addWidget(bb, row++, 0, 1, 2); - dlgL->setRowStretch(row, 1); - - connect(copyMode, static_cast(&QComboBox::currentIndexChanged), [=](int) { - compareType = copyMode->currentData().toInt(); - }); - - // function to dis/enable the OVERWR_ALWAYS option depending on sync direction - connect(syncDir, static_cast(&QComboBox::currentIndexChanged), [=](int) { - int dir = syncDir->currentData().toInt(); - int idx = copyMode->findData(SyncProcess::OVERWR_ALWAYS); - int flg = (dir == SyncProcess::SYNC_A2B || dir == SyncProcess::SYNC_B2A) ? 33 : 0; - if (!flg && idx == copyMode->currentIndex()) - copyMode->setCurrentIndex(copyMode->findData(SyncProcess::OVERWR_NEWER_IF_DIFF)); - copyMode->setItemData(idx, flg, Qt::UserRole - 1); - syncDirection = dir; - }); - - // function to set magnitude of file size spinbox, KB or MB - connect(maxSize, static_cast(&QSpinBox::valueChanged), [=](int value) { - int multi = maxSize->property("multi").isValid() ? maxSize->property("multi").toInt() : 0; - maxSize->blockSignals(true); - if (value >= 10 * 1024 && multi != 1024 * 1024) { - // KB -> MB - multi = 1024 * 1024; - maxSize->setValue(value / 1024); - maxSize->setMaximum(100); - maxSize->setSingleStep(1); - maxSize->setSuffix(tr(" MB")); + connect(dlg.data(), &FileSyncDialog::finished, [=](int) { + if (!dlg.isNull()) { + syncOpts = dlg->syncOptions(); + showExtraOptions = dlg->extraOptionsVisible(); } - else if ((value < 10 && multi != 1024) || !multi) { - // MB -> KB - if (multi) - value *= 1024; - multi = 1024; - if (value == 9 * 1024) - value += 1024 - 32; // avoid large jump when stepping from 10MB to 10,208KB - maxSize->setMaximum(100 * 1024); - maxSize->setValue(value); - maxSize->setSingleStep(32); - maxSize->setSuffix(tr(" KB")); - } - maxSize->setProperty("multi", multi); - maxSize->blockSignals(false); - maxFileSize = value * multi; }); - - copyMode->setCurrentIndex(copyMode->findData(compareType)); - syncDir->setCurrentIndex(syncDir->findData(syncDirection)); - maxSize->setValue(maxFileSize / 1024); - testRun->setChecked(dryRun); - - connect(bb, &QDialogButtonBox::accepted, &dlg, &QDialog::accept); - connect(bb, &QDialogButtonBox::rejected, &dlg, &QDialog::reject); - - // to restart dialog on error/etc - openDialog: - - // show the modal options dialog - if (dlg.exec() == QDialog::Rejected) - return; - - // validate - errorMsgs.clear(); - if (sourcePath == destPath) - errorMsgs << tr("Source and destination folders are the same!"); - if (sourcePath.isEmpty() || !QFile::exists(sourcePath)) - errorMsgs << tr("Source folder not found: %1").arg(sourcePath); - if (destPath.isEmpty() || !QFile::exists(destPath)) - errorMsgs << tr("Destination folder not found: %1").arg(destPath); - - if (!errorMsgs.isEmpty()) { - QMessageBox::warning(this, dlgTtl % tr(" :: Error"), errorMsgs.join('\n')); - goto openDialog; - } - - // set up the progress dialog and the sync process worker - ProgressDialog * progressDlg = new ProgressDialog(this, dlgTtl % tr(" :: Progress"), dlgIcn); - progressDlg->setAttribute(Qt::WA_DeleteOnClose, true); - ProgressWidget * progWidget = progressDlg->progress(); - SyncProcess * syncProcess = new SyncProcess(sourcePath, destPath, syncDirection, compareType, maxFileSize, dryRun); - - // move sync process to separate thread, we only use signals/slots from here on... - QThread * syncThread = new QThread(this); - syncProcess->moveToThread(syncThread); - - // ...and quite a few of them! - connect(this, &MainWindow::startSync, syncProcess, &SyncProcess::run); - connect(syncThread, &QThread::finished, syncProcess, &SyncProcess::deleteLater); - connect(syncProcess, &SyncProcess::finished, syncThread, &QThread::quit); - connect(syncProcess, &SyncProcess::destroyed, syncThread, &QThread::quit); - connect(syncProcess, &SyncProcess::destroyed, syncThread, &QThread::deleteLater); - connect(syncProcess, &SyncProcess::fileCountChanged, progWidget, &ProgressWidget::setMaximum); - connect(syncProcess, &SyncProcess::progressStep, progWidget, &ProgressWidget::setValue); - connect(syncProcess, &SyncProcess::progressMessage, progWidget, &ProgressWidget::addMessage); - connect(syncProcess, &SyncProcess::statusMessage, progWidget, &ProgressWidget::setInfo); - connect(syncProcess, &SyncProcess::started, progressDlg, &ProgressDialog::setProcessStarted); - connect(syncProcess, &SyncProcess::finished, progressDlg, &ProgressDialog::setProcessStopped); - connect(syncProcess, &SyncProcess::finished, [=]() { QApplication::alert(this); }); - connect(progressDlg, &ProgressDialog::rejected, syncProcess, &SyncProcess::stop); - connect(progressDlg, &ProgressDialog::rejected, syncProcess, &SyncProcess::deleteLater); - - // go (finally) - syncThread->start(); - emit startSync(); } void MainWindow::changelog() diff --git a/companion/src/process_sync.cpp b/companion/src/process_sync.cpp index acfd01cf0..2040b94b7 100644 --- a/companion/src/process_sync.cpp +++ b/companion/src/process_sync.cpp @@ -22,128 +22,219 @@ #include #include -#include +#include #include -#include -#include + +#define SYNC_MAX_ERRORS 50 // give up after this many errors per destination + +// a flood of log messages can make the UI unresponsive so we'll introduce a dynamic sleep period based on log frequency (values in [us]) +#define PAUSE_FACTOR 60UL +#define PAUSE_RECOVERY (PAUSE_FACTOR / 3 * 2) +#define PAUSE_MINTM 100UL +#define PAUSE_MAXTM 75000UL #if (QT_VERSION < QT_VERSION_CHECK(5, 5, 0)) -#define QtInfoMsg QtMsgType(4) + #define QtInfoMsg QtMsgType(4) #endif -#define PRINT_INFO(str) emit progressMessage((str), QtInfoMsg) -#define PRINT_CREATE(str) emit progressMessage((str), QtInfoMsg) -#define PRINT_REPLACE(str) emit progressMessage((str), QtWarningMsg) -#define PRINT_DELETE(str) emit progressMessage((str), QtCriticalMsg) -#define PRINT_ERROR(str) emit progressMessage((str), QtFatalMsg) -//#define PRINT_SKIP(str) emit progressMessage((str), QtDebugMsg) // mostly useless noise (maybe make an option later) -#define PRINT_SKIP(str) -#define PRINT_SEP() PRINT_INFO(QString(80, '=')) +#define PRINT_CREATE(str) emitProgressMessage((str), QtInfoMsg) +#define PRINT_REPLACE(str) emitProgressMessage((str), QtWarningMsg) +//#define PRINT_DELETE(str) emitProgressMessage((str), QtCriticalMsg) // unused +#define PRINT_ERROR(str) emitProgressMessage((str), QtFatalMsg) +#define PRINT_SKIP(str) emitProgressMessage((str), QtDebugMsg) +#define PRINT_INFO(str) emit progressMessage((str)) // this is always emitted regardless of logLevel option +#define PRINT_SEP() PRINT_INFO(QString(70, '=')) -#define SYNC_MAX_ERRORS 50 // give up after this many errors per destination +#ifdef Q_OS_WIN + extern Q_CORE_EXPORT int qt_ntfs_permission_lookup; + #define FILTER_RE_SYNTX QRegExp::Wildcard +#else + #define FILTER_RE_SYNTX QRegExp::WildcardUnix +#endif -SyncProcess::SyncProcess(const QString & folderA, const QString & folderB, const int & syncDirection, const int & compareType, const qint64 & maxFileSize, const bool dryRun): - folder1(folderA), - folder2(folderB), - direction((SyncDirection)syncDirection), - ctype((SyncCompareType)compareType), - maxFileSize(qMax(0, maxFileSize)), - dryRun(dryRun), +SyncProcess::SyncProcess(const SyncProcess::SyncOptions & options) : + m_options(options), + m_pauseTime(PAUSE_MINTM), stopping(false) { - if (direction == SYNC_B2A_A2B) { - folder1 = folderB; - folder2 = folderA; - direction = SYNC_A2B_B2A; + qRegisterMetaType(); + + if (m_options.compareType == OVERWR_ALWAYS && (m_options.direction == SYNC_A2B_B2A || m_options.direction == SYNC_B2A_A2B)) + m_options.compareType = OVERWR_IF_DIFF; + + m_dirFilters = QDir::Filters(m_options.dirFilterFlags); + if (!(m_dirFilters & QDir::Dirs)) + m_dirFilters &= ~(QDir::AllDirs); + + if (!m_options.includeFilter.isEmpty() && m_options.includeFilter != "*") + m_dirIteratorFilters = m_options.includeFilter.split(',', QString::SkipEmptyParts); + + if (!m_options.excludeFilter.isEmpty()) { + for (const QString & f : m_options.excludeFilter.split(',', QString::SkipEmptyParts)) + m_excludeFilters.append(QRegExp(f, ((m_dirFilters & QDir::CaseSensitive) ? Qt::CaseSensitive : Qt::CaseInsensitive), FILTER_RE_SYNTX)); } - if (ctype == OVERWR_ALWAYS && direction == SYNC_A2B_B2A) - ctype = OVERWR_IF_DIFF; - - dirFilters = QDir::Filters(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot); - - reportTemplate = tr("New: %1; Updated: %2; Skipped: %3; Errors: %4;"); - if (dryRun) + if (m_options.flags & OPT_DRY_RUN) testRunStr = tr("[TEST RUN] "); + + //qDebug() << m_options; +#ifdef Q_OS_WIN + qt_ntfs_permission_lookup++; // global enable NTFS permissions checking +#endif +} + +SyncProcess::~SyncProcess() +{ +#ifdef Q_OS_WIN + qt_ntfs_permission_lookup--; // global revert NTFS permissions checking +#endif } void SyncProcess::stop() { - QMutexLocker locker(&stopReqMutex); + QWriteLocker locker(&stopReqMutex); stopping = true; } bool SyncProcess::isStopRequsted() { - QMutexLocker locker(&stopReqMutex); + QReadLocker locker(&stopReqMutex); return stopping; } void SyncProcess::run() { - count = index = created = updated = skipped = errored = 0; + const QString folderA = (m_options.direction == SYNC_B2A_A2B ? m_options.folderB : m_options.folderA); + const QString folderB = (m_options.direction == SYNC_B2A_A2B ? m_options.folderA : m_options.folderB); + const SyncDirection direction = (m_options.direction == SYNC_B2A_A2B ? SYNC_A2B_B2A : SyncDirection(m_options.direction)); + const QString gathering = tr("Gathering file information for %1..."); + const QString noFiles = tr("No files found in %1"); + int count = 0; + + m_stat.clear(); + m_startTime = QDateTime::currentDateTime(); emit started(); - emit progressStep(index); - emit statusMessage(tr("Gathering file information...")); + emit fileCountChanged(0); + emit statusUpdate(m_stat); - if (direction == SYNC_A2B_B2A || direction == SYNC_A2B) - count += getFilesCount(folder1); + if (direction == SYNC_A2B_B2A || direction == SYNC_A2B) { + emit statusMessage(gathering.arg(folderA)); + count = getFilesCount(folderA); - if (isStopRequsted()) { - finish(); - return; + if (isStopRequsted()) + goto endrun; + + if (count) { + m_stat.count = count; + if (m_options.direction == SYNC_A2B_B2A) + count *= 2; // assume this direction is only 50% of total, exact will be calculated later + emit fileCountChanged(count); + updateDir(folderA, folderB); + if (isStopRequsted()) + goto endrun; + } + else { + PRINT_INFO(noFiles.arg(folderA)); + PRINT_SEP(); + } } - if (direction == SYNC_A2B_B2A || direction == SYNC_B2A) - count += getFilesCount(folder2); + if (direction == SYNC_A2B_B2A || direction == SYNC_B2A) { + emit statusMessage(gathering.arg(folderB)); + count = getFilesCount(folderB); - if (isStopRequsted()) { - finish(); - return; + if (isStopRequsted()) + goto endrun; + + m_stat.count += count; + emit fileCountChanged(m_stat.count); + + if (count) { + updateDir(folderB, folderA); + } + else { + PRINT_INFO(noFiles.arg(folderB)); + PRINT_SEP(); + } } - emit fileCountChanged(count); - - if (!count) { - QString nf = tr("Synchronization failed, nothing found to copy."); - emit statusMessage(nf); - PRINT_ERROR(nf); + if (!m_stat.count) { + emit statusMessage(tr("Synchronization failed, nothing found to copy."), QtWarningMsg); emit finished(); return; } - if (direction == SYNC_A2B_B2A || direction == SYNC_A2B) - updateDir(folder1, folder2); - - if (isStopRequsted()) { - finish(); - return; - } - - if (direction == SYNC_A2B_B2A || direction == SYNC_B2A) - updateDir(folder2, folder1); - + endrun: finish(); } void SyncProcess::finish() { - QString endStr = testRunStr % tr("Synchronization finished. ") % reportTemplate; - emit statusMessage(endStr.arg(created).arg(updated).arg(skipped).arg(errored).arg(errored ? "red" : "black")); + const std::lldiv_t elapsed = std::lldiv(m_startTime.secsTo(QDateTime::currentDateTime()), 60); + QString endStr = testRunStr; + if (m_stat.index < m_stat.count) + endStr.append(tr("Synchronization aborted at %1 of %2 files.").arg(m_stat.index).arg(m_stat.count)); + else + endStr.append(tr("Synchronization finished with %1 files in %2m %3s.").arg(m_stat.count).arg(elapsed.quot).arg(elapsed.rem)); + emit statusMessage(endStr); emit finished(); } +SyncProcess::FileFilterResult SyncProcess::fileFilter(const QFileInfo & fileInfo) +{ + // Windows Junctions (mount points) are not detected as links (QTBUG-45344), but that's OK since they're really "hard links." + const bool chkDirLnk = ((m_dirFilters & QDir::NoSymLinks) && !(m_dirFilters & QDir::AllDirs)) || ((m_options.flags & OPT_SKIP_DIR_LINKS) && fileInfo.isDir()); + if ((chkDirLnk || ((m_dirFilters & QDir::NoSymLinks) && fileInfo.isFile())) && QFileInfo(fileInfo.absoluteFilePath()).isSymLink()) // MUST create a new QFileInfo here (QTBUG-69001) + return FILE_LINK_IGNORE; + + if (m_options.maxFileSize > 0 && fileInfo.isFile() && fileInfo.size() > m_options.maxFileSize) + return FILE_OVERSIZE; + + if (!m_excludeFilters.isEmpty() && (!(m_dirFilters & QDir::AllDirs) || fileInfo.isFile())) { + for (QVector::const_iterator it = m_excludeFilters.constBegin(), end = m_excludeFilters.constEnd(); it != end; ++it) { + if (QRegExp(*it).exactMatch(fileInfo.fileName())) + return FILE_EXCLUDE; + } + } + + return FILE_ALLOW; +} + +QFileInfoList SyncProcess::dirInfoList(const QString & directory) +{ + QDir::Filters flt = m_dirFilters; + if (!(flt & QDir::Dirs) && (m_options.flags & OPT_RECURSIVE)) + flt |= ((m_options.dirFilterFlags & QDir::AllDirs) ? QDir::AllDirs : QDir::Dirs); + return QDir(directory).entryInfoList(m_dirIteratorFilters, flt, QDir::Name | QDir::DirsLast); +} + +void SyncProcess::pushDirEntries(const QFileInfo & fileInfo, QMutableListIterator & it) +{ + if ((m_options.flags & OPT_RECURSIVE) && fileInfo.isDir()) { + for (const QFileInfo &fi : dirInfoList(fileInfo.absoluteFilePath())) + it.insert(fi); + } +} + int SyncProcess::getFilesCount(const QString & directory) { if (!QFile::exists(directory)) return 0; int result = 0; - QDirIterator it(directory, dirFilters, QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); - while (it.hasNext() && !isStopRequsted()) { - it.next(); - result++; + QFileInfoList infoList = dirInfoList(directory); + QMutableListIterator it(infoList); + it.toBack(); + while (it.hasPrevious() && !isStopRequsted()) { + const QFileInfo fi(it.previous()); + it.remove(); + if (fileFilter(fi) == FILE_ALLOW) { + pushDirEntries(fi, it); + if (fi.isFile()) { + result++; + } + } QApplication::processEvents(); } return result; @@ -151,72 +242,117 @@ int SyncProcess::getFilesCount(const QString & directory) void SyncProcess::updateDir(const QString & source, const QString & destination) { - int counts[4] = { created, updated, skipped, errored }; - QString statusStr = testRunStr % tr("Synchronizing %1 -> %2: %3").arg(source, destination, "%1|%2 (%3)"); + SyncStatus pStat = m_stat; + const QDir srcDir(source), dstDir(destination); + FileFilterResult ffr; + emit statusMessage(testRunStr % tr("Synchronizing: %1\n To: %2").arg(source, destination)); + PRINT_INFO(testRunStr % tr("Starting synchronization:\n %1 -> %2\n").arg(source, destination)); - PRINT_INFO(testRunStr % tr("Starting synchronization: %1 -> %2
").arg(source, destination)); - - QDirIterator it(source, dirFilters, QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); - while (it.hasNext() && !isStopRequsted()) { - it.next(); - ++index; - emit statusMessage(statusStr.arg(index).arg(count).arg(reportTemplate.arg(created).arg(updated).arg(skipped).arg(errored).arg(errored ? "red" : "black"))); - emit progressStep(index); - if (maxFileSize && it.fileInfo().isFile() && it.fileInfo().size() > maxFileSize) { - PRINT_SKIP(tr("Skipping large file: %1 (%2KB)").arg(it.fileName()).arg(int(it.fileInfo().size() / 1024))); - ++skipped; - } - else { - updateEntry(it.filePath(), source, destination); - if (errored - counts[3] > SYNC_MAX_ERRORS) { - PRINT_ERROR(tr("
Too many errors, giving up.")); - break; + QFileInfoList infoList = dirInfoList(source); + QMutableListIterator it(infoList); + it.toBack(); + while (it.hasPrevious() && !isStopRequsted()) { + const QFileInfo fi(it.previous()); + it.remove(); + if ((ffr = fileFilter(fi)) == FILE_ALLOW) { + pushDirEntries(fi, it); + if ((m_dirFilters & QDir::Dirs) || fi.isFile()) { + updateEntry(fi.filePath(), srcDir, dstDir); + if (fi.isFile()) + ++m_stat.index; + emit statusUpdate(m_stat); + if (m_stat.errored - pStat.errored > SYNC_MAX_ERRORS) { + PRINT_ERROR(tr("\nToo many errors, giving up.")); + break; + } } } - QApplication::processEvents(); + else if (m_options.logLevel == QtDebugMsg) { + switch (ffr) { + case FILE_OVERSIZE: + PRINT_SKIP(tr("Skipping large file: %1 (%2KB)").arg(fi.fileName()).arg(int(fi.size() / 1024))); + break; + case FILE_EXCLUDE: + PRINT_SKIP(tr("Skipping filtered file: %1").arg(fi.fileName())); + break; + case FILE_LINK_IGNORE: + PRINT_SKIP(tr("Skipping linked file: %1").arg(fi.fileName())); + break; + default: + break; + } + // don't count as skipped because these weren't included in the total file count to begin with + } + // throttle if needed + m_pauseTime = qMax(m_pauseTime - PAUSE_RECOVERY, PAUSE_MINTM); + pause(); } - QString endStr = "
" % testRunStr % tr("Finished synchronizing %1 -> %2 :
     %3").arg(source, destination, reportTemplate); - PRINT_INFO(endStr.arg(created-counts[0]).arg(updated-counts[1]).arg(skipped-counts[2]).arg(errored-counts[3]).arg(errored-counts[3] ? "red" : "black")); + QString endStr = "\n" % testRunStr; + if (isStopRequsted()) + endStr.append(tr("Aborted synchronization of:")); + else + endStr.append(tr("Finished synchronizing:")); + endStr.append(QString("\n %1 -> %2\n ").arg(source, destination)); + endStr.append(tr("Created: %1; Updated: %2; Skipped: %3; Errors: %4;").arg(m_stat.created-pStat.created).arg(m_stat.updated-pStat.updated).arg(m_stat.skipped-pStat.skipped).arg(m_stat.errored-pStat.errored)); + PRINT_INFO(endStr); PRINT_SEP(); } bool SyncProcess::updateEntry(const QString & entry, const QDir & source, const QDir & destination) { - QString srcPath = source.toNativeSeparators(source.absoluteFilePath(entry)); - QString relPath = source.relativeFilePath(entry); - QString destPath = destination.toNativeSeparators(destination.absoluteFilePath(relPath)); - QFileInfo sourceInfo(srcPath); - QFileInfo destInfo(destPath); + const QString srcPath = QDir::toNativeSeparators(source.absoluteFilePath(entry)); + const QString destPath = QDir::toNativeSeparators(destination.absoluteFilePath(source.relativeFilePath(entry))); + const QFileInfo sourceInfo(srcPath); + const QFileInfo destInfo(destPath); + static QString lastMkPath; - if (sourceInfo.isDir()) { - if (!destInfo.exists()) { - PRINT_CREATE(tr("Creating directory: %1").arg(destPath)); - if (!dryRun && !destination.mkpath(destPath)) { - PRINT_ERROR(tr("Could not create directory: %1").arg(destPath)); - ++errored; - return false; + // check if this is a directory OR if we're copying a file with a path which doesn't exist yet. + if (sourceInfo.isDir() || !destInfo.absoluteDir().exists()) { + const QString mkPath = sourceInfo.isDir() ? destPath : QDir::toNativeSeparators(destInfo.absolutePath()); + if (!destination.exists(mkPath)) { + if (mkPath == lastMkPath) { + // we've already tried, and apparently failed, to create this folder... bail out but log as error. + if (!(m_options.flags & OPT_DRY_RUN)) { + ++m_stat.errored; + return false; + } + } + else { + lastMkPath = mkPath; + PRINT_CREATE(tr("Creating directory: %1").arg(mkPath)); + if (!(m_options.flags & OPT_DRY_RUN) && !destination.mkpath(mkPath)) { + PRINT_ERROR(tr("Could not create directory: %1").arg(mkPath)); + ++m_stat.errored; + return false; + } } - ++created; } - else { - PRINT_SKIP(tr("Destination directory exists: %1").arg(destPath)); - ++skipped; + else if (m_dirFilters & QDir::Dirs) { + PRINT_SKIP(tr("Directory exists: %1").arg(mkPath)); } - return true; + if (sourceInfo.isDir()) + return true; } + //qDebug() << destPath; QFile sourceFile(srcPath); QFile destinationFile(destPath); - bool destExists = destInfo.exists(); - bool checkDate = (ctype == OVERWR_NEWER_IF_DIFF || ctype == OVERWR_NEWER_ALWAYS); - bool checkContent = (ctype == OVERWR_NEWER_IF_DIFF || ctype == OVERWR_IF_DIFF); + const bool destExists = destInfo.exists(); + bool checkDate = (m_options.compareType == OVERWR_NEWER_IF_DIFF || m_options.compareType == OVERWR_NEWER_ALWAYS); + bool checkContent = (m_options.compareType == OVERWR_NEWER_IF_DIFF || m_options.compareType == OVERWR_IF_DIFF); bool existed = false; if (destExists && checkDate) { + const QDate cmprDate = QDate::currentDate(); + if (sourceInfo.lastModified().date() > cmprDate || destInfo.lastModified().date() > cmprDate) { + PRINT_ERROR(tr("At least one of the file modification dates is in the future, error on: %1").arg(srcPath)); + ++m_stat.errored; + return false; + } if (sourceInfo.lastModified() <= destInfo.lastModified()) { PRINT_SKIP(tr("Skipping older file: %1").arg(srcPath)); - ++skipped; + ++m_stat.skipped; return true; } checkDate = false; @@ -225,21 +361,21 @@ bool SyncProcess::updateEntry(const QString & entry, const QDir & source, const if (destExists && checkContent) { if (!sourceFile.open(QFile::ReadOnly)) { PRINT_ERROR(tr("Could not open source file '%1': %2").arg(srcPath, sourceFile.errorString())); - ++errored; + ++m_stat.errored; return false; } if (!destinationFile.open(QFile::ReadOnly)) { PRINT_ERROR(tr("Could not open destination file '%1': %2").arg(destPath, destinationFile.errorString())); - ++errored; + ++m_stat.errored; return false; } - bool skip = QCryptographicHash::hash(sourceFile.readAll(), QCryptographicHash::Md5) == QCryptographicHash::hash(destinationFile.readAll(), QCryptographicHash::Md5); + const bool skip = QCryptographicHash::hash(sourceFile.readAll(), QCryptographicHash::Md5) == QCryptographicHash::hash(destinationFile.readAll(), QCryptographicHash::Md5); sourceFile.close(); destinationFile.close(); if (skip) { PRINT_SKIP(tr("Skipping identical file: %1").arg(srcPath)); - ++skipped; + ++m_stat.skipped; return true; } checkContent = false; @@ -248,27 +384,44 @@ bool SyncProcess::updateEntry(const QString & entry, const QDir & source, const if (!destExists || (!checkDate && !checkContent)) { if (destInfo.exists()) { existed = true; - PRINT_REPLACE(tr("Replacing destination file: %1").arg(destPath)); - if (!dryRun && !destinationFile.remove()) { + PRINT_REPLACE(tr("Replacing file: %1").arg(destPath)); + if (!(m_options.flags & OPT_DRY_RUN) && !destinationFile.remove()) { PRINT_ERROR(tr("Could not delete destination file '%1': %2").arg(destPath, destinationFile.errorString())); - ++errored; + ++m_stat.errored; return false; } } else { - PRINT_CREATE(tr("Creating destination file: %1").arg(destPath)); + PRINT_CREATE(tr("Creating file: %1").arg(destPath)); } - if (!dryRun && !sourceFile.copy(destPath)) { + if (!(m_options.flags & OPT_DRY_RUN) && !sourceFile.copy(destPath)) { PRINT_ERROR(tr("Copy failed: '%1' to '%2': %3").arg(srcPath, destPath, sourceFile.errorString())); - ++errored; + ++m_stat.errored; return false; } if (existed) - ++updated; + ++m_stat.updated; else - ++created; + ++m_stat.created; } return true; } + +void SyncProcess::pause() +{ + QElapsedTimer tim; + const qint64 exp = m_pauseTime * 1000; + tim.start(); + while (tim.nsecsElapsed() < exp && !isStopRequsted()) + QApplication::processEvents(); +} + +void SyncProcess::emitProgressMessage(const QString & text, int type) +{ + if (m_options.logLevel == QtDebugMsg || (m_options.logLevel == QtInfoMsg && type > QtDebugMsg) || (type < QtInfoMsg && type >= m_options.logLevel)) { + emit progressMessage(text, type); + m_pauseTime = qMin(m_pauseTime + PAUSE_FACTOR, PAUSE_MAXTM); + } +} diff --git a/companion/src/process_sync.h b/companion/src/process_sync.h index 636b3b9b8..67a139791 100644 --- a/companion/src/process_sync.h +++ b/companion/src/process_sync.h @@ -18,13 +18,17 @@ * GNU General Public License for more details. */ -#ifndef _PROCESS_SYNC_H_ -#define _PROCESS_SYNC_H_ +#ifndef PROCESS_SYNC_H +#define PROCESS_SYNC_H #include +#include +#include #include -#include -#include +#include +#include +#include +#include class SyncProcess : public QObject { @@ -32,25 +36,75 @@ class SyncProcess : public QObject public: enum SyncDirection { - SYNC_A2B_B2A, + SYNC_A2B_B2A = 0, SYNC_B2A_A2B, SYNC_A2B, SYNC_B2A }; + Q_ENUM(SyncDirection) enum SyncCompareType { - OVERWR_NEWER_IF_DIFF, + OVERWR_NEWER_IF_DIFF = 0, OVERWR_NEWER_ALWAYS, OVERWR_IF_DIFF, OVERWR_ALWAYS }; + Q_ENUM(SyncCompareType) - SyncProcess(const QString & folderA, - const QString & folderB, - const int & syncDirection = SYNC_A2B_B2A, - const int & compareType = OVERWR_NEWER_IF_DIFF, - const qint64 & maxFileSize = 5*1024*1024, - const bool dryRun = false); + enum SyncOptionFlag { + OPT_NONE = 0, + OPT_DRY_RUN = 0x01, + OPT_SKIP_EMPTY_DIR = 0x02, + OPT_RECURSIVE = 0x04, + OPT_SKIP_DIR_LINKS = 0x08 + }; + Q_DECLARE_FLAGS(SyncOptionFlags, SyncOptionFlag) + Q_FLAG(SyncOptionFlags) + + // TODO: perhaps move this to AppData/saved settings once that refactoring is merged. + struct SyncOptions { + explicit SyncOptions() { reset(); } + + QString folderA; + QString folderB; + QString excludeFilter; // CSV list of glob pattern(s) + QString includeFilter; // CSV list of glob pattern(s) + qint64 maxFileSize; // Bytes + int direction; // SyncDirection + int compareType; // SyncCompareType + int flags; // SyncOptionFlags + int dirFilterFlags; // QDir::Filters + int logLevel; // QtMsgType (see ProgressWidget::addMessage()) + + void reset() + { + excludeFilter = QStringLiteral(".*,System Volume*"); + includeFilter.clear(); + direction = SYNC_A2B_B2A; + compareType = OVERWR_NEWER_IF_DIFF; + maxFileSize = 2 * 1024 * 1024; + logLevel = QtWarningMsg; + flags = OPT_RECURSIVE; + dirFilterFlags = QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot | QDir::Readable; + } + + friend inline QDebug operator<<(QDebug debug, const SyncOptions &o) { + return debug << "Incl:" << o.includeFilter << "Excl:" << o.excludeFilter << SyncDirection(o.direction) << SyncCompareType(o.compareType) << SyncOptionFlags(o.flags) << QDir::Filters(o.dirFilterFlags); + } + }; + + struct SyncStatus { + int index; + int count; + int created; + int updated; + int skipped; + int errored; + void clear() { memset(this, 0, sizeof(SyncStatus)); } + }; + + explicit SyncProcess(const SyncOptions & options); + ~SyncProcess(); public slots: void run(); @@ -60,34 +114,39 @@ class SyncProcess : public QObject void started(); void finished(); void fileCountChanged(int count); - void progressStep(int step); - void progressMessage(const QString & text, const int & type); - void statusMessage(const QString & text); + void statusUpdate(const SyncProcess::SyncStatus & status); + void statusMessage(const QString & text, const int & type = QtInfoMsg); + void progressMessage(const QString & text, const int & type = QtInfoMsg, bool richText = false); protected: + enum FileFilterResult { FILE_ALLOW, FILE_OVERSIZE, FILE_EXCLUDE, FILE_LINK_IGNORE }; + bool isStopRequsted(); void finish(); + FileFilterResult fileFilter(const QFileInfo & fileInfo); + QFileInfoList dirInfoList(const QString & directory); int getFilesCount(const QString & directory); void updateDir(const QString & source, const QString & destination); + void pushDirEntries(const QFileInfo & fi, QMutableListIterator &it); bool updateEntry(const QString & entry, const QDir & source, const QDir & destination); + void pause(); + void emitProgressMessage(const QString &text, int type); - QString folder1; - QString folder2; - SyncDirection direction; - SyncCompareType ctype; - qint64 maxFileSize; // Bytes - QDir::Filters dirFilters; - QMutex stopReqMutex; - QString reportTemplate; + SyncOptions m_options; + SyncStatus m_stat; + QReadWriteLock stopReqMutex; QString testRunStr; - int index; - int count; - int created; - int updated; - int skipped; - int errored; - bool dryRun; + QVector m_excludeFilters; + QStringList m_dirIteratorFilters; + QDir::Filters m_dirFilters; + QDateTime m_startTime; + unsigned long m_pauseTime; bool stopping; }; -#endif // _PROCESS_SYNC_H_ +Q_DECLARE_METATYPE(SyncProcess::SyncOptions) +Q_DECLARE_TYPEINFO(SyncProcess::SyncOptions, Q_MOVABLE_TYPE); +Q_DECLARE_METATYPE(SyncProcess::SyncStatus) +Q_DECLARE_TYPEINFO(SyncProcess::SyncStatus, Q_PRIMITIVE_TYPE); + +#endif // PROCESS_SYNC_H