diff --git a/.github/workflows/build-checks.yml b/.github/workflows/build-checks.yml index fb8bd2cfbda7..a6b6b4f2c885 100644 --- a/.github/workflows/build-checks.yml +++ b/.github/workflows/build-checks.yml @@ -24,6 +24,7 @@ jobs: libebur128-dev \ libfftw3-dev \ libflac-dev \ + libgrantlee5-dev \ libid3tag0-dev \ liblilv-dev \ libmad0-dev \ diff --git a/CMakeLists.txt b/CMakeLists.txt index 26190df63fe6..54355d102a5e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -656,7 +656,6 @@ add_library(mixxx-lib STATIC EXCLUDE_FROM_ALL src/library/dlgtrackmetadataexport.cpp src/library/export/dlgtrackexport.ui src/library/export/trackexportdlg.cpp - src/library/export/trackexportwizard.cpp src/library/export/trackexportworker.cpp src/library/externaltrackcollection.cpp src/library/hiddentablemodel.cpp @@ -708,7 +707,9 @@ add_library(mixxx-lib STATIC EXCLUDE_FROM_ALL src/library/trackset/crate/cratefeaturehelper.cpp src/library/trackset/crate/cratestorage.cpp src/library/trackset/crate/cratetablemodel.cpp + src/library/trackset/crate/cratesummary.cpp src/library/trackset/playlistfeature.cpp + src/library/trackset/playlistsummary.cpp src/library/trackset/setlogfeature.cpp src/library/trackset/tracksettablemodel.cpp src/library/traktor/traktorfeature.cpp @@ -868,6 +869,8 @@ add_library(mixxx-lib STATIC EXCLUDE_FROM_ALL src/util/dnd.cpp src/util/duration.cpp src/util/experiment.cpp + src/util/fileutils.cpp + src/util/formatter.cpp src/util/fileaccess.cpp src/util/fileinfo.cpp src/util/imageutils.cpp @@ -1499,6 +1502,8 @@ add_executable(mixxx-test src/test/enginemastertest.cpp src/test/enginemicrophonetest.cpp src/test/enginesynctest.cpp + src/test/formatter_test.cpp + src/test/fileutils_test.cpp src/test/fileinfo_test.cpp src/test/globaltrackcache_test.cpp src/test/hotcuecontrol_test.cpp @@ -1890,6 +1895,29 @@ target_link_libraries(mixxx-lib PUBLIC FpClassify) # in production code target_include_directories(mixxx-lib SYSTEM PUBLIC "${gtest_SOURCE_DIR}/include") +# Grantlee5 +find_package(Grantlee5 REQUIRED) + +target_link_libraries(mixxx-lib PUBLIC + Grantlee5::Templates + Grantlee5::TextDocument +) + +# grantlee plugin +add_library(mixxxformatter MODULE + src/util/formatterplugin/mixxxformatter.cpp + src/util/fileutils.cpp +) + +set_target_properties(mixxxformatter PROPERTIES AUTOMOC ON) +target_include_directories(mixxxformatter PRIVATE src) + +grantlee_adjust_plugin_name(mixxxformatter) + +target_link_libraries(mixxxformatter + Grantlee5::Templates +) + # LAME find_package(mp3lame REQUIRED) target_link_libraries(mixxx-lib PUBLIC mp3lame::mp3lame) diff --git a/packaging/debian/control.in b/packaging/debian/control.in index 0230b106aefc..8b5f41a91c86 100644 --- a/packaging/debian/control.in +++ b/packaging/debian/control.in @@ -27,6 +27,7 @@ Build-Depends: debhelper (>= 11), # Only needed for running tests that use SQLite. libqt5sql5-sqlite, libqt5x11extras5-dev, + libgrantlee5-dev, cmake (>= 3.13), libjack-dev, portaudio19-dev, diff --git a/src/library/dao/playlistdao.cpp b/src/library/dao/playlistdao.cpp index 75d91bfa0601..c72be749f2c0 100644 --- a/src/library/dao/playlistdao.cpp +++ b/src/library/dao/playlistdao.cpp @@ -11,6 +11,7 @@ #include "library/trackcollection.h" #include "track/track.h" #include "util/compatibility.h" +#include "util/db/dbconnection.h" #include "util/db/fwdsqlquery.h" #include "util/math.h" @@ -21,6 +22,32 @@ PlaylistDAO::PlaylistDAO() void PlaylistDAO::initialize(const QSqlDatabase& database) { DAO::initialize(database); populatePlaylistMembershipCache(); + + // create temporary views + QString queryString = QLatin1String( + "CREATE TEMPORARY VIEW IF NOT EXISTS PlaylistsCountsDurations " + "AS SELECT " + " Playlists.id AS id, " + " Playlists.name AS name, " + " LOWER(Playlists.name) AS sort_name, " + " COUNT(case library.mixxx_deleted when 0 then 1 else null end) " + " AS count, " + " SUM(case library.mixxx_deleted " + " when 0 then library.duration else 0 end) AS durationSeconds " + "FROM Playlists " + "LEFT JOIN PlaylistTracks " + " ON PlaylistTracks.playlist_id = Playlists.id " + "LEFT JOIN library " + " ON PlaylistTracks.track_id = library.id " + " WHERE Playlists.hidden = 0 " + " GROUP BY Playlists.id"); + queryString.append( + mixxx::DbConnection::collateLexicographically( + " ORDER BY sort_name")); + QSqlQuery query(m_database); + if (!query.exec(queryString)) { + LOG_FAILED_QUERY(query); + } } void PlaylistDAO::populatePlaylistMembershipCache() { @@ -1160,6 +1187,122 @@ void PlaylistDAO::getPlaylistsTrackIsIn(TrackId trackId, } } +QList PlaylistDAO::createPlaylistSummaryForTracks(const QList& tracks) { + QSet allPlaylistIds; + QSet playlistIds; + QMap trackCount; + for (TrackId trackId : tracks) { + PlaylistDAO::getPlaylistsTrackIsIn(trackId, &playlistIds); + allPlaylistIds += playlistIds; + for (int playlistId : qAsConst(playlistIds)) { + trackCount[playlistId] = trackCount.value(playlistId, 0) + 1; + } + } + QList summaries = PlaylistDAO::createPlaylistSummary(&allPlaylistIds); + for (PlaylistSummary summary : summaries) { + DEBUG_ASSERT(trackCount.contains(summary.id())); + summary.setMatches(trackCount.value(summary.id())); + } + return summaries; +} + +QList PlaylistDAO::createPlaylistSummary(const QSet* playlistIds) { + QList playlistLabels; + + // Setup the sidebar playlist model + QSqlTableModel playlistTableModel(this, m_database); + playlistTableModel.setTable("PlaylistsCountsDurations"); + + if (playlistIds) { + QStringList idList; + for (const auto& playlistId : *playlistIds) { + idList.append(QString::number(playlistId)); + } + playlistTableModel.setFilter(QString("id in [%1]").arg(idList.join(","))); + } + + playlistTableModel.select(); + while (playlistTableModel.canFetchMore()) { + playlistTableModel.fetchMore(); + } + QSqlRecord record = playlistTableModel.record(); + int nameColumn = record.indexOf("name"); + int idColumn = record.indexOf("id"); + int countColumn = record.indexOf("count"); + int durationColumn = record.indexOf("durationSeconds"); + + for (int row = 0; row < playlistTableModel.rowCount(); ++row) { + int id = + playlistTableModel + .data(playlistTableModel.index(row, idColumn)) + .toInt(); + QString name = + playlistTableModel + .data(playlistTableModel.index(row, nameColumn)) + .toString(); + int count = + playlistTableModel + .data(playlistTableModel.index(row, countColumn)) + .toInt(); + int duration = + playlistTableModel + .data(playlistTableModel.index(row, durationColumn)) + .toInt(); + PlaylistSummary playlist(id, name); + playlist.setCount(count); + playlist.setDuration(duration); + playlistLabels.append(playlist); + } + return playlistLabels; +} +PlaylistSummary PlaylistDAO::getPlaylistSummary(int playlistId) { + if (playlistId == -1) { + return PlaylistSummary(); + } + QSqlTableModel playlistTableModel(this, m_database); + playlistTableModel.setTable("PlaylistsCountsDurations"); + + playlistTableModel.setFilter(QString("id = %1").arg(playlistId)); + + playlistTableModel.select(); + while (playlistTableModel.canFetchMore()) { + playlistTableModel.fetchMore(); + } + QSqlRecord record = playlistTableModel.record(); + int nameColumn = record.indexOf("name"); + int idColumn = record.indexOf("id"); + int countColumn = record.indexOf("count"); + int durationColumn = record.indexOf("durationSeconds"); + + if (playlistTableModel.rowCount() == 0) { + return PlaylistSummary(); + } + VERIFY_OR_DEBUG_ASSERT(playlistTableModel.rowCount() == 1) { + qWarning() << "playlist id matched to multiple results"; + } + + int id = + playlistTableModel + .data(playlistTableModel.index(0, idColumn)) + .toInt(); + QString name = + playlistTableModel + .data(playlistTableModel.index(0, nameColumn)) + .toString(); + int count = + playlistTableModel + .data(playlistTableModel.index(0, countColumn)) + .toInt(); + int duration = + playlistTableModel + .data(playlistTableModel.index(0, durationColumn)) + .toInt(); + PlaylistSummary summary(id, name); + summary.setCount(count); + summary.setDuration(duration); + return summary; +} + void PlaylistDAO::setAutoDJProcessor(AutoDJProcessor* pAutoDJProcessor) { m_pAutoDJProcessor = pAutoDJProcessor; } diff --git a/src/library/dao/playlistdao.h b/src/library/dao/playlistdao.h index e7fa7354ab56..9d1c925bf6a5 100644 --- a/src/library/dao/playlistdao.h +++ b/src/library/dao/playlistdao.h @@ -2,10 +2,11 @@ #include #include -#include #include +#include #include "library/dao/dao.h" +#include "library/trackset/playlistsummary.h" #include "track/trackid.h" #include "util/class.h" @@ -117,6 +118,9 @@ class PlaylistDAO : public QObject, public virtual DAO { bool isTrackInPlaylist(TrackId trackId, const int playlistId) const; void getPlaylistsTrackIsIn(TrackId trackId, QSet* playlistSet) const; + QList createPlaylistSummary(const QSet* playlistIds = nullptr); + QList createPlaylistSummaryForTracks(const QList& tracks); + PlaylistSummary getPlaylistSummary(int playlistId); void setAutoDJProcessor(AutoDJProcessor* pAutoDJProcessor); diff --git a/src/library/export/dlgtrackexport.ui b/src/library/export/dlgtrackexport.ui index 6f12538d8191..1192233d74ba 100644 --- a/src/library/export/dlgtrackexport.ui +++ b/src/library/export/dlgtrackexport.ui @@ -6,8 +6,8 @@ 0 0 - 600 - 200 + 556 + 429 @@ -31,109 +31,296 @@ Export Tracks + + false + - - - true - - - - 0 - 0 - + + + 0 - - Exporting Tracks - - - - - 10 - 26 - 561 - 150 - - - - - 0 - 0 - - - - - 6 - - - - - 10 - - - 0 - - - - - - + + + &Options + + - - - (status text) - - + + true - - - - - - Qt::Vertical - - - - 20 - 20 - + + + 0 + 0 + - + + + + + + + Create Playlist + + + + + + + + + + + + Browse + + + + + + + + + Preview + + + + + + + Pattern + + + + + + + Folder + + + + + + + + + + + + + .m3u8 + + + + + .m3u + + + + + .pls + + + + + + + + + + 0 + + + + + + 0 + 0 + + + + + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + Qt::TextSelectableByMouse + + + + + + + + 0 + 0 + + + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + true + + + true + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + &Progress + + - - - 10 - + - - - &Cancel + + + 24 - - - - Qt::Horizontal - - - - 40 - 20 - - - - + + + + QAbstractItemView::NoEditTriggers + + + false + + + QAbstractItemView::ExtendedSelection + + + false + + + + From + + + + + Destination + + + + + Result + + + + + + + + + + + 0 + 0 + + + + Help + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + &Close + + + + + + + + 0 + 0 + + + + &Start + + + + + + + tabWidget + folderEdit + browseButton + patternCombo + playlistExport + playlistName + playlistSuffix + pushButton + cancelButton + startButton + statusTable + diff --git a/src/library/export/trackexportdlg.cpp b/src/library/export/trackexportdlg.cpp index d6c6c0335e14..e848ee706e7f 100644 --- a/src/library/export/trackexportdlg.cpp +++ b/src/library/export/trackexportdlg.cpp @@ -1,34 +1,122 @@ #include "library/export/trackexportdlg.h" #include +#include #include +#include #include +#include #include "moc_trackexportdlg.cpp" #include "util/assert.h" +#include "util/fileutils.h" -TrackExportDlg::TrackExportDlg(QWidget *parent, - UserSettingsPointer pConfig, - TrackExportWorker* worker) +namespace { + +const QStringList kDefaultPatterns = QStringList() + << "{{ track.fileName }}" + << "{{ track.artist }} - {{track.title }}.{{ track.extension }}" + << "{{ index|zeropad:\"3\"}} - {{ track.artist }} - " + "{{ track.title }}.{{ track.extension }}" + << "{{ track.bpm|round }} - {{ track.artist }} - {{track.title }}" + ".{{ track.extension }}" + << "{{ track.bpm|round }}/{{ track.artist }} - {{track.title }}" + ".{{ track.extension }}" + << "{{ track.bpm|round }}/{{ track.key.openkey }} - {{ track.artist }} " + "- {{track.title }}.{{ track.extension }}" + << "{{ track.bpm|round }}/{{ track.key.lancelot }} - {{ track.artist " + "}} - {{track.title }}.{{ track.extension }}" + << "{{ track.bpm|rangegroup }}/{{ track.artist }} - {{track.title " + "}}.{{ track.extension }}" + << "{{ crate.name }}/{{ index|zeropad:\"3\" }} - {{ track.artist }}" + " - {{ track.title }}.{{ track.extension }}" + << "{{ playlist.name }}/{{ index|zeropad:\"3\" }} - {{ track.artist }} - " + "{{ track.title }}.{{ track.extension }}"; + +} // anonymous namespace + +TrackExportDlg::TrackExportDlg(QWidget* parent, + UserSettingsPointer pConfig, + TrackPointerList& tracks, + Grantlee::Context* context, + const QString* playlist) : QDialog(parent), Ui::DlgTrackExport(), m_pConfig(pConfig), - m_worker(worker) { + m_tracks(tracks), + m_worker(nullptr) { setupUi(this); + + QString musicDir = QStandardPaths::writableLocation(QStandardPaths::MusicLocation); + auto exportDir = QDir(musicDir + "/Mixxx/Export"); + QString lastExportDirectory = m_pConfig->getValue( + ConfigKey("[Library]", "LastTrackCopyDirectory"), + exportDir.absolutePath()); + folderEdit->setText(lastExportDirectory); + + m_worker = new TrackExportWorker(folderEdit->text(), m_tracks, context); + + if (playlist) { + // use the escaped version as suggestion for playlist name + playlistName->setText(FileUtils::escapeFileName(*playlist)); + } + + populateDefaultPatterns(); + connect(cancelButton, &QPushButton::clicked, this, &TrackExportDlg::cancelButtonClicked); + connect(startButton, + &QPushButton::clicked, + this, + &TrackExportDlg::slotStartExport); exportProgress->setMinimum(0); exportProgress->setMaximum(1); exportProgress->setValue(0); - statusLabel->setText(""); setModal(true); + QHeaderView* headerView = statusTable->horizontalHeader(); + headerView->resizeSection(0, 600); + headerView->resizeSection(1, 600); + headerView->resizeSection(2, 100); + headerView->setStretchLastSection(true); + statusTable->setHorizontalHeader(headerView); + + connect(browseButton, + &QAbstractButton::clicked, + this, + &TrackExportDlg::slotBrowseFolder); + connect(folderEdit, + &QLineEdit::textChanged, + [=](const QString& x) { + Q_UNUSED(x); + updatePreview(); + }); + + connect(patternCombo, + &QComboBox::currentTextChanged, + [this](const QString& x) { + Q_UNUSED(x); + updatePreview(); + }); + connect(patternCombo, + &QComboBox::editTextChanged, + this, + &TrackExportDlg::slotPatternEdited); + connect(patternCombo, + QOverload::of(&QComboBox::activated), + this, + &TrackExportDlg::slotPatternSelected); + connect(m_worker, &TrackExportWorker::progress, this, &TrackExportDlg::slotProgress); + connect(m_worker, + &TrackExportWorker::result, + this, + &TrackExportDlg::slotResult); connect(m_worker, &TrackExportWorker::askOverwriteMode, this, @@ -36,27 +124,185 @@ TrackExportDlg::TrackExportDlg(QWidget *parent, connect(m_worker, &TrackExportWorker::canceled, this, - &TrackExportDlg::cancelButtonClicked); + &TrackExportDlg::stopWorker); + + updatePreview(); + setEnableControls(true); + tabWidget->setCurrentIndex(0); +} + +TrackExportDlg::~TrackExportDlg() { +} + +void TrackExportDlg::populateDefaultPatterns() { + for (const auto& pattern : kDefaultPatterns) { + patternCombo->addItem(pattern, true); + } } -void TrackExportDlg::showEvent(QShowEvent* event) { - QDialog::showEvent(event); - VERIFY_OR_DEBUG_ASSERT(m_worker) { - // It's not worth checking for m_exporter != nullptr elsewhere in this - // class... it'll be clear very quickly that someone screwed up and - // forgot to call selectDestinationDirectory(). - qDebug() << "Programming error: did not initialize m_exporter, about to crash"; +void TrackExportDlg::removeDups(const QVariant& data) { + for (int i = 0; i < patternCombo->count(); i++) { + if (patternCombo->itemData(i, Qt::DisplayRole) == data) { + // if the the item was user entered and is exactly the same, + // remove the old one + if (patternCombo->itemData(i, Qt::UserRole).toBool() == false) { + patternCombo->removeItem(i); + qDebug() << "remove i" << i; + i--; + } + } + } +} + +void TrackExportDlg::slotPatternSelected(int index) { + QVariant data = patternCombo->itemData(index, Qt::DisplayRole); + removeDups(data); + patternCombo->insertItem(0, data.toString(), false); + patternCombo->setCurrentIndex(0); + m_patternComboSwitched = true; + // we can also remove the item 1 if it is not fixed and a fixed entry exists + QVariant oldEdit = patternCombo->itemData(1, Qt::DisplayRole); + bool found = false; + for (int i = patternCombo->count() - 1; i > 1; i--) { + if (patternCombo->itemData(i, Qt::DisplayRole) == oldEdit) { + found = true; + break; + } + } + if (found && !patternCombo->itemData(1, Qt::UserRole).toBool()) { + patternCombo->removeItem(1); + } +} + +void TrackExportDlg::slotPatternEdited(const QString& text) { + if (m_patternComboSwitched) { + m_patternComboSwitched = false; return; } + // in case the user tries to edit a protected pattern, make a copy first + if (patternCombo->currentIndex() == 0) { + bool fixed = patternCombo->itemData(0, Qt::UserRole).toBool(); + if (fixed) { + patternCombo->insertItem(0, text, false); + patternCombo->setCurrentIndex(0); + } + patternCombo->setItemData(0, text, Qt::DisplayRole); + } +} + +bool TrackExportDlg::browseFolder() { + QString destDir = QFileDialog::getExistingDirectory( + nullptr, tr("Export Track Files To"), folderEdit->text()); + if (destDir.isEmpty()) { + return false; + } + folderEdit->setText(destDir); + return true; +} +void TrackExportDlg::closeEvent(QCloseEvent* event) { + if (m_worker->isRunning()) { + stopWorker(); + } + QDialog::closeEvent(event); +} + +void TrackExportDlg::setEnableControls(bool enabled) { + startButton->setEnabled(enabled); + patternCombo->setEnabled(enabled); + folderEdit->setEnabled(enabled); + browseButton->setEnabled(enabled); + playlistName->setEnabled(enabled); + playlistExport->setEnabled(enabled); + playlistSuffix->setEnabled(enabled); +} + +void TrackExportDlg::slotStartExport() { + VERIFY_OR_DEBUG_ASSERT(m_worker->isRunning() == false) { + qWarning() << "Export already running"; + return; + } + + m_errorCount = 0; + m_okCount = 0; + m_skippedCount = 0; + + m_pConfig->setValue( + ConfigKey("[Library]", "LastTrackCopyDirectory"), + folderEdit->text()); + + // sets destDirectory and Pattern + updatePreview(); + setEnableControls(false); + tabWidget->setCurrentIndex(1); + + cancelButton->setText(tr("&Cancel")); + + // enable playlist export + if (playlistExport->isChecked()) { + m_worker->setPlaylist(playlistName->text() + playlistSuffix->currentText()); + } else { + m_worker->setPlaylist(QString()); + } + m_worker->start(); } -void TrackExportDlg::slotProgress(const QString& filename, int progress, int count) { - if (progress == count) { - statusLabel->setText(tr("Export finished")); +void TrackExportDlg::updatePreview() { + VERIFY_OR_DEBUG_ASSERT(!m_tracks.isEmpty()) { + return; + } + QString pattern = patternCombo->currentText(); + m_worker->setPattern(&pattern); + m_worker->setDestDir(folderEdit->text()); + previewLabel->setText(m_worker->applyPattern(m_tracks[0], 1)); + errorLabel->setText(m_worker->errorMessage()); +} + +int TrackExportDlg::addStatus(const QString& status, const QString& to) { + auto scrollbar = statusTable->verticalScrollBar(); + bool atEnd = scrollbar->value() == scrollbar->maximum(); + int row = statusTable->rowCount(); + statusTable->setRowCount(row + 1); + auto item = new QTableWidgetItem(status); + statusTable->setItem(row, 0, item); + if (atEnd) { + statusTable->scrollToBottom(); + } + if (!to.isEmpty()) { + auto toItem = new QTableWidgetItem(to); + statusTable->setItem(row, 1, toItem); + } + return row; +} + +void TrackExportDlg::slotResult(TrackExportWorker::ExportResult result, const QString& msg) { + int type = QTableWidgetItem::UserType; + if (result == TrackExportWorker::ExportResult::EXPORT_COMPLETE) { + exportProgress->setValue(exportProgress->maximum()); + addStatus(QString(tr("Export finished. %1 ok. %2 errors. %3 skipped")) + .arg(m_okCount) + .arg(m_errorCount) + .arg(m_skippedCount), + nullptr); finish(); - } else { - statusLabel->setText(tr("Exporting %1").arg(filename)); + return; + } else if (result == TrackExportWorker::ExportResult::OK) { + m_okCount++; + } else if (result == TrackExportWorker::ExportResult::SKIPPED) { + m_skippedCount++; + type = QTableWidgetItem::UserType + 2; + } else if (result == TrackExportWorker::ExportResult::FAILED) { + m_errorCount++; + type = QTableWidgetItem::UserType + 3; + } + auto item = new QTableWidgetItem(msg, type); + int row = statusTable->rowCount() - 1; + statusTable->setItem(row, 2, item); +} + +void TrackExportDlg::slotProgress(const QString& from, const QString& to, int progress, int count) { + if (!from.isEmpty() || !to.isEmpty()) { + addStatus(from, to); } exportProgress->setMinimum(0); exportProgress->setMaximum(count); @@ -98,20 +344,46 @@ void TrackExportDlg::slotAskOverwriteMode( } void TrackExportDlg::cancelButtonClicked() { - finish(); + // we cancel the worker if running, otherwise we close the window + if (m_worker->isRunning()) { + stopWorker(); + } else { + hide(); + accept(); + } } -void TrackExportDlg::finish() { +void TrackExportDlg::stopWorker() { m_worker->stop(); m_worker->wait(); - if (m_worker->errorMessage().length()) { + setEnableControls(true); + cancelButton->setText(tr("&Close")); +} + +void TrackExportDlg::open() { + bool empty = true; + // check if at least one trackpointer is valid + for (const TrackPointer& track : qAsConst(m_tracks)) { + if (track) { + empty = false; + break; + } + } + + if (empty) { QMessageBox::warning( nullptr, tr("Export Error"), - m_worker->errorMessage(), + tr("No files selected"), QMessageBox::Ok, QMessageBox::Ok); + hide(); + accept(); + return; } - hide(); - accept(); + QDialog::open(); +} + +void TrackExportDlg::finish() { + stopWorker(); } diff --git a/src/library/export/trackexportdlg.h b/src/library/export/trackexportdlg.h index f4b2776aeb8e..ede6582d3950 100644 --- a/src/library/export/trackexportdlg.h +++ b/src/library/export/trackexportdlg.h @@ -1,5 +1,8 @@ #pragma once +#include + +#include #include #include #include @@ -10,6 +13,7 @@ #include "preferences/usersettings.h" #include "track/track_decl.h" +class QMenu; // A dialog for interacting with the track exporter in an interactive manner. // Handles errors and user interactions. class TrackExportDlg : public QDialog, public Ui::DlgTrackExport { @@ -21,31 +25,53 @@ class TrackExportDlg : public QDialog, public Ui::DlgTrackExport { SKIP_ALL, }; - // The dialog is prepared, but not shown on construction. Does not - // take ownership of the export worker. - TrackExportDlg(QWidget *parent, UserSettingsPointer pConfig, - TrackExportWorker* worker); - virtual ~TrackExportDlg() { } + /// The dialog is prepared, but not shown on construction. + /// You can pass additional information through a Context. + /// The dialog will take ownership of the Context. + TrackExportDlg(QWidget* parent, + UserSettingsPointer pConfig, + TrackPointerList& tracks, + Grantlee::Context* context = nullptr, + const QString* playlistName = nullptr); + virtual ~TrackExportDlg(); + void open() override; public slots: - void slotProgress(const QString& filename, int progress, int count); + void slotProgress(const QString& from, const QString& to, int progress, int count); + void slotResult(TrackExportWorker::ExportResult result, const QString& msg); void slotAskOverwriteMode( const QString& filename, std::promise* promise); void cancelButtonClicked(); + void slotBrowseFolder() { + browseFolder(); + }; + void slotStartExport(); protected: - // First pops up a directory selector on show(), then does the actual - // copying. - void showEvent(QShowEvent* event) override; + bool browseFolder(); + + private slots: + void slotPatternSelected(int index); + void slotPatternEdited(const QString& text); private: // Called when progress is complete or the procedure has been canceled. - // Displays a final message box indicating success or failure. // Makes sure the exporter thread has exited. void finish(); + void stopWorker(); + int addStatus(const QString& status, const QString& to); + void updatePreview(); + void setEnableControls(bool enabled); + void closeEvent(QCloseEvent* event) override; + void populateDefaultPatterns(); + void removeDups(const QVariant& data); UserSettingsPointer m_pConfig; TrackPointerList m_tracks; TrackExportWorker* m_worker; + int m_errorCount = 0; + int m_skippedCount = 0; + int m_okCount = 0; + bool m_patternComboSwitched = 0; }; diff --git a/src/library/export/trackexportwizard.cpp b/src/library/export/trackexportwizard.cpp deleted file mode 100644 index 932b865b3f25..000000000000 --- a/src/library/export/trackexportwizard.cpp +++ /dev/null @@ -1,35 +0,0 @@ -#include "library/export/trackexportwizard.h" - -#include -#include -#include -#include - -#include "moc_trackexportwizard.cpp" -#include "util/assert.h" - -void TrackExportWizard::exportTracks() { - if (!selectDestinationDirectory()) { - return; - } - // Ignore return value, upstream callers don't need it. - m_dialog->exec(); -} - -bool TrackExportWizard::selectDestinationDirectory() { - QString lastExportDirectory = m_pConfig->getValue( - ConfigKey("[Library]", "LastTrackCopyDirectory"), - QStandardPaths::writableLocation(QStandardPaths::MusicLocation)); - - QString destDir = QFileDialog::getExistingDirectory( - nullptr, tr("Export Track Files To"), lastExportDirectory); - if (destDir.isEmpty()) { - return false; - } - m_pConfig->set(ConfigKey("[Library]", "LastTrackCopyDirectory"), - ConfigValue(destDir)); - - m_worker.reset(new TrackExportWorker(destDir, m_tracks)); - m_dialog.reset(new TrackExportDlg(m_parent, m_pConfig, m_worker.data())); - return true; -} diff --git a/src/library/export/trackexportwizard.h b/src/library/export/trackexportwizard.h deleted file mode 100644 index 9954b8fa39f8..000000000000 --- a/src/library/export/trackexportwizard.h +++ /dev/null @@ -1,40 +0,0 @@ -// TrackExportWizard handles exporting a list of tracks to an external directory -// -// TODO: -// * Offer customizable file renaming -// * Offer the option to transcode files to the codec of choice (e.g., -// FLAC -> AIFF for CDJ -// * Export sidecar metadata files for import into Mixxx - -#pragma once - -#include -#include - -#include "library/export/trackexportdlg.h" -#include "library/export/trackexportworker.h" -#include "preferences/usersettings.h" -#include "track/track_decl.h" - -// A controller class for creating the export worker and UI. -class TrackExportWizard : public QObject { - Q_OBJECT - public: - TrackExportWizard(QWidget* parent, UserSettingsPointer pConfig, const TrackPointerList& tracks) - : m_parent(parent), m_pConfig(pConfig), m_tracks(tracks) { - } - virtual ~TrackExportWizard() { } - - // Displays a dialog requesting destination directory, then performs - // track export if a folder is chosen. Handles errors gracefully. - void exportTracks(); - - private: - bool selectDestinationDirectory(); - - QWidget* m_parent; - UserSettingsPointer m_pConfig; - TrackPointerList m_tracks; - QScopedPointer m_dialog; - QScopedPointer m_worker; -}; diff --git a/src/library/export/trackexportworker.cpp b/src/library/export/trackexportworker.cpp index dd9a16f56772..7aa7467a75c3 100644 --- a/src/library/export/trackexportworker.cpp +++ b/src/library/export/trackexportworker.cpp @@ -1,19 +1,53 @@ #include "library/export/trackexportworker.h" +#include +#include +#include + #include #include #include +#include +#include +#include +#include "library/parser.h" #include "moc_trackexportworker.cpp" #include "track/track.h" +#include "util/fileutils.h" +#include "util/formatter.h" namespace { -QString rewriteFilename(const QFileInfo& fileinfo, int index) { - // We don't have total control over the inputs, so definitely - // don't use .arg().arg().arg(). - const QString index_str = QString("%1").arg(index, 4, 10, QChar('0')); - return QString("%1-%2.%3").arg(fileinfo.baseName(), index_str, fileinfo.completeSuffix()); +const auto kBadFileCharacters = QRegularExpression( + QStringLiteral("\n"), QRegularExpression::MultilineOption); +const auto kResultSkipped = QStringLiteral("skipped"); +const auto kResultCanceled = QStringLiteral("Export canceled"); +const auto kResultEmptyPattern = QStringLiteral("empty pattern result, skipped"); +const auto kResultOk = QStringLiteral("ok"); +const auto kResultCantCreateDirectory = QStringLiteral("Could not create folder"); +const auto kResultCantCreateFile = QStringLiteral("Could not create file"); +const auto kDefaultPattern = QStringLiteral( + "{{ track.basename }}{% if dup %}-{{dup}}{% endif %}" + ".{{track.extension}}"); +const auto kEmptyMsg = QLatin1String(""); +} // namespace + +TrackExportWorker::TrackExportWorker(const QString& destDir, + TrackPointerList& tracks, + Grantlee::Context* context) + : m_running(false), + m_destDir(destDir), + m_tracks(tracks), + m_context(context) { + qRegisterMetaType("TrackExportWorker::ExportResult"); + if (!m_context) { + m_context = new Grantlee::Context(); + } +} + +TrackExportWorker::~TrackExportWorker() { + delete m_context; } // Iterate over a list of tracks and generate a minimal set of files to copy. @@ -21,12 +55,21 @@ QString rewriteFilename(const QFileInfo& fileinfo, int index) { // and skips if they refer to the same disk location. Returns a map from // QString (the destination possibly-munged filenames) to QFileinfo (the source // file information). -QMap createCopylist(const TrackPointerList& tracks) { +// Tracks for which a empty filename was generated will be added to the skippedTracks +// list. +QMap TrackExportWorker::createCopylist(const TrackPointerList& tracks, + TrackPointerList* skippedTracks) { // QMap is a non-obvious return value, but it's easy for callers to use // in practice and is the best object for producing the final list // efficiently. QMap copylist; - for (const auto& it : tracks) { + int index = 0; + for (auto& it : tracks) { + index++; + if (!it.get()) { + qWarning() << "nullptr in tracklist"; + continue; + } if (it->getCanonicalLocation().isEmpty()) { qWarning() << "File not found or inaccessible while exporting" @@ -40,7 +83,12 @@ QMap createCopylist(const TrackPointerList& tracks) { const auto trackFile = it->getFileInfo(); const auto fileName = trackFile.fileName(); - auto destFileName = fileName; + QString destFileName = generateFilename(it, index, 0); + if (destFileName.isEmpty()) { + //qWarning() << "pattern generated empty filename for:" << it; + skippedTracks->append(it); + continue; + } int duplicateCounter = 0; do { const auto duplicateIter = copylist.find(destFileName); @@ -63,31 +111,157 @@ QMap createCopylist(const TrackPointerList& tracks) { break; } // Next round - destFileName = rewriteFilename(trackFile.asFileInfo(), duplicateCounter); + destFileName = generateFilename(it, index, duplicateCounter); } while (!destFileName.isEmpty()); } return copylist; } -} // namespace +void TrackExportWorker::setPattern(QString* pattern) { + if (pattern == nullptr) { + m_pattern = nullptr; + if (!m_template.isNull()) { + m_template_valid = false; + m_template.reset(); + } + return; + } + if (!m_engine) { + m_engine = Formatter::getEngine(this); + // smartTrimEnabled would be good, but causes crashes on invalid pattern '{{ }}' + // m_engine->setSmartTrimEnabled(true); + } + m_pattern = pattern; + updateTemplate(); +} + +void TrackExportWorker::updateTemplate() { + QString fullPattern; + if (m_pattern) { + QString trimmed = m_pattern->trimmed(); + fullPattern = m_destDir + QDir::separator().toLatin1() + trimmed; + } else { + fullPattern = m_destDir + QDir::separator().toLatin1() + kDefaultPattern; + } + m_template = m_engine->newTemplate(fullPattern, QStringLiteral("export")); + if (m_template->error()) { + m_errorMessage = m_template->errorString(); + m_template_valid = false; + } else { + m_errorMessage = QString(); + m_template_valid = true; + } +} void TrackExportWorker::run() { + m_running = true; + m_bStop = false; + m_overwriteMode = OverwriteMode::ASK; int i = 0; - QMap copy_list = createCopylist(m_tracks); + auto skippedTracks = TrackPointerList(); + QMap copy_list = createCopylist(m_tracks, &skippedTracks); + int jobsTotal = copy_list.size(); + + if (!m_playlist.isEmpty()) { + jobsTotal++; + } + + for (const TrackPointer& track : qAsConst(skippedTracks)) { + QString fileName = track->fileName(); + emit progress(fileName, nullptr, 0, jobsTotal); + emit result(TrackExportWorker::ExportResult::SKIPPED, kResultEmptyPattern); + } + for (auto it = copy_list.constBegin(); it != copy_list.constEnd(); ++it) { // We emit progress twice per loop, which may seem excessive, but it // guarantees that we emit a sane progress before we start and after // we end. In between, each filename will get its own visible tick // on the bar, which looks really nice. - emit progress(it->fileName(), i, copy_list.size()); - copyFile((*it).asFileInfo(), it.key()); + QString fileName = it->fileName(); + QString target = it.key(); + emit progress(fileName, target, i, jobsTotal); + copyFile((*it).asFileInfo(), target); if (atomicLoadAcquire(m_bStop)) { emit canceled(); + m_running = false; return; } ++i; - emit progress(it->fileName(), i, copy_list.size()); } + if (!m_playlist.isEmpty()) { + const auto targetDir = QDir(m_destDir); + const QString plsPath = targetDir.filePath(FileUtils::escapeFileName(m_playlist)); + QFileInfo plsPathFileinfo(plsPath); + + emit progress(QStringLiteral("export playlist"), plsPath, i, jobsTotal); + + QDir plsDir = plsPathFileinfo.absoluteDir(); + if (!plsDir.mkpath(plsDir.absolutePath())) { + emit result(TrackExportWorker::ExportResult::FAILED, kResultCantCreateDirectory); + } else { + QStringList playlistItemLocations = QStringList(); + for (auto it = copy_list.constBegin(); it != copy_list.constEnd(); ++it) { + // calculate the relative path between the exported file and the playlist + QString relpath = plsPathFileinfo.dir().relativeFilePath( + targetDir.filePath(it.key())); + playlistItemLocations.append(relpath); + } + bool playlistOk = Parser::exportPlaylistItemsIntoFile( + plsPath, + playlistItemLocations, + true); + if (playlistOk) { + emit result(TrackExportWorker::ExportResult::OK, kResultOk); + } else { + emit result(TrackExportWorker::ExportResult::FAILED, + kResultCantCreateDirectory); + } + } + i++; + } + + emit progress(kEmptyMsg, kEmptyMsg, i, jobsTotal); + emit result(TrackExportWorker::ExportResult::EXPORT_COMPLETE, kResultOk); + m_running = false; +} + +// Returns the new filename for the track. Applies the pattern if set. +QString TrackExportWorker::generateFilename(TrackPointer track, int index, int dupCounter) { + return FileUtils::safeFilename(applyPattern(track, index, dupCounter).trimmed()); +} + +// Applies the pattern on track +QString TrackExportWorker::applyPattern( + TrackPointer track, + int index, + int duplicateCounter) { + if (!m_template_valid) { + return QString(); + } + VERIFY_OR_DEBUG_ASSERT(!m_destDir.isEmpty()) { + qWarning() << "empty target directory"; + return QString(); + } + VERIFY_OR_DEBUG_ASSERT(m_engine) { + qWarning() << "engine missing"; + return QString(); + } + + // fill the context with the proper variables. + m_context->push(); + m_context->insert(QStringLiteral("directory"), m_destDir); + // this is safe since the context stack is popped after rendering + m_context->insert(QStringLiteral("track"), track.get()); + m_context->insert(QStringLiteral("index"), QVariant(index)); + m_context->insert(QStringLiteral("dup"), QVariant(duplicateCounter)); + + QString newName = Formatter::renderFilenameEscape(m_template, *m_context); + + // remove the context stack so it is clean again + m_context->pop(); + + // replace bad filename characters with spaces + return newName.trimmed(); } void TrackExportWorker::copyFile(const QFileInfo& source_fileinfo, @@ -96,6 +270,11 @@ void TrackExportWorker::copyFile(const QFileInfo& source_fileinfo, const QString dest_path = QDir(m_destDir).filePath(dest_filename); QFileInfo dest_fileinfo(dest_path); + QDir destDir = dest_fileinfo.absoluteDir(); + if (!destDir.mkpath(destDir.absolutePath())) { + emit result(TrackExportWorker::ExportResult::FAILED, kResultCantCreateDirectory); + } + if (dest_fileinfo.exists()) { switch (m_overwriteMode) { // Give the user the option to overwrite existing files in the destination. @@ -104,18 +283,20 @@ void TrackExportWorker::copyFile(const QFileInfo& source_fileinfo, case OverwriteAnswer::SKIP: case OverwriteAnswer::SKIP_ALL: qDebug() << "skipping" << sourceFilename; + emit result(TrackExportWorker::ExportResult::SKIPPED, kResultSkipped); return; case OverwriteAnswer::OVERWRITE: case OverwriteAnswer::OVERWRITE_ALL: break; case OverwriteAnswer::CANCEL: - m_errorMessage = tr("Export process was canceled"); + emit result(TrackExportWorker::ExportResult::EXPORT_COMPLETE, kResultCanceled); stop(); return; } break; case OverwriteMode::SKIP_ALL: qDebug() << "skipping" << sourceFilename; + emit result(TrackExportWorker::ExportResult::SKIPPED, kResultSkipped); return; case OverwriteMode::OVERWRITE_ALL:; } @@ -125,11 +306,11 @@ void TrackExportWorker::copyFile(const QFileInfo& source_fileinfo, qDebug() << "Removing existing file" << dest_path; if (!dest_file.remove()) { const QString error_message = tr( - "Error removing file %1: %2. Stopping.").arg( - dest_path, dest_file.errorString()); + "Error removing file %1: %2") + .arg(dest_path, dest_file.errorString()); qWarning() << error_message; - m_errorMessage = error_message; - stop(); + auto msg = QString("Failed: %1").arg(error_message); + emit result(TrackExportWorker::ExportResult::FAILED, msg); return; } } @@ -141,10 +322,11 @@ void TrackExportWorker::copyFile(const QFileInfo& source_fileinfo, "Error exporting track %1 to %2: %3. Stopping.").arg( sourceFilename, dest_path, source_file.errorString()); qWarning() << error_message; - m_errorMessage = error_message; - stop(); + auto msg = QString("Failed: %1").arg(error_message); + emit result(TrackExportWorker::ExportResult::FAILED, msg); return; } + emit result(TrackExportWorker::ExportResult::OK, kResultOk); } TrackExportWorker::OverwriteAnswer TrackExportWorker::makeOverwriteRequest( @@ -168,7 +350,6 @@ TrackExportWorker::OverwriteAnswer TrackExportWorker::makeOverwriteRequest( if (!mode_future.valid()) { qWarning() << "TrackExportWorker::makeOverwriteRequest invalid answer from future"; - m_errorMessage = tr("Error exporting tracks"); stop(); return OverwriteAnswer::CANCEL; } @@ -183,7 +364,6 @@ TrackExportWorker::OverwriteAnswer TrackExportWorker::makeOverwriteRequest( break; case OverwriteAnswer::CANCEL: // Handle cancellation as a result of the question. - m_errorMessage = tr("Export process was canceled"); stop(); break; default:; diff --git a/src/library/export/trackexportworker.h b/src/library/export/trackexportworker.h index 353abc002ffe..5df2d74de871 100644 --- a/src/library/export/trackexportworker.h +++ b/src/library/export/trackexportworker.h @@ -1,15 +1,21 @@ #pragma once +#include + #include #include #include #include #include -#include "track/track_decl.h" +#include "track/track.h" class QFileInfo; +namespace Grantlee { +class Context; +class Engine; +} // namespace Grantlee // A QThread class for copying a list of files to a single destination directory. // Currently does not preserve subdirectory relationships. This class performs // all copies in a blocking style within its own thread. May be canceled from @@ -17,6 +23,14 @@ class QFileInfo; class TrackExportWorker : public QThread { Q_OBJECT public: + enum ExportResult { + OK, + SKIPPED, + FAILED, + EXPORT_COMPLETE + }; + Q_ENUM(ExportResult) + enum class OverwriteMode { ASK, OVERWRITE_ALL, @@ -35,20 +49,53 @@ class TrackExportWorker : public QThread { // Constructor does not validate the destination directory. Calling classes // should do that. - TrackExportWorker(const QString& destDir, const TrackPointerList& tracks) - : m_destDir(destDir), m_tracks(tracks) { - } - virtual ~TrackExportWorker() { }; + // pattern will + TrackExportWorker(const QString& destDir, + TrackPointerList& tracks, + Grantlee::Context* context = nullptr); + + virtual ~TrackExportWorker(); // exports ALL the tracks. Thread joins on success or failure. void run() override; + bool isRunning() { + return m_running; + }; // Calling classes can call errorMessage after a failure for a user-friendly // message about what happened. QString errorMessage() const { return m_errorMessage; } + void setDestDir(const QString& destDir) { + m_destDir = destDir; + updateTemplate(); + } + + /// Sets the filename pattern + void setPattern(QString* pattern); + + /// returns the current filename pattern + QString* getPattern() { + return m_pattern; + } + + /// Sets the filename for the playlist to generate + void setPlaylist(const QString& playlist) { + m_playlist = playlist; + } + /// returns the playlist filename + QString getPlaylist() { + return m_playlist; + } + + // Returns the new filename for the track. Applies the pattern if set. + QString generateFilename(TrackPointer track, int index = 0, int dupCounter = 0); + + /// Applies the filename pattern on track + QString applyPattern(TrackPointer track, int index, int duplicateCounter = 0); + // Cancels the export after the current copy operation. // May be called from another thread. void stop(); @@ -62,7 +109,8 @@ class TrackExportWorker : public QThread { void askOverwriteMode( const QString& filename, std::promise* promise); - void progress(const QString& filename, int progress, int count); + void progress(const QString& from, const QString& to, int progress, int count); + void result(TrackExportWorker::ExportResult result, const QString& msg); void canceled(); private: @@ -73,15 +121,27 @@ class TrackExportWorker : public QThread { // process entirely. void copyFile(const QFileInfo& source_fileinfo, const QString& dest_filename); + QMap createCopylist( + const TrackPointerList& tracks, + TrackPointerList* skippedTracks); + void exportPlaylist(); + void updateTemplate(); // Emit a signal requesting overwrite mode, and block until we get an // answer. Updates m_overwriteMode appropriately. OverwriteAnswer makeOverwriteRequest(const QString& filename); QAtomicInt m_bStop = false; + bool m_running = false; QString m_errorMessage; OverwriteMode m_overwriteMode = OverwriteMode::ASK; - const QString m_destDir; - const TrackPointerList m_tracks; + QString m_destDir; + TrackPointerList m_tracks; + QString* m_pattern; + Grantlee::Context* m_context; + Grantlee::Template m_template; + bool m_template_valid{false}; + Grantlee::Engine* m_engine{nullptr}; + QString m_playlist; }; diff --git a/src/library/libraryfeature.cpp b/src/library/libraryfeature.cpp index 20de2aa31891..c3407c990f6e 100644 --- a/src/library/libraryfeature.cpp +++ b/src/library/libraryfeature.cpp @@ -3,8 +3,6 @@ #include #include "library/library.h" -#include "library/parserm3u.h" -#include "library/parserpls.h" #include "moc_libraryfeature.cpp" #include "util/logger.h" @@ -45,50 +43,3 @@ QStringList LibraryFeature::getPlaylistFiles(QFileDialog::FileMode mode) const { } return dialog.selectedFiles(); } - -bool LibraryFeature::exportPlaylistItemsIntoFile( - QString playlistFilePath, - const QList& playlistItemLocations, - bool useRelativePath) { - if (playlistFilePath.endsWith( - QStringLiteral(".pls"), - Qt::CaseInsensitive)) { - return ParserPls::writePLSFile( - playlistFilePath, - playlistItemLocations, - useRelativePath); - } else if (playlistFilePath.endsWith( - QStringLiteral(".m3u8"), - Qt::CaseInsensitive)) { - return ParserM3u::writeM3U8File( - playlistFilePath, - playlistItemLocations, - useRelativePath); - } else { - //default export to M3U if file extension is missing - if (!playlistFilePath.endsWith( - QStringLiteral(".m3u"), - Qt::CaseInsensitive)) { - kLogger.debug() - << "No valid file extension for playlist export specified." - << "Appending .m3u and exporting to M3U."; - playlistFilePath.append(QStringLiteral(".m3u")); - if (QFileInfo::exists(playlistFilePath)) { - auto overwrite = QMessageBox::question( - nullptr, - tr("Overwrite File?"), - tr("A playlist file with the name \"%1\" already exists.\n" - "The default \"m3u\" extension was added because none was specified.\n\n" - "Do you really want to overwrite it?") - .arg(playlistFilePath)); - if (overwrite != QMessageBox::StandardButton::Yes) { - return false; - } - } - } - return ParserM3u::writeM3UFile( - playlistFilePath, - playlistItemLocations, - useRelativePath); - } -} diff --git a/src/library/libraryfeature.h b/src/library/libraryfeature.h index 465b48c23f21..4227d33f0e5c 100644 --- a/src/library/libraryfeature.h +++ b/src/library/libraryfeature.h @@ -124,14 +124,6 @@ class LibraryFeature : public QObject { void enableCoverArtDisplay(bool); void trackSelected(TrackPointer pTrack); - protected: - // TODO: Move common crate/playlist functions into - // a separate base class - static bool exportPlaylistItemsIntoFile( - QString playlistFilePath, - const QList& playlistItemLocations, - bool useRelativePath); - private: QStringList getPlaylistFiles(QFileDialog::FileMode mode) const; }; diff --git a/src/library/parser.cpp b/src/library/parser.cpp index eca875f3682f..fa1f7b8d5063 100644 --- a/src/library/parser.cpp +++ b/src/library/parser.cpp @@ -11,13 +11,25 @@ // // -#include +#include "library/parser.h" + #include #include #include +#include +#include #include +#include -#include "library/parser.h" +#include "library/parserm3u.h" +#include "library/parserpls.h" +#include "util/logger.h" + +namespace { + +const mixxx::Logger kLogger("Parser"); + +} // anonymous namespace Parser::Parser() { } @@ -158,3 +170,50 @@ TrackFile Parser::playlistEntryToTrackFile( return TrackFile(QDir(basePath), filePath); } } + +bool Parser::exportPlaylistItemsIntoFile( + QString playlistFilePath, + const QList& playlistItemLocations, + bool useRelativePath) { + if (playlistFilePath.endsWith( + QStringLiteral(".pls"), + Qt::CaseInsensitive)) { + return ParserPls::writePLSFile( + playlistFilePath, + playlistItemLocations, + useRelativePath); + } else if (playlistFilePath.endsWith( + QStringLiteral(".m3u8"), + Qt::CaseInsensitive)) { + return ParserM3u::writeM3U8File( + playlistFilePath, + playlistItemLocations, + useRelativePath); + } else { + //default export to M3U if file extension is missing + if (!playlistFilePath.endsWith( + QStringLiteral(".m3u"), + Qt::CaseInsensitive)) { + kLogger.debug() + << "No valid file extension for playlist export specified." + << "Appending .m3u and exporting to M3U."; + playlistFilePath.append(QStringLiteral(".m3u")); + if (QFileInfo::exists(playlistFilePath)) { + auto overwrite = QMessageBox::question( + nullptr, + tr("Overwrite File?"), + tr("A playlist file with the name \"%1\" already exists.\n" + "The default \"m3u\" extension was added because none was specified.\n\n" + "Do you really want to overwrite it?") + .arg(playlistFilePath)); + if (overwrite != QMessageBox::StandardButton::Yes) { + return false; + } + } + } + return ParserM3u::writeM3UFile( + playlistFilePath, + playlistItemLocations, + useRelativePath); + } +} diff --git a/src/library/parser.h b/src/library/parser.h index 4a666bdde812..cc4e7850be45 100644 --- a/src/library/parser.h +++ b/src/library/parser.h @@ -43,6 +43,11 @@ class Parser : public QObject { or 0 in order for the trackimporter to function**/ virtual QList parse(const QString&) = 0; + static bool exportPlaylistItemsIntoFile( + QString playlistFilePath, + const QList& playlistItemLocations, + bool useRelativePath); + protected: // Pointer to the parsed Filelocations QList m_sLocations; diff --git a/src/library/trackset/baseplaylistfeature.cpp b/src/library/trackset/baseplaylistfeature.cpp index 3f686e158e9d..578326a47385 100644 --- a/src/library/trackset/baseplaylistfeature.cpp +++ b/src/library/trackset/baseplaylistfeature.cpp @@ -6,7 +6,7 @@ #include #include "controllers/keyboard/keyboardeventfilter.h" -#include "library/export/trackexportwizard.h" +#include "library/export/trackexportdlg.h" #include "library/library.h" #include "library/parser.h" #include "library/parsercsv.h" @@ -15,6 +15,7 @@ #include "library/playlisttablemodel.h" #include "library/trackcollection.h" #include "library/trackcollectionmanager.h" +#include "library/trackset/playlistsummary.h" #include "library/treeitem.h" #include "library/treeitemmodel.h" #include "moc_baseplaylistfeature.cpp" @@ -591,7 +592,7 @@ void BasePlaylistFeature::slotExportPlaylist() { QModelIndex index = pPlaylistTableModel->index(i, 0); playlist_items << pPlaylistTableModel->getTrackLocation(index); } - exportPlaylistItemsIntoFile( + Parser::exportPlaylistItemsIntoFile( file_location, playlist_items, useRelativePath); } } @@ -601,8 +602,12 @@ void BasePlaylistFeature::slotExportTrackFiles() { new PlaylistTableModel(this, m_pLibrary->trackCollections(), "mixxx.db.model.playlist_export")); - - pPlaylistTableModel->setTableModel(m_pPlaylistTableModel->getPlaylist()); + int id = playlistIdFromIndex(m_lastRightClickedIndex); + VERIFY_OR_DEBUG_ASSERT(id != -1) { + qWarning() << "try to export invalid playlist id"; + return; + } + pPlaylistTableModel->setTableModel(id); pPlaylistTableModel->setSort(pPlaylistTableModel->fieldIndex( ColumnCache::COLUMN_PLAYLISTTRACKSTABLE_POSITION), Qt::AscendingOrder); @@ -615,8 +620,24 @@ void BasePlaylistFeature::slotExportTrackFiles() { tracks.push_back(pPlaylistTableModel->getTrack(index)); } - TrackExportWizard track_export(nullptr, m_pConfig, tracks); - track_export.exportTracks(); + Grantlee::Context* context = new Grantlee::Context(); + + PlaylistSummary summary = m_playlistDao.getPlaylistSummary(id); + PlaylistSummaryWrapper* wrapper = nullptr; + QString playlistName = QString(); + if (summary.isValid()) { + wrapper = new PlaylistSummaryWrapper(summary); + context->insert(QStringLiteral("playlist"), wrapper); + playlistName = summary.name(); + } + + auto exportDialog = new TrackExportDlg(nullptr, m_pConfig, tracks, context, &playlistName); + + if (wrapper) { + wrapper->setParent(exportDialog); + } + + exportDialog->open(); } void BasePlaylistFeature::slotAddToAutoDJ() { diff --git a/src/library/trackset/baseplaylistfeature.h b/src/library/trackset/baseplaylistfeature.h index 60b18cb9a33a..110d450f7f44 100644 --- a/src/library/trackset/baseplaylistfeature.h +++ b/src/library/trackset/baseplaylistfeature.h @@ -12,6 +12,7 @@ #include "library/dao/playlistdao.h" #include "library/trackset/basetracksetfeature.h" +#include "library/trackset/playlistsummary.h" #include "track/track_decl.h" class WLibrary; @@ -76,10 +77,6 @@ class BasePlaylistFeature : public BaseTrackSetFeature { void slotAnalyzePlaylist(); protected: - struct IdAndLabel { - int id; - QString label; - }; virtual void updateChildModel(int selected_id); virtual void clearChildModel(); diff --git a/src/library/trackset/crate/cratefeature.cpp b/src/library/trackset/crate/cratefeature.cpp index 9299e9f4cdf4..f0e42e65f12c 100644 --- a/src/library/trackset/crate/cratefeature.cpp +++ b/src/library/trackset/crate/cratefeature.cpp @@ -8,7 +8,7 @@ #include #include -#include "library/export/trackexportwizard.h" +#include "library/export/trackexportdlg.h" #include "library/library.h" #include "library/parser.h" #include "library/parsercsv.h" @@ -745,7 +745,7 @@ void CrateFeature::slotExportPlaylist() { QModelIndex index = m_crateTableModel.index(i, 0); playlist_items << m_crateTableModel.getTrackLocation(index); } - exportPlaylistItemsIntoFile( + Parser::exportPlaylistItemsIntoFile( file_location, playlist_items, useRelativePath); @@ -754,9 +754,10 @@ void CrateFeature::slotExportPlaylist() { void CrateFeature::slotExportTrackFiles() { // Create a new table model since the main one might have an active search. + CrateId id = crateIdFromIndex(m_lastRightClickedIndex); QScopedPointer pCrateTableModel( new CrateTableModel(this, m_pLibrary->trackCollections())); - pCrateTableModel->selectCrate(m_crateTableModel.selectedCrate()); + pCrateTableModel->selectCrate(id); pCrateTableModel->select(); int rows = pCrateTableModel->rowCount(); @@ -766,8 +767,25 @@ void CrateFeature::slotExportTrackFiles() { trackpointers.push_back(m_crateTableModel.getTrack(index)); } - TrackExportWizard track_export(nullptr, m_pConfig, trackpointers); - track_export.exportTracks(); + // ownership is transferred to TrackExportDlg + Grantlee::Context* context = new Grantlee::Context(); + + auto summary = new CrateSummary(); + m_pTrackCollection->crates().readCrateSummaryById(id, summary); + CrateSummaryWrapper* summaryWrapper = nullptr; + if (summary->isValid()) { + summaryWrapper = new CrateSummaryWrapper(*summary); + context->insert(QStringLiteral("crate"), summaryWrapper); + } else { + qWarning() << "CrateSummary is empty"; + } + + auto exportDialog = new TrackExportDlg( + nullptr, m_pConfig, trackpointers, context, &summary->getName()); + if (summaryWrapper) { + summaryWrapper->setParent(exportDialog); + } + exportDialog->open(); } void CrateFeature::slotCrateTableChanged(CrateId crateId) { diff --git a/src/library/trackset/crate/cratestorage.cpp b/src/library/trackset/crate/cratestorage.cpp index d1c5af35c06d..c2484c984ec4 100644 --- a/src/library/trackset/crate/cratestorage.cpp +++ b/src/library/trackset/crate/cratestorage.cpp @@ -390,6 +390,8 @@ bool CrateStorage::readCrateSummaryById( } else { kLogger.warning() << "Crate summary not found by id:" << id; } + } else { + kLogger.warning() << "Error fetching summary" << query.lastError(); } return false; } diff --git a/src/library/trackset/crate/cratesummary.cpp b/src/library/trackset/crate/cratesummary.cpp new file mode 100644 index 000000000000..2ac5f4d576a7 --- /dev/null +++ b/src/library/trackset/crate/cratesummary.cpp @@ -0,0 +1,8 @@ +#include "library/trackset/crate/cratesummary.h" + +CrateSummaryWrapper::CrateSummaryWrapper(CrateSummary& summary) + : QObject(nullptr), + m_summary(summary) { +} + +CrateSummaryWrapper::~CrateSummaryWrapper(){}; diff --git a/src/library/trackset/crate/cratesummary.h b/src/library/trackset/crate/cratesummary.h index 3e28d3f88ef4..3bb3dbf5ae3b 100644 --- a/src/library/trackset/crate/cratesummary.h +++ b/src/library/trackset/crate/cratesummary.h @@ -1,17 +1,24 @@ #pragma once +#include + #include "library/trackset/crate/crate.h" #include "util/duration.h" // A crate with aggregated track properties (total count + duration) class CrateSummary : public Crate { public: - explicit CrateSummary(CrateId id = CrateId()) + CrateSummary(CrateId id = CrateId()) : Crate(id), m_trackCount(0), m_trackDuration(0.0) { } - ~CrateSummary() override = default; + + ~CrateSummary(){}; + + bool isValid() { + return getId().isValid(); + } // The number of all tracks in this crate uint getTrackCount() const { @@ -37,3 +44,40 @@ class CrateSummary : public Crate { uint m_trackCount; double m_trackDuration; }; + +class CrateSummaryWrapper : public QObject { + Q_OBJECT + public: + Q_PROPERTY(uint trackCount READ getTrackCount WRITE setTrackCount) + Q_PROPERTY(double trackDuration READ getTrackDuration WRITE setTrackDuration) + Q_PROPERTY(QString name READ getName WRITE setName) + + CrateSummaryWrapper(CrateSummary& summary); + ~CrateSummaryWrapper(); + + QString getName() const { + return m_summary.getName(); + }; + void setName(const QString& name) { + m_summary.setName(name); + }; + + // The number of all tracks in this crate + uint getTrackCount() const { + return m_summary.getTrackCount(); + } + void setTrackCount(uint trackCount) { + m_summary.setTrackCount(trackCount); + } + + // The total duration (in seconds) of all tracks in this crate + double getTrackDuration() const { + return m_summary.getTrackDuration(); + } + void setTrackDuration(double trackDuration) { + m_summary.setTrackDuration(trackDuration); + } + + private: + CrateSummary m_summary; +}; diff --git a/src/library/trackset/playlistfeature.cpp b/src/library/trackset/playlistfeature.cpp index 14570656fdfe..6af13c7f96e3 100644 --- a/src/library/trackset/playlistfeature.cpp +++ b/src/library/trackset/playlistfeature.cpp @@ -21,21 +21,6 @@ #include "widget/wlibrarysidebar.h" #include "widget/wlibrarytextbrowser.h" -namespace { - -QString createPlaylistLabel( - const QString& name, - int count, - int duration) { - return QStringLiteral("%1 (%2) %3") - .arg(name, - QString::number(count), - mixxx::Duration::formatTime( - duration, mixxx::Duration::Precision::SECONDS)); -} - -} // anonymous namespace - PlaylistFeature::PlaylistFeature(Library* pLibrary, UserSettingsPointer pConfig) : BasePlaylistFeature(pLibrary, pConfig, @@ -130,74 +115,6 @@ bool PlaylistFeature::dragMoveAcceptChild(const QModelIndex& index, const QUrl& return !locked && formatSupported; } -QList PlaylistFeature::createPlaylistLabels() { - QSqlDatabase database = - m_pLibrary->trackCollections()->internalCollection()->database(); - - QList playlistLabels; - QString queryString = QStringLiteral( - "CREATE TEMPORARY VIEW IF NOT EXISTS PlaylistsCountsDurations " - "AS SELECT " - " Playlists.id AS id, " - " Playlists.name AS name, " - " LOWER(Playlists.name) AS sort_name, " - " COUNT(case library.mixxx_deleted when 0 then 1 else null end) " - " AS count, " - " SUM(case library.mixxx_deleted " - " when 0 then library.duration else 0 end) AS durationSeconds " - "FROM Playlists " - "LEFT JOIN PlaylistTracks " - " ON PlaylistTracks.playlist_id = Playlists.id " - "LEFT JOIN library " - " ON PlaylistTracks.track_id = library.id " - " WHERE Playlists.hidden = 0 " - " GROUP BY Playlists.id"); - queryString.append( - mixxx::DbConnection::collateLexicographically( - " ORDER BY sort_name")); - QSqlQuery query(database); - if (!query.exec(queryString)) { - LOG_FAILED_QUERY(query); - } - - // Setup the sidebar playlist model - QSqlTableModel playlistTableModel(this, database); - playlistTableModel.setTable("PlaylistsCountsDurations"); - playlistTableModel.select(); - while (playlistTableModel.canFetchMore()) { - playlistTableModel.fetchMore(); - } - QSqlRecord record = playlistTableModel.record(); - int nameColumn = record.indexOf("name"); - int idColumn = record.indexOf("id"); - int countColumn = record.indexOf("count"); - int durationColumn = record.indexOf("durationSeconds"); - - for (int row = 0; row < playlistTableModel.rowCount(); ++row) { - int id = - playlistTableModel - .data(playlistTableModel.index(row, idColumn)) - .toInt(); - QString name = - playlistTableModel - .data(playlistTableModel.index(row, nameColumn)) - .toString(); - int count = - playlistTableModel - .data(playlistTableModel.index(row, countColumn)) - .toInt(); - int duration = - playlistTableModel - .data(playlistTableModel.index(row, durationColumn)) - .toInt(); - BasePlaylistFeature::IdAndLabel idAndLabel; - idAndLabel.id = id; - idAndLabel.label = createPlaylistLabel(name, count, duration); - playlistLabels.append(idAndLabel); - } - return playlistLabels; -} - QString PlaylistFeature::fetchPlaylistLabel(int playlistId) { // Setup the sidebar playlist model QSqlDatabase database = @@ -227,7 +144,7 @@ QString PlaylistFeature::fetchPlaylistLabel(int playlistId) { playlistTableModel .data(playlistTableModel.index(0, durationColumn)) .toInt(); - return createPlaylistLabel(name, count, duration); + return PlaylistSummary::createPlaylistLabel(name, count, duration); } return QString(); } @@ -241,10 +158,10 @@ QModelIndex PlaylistFeature::constructChildModel(int selectedId) { int selectedRow = -1; int row = 0; - const QList playlistLabels = createPlaylistLabels(); + const QList playlistLabels = m_playlistDao.createPlaylistSummary(); for (const auto& idAndLabel : playlistLabels) { - int playlistId = idAndLabel.id; - QString playlistLabel = idAndLabel.label; + int playlistId = idAndLabel.id(); + QString playlistLabel = idAndLabel.getLabel(); if (selectedId == playlistId) { // save index for selection diff --git a/src/library/trackset/playlistfeature.h b/src/library/trackset/playlistfeature.h index 2fe64869d8c8..112dd6fee48f 100644 --- a/src/library/trackset/playlistfeature.h +++ b/src/library/trackset/playlistfeature.h @@ -9,6 +9,7 @@ #include #include "library/trackset/baseplaylistfeature.h" +#include "library/trackset/playlistsummary.h" #include "preferences/usersettings.h" class TrackCollection; @@ -44,7 +45,6 @@ class PlaylistFeature : public BasePlaylistFeature { protected: QString fetchPlaylistLabel(int playlistId) override; void decorateChild(TreeItem* pChild, int playlistId) override; - QList createPlaylistLabels(); QModelIndex constructChildModel(int selectedId); private: diff --git a/src/library/trackset/playlistsummary.cpp b/src/library/trackset/playlistsummary.cpp new file mode 100644 index 000000000000..9df8a27d620e --- /dev/null +++ b/src/library/trackset/playlistsummary.cpp @@ -0,0 +1,8 @@ +#include "library/trackset/playlistsummary.h" + +PlaylistSummaryWrapper::PlaylistSummaryWrapper(PlaylistSummary& summary) + : m_summary(summary) { +} + +PlaylistSummaryWrapper::~PlaylistSummaryWrapper() { +} diff --git a/src/library/trackset/playlistsummary.h b/src/library/trackset/playlistsummary.h new file mode 100644 index 000000000000..d6c872a9f41a --- /dev/null +++ b/src/library/trackset/playlistsummary.h @@ -0,0 +1,136 @@ +#pragma once + +#include + +#include "util/duration.h" + +class PlaylistSummary { + public: + explicit PlaylistSummary(int id = -1, const QString& label = nullptr) + : m_id(id), + m_name(label), + m_count(0), + m_duration(0), + m_matches(0) { + } + ~PlaylistSummary() = default; + + int id() const { + return m_id; + } + bool isValid() const { + return m_id != -1; + } + + void setCount(int count) { + m_count = count; + } + int count() const { + return m_count; + } + + void setDuration(int duration) { + m_duration = duration; + } + int duration() const { + return m_duration; + } + + QString name() const { + return m_name; + } + void setName(const QString& name) { + m_name = name; + } + + int matches() const { + return m_matches; + } + void setMatches(int matches) { + m_matches = matches; + } + + QString getLabel() const { + return createPlaylistLabel( + m_name, + m_count, + m_duration); + } + + static QString createPlaylistLabel( + const QString& name, + int count, + int duration) { + if (!count && !duration) { + return QString(name); + } else { + return QStringLiteral("%1 (%2) %3") + .arg(name, + QString::number(count), + mixxx::Duration::formatTime( + duration, mixxx::Duration::Precision::SECONDS)); + } + } + + private: + int m_id; + QString m_name; + int m_count; + int m_duration; + /// m_matches is used when querying track playlists for + int m_matches; +}; + +Q_DECLARE_METATYPE(PlaylistSummary); + +class PlaylistSummaryWrapper : public QObject { + Q_OBJECT + public: + Q_PROPERTY(QString name READ getName WRITE setName) + Q_PROPERTY(int id READ getId) + + Q_PROPERTY(uint trackCount READ getTrackCount WRITE setTrackCount) + Q_PROPERTY(int trackDuration READ getTrackDuration WRITE setTrackDuration) + Q_PROPERTY(int matches READ getMatches WRITE setMatches) + + PlaylistSummaryWrapper(PlaylistSummary& summary); + ~PlaylistSummaryWrapper(); + + int getId() const { + return m_summary.id(); + } + + QString getName() const { + return m_summary.name(); + }; + void setName(const QString& name) { + m_summary.setName(name); + }; + + // The number of all tracks in this crate + uint getTrackCount() const { + return m_summary.count(); + } + void setTrackCount(uint trackCount) { + m_summary.setCount(trackCount); + } + + // The total duration (in seconds) of all tracks in this crate + int getTrackDuration() const { + return m_summary.duration(); + } + void setTrackDuration(int trackDuration) { + m_summary.setDuration(trackDuration); + } + + // The total duration (in seconds) of all tracks in this crate + int16_t getMatches() const { + return m_summary.matches(); + } + void setMatches(int matches) { + m_summary.setMatches(matches); + } + + private: + PlaylistSummary m_summary; +}; diff --git a/src/test/fileutils_test.cpp b/src/test/fileutils_test.cpp new file mode 100644 index 000000000000..89205e702f14 --- /dev/null +++ b/src/test/fileutils_test.cpp @@ -0,0 +1,37 @@ +#include "util/fileutils.h" + +#include + +#include "test/mixxxtest.h" + +class FileUtilsTest : public testing::Test { +}; + +TEST_F(FileUtilsTest, TestSafeFilename) { + // Generate a file name for the temporary file + const auto fileName = QStringLiteral("broken/ <> :\"\\|ok?*!.mp3"); + const auto expected = QStringLiteral("broken/ ## ##\\#ok##!.mp3"); + auto output = FileUtils::safeFilename(fileName); + ASSERT_EQ(expected, output); + + // test 0 byte characters + const auto fileName0 = QStringLiteral("t2\0\10Z"); + auto output2 = FileUtils::safeFilename(fileName0); + ASSERT_EQ(QStringLiteral("t2##Z"), output2); +} + +TEST_F(FileUtilsTest, TestDirReplace) { + // Generate a file name for the temporary file + const auto fileName = QStringLiteral("filename/with\\chara-cters.mp3"); + const auto expected = QStringLiteral("filename-with-chara-cters.mp3"); + auto output = FileUtils::replaceDirChars(fileName); + ASSERT_EQ(expected, output); +} + +TEST_F(FileUtilsTest, TestFileEscape) { + // Generate a file name for the temporary file + const auto fileName = QStringLiteral("A<>ame/with\\chars.mp3"); + const auto expected = QStringLiteral("A##ame-with-chars.mp3"); + auto output = FileUtils::escapeFileName(fileName); + ASSERT_EQ(expected, output); +} diff --git a/src/test/formatter_test.cpp b/src/test/formatter_test.cpp new file mode 100644 index 000000000000..05343f8bb492 --- /dev/null +++ b/src/test/formatter_test.cpp @@ -0,0 +1,118 @@ +#include "util/formatter.h" + +#include +#include + +#include "test/mixxxtest.h" + +class FormatterTest : public testing::Test { +}; + +using namespace Grantlee; + +TEST_F(FormatterTest, TestRangeGroupFilter) { + // Generate a file name for the temporary file + auto engine = Formatter::getEngine(nullptr); + auto context = new Context(); + //context->insert("x1", "122"); + Template t1 = engine->newTemplate(QStringLiteral("{{143|rangegroup}}"), QStringLiteral("t1")); + + auto pattern = t1->render(context); + + EXPECT_EQ(t1->render(context), + QString("140-150")); + + Template t2 = engine->newTemplate(QStringLiteral("{{x1|rangegroup}}"), QStringLiteral("t2")); + context->insert("x1", "122"); + EXPECT_EQ(t2->render(context), QString("120-130")); + context->insert(QStringLiteral("x1"), QVariant(123)); + EXPECT_EQ(t2->render(context), QString("120-130")); + context->insert(QStringLiteral("x1"), QVariant(130)); + EXPECT_EQ(t2->render(context), QString("130-140")); + context->insert(QStringLiteral("x1"), QVariant(QString("131"))); + EXPECT_EQ(t2->render(context), QString("130-140")); +#if 0 + // FIXME(XXX) why is filter argument always QVariant(Invalid) ??? + // use different grouping size + Template t3 = engine->newTemplate(QStringLiteral("{{x1|rangegroup:\"5\"}}"), QStringLiteral("t3")); + context->insert("x1", "122"); + qDebug() << t2->render(context); + EXPECT_EQ(t2->render(context), QString("120-125")); + context->insert("x1", QVariant(122.3)); + EXPECT_EQ(t2->render(context), QString("120-125")); + context->insert("x1", QVariant(120.000001)); + EXPECT_EQ(t2->render(context), QString("120-125")); + context->insert("x1", QVariant(119.99999)); + EXPECT_EQ(t2->render(context), QString("115-120")); + + // use float group size + Template t4 = engine->newTemplate(QStringLiteral("{{x1|rangegroup:\"2.5\"}}"), QStringLiteral("t4")); + + context->insert("x1", QVariant(119.99999)); + EXPECT_EQ(t2->render(context), QString("117.5-120")); + context->insert("x1", QVariant(120.0)); + EXPECT_EQ(t2->render(context), QString("120-122.5")); +#endif +} + +TEST_F(FormatterTest, TestZeropadFilter) { + // Generate a file name for the temporary file + auto engine = Formatter::getEngine(nullptr); + auto context = new Context(); + //context->insert("x1", "122"); + Template t1 = engine->newTemplate(QStringLiteral("{{1|zeropad}}"), QStringLiteral("t1")); + + EXPECT_EQ(t1->render(context), + QString("01")); + + Template t2 = engine->newTemplate(QStringLiteral("{{1|zeropad:\"3\"}}"), QStringLiteral("t2")); + + EXPECT_EQ(t2->render(context), + QString("001")); + + Template t3 = engine->newTemplate(QStringLiteral("{{x1|zeropad:\"4\"}}"), QStringLiteral("t3")); + context->insert("x1", QVariant(23)); + + EXPECT_EQ(t3->render(context), + QString("0023")); +} + +TEST_F(FormatterTest, TestRoundFilter) { + // Generate a file name for the temporary file + auto engine = Formatter::getEngine(nullptr); + auto context = new Context(); + //context->insert("x1", "122"); + Template t1 = engine->newTemplate(QStringLiteral("{{x1|round}}"), QStringLiteral("t1")); + context->insert("x1", QVariant(1.49)); + EXPECT_EQ(t1->render(context), + QString("1")); + context->insert("x1", QVariant(1.51)); + EXPECT_EQ(t1->render(context), + QString("2")); + + context->insert("x1", QVariant(156.49567)); + EXPECT_EQ(t1->render(context), + QString("156")); + + Template t2 = engine->newTemplate(QStringLiteral("{{x1|round:\"2\"}}"), QStringLiteral("t3")); + + context->insert("x1", QVariant(1.234567)); + EXPECT_EQ(t2->render(context), + QString("1.23")); + + context->insert("x1", QVariant(1.23567)); + EXPECT_EQ(t2->render(context), + QString("1.24")); +} + +TEST_F(FormatterTest, TestEscape) { + // Generate a file name for the temporary file + auto engine = Formatter::getEngine(nullptr); + auto context = new Context(); + context->insert(QStringLiteral("file"), QStringLiteral("file<>|with_/&terr-ible.name")); + context->insert(QStringLiteral("dir"), QStringLiteral("extra/bad\\directory")); + Template t1 = engine->newTemplate(QStringLiteral("{{dir}}/{{file}}"), QStringLiteral("t1")); + + EXPECT_EQ(Formatter::renderFilenameEscape(t1, *context), + QString("extra-bad-directory/file###with_-&terr-ible.name")); +} diff --git a/src/test/trackexport_test.cpp b/src/test/trackexport_test.cpp index d897eba5205c..6a3ae5b3c13b 100644 --- a/src/test/trackexport_test.cpp +++ b/src/test/trackexport_test.cpp @@ -3,6 +3,8 @@ #include "test/trackexport_test.h" +#include + #include #include @@ -11,8 +13,12 @@ FakeOverwriteAnswerer::~FakeOverwriteAnswerer() { } -void FakeOverwriteAnswerer::slotProgress(const QString& filename, int progress, int count) { +void FakeOverwriteAnswerer::slotProgress( + const QString filename, const QString to, int progress, int count) { m_progress_filename = filename; + if (!to.isEmpty()) { + m_progress_to = to; + } m_progress = progress; m_progress_count = count; } @@ -300,3 +306,39 @@ TEST_F(TrackExporterTest, MungeFilename) { // Remove the track we created. tempPath.remove("cover-test.ogg"); } + +TEST_F(TrackExporterTest, PatternExport) { + // Create a simple list of trackpointers and export them. + TrackFile fileinfo1(m_testDataDir.filePath("cover-test.ogg")); + TrackPointer track1(Track::newTemporary(fileinfo1)); + TrackFile fileinfo2(m_testDataDir.filePath("cover-test.flac")); + TrackPointer track2(Track::newTemporary(fileinfo2)); + TrackFile fileinfo3(m_testDataDir.filePath("cover-test-itunes-12.3.0-aac.m4a")); + TrackPointer track3(Track::newTemporary(fileinfo3)); + + // An initializer list would be prettier here, but it doesn't compile + // on MSVC or OSX. + TrackPointerList tracks; + tracks.append(track1); + tracks.append(track2); + tracks.append(track3); + auto context = new Grantlee::Context(); + context->insert("t", "t42/"); + auto pattern = QStringLiteral( + "{{t}}{{track.baseName}}-{{track.extension}}-" + "{{track.bpm}}{% if index %}-{{index}}{%endif%}#{{ dup }}"); + TrackExportWorker worker(m_exportDir.canonicalPath(), tracks, context); + worker.setPattern(&pattern); + + EXPECT_EQ(worker.generateFilename(track1, 0), + QStringLiteral("t42/cover-test-ogg-0#0")); + EXPECT_EQ(worker.generateFilename(track2, 1), + QStringLiteral("t42/cover-test-flac-0-1#0")); + EXPECT_EQ(worker.generateFilename(track3, 0, 23), + QStringLiteral("t42/cover-test-itunes-12-m4a-0#23")); + + auto pattern2 = QStringLiteral("{{track.fileName}}"); + worker.setPattern(&pattern2); + EXPECT_EQ(worker.generateFilename(track1, 0), + QStringLiteral("cover-test.ogg")); +} diff --git a/src/test/trackexport_test.h b/src/test/trackexport_test.h index 00a543bf6aeb..cd38b29b689e 100644 --- a/src/test/trackexport_test.h +++ b/src/test/trackexport_test.h @@ -48,7 +48,7 @@ class FakeOverwriteAnswerer : public QObject { } public slots: - void slotProgress(const QString& filename, int progress, int count); + void slotProgress(const QString status, const QString to, int progress, int count); void slotAskOverwriteMode( const QString& filename, std::promise* promise); @@ -58,6 +58,7 @@ class FakeOverwriteAnswerer : public QObject { TrackExportWorker* m_worker; QMap m_answers; QString m_progress_filename; + QString m_progress_to; int m_progress = 0; int m_progress_count = 0; }; diff --git a/src/track/keys.cpp b/src/track/keys.cpp index 2d6e5c6b0ee3..562aa96d8dd5 100644 --- a/src/track/keys.cpp +++ b/src/track/keys.cpp @@ -1,7 +1,9 @@ +#include "track/keys.h" + #include #include -#include "track/keys.h" +#include "track/keyutils.h" using mixxx::track::io::key::ChromaticKey; using mixxx::track::io::key::KeyMap; @@ -16,6 +18,16 @@ Keys::Keys(const KeyMap& keyMap) : m_keyMap(keyMap) { } +QString Keys::getTraditional() const { + return KeyUtils::getGlobalKeyText(*this, KeyUtils::KeyNotation::Traditional); +} +QString Keys::getOpenkey() const { + return KeyUtils::getGlobalKeyText(*this, KeyUtils::KeyNotation::OpenKey); +} +QString Keys::getLancelot() const { + return KeyUtils::getGlobalKeyText(*this, KeyUtils::KeyNotation::Lancelot); +} + QByteArray Keys::toByteArray() const { std::string output; m_keyMap.SerializeToString(&output); diff --git a/src/track/keys.h b/src/track/keys.h index 7e286c0d49fb..8e14f4fe4148 100644 --- a/src/track/keys.h +++ b/src/track/keys.h @@ -1,6 +1,9 @@ #pragma once +#include + #include +#include #include #include @@ -13,9 +16,19 @@ typedef QVector > KeyChangeLi class KeyFactory; class Keys final { + Q_GADGET public: + Q_PROPERTY(QString traditional READ getTraditional) + Q_PROPERTY(QString openkey READ getOpenkey) + Q_PROPERTY(QString lancelot READ getLancelot) + Q_PROPERTY(bool isValid READ isValid) + explicit Keys(const QByteArray* pByteArray = nullptr); + QString getTraditional() const; + QString getOpenkey() const; + QString getLancelot() const; + // Serialization QByteArray toByteArray() const; @@ -58,3 +71,18 @@ bool operator==(const Keys& lhs, const Keys& rhs); inline bool operator!=(const Keys& lhs, const Keys& rhs) { return !(lhs == rhs); } + +Q_DECLARE_METATYPE(Keys) + +// FIXME(XXX) This should work according to grentlee docs, but it does not +GRANTLEE_BEGIN_LOOKUP(Keys) +if (property.isEmpty()) { + return object.getOpenkey(); +} +GRANTLEE_END_LOOKUP + +GRANTLEE_BEGIN_LOOKUP_PTR(Keys) +if (property.isEmpty()) { + return object->getOpenkey(); +} +GRANTLEE_END_LOOKUP diff --git a/src/track/track.h b/src/track/track.h index 650adc5d5e9c..20695eae2413 100644 --- a/src/track/track.h +++ b/src/track/track.h @@ -52,16 +52,26 @@ class Track : public QObject { Q_PROPERTY(QString track_number READ getTrackNumber WRITE setTrackNumber) Q_PROPERTY(QString track_total READ getTrackTotal WRITE setTrackTotal) Q_PROPERTY(int times_played READ getTimesPlayed) + Q_PROPERTY(QDateTime lastPlayed READ getLastPlayedAt) Q_PROPERTY(QString comment READ getComment WRITE setComment) - Q_PROPERTY(double bpm READ getBpm) + Q_PROPERTY(int bitrate READ getBitrate WRITE setBitrate) + Q_PROPERTY(double bpm READ getBpm WRITE trySetBpm) Q_PROPERTY(QString bpmFormatted READ getBpmText STORED false) - Q_PROPERTY(QString key READ getKeyText WRITE setKeyText) + Q_PROPERTY(Keys key READ getKeys WRITE setKeys) Q_PROPERTY(double duration READ getDuration) Q_PROPERTY(QString durationFormatted READ getDurationTextSeconds STORED false) Q_PROPERTY(QString durationFormattedCentiseconds READ getDurationTextCentiseconds STORED false) Q_PROPERTY(QString durationFormattedMilliseconds READ getDurationTextMilliseconds STORED false) Q_PROPERTY(QString info READ getInfo STORED false) Q_PROPERTY(QString titleInfo READ getTitleInfo STORED false) + Q_PROPERTY(int rating READ getRating WRITE setRating) + + Q_PROPERTY(QString location READ getLocation STORED false) + Q_PROPERTY(QString directory READ directory STORED false) + Q_PROPERTY(QString baseName READ baseName STORED false) + Q_PROPERTY(QString fileName READ fileName STORED false) + Q_PROPERTY(QString extension READ extension STORED false) + Q_PROPERTY(QString url READ getURL WRITE setURL) TrackFile getFileInfo() const { // Copying TrackFile/QFileInfo is thread-safe (implicit sharing), no locking needed. @@ -78,6 +88,20 @@ class Track : public QObject { QString getLocation() const { return m_fileInfo.location(); } + + QString directory() const { + return m_fileInfo.directory(); + } + QString baseName() const { + return m_fileInfo.baseName(); + } + QString fileName() const { + return m_fileInfo.fileName(); + } + QString extension() const { + return m_fileInfo.extension(); + } + // The (refreshed) canonical location QString getCanonicalLocation() const; // Checks if the file exists diff --git a/src/track/trackfile.h b/src/track/trackfile.h index 5a5c411caef8..7bfc7268061b 100644 --- a/src/track/trackfile.h +++ b/src/track/trackfile.h @@ -70,6 +70,9 @@ class TrackFile { QString baseName() const { return m_fileInfo.baseName(); } + QString extension() const { + return m_fileInfo.suffix(); + } QString fileName() const { return m_fileInfo.fileName(); } diff --git a/src/util/fileutils.cpp b/src/util/fileutils.cpp new file mode 100644 index 000000000000..68ccb004839f --- /dev/null +++ b/src/util/fileutils.cpp @@ -0,0 +1,30 @@ +#include "util/fileutils.h" + +#include + +namespace { +// see https://stackoverflow.com/questions/1976007/what-characters-are-forbidden-in-windows-and-linux-directory-names +const auto kIllegalCharacters = QRegExp("([<>:\"\\|\\?\\*]|[\x01-\x1F])"); +const auto kDirChars = QRegExp("[/\\\\]"); +} // namespace + +QString FileUtils::safeFilename(const QString& input, const QString& replacement) { + auto output = QString(input); + output.replace(kIllegalCharacters, replacement); + return output.replace(QChar::Null, replacement); +} + +QString FileUtils::replaceDirChars(const QString& input, const QString& replacement) { + auto output = QString(input); + return output.replace(kDirChars, replacement); +} + +QString FileUtils::escapeFileName( + const QString& input, + const QString& fileReplaceChar, + const QString& dirReplaceChar) { + auto output = QString(input); + output.replace(kDirChars, dirReplaceChar); + output.replace(kIllegalCharacters, fileReplaceChar); + return output.replace(QChar::Null, fileReplaceChar); +} diff --git a/src/util/fileutils.h b/src/util/fileutils.h new file mode 100644 index 000000000000..78f1f67ae0d1 --- /dev/null +++ b/src/util/fileutils.h @@ -0,0 +1,18 @@ +#include + +namespace { +const QString kDefaultFileReplacementCharacter = QString("#"); +const QString kDefaultDirReplacementCharacter = QString("-"); +} // namespace + +class FileUtils { + public: + // returns a filename that is safe on all platforms and does not contain unwanted characters like newline + static QString safeFilename(const QString& input, + const QString& replacement = kDefaultFileReplacementCharacter); + static QString replaceDirChars(const QString& input, + const QString& replacement = kDefaultDirReplacementCharacter); + static QString escapeFileName(const QString& input, + const QString& fileReplaceChar = kDefaultFileReplacementCharacter, + const QString& dirReplaceChar = kDefaultDirReplacementCharacter); +}; diff --git a/src/util/formatter.cpp b/src/util/formatter.cpp new file mode 100644 index 000000000000..3b11984234e4 --- /dev/null +++ b/src/util/formatter.cpp @@ -0,0 +1,66 @@ +#include "util/formatter.h" + +#include +#include +#include +#include + +#include +#include + +#include "track/keys.h" +#include "util/fileutils.h" + +NoEscapeStream::NoEscapeStream() + : Grantlee::OutputStream() { +} +NoEscapeStream::NoEscapeStream(QTextStream* stream) + : Grantlee::OutputStream(stream) { +} + +NoEscapeStream::~NoEscapeStream(){}; + +QSharedPointer NoEscapeStream::clone(QTextStream* stream) const { + return QSharedPointer::create(stream); +} + +FileEscapeStream::FileEscapeStream() + : Grantlee::OutputStream() { +} +FileEscapeStream::FileEscapeStream(QTextStream* stream) + : Grantlee::OutputStream(stream) { +} +FileEscapeStream::~FileEscapeStream(){}; + +QString FileEscapeStream::escape(const QString& input) const { + return FileUtils::escapeFileName(input); +} + +QSharedPointer FileEscapeStream::clone(QTextStream* stream) const { + return QSharedPointer::create(stream); +} + +Grantlee::Engine* Formatter::getEngine(QObject* parent) { + Grantlee::registerMetaType(); + + auto engine = new Grantlee::Engine(parent); + engine->addDefaultLibrary(QStringLiteral("mixxxformatter")); + // register custom + return engine; +} + +QString Formatter::renderNoEscape(Grantlee::Template& tmpl, Grantlee::Context& context) { + auto rendered = QString(); + auto stream = QTextStream(&rendered); + NoEscapeStream os(&stream); + tmpl->render(&os, &context); + return rendered; +} + +QString Formatter::renderFilenameEscape(Grantlee::Template& tmpl, Grantlee::Context& context) { + auto rendered = QString(); + auto stream = QTextStream(&rendered); + FileEscapeStream os(&stream); + tmpl->render(&os, &context); + return rendered; +} diff --git a/src/util/formatter.h b/src/util/formatter.h new file mode 100644 index 000000000000..70d7cf177aa5 --- /dev/null +++ b/src/util/formatter.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +#include +#include +#include + +class NoEscapeStream : public Grantlee::OutputStream { + public: + NoEscapeStream(); + NoEscapeStream(QTextStream* stream); + ~NoEscapeStream() override; + + QString escape(const QString& input) const override { + return input; + }; + QSharedPointer clone(QTextStream* stream) const override; +}; + +class FileEscapeStream : public Grantlee::OutputStream { + public: + FileEscapeStream(); + FileEscapeStream(QTextStream* stream); + ~FileEscapeStream() override; + + QString escape(const QString& input) const override; + QSharedPointer clone(QTextStream* stream) const override; +}; + +class Formatter { + public: + static Grantlee::Engine* getEngine(QObject* parent); + // render template without escaping html characters + static QString renderNoEscape( + Grantlee::Template& templ, + Grantlee::Context& context); + static QString renderFilenameEscape( + Grantlee::Template& templ, + Grantlee::Context& context); +}; diff --git a/src/util/formatterplugin/mixxxformatter.cpp b/src/util/formatterplugin/mixxxformatter.cpp new file mode 100644 index 000000000000..68cefee08cf7 --- /dev/null +++ b/src/util/formatterplugin/mixxxformatter.cpp @@ -0,0 +1,140 @@ +#include "mixxxformatter.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "moc_mixxxformatter.cpp" +#include "util/fileutils.h" + +using namespace Grantlee; + +namespace { +double kDefaultGroupSize = 10.0; +} + +/// Groups numeric values into ranges of values (parameter is group size, default 10) +QVariant RangeGroup::doFilter(const QVariant& input, + const QVariant& argument, + bool autoescape) const { + Q_UNUSED(autoescape); + auto safeInput = getSafeString(input); + + bool ok = false; + double finput = 0.0; + if (!safeInput.get().isNull()) { + finput = safeInput.get().toDouble(&ok); + if (!ok) { + qWarning() << input << "rangegroup filter input is not a number"; + return input; + } + } else { + finput = input.toDouble(&ok); + if (!ok) { + qWarning() << input << "rangegroup filter input is not a number"; + return input; + } + } + + double modulus = getSafeString(argument).get().toDouble(&ok); + + if (ok) { + if (modulus <= 0.0) { + qWarning() << argument << "rangegroup filter group size is not a positive number"; + modulus = kDefaultGroupSize; + } + } else { + modulus = kDefaultGroupSize; + } + + double rest = std::fmod(finput, modulus); + double groupStart = finput - rest; + double groupEnd = groupStart + modulus; + QString startString; + QString endString; + if (abs(groupStart - (qRound(groupStart))) < 0.01) { + startString = QString::number(static_cast(groupStart)); + } else { + startString = QString("%1").arg(groupStart, 0, 'f', 2); + } + if (abs(groupEnd - (qRound(groupEnd))) < 0.01) { + endString = QString::number(static_cast(groupEnd)); + } else { + endString = QString("%1").arg(groupEnd, 0, 'f', 2); + } + + return QVariant(startString + QStringLiteral("-") + endString); +} + +QVariant ZeroPad::doFilter(const QVariant& input, + const QVariant& argument, + bool autoescape) const { + Q_UNUSED(autoescape) + auto value = getSafeString(input); + + bool ok; + int iValue = value.get().toInt(&ok); + if (!ok) { + return QString(); + } + int arg = getSafeString(argument).get().toInt(&ok); + if (!ok) { + arg = 2; + } + + return SafeString(QString("%1").arg(iValue, arg, 10, QChar('0'))); +} + +QVariant Rounder::doFilter(const QVariant& input, + const QVariant& argument, + bool autoescape) const { + Q_UNUSED(autoescape) + auto value = getSafeString(input); + + bool ok; + double dValue = value.get().toDouble(&ok); + if (!ok) { + return QString(); + } + int arg = getSafeString(argument).get().toInt(&ok); + if (!ok) { + arg = 0; + } + + double rValue = qRound(dValue * qPow(10, arg)) / qPow(10, arg); + + return SafeString(QString("%1").arg(rValue, 0, 'f', arg)); +} + +QVariant NoDir::doFilter(const QVariant& input, + const QVariant& argument, + bool autoescape) const { + Q_UNUSED(autoescape) + auto value = getSafeString(input); + + return SafeString(FileUtils::replaceDirChars(value.get())); +} + +QHash FormatterPlugin::nodeFactories(const QString& name) { + Q_UNUSED(name); + QHash nodes; + return nodes; +} + +QHash FormatterPlugin::filters(const QString& name) { + Q_UNUSED(name); + QHash filters; + + filters.insert("rangegroup", new RangeGroup()); + filters.insert("zeropad", new ZeroPad()); + filters.insert("round", new Rounder()); + filters.insert("nodir", new NoDir()); + + return filters; +} diff --git a/src/util/formatterplugin/mixxxformatter.h b/src/util/formatterplugin/mixxxformatter.h new file mode 100644 index 000000000000..5581cbe089be --- /dev/null +++ b/src/util/formatterplugin/mixxxformatter.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +using namespace Grantlee; + +/// Groups numeric values into ranges of values (parameter is group size, default 10) +class RangeGroup : public Filter { + public: + RangeGroup(){}; + ~RangeGroup(){}; + + QVariant doFilter(const QVariant& input, + const QVariant& arg = {}, + bool autoescape = false) const override; + bool isSafe() const override { + return true; + }; +}; + +/// Pads a integer with 0 +class ZeroPad : public Filter { + public: + ZeroPad(){}; + ~ZeroPad(){}; + + QVariant doFilter(const QVariant& input, + const QVariant& arg = {}, + bool autoescape = false) const override; + bool isSafe() const override { + return true; + }; +}; + +/// Rounds a double to n precission (default = 0) +class Rounder : public Filter { + public: + Rounder(){}; + ~Rounder(){}; + + QVariant doFilter(const QVariant& input, + const QVariant& arg = {}, + bool autoescape = false) const override; + bool isSafe() const override { + return true; + }; +}; + +/// SafeFileName +class NoDir : public Filter { + public: + NoDir(){}; + ~NoDir(){}; + + QVariant doFilter(const QVariant& input, + const QVariant& arg = {}, + bool autoescape = false) const override; + bool isSafe() const override { + return true; + }; +}; + +class FormatterPlugin : public QObject, public TagLibraryInterface { + Q_OBJECT + Q_INTERFACES(Grantlee::TagLibraryInterface) + Q_PLUGIN_METADATA(IID "org.grantlee.TagLibraryInterface") + public: + FormatterPlugin(QObject* parent = nullptr) + : QObject(parent){}; + + QHash nodeFactories(const QString& name = QString()); + QHash filters(const QString& name = QString()); +}; diff --git a/src/widget/wtrackmenu.cpp b/src/widget/wtrackmenu.cpp index b6f4ce976753..5c65db4e1d56 100644 --- a/src/widget/wtrackmenu.cpp +++ b/src/widget/wtrackmenu.cpp @@ -12,6 +12,7 @@ #include "library/dlgtagfetcher.h" #include "library/dlgtrackinfo.h" #include "library/dlgtrackmetadataexport.h" +#include "library/export/trackexportdlg.h" #include "library/externaltrackcollection.h" #include "library/library.h" #include "library/librarytablemodel.h" @@ -63,7 +64,6 @@ WTrackMenu::WTrackMenu( // Remove unsupported features m_eActiveFeatures &= !m_eTrackModelFeatures; } - createMenus(); createActions(); setupActions(); @@ -165,6 +165,9 @@ void WTrackMenu::createMenus() { m_pLibrary->searchTracksInCollection(searchQuery); }); } + // file menu used for browse/export and other features + m_pFileMenu = new QMenu(this); + m_pFileMenu->setTitle(tr("File")); } void WTrackMenu::createActions() { @@ -214,10 +217,15 @@ void WTrackMenu::createActions() { } if (featureIsEnabled(Feature::FileBrowser)) { - m_pFileBrowserAct = new QAction(tr("Open in File Browser"), this); + m_pFileBrowserAct = new QAction(tr("Open in File Browser"), m_pFileMenu); connect(m_pFileBrowserAct, &QAction::triggered, this, &WTrackMenu::slotOpenInFileBrowser); } + if (featureIsEnabled(Feature::Export)) { + m_pFileExportAct = new QAction(tr("Export Files"), m_pFileMenu); + connect(m_pFileExportAct, &QAction::triggered, this, &WTrackMenu::slotExportFiles); + } + if (featureIsEnabled(Feature::Metadata)) { m_pImportMetadataFromFileAct = new QAction(tr("Import From File Tags"), m_pMetadataMenu); @@ -485,8 +493,17 @@ void WTrackMenu::setupActions() { } } - if (featureIsEnabled(Feature::FileBrowser)) { - addAction(m_pFileBrowserAct); + if (featureIsEnabled(Feature::FileBrowser) || + featureIsEnabled(Feature::Export)) { + addMenu(m_pFileMenu); + + if (featureIsEnabled(Feature::FileBrowser)) { + m_pFileMenu->addAction(m_pFileBrowserAct); + } + + if (featureIsEnabled(Feature::Export)) { + m_pFileMenu->addAction(m_pFileExportAct); + } } if (featureIsEnabled(Feature::Properties)) { @@ -897,6 +914,21 @@ void WTrackMenu::slotOpenInFileBrowser() { mixxx::DesktopHelper::openInFileBrowser(locations); } +void WTrackMenu::slotExportFiles() { + const auto pTrackPointerIter = newTrackPointerIterator(); + if (!pTrackPointerIter) { + // Empty, i.e. nothing to do + return; + } + auto trackList = TrackPointerList(); + while (auto nextTrackPointer = pTrackPointerIter->nextItem()) { + const auto pTrack = *nextTrackPointer; + trackList.append(pTrack); + } + TrackExportDlg* exporter = new TrackExportDlg(nullptr, m_pConfig, trackList); + exporter->open(); +} + namespace { class ImportMetadataFromFileTagsTrackPointerOperation : public mixxx::TrackPointerOperation { @@ -1784,6 +1816,8 @@ bool WTrackMenu::featureIsEnabled(Feature flag) const { m_pTrackModel->hasCapabilities(TrackModel::Capability::Purge); case Feature::FileBrowser: return true; + case Feature::Export: + return true; case Feature::Properties: return m_pTrackModel->hasCapabilities(TrackModel::Capability::EditMetadata); case Feature::SearchRelated: diff --git a/src/widget/wtrackmenu.h b/src/widget/wtrackmenu.h index 71f5c841f0cb..85c774298d9a 100644 --- a/src/widget/wtrackmenu.h +++ b/src/widget/wtrackmenu.h @@ -46,10 +46,11 @@ class WTrackMenu : public QMenu { FileBrowser = 1 << 10, Properties = 1 << 11, SearchRelated = 1 << 12, + Export = 1 << 13, TrackModelFeatures = Remove | HideUnhidePurge, All = AutoDJ | LoadTo | Playlist | Crate | Remove | Metadata | Reset | BPM | Color | HideUnhidePurge | FileBrowser | Properties | - SearchRelated + SearchRelated | Export }; Q_DECLARE_FLAGS(Features, Feature) @@ -81,6 +82,7 @@ class WTrackMenu : public QMenu { private slots: // File void slotOpenInFileBrowser(); + void slotExportFiles(); // Row color void slotColorPicked(const mixxx::RgbColor::optional_t& color); @@ -203,6 +205,7 @@ class WTrackMenu : public QMenu { QMenu* m_pClearMetadataMenu{}; QMenu* m_pBPMMenu{}; QMenu* m_pColorMenu{}; + QMenu* m_pFileMenu{}; WCoverArtMenu* m_pCoverMenu{}; parented_ptr m_pSearchRelatedMenu; @@ -234,6 +237,8 @@ class WTrackMenu : public QMenu { // Open file in default file browser QAction* m_pFileBrowserAct{}; + // Export files + QAction* m_pFileExportAct{}; // BPM feature QAction* m_pBpmLockAction{}; diff --git a/src/widget/wtrackproperty.cpp b/src/widget/wtrackproperty.cpp index 67cea1b497b7..b3a12f8cc160 100644 --- a/src/widget/wtrackproperty.cpp +++ b/src/widget/wtrackproperty.cpp @@ -19,6 +19,7 @@ constexpr WTrackMenu::Features kTrackMenuFeatures = WTrackMenu::Feature::BPM | WTrackMenu::Feature::Color | WTrackMenu::Feature::FileBrowser | + WTrackMenu::Feature::Export | WTrackMenu::Feature::Properties; } // namespace diff --git a/src/widget/wtracktext.cpp b/src/widget/wtracktext.cpp index 3c1ddcf7a4cf..9f4fd77ae426 100644 --- a/src/widget/wtracktext.cpp +++ b/src/widget/wtracktext.cpp @@ -19,6 +19,7 @@ constexpr WTrackMenu::Features kTrackMenuFeatures = WTrackMenu::Feature::BPM | WTrackMenu::Feature::Color | WTrackMenu::Feature::FileBrowser | + WTrackMenu::Feature::Export | WTrackMenu::Feature::Properties; } // namespace diff --git a/src/widget/wtrackwidgetgroup.cpp b/src/widget/wtrackwidgetgroup.cpp index df6fc93dadc4..1c9375b6fa04 100644 --- a/src/widget/wtrackwidgetgroup.cpp +++ b/src/widget/wtrackwidgetgroup.cpp @@ -23,6 +23,7 @@ constexpr WTrackMenu::Features kTrackMenuFeatures = WTrackMenu::Feature::BPM | WTrackMenu::Feature::Color | WTrackMenu::Feature::FileBrowser | + WTrackMenu::Feature::Export | WTrackMenu::Feature::Properties; } // anonymous namespace diff --git a/tools/debian_buildenv.sh b/tools/debian_buildenv.sh index 1effdcb7660d..be6be9652dc4 100755 --- a/tools/debian_buildenv.sh +++ b/tools/debian_buildenv.sh @@ -55,6 +55,7 @@ case "$COMMAND" in libfaad-dev \ libfftw3-dev \ libflac-dev \ + libgrantlee5-dev \ libhidapi-dev \ libid3tag0-dev \ liblilv-dev \