1 /****************************************************************************************
2 * Copyright (c) 2012 Matěj Laitl <matej@laitl.cz> *
3 * *
4 * This program is free software; you can redistribute it and/or modify it under *
5 * the terms of the GNU General Public License as published by the Free Software *
6 * Foundation; either version 2 of the License, or (at your option) any later *
7 * version. *
8 * *
9 * This program is distributed in the hope that it will be useful, but WITHOUT ANY *
10 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A *
11 * PARTICULAR PURPOSE. See the GNU General Public License for more details. *
12 * *
13 * You should have received a copy of the GNU General Public License along with *
14 * this program. If not, see <http://www.gnu.org/licenses/>. *
15 ****************************************************************************************/
16
17 #include "MatchedTracksPage.h"
18
19 #include "App.h"
20 #include "core/meta/support/MetaConstants.h"
21 #include "core/support/Debug.h"
22 #include "statsyncing/TrackTuple.h"
23 #include "statsyncing/models/MatchedTracksModel.h"
24 #include "statsyncing/ui/TrackDelegate.h"
25
26 #include <QEvent>
27 #include <QMenu>
28 #include <QPushButton>
29 #include <QSortFilterProxyModel>
30 #include <QStandardItemModel>
31
32 // needed for QCombobox payloads:
33 Q_DECLARE_METATYPE( StatSyncing::ProviderPtr )
34
35 namespace StatSyncing
36 {
37 class SortFilterProxyModel : public QSortFilterProxyModel
38 {
39 public:
SortFilterProxyModel(QObject * parent=nullptr)40 SortFilterProxyModel( QObject *parent = nullptr )
41 : QSortFilterProxyModel( parent )
42 , m_tupleFilter( -1 )
43 {
44 // filer all columns, accept when at least one column matches:
45 setFilterKeyColumn( -1 );
46 }
47
48 /**
49 * Filter tuples based on their MatchedTracksModel::TupleFlag flag. Set to -1
50 * to accept tuples with any flags.
51 */
setTupleFilter(int filter)52 void setTupleFilter( int filter )
53 {
54 m_tupleFilter = filter;
55 invalidateFilter();
56 sort( sortColumn(), sortOrder() ); // this doesn't happen automatically
57 }
58
59 protected:
filterAcceptsRow(int source_row,const QModelIndex & source_parent) const60 bool filterAcceptsRow( int source_row, const QModelIndex &source_parent ) const override
61 {
62 if( source_parent.isValid() )
63 return true; // we match all child items, we filter only root ones
64 if( m_tupleFilter != -1 )
65 {
66 QModelIndex index = sourceModel()->index( source_row, 0, source_parent );
67 int flags = sourceModel()->data( index, MatchedTracksModel::TupleFlagsRole ).toInt();
68 if( !(flags & m_tupleFilter) )
69 return false;
70 }
71 return QSortFilterProxyModel::filterAcceptsRow( source_row, source_parent );
72 }
73
lessThan(const QModelIndex & left,const QModelIndex & right) const74 bool lessThan( const QModelIndex &left, const QModelIndex &right ) const override
75 {
76 if( left.parent().isValid() ) // we are comparing childs, special mode:
77 {
78 // take providers, e.g. reset column to 0
79 QModelIndex l = sourceModel()->index( left.row(), 0, left.parent() );
80 QModelIndex r = sourceModel()->index( right.row(), 0, right.parent() );
81 QString leftProvider = sourceModel()->data( l, Qt::DisplayRole ).toString();
82 QString rightProvider = sourceModel()->data( r, Qt::DisplayRole ).toString();
83
84 // make this sorting ignore the sort order, always sort ascendingly:
85 if( sortOrder() == Qt::AscendingOrder )
86 return leftProvider.localeAwareCompare( rightProvider ) < 0;
87 else
88 return leftProvider.localeAwareCompare( rightProvider ) > 0;
89 }
90 return QSortFilterProxyModel::lessThan( left, right );
91 }
92
93 private:
94 int m_tupleFilter;
95 };
96 }
97
98 using namespace StatSyncing;
99
MatchedTracksPage(QWidget * parent,Qt::WindowFlags f)100 MatchedTracksPage::MatchedTracksPage( QWidget *parent, Qt::WindowFlags f )
101 : QWidget( parent, f )
102 , m_matchedTracksModel( 0 )
103 {
104 setupUi( this );
105 // this group box is only shown upon setTracksToScrobble() call
106 scrobblingGroupBox->hide();
107
108 m_matchedProxyModel = new SortFilterProxyModel( this );
109 m_uniqueProxyModel = new QSortFilterProxyModel( this );
110 m_excludedProxyModel = new QSortFilterProxyModel( this );
111
112 #define SETUP_MODEL( proxyModel, name, Name ) \
113 proxyModel->setSortLocaleAware( true ); \
114 proxyModel->setSortCaseSensitivity( Qt::CaseInsensitive ); \
115 proxyModel->setFilterCaseSensitivity( Qt::CaseInsensitive ); \
116 connect( proxyModel, &QSortFilterProxyModel::modelReset, this, &MatchedTracksPage::refresh##Name##StatusText ); \
117 connect( proxyModel, &QSortFilterProxyModel::rowsInserted, this, &MatchedTracksPage::refresh##Name##StatusText ); \
118 connect( proxyModel, &QSortFilterProxyModel::rowsRemoved, this, &MatchedTracksPage::refresh##Name##StatusText ); \
119 name##TreeView->setModel( m_##name##ProxyModel ); \
120 name##TreeView->setItemDelegate( new TrackDelegate( name##TreeView ) ); \
121 connect( name##FilterLine, &QLineEdit::textChanged, proxyModel, &QSortFilterProxyModel::setFilterFixedString ); \
122 name##TreeView->header()->setStretchLastSection( false ); \
123 name##TreeView->header()->setDefaultSectionSize( 80 );
124
125 SETUP_MODEL( m_matchedProxyModel, matched, Matched )
126 SETUP_MODEL( m_uniqueProxyModel, unique, Unique )
127 SETUP_MODEL( m_excludedProxyModel, excluded, Excluded )
128 #undef SETUP_MODEL
129
130 connect( uniqueFilterCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
131 this, &MatchedTracksPage::changeUniqueTracksProvider );
132 connect( excludedFilterCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
133 this, &MatchedTracksPage::changeExcludedTracksProvider );
134
135 QPushButton *configure = buttonBox->addButton( i18n( "Configure Synchronization..." ), QDialogButtonBox::ActionRole );
136 connect( configure, &QPushButton::clicked, this, &MatchedTracksPage::openConfiguration );
137
138 QPushButton *back = buttonBox->addButton( i18n( "Back" ), QDialogButtonBox::ActionRole );
139
140 QPushButton *synchronize = buttonBox->addButton( i18n( "Synchronize" ), QDialogButtonBox::AcceptRole );
141 synchronize->setIcon( QIcon( "document-save" ) );
142
143 connect( back, &QAbstractButton::clicked, this, &MatchedTracksPage::back );
144 connect( buttonBox, &QDialogButtonBox::accepted, this, &MatchedTracksPage::accepted );
145 connect( buttonBox, &QDialogButtonBox::rejected, this, &MatchedTracksPage::rejected );
146
147 tabWidget->setTabEnabled( 1, false );
148 tabWidget->setTabToolTip( 1, i18n( "There are no tracks unique to one of the sources "
149 "participating in the synchronization" ) );
150 tabWidget->setTabEnabled( 2, false );
151 tabWidget->setTabToolTip( 2, i18n( "There are no tracks excluded from "
152 "synchronization" ) );
153
154 QMenu *menu = new QMenu( matchedExpandButton );
155 menu->addAction( i18n( "Expand Tracks With Conflicts" ), this, &MatchedTracksPage::expand )->setData(
156 MatchedTracksModel::HasConflict );
157 menu->addAction( i18n( "Expand Updated" ), this, &MatchedTracksPage::expand )->setData(
158 MatchedTracksModel::HasUpdate );
159 menu->addAction( i18n( "Expand All" ), this, &MatchedTracksPage::expand )->setData( 0 );
160 matchedExpandButton->setMenu( menu );
161
162 menu = new QMenu( matchedCollapseButton );
163 menu->addAction( i18n( "Collapse Tracks Without Conflicts" ), this, &MatchedTracksPage::collapse )->setData(
164 MatchedTracksModel::HasConflict );
165 menu->addAction( i18n( "Collapse Not Updated" ), this, &MatchedTracksPage::collapse )->setData(
166 MatchedTracksModel::HasUpdate );
167 menu->addAction( i18n( "Collapse All" ), this, &MatchedTracksPage::collapse )->setData( 0 );
168 matchedCollapseButton->setMenu( menu );
169 }
170
~MatchedTracksPage()171 MatchedTracksPage::~MatchedTracksPage()
172 {
173 }
174
175 void
setProviders(const ProviderPtrList & providers)176 MatchedTracksPage::setProviders( const ProviderPtrList &providers )
177 {
178 // populate menu of the "Take Ratings From" button
179 QMenu *takeRatingsMenu = new QMenu( matchedRatingsButton );
180 foreach( const ProviderPtr &provider, providers )
181 {
182 QAction *action = takeRatingsMenu->addAction( provider->icon(), provider->prettyName(),
183 this, &MatchedTracksPage::takeRatingsFrom );
184 action->setData( QVariant::fromValue<ProviderPtr>( provider ) );
185 }
186 takeRatingsMenu->addAction( i18n( "Reset All Ratings to Undecided" ), this, &MatchedTracksPage::takeRatingsFrom );
187 matchedRatingsButton->setMenu( takeRatingsMenu );
188 matchedRatingsButton->setIcon( QIcon::fromTheme( Meta::iconForField( Meta::valRating ) ) );
189
190 // populate menu of the "Labels" button
191 QMenu *labelsMenu = new QMenu( matchedLabelsButton );
192 foreach( const ProviderPtr &provider, providers )
193 {
194 QString text = i18nc( "%1 is collection name", "Include Labels from %1", provider->prettyName() );
195 QAction *action = labelsMenu->addAction( provider->icon(), text, this, &MatchedTracksPage::includeLabelsFrom );
196 action->setData( QVariant::fromValue<ProviderPtr>( provider ) );
197
198 text = i18nc( "%1 is collection name", "Exclude Labels from %1", provider->prettyName() );
199 action = labelsMenu->addAction( provider->icon(), text, this, &MatchedTracksPage::excludeLabelsFrom );
200 action->setData( QVariant::fromValue<ProviderPtr>( provider ) );
201 }
202 labelsMenu->addAction( i18n( "Reset All Labels to Undecided (Don't Synchronize Them)" ),
203 this, &MatchedTracksPage::excludeLabelsFrom );
204 matchedLabelsButton->setMenu( labelsMenu );
205 matchedLabelsButton->setIcon( QIcon::fromTheme( Meta::iconForField( Meta::valLabel ) ) );
206 }
207
208 void
setMatchedTracksModel(MatchedTracksModel * model)209 MatchedTracksPage::setMatchedTracksModel( MatchedTracksModel *model )
210 {
211 m_matchedTracksModel = model;
212 Q_ASSERT( m_matchedTracksModel );
213 m_matchedProxyModel->setSourceModel( m_matchedTracksModel );
214
215 setHeaderSizePoliciesFromModel( matchedTreeView->header(), m_matchedTracksModel );
216 m_matchedProxyModel->sort( 0, Qt::AscendingOrder );
217 // initially, expand tuples with conflicts:
218 expand( MatchedTracksModel::HasConflict );
219
220 connect( m_matchedProxyModel, &StatSyncing::SortFilterProxyModel::rowsAboutToBeRemoved,
221 this, &MatchedTracksPage::rememberExpandedState );
222 connect( m_matchedProxyModel, &StatSyncing::SortFilterProxyModel::rowsInserted,
223 this, &MatchedTracksPage::restoreExpandedState );
224
225 // re-fill combo box and disable choices without tracks
226 bool hasConflict = m_matchedTracksModel->hasConflict();
227 matchedFilterCombo->clear();
228 matchedFilterCombo->addItem( i18n( "All Tracks" ), -1 );
229 matchedFilterCombo->addItem( i18n( "Updated Tracks" ), int( MatchedTracksModel::HasUpdate ) );
230 matchedFilterCombo->addItem( i18n( "Tracks With Conflicts" ), int( MatchedTracksModel::HasConflict ) );
231 QStandardItemModel *comboModel = dynamic_cast<QStandardItemModel *>( matchedFilterCombo->model() );
232 int bestIndex = 0;
233 if( comboModel )
234 {
235 bestIndex = 2;
236 if( !hasConflict )
237 {
238 comboModel->item( 2 )->setFlags( Qt::NoItemFlags );
239 matchedFilterCombo->setItemData( 2, i18n( "There are no tracks with conflicts" ),
240 Qt::ToolTipRole );
241 bestIndex = 1;
242 if( !m_matchedTracksModel->hasUpdate() )
243 {
244 comboModel->item( 1 )->setFlags( Qt::NoItemFlags );
245 matchedFilterCombo->setItemData( 1, i18n( "There are no tracks going to be "
246 "updated" ), Qt::ToolTipRole );
247 bestIndex = 0; // no other possibility
248 }
249 }
250 }
251
252 matchedFilterCombo->setCurrentIndex( bestIndex );
253 changeMatchedTracksFilter( bestIndex );
254 connect( matchedFilterCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
255 this, &MatchedTracksPage::changeMatchedTracksFilter );
256
257 matchedRatingsButton->setEnabled( hasConflict );
258 matchedLabelsButton->setEnabled( hasConflict );
259 }
260
261 void
addUniqueTracksModel(ProviderPtr provider,QAbstractItemModel * model)262 MatchedTracksPage::addUniqueTracksModel( ProviderPtr provider, QAbstractItemModel *model )
263 {
264 bool first = m_uniqueTracksModels.isEmpty();
265 m_uniqueTracksModels.insert( provider, model );
266 uniqueFilterCombo->addItem( provider->icon(), provider->prettyName(),
267 QVariant::fromValue<ProviderPtr>( provider ) );
268
269 if( first )
270 {
271 tabWidget->setTabEnabled( 1, true );
272 tabWidget->setTabToolTip( 1, i18n( "Tracks that are unique to their sources" ) );
273 setHeaderSizePoliciesFromModel( uniqueTreeView->header(), model );
274 uniqueFilterCombo->setCurrentIndex( 0 );
275 m_uniqueProxyModel->sort( 0, Qt::AscendingOrder );
276 }
277 }
278
279 void
addExcludedTracksModel(ProviderPtr provider,QAbstractItemModel * model)280 MatchedTracksPage::addExcludedTracksModel( ProviderPtr provider, QAbstractItemModel *model )
281 {
282 bool first = m_excludedTracksModels.isEmpty();
283 m_excludedTracksModels.insert( provider, model );
284 excludedFilterCombo->addItem( provider->icon(), provider->prettyName(),
285 QVariant::fromValue<ProviderPtr>( provider ) );
286
287 if( first )
288 {
289 tabWidget->setTabEnabled( 2, true );
290 tabWidget->setTabToolTip( 2, i18n( "Tracks that have been excluded from "
291 "synchronization due to ambiguity" ) );
292 setHeaderSizePoliciesFromModel( excludedTreeView->header(), model );
293 excludedFilterCombo->setCurrentIndex( 0 );
294 m_excludedProxyModel->sort( 0, Qt::AscendingOrder );
295 }
296 }
297
298 void
setTracksToScrobble(const TrackList & tracksToScrobble,const QList<ScrobblingServicePtr> & services)299 MatchedTracksPage::setTracksToScrobble( const TrackList &tracksToScrobble,
300 const QList<ScrobblingServicePtr> &services )
301 {
302 int tracks = tracksToScrobble.count();
303 int plays = 0;
304 foreach( const TrackPtr &track, tracksToScrobble )
305 {
306 plays += track->recentPlayCount();
307 }
308 QStringList serviceNames;
309 foreach( const ScrobblingServicePtr &service, services )
310 {
311 serviceNames << "<b>" + service->prettyName() + "</b>";
312 }
313
314 if( plays )
315 {
316 QString playsText = i18np( "<b>One</b> play", "<b>%1</b> plays", plays );
317 QString text = i18ncp( "%2 is the 'X plays message above'",
318 "%2 of <b>one</b> track will be scrobbled to %3.",
319 "%2 of <b>%1</b> tracks will be scrobbled to %3.", tracks, playsText,
320 serviceNames.join( i18nc( "comma between list words", ", " ) ) );
321 scrobblingLabel->setText( text );
322 scrobblingGroupBox->show();
323 }
324 else
325 scrobblingGroupBox->hide();
326 }
327
328 void
changeMatchedTracksFilter(int index)329 MatchedTracksPage::changeMatchedTracksFilter( int index )
330 {
331 int filter = matchedFilterCombo->itemData( index ).toInt();
332 m_matchedProxyModel->setTupleFilter( filter );
333 }
334
335 void
changeUniqueTracksProvider(int index)336 MatchedTracksPage::changeUniqueTracksProvider( int index )
337 {
338 ProviderPtr provider = uniqueFilterCombo->itemData( index ).value<ProviderPtr>();
339 m_uniqueProxyModel->setSourceModel( m_uniqueTracksModels.value( provider ) );
340 // trigger re-sort, Qt doesn't do that automatically apparently
341 m_uniqueProxyModel->sort( m_uniqueProxyModel->sortColumn(), m_uniqueProxyModel->sortOrder() );
342 }
343
344 void
changeExcludedTracksProvider(int index)345 MatchedTracksPage::changeExcludedTracksProvider( int index )
346 {
347 ProviderPtr provider = excludedFilterCombo->itemData( index ).value<ProviderPtr>();
348 m_excludedProxyModel->setSourceModel( m_excludedTracksModels.value( provider ) );
349 // trigger re-sort, Qt doesn't do that automatically apparently
350 m_excludedProxyModel->sort( m_excludedProxyModel->sortColumn(), m_excludedProxyModel->sortOrder() );
351 }
352
353 void
refreshMatchedStatusText()354 MatchedTracksPage::refreshMatchedStatusText()
355 {
356 refreshStatusTextHelper( m_matchedProxyModel, matchedStatusBar );
357 }
358
359 void
refreshUniqueStatusText()360 MatchedTracksPage::refreshUniqueStatusText()
361 {
362 refreshStatusTextHelper( m_uniqueProxyModel, uniqueStatusBar );
363 }
364
365 void
refreshExcludedStatusText()366 MatchedTracksPage::refreshExcludedStatusText()
367 {
368 refreshStatusTextHelper( m_excludedProxyModel, excludedStatusBar );
369 }
370
371 void
refreshStatusTextHelper(QSortFilterProxyModel * topModel,QLabel * label)372 MatchedTracksPage::refreshStatusTextHelper( QSortFilterProxyModel *topModel , QLabel *label )
373 {
374 int bottomModelRows = topModel->sourceModel() ?
375 topModel->sourceModel()->rowCount() : 0;
376 int topModelRows = topModel->rowCount();
377
378 QString bottomText = i18np( "%1 track", "%1 tracks", bottomModelRows );
379 if( topModelRows == bottomModelRows )
380 label->setText( bottomText );
381 else
382 {
383 QString text = i18nc( "%2 is the above '%1 track(s)' message", "Showing %1 out "
384 "of %2", topModelRows, bottomText );
385 label->setText( text );
386 }
387 }
388
389 void
rememberExpandedState(const QModelIndex & parent,int start,int end)390 MatchedTracksPage::rememberExpandedState( const QModelIndex &parent, int start, int end )
391 {
392 if( parent.isValid() )
393 return;
394 for( int topModelRow = start; topModelRow <= end; topModelRow++ )
395 {
396 QModelIndex topModelIndex = m_matchedProxyModel->index( topModelRow, 0 );
397 int bottomModelRow = m_matchedProxyModel->mapToSource( topModelIndex ).row();
398 if( matchedTreeView->isExpanded( topModelIndex ) )
399 m_expandedTuples.insert( bottomModelRow );
400 else
401 m_expandedTuples.remove( bottomModelRow );
402 }
403 }
404
405 void
restoreExpandedState(const QModelIndex & parent,int start,int end)406 MatchedTracksPage::restoreExpandedState( const QModelIndex &parent, int start, int end )
407 {
408 if( parent.isValid() )
409 return;
410 for( int topModelRow = start; topModelRow <= end; topModelRow++ )
411 {
412 QModelIndex topIndex = m_matchedProxyModel->index( topModelRow, 0 );
413 int bottomModelRow = m_matchedProxyModel->mapToSource( topIndex ).row();
414 if( m_expandedTuples.contains( bottomModelRow ) )
415 matchedTreeView->expand( topIndex );
416 }
417 }
418
419 void
takeRatingsFrom()420 MatchedTracksPage::takeRatingsFrom()
421 {
422 QAction *action = qobject_cast<QAction *>( sender() );
423 if( !action )
424 {
425 warning() << __PRETTY_FUNCTION__ << "must only be called from QAction";
426 return;
427 }
428
429 // provider may be null, it means "reset all ratings to undecided"
430 ProviderPtr provider = action->data().value<ProviderPtr>();
431 m_matchedTracksModel->takeRatingsFrom( provider );
432 }
433
434 void
includeLabelsFrom()435 MatchedTracksPage::includeLabelsFrom()
436 {
437 QAction *action = qobject_cast<QAction *>( sender() );
438 if( !action )
439 {
440 warning() << __PRETTY_FUNCTION__ << "must only be called from QAction";
441 return;
442 }
443
444 ProviderPtr provider = action->data().value<ProviderPtr>();
445 if( provider ) // no sense with null provider
446 m_matchedTracksModel->includeLabelsFrom( provider );
447 }
448
449 void
excludeLabelsFrom()450 MatchedTracksPage::excludeLabelsFrom()
451 {
452 QAction *action = qobject_cast<QAction *>( sender() );
453 if( !action )
454 {
455 warning() << __PRETTY_FUNCTION__ << "must only be called from QAction";
456 return;
457 }
458
459 // provider may be null, it means "reset all labels to undecided"
460 ProviderPtr provider = action->data().value<ProviderPtr>();
461 m_matchedTracksModel->excludeLabelsFrom( provider );
462 }
463
464 void
expand(int onlyWithTupleFlags)465 MatchedTracksPage::expand( int onlyWithTupleFlags )
466 {
467 if( onlyWithTupleFlags < 0 )
468 {
469 QAction *action = qobject_cast<QAction *>( sender() );
470 if( action )
471 onlyWithTupleFlags = action->data().toInt();
472 else
473 onlyWithTupleFlags = 0;
474 }
475
476 for( int i = 0; i < m_matchedProxyModel->rowCount(); i++ )
477 {
478 QModelIndex idx = m_matchedProxyModel->index( i, 0 );
479 if( matchedTreeView->isExpanded( idx ) )
480 continue;
481
482 int flags = idx.data( MatchedTracksModel::TupleFlagsRole ).toInt();
483 if( ( flags & onlyWithTupleFlags ) == onlyWithTupleFlags )
484 matchedTreeView->expand( idx );
485 }
486 }
487
488 void
collapse()489 MatchedTracksPage::collapse()
490 {
491 int excludingFlags;
492 QAction *action = qobject_cast<QAction *>( sender() );
493 if( action )
494 excludingFlags = action->data().toInt();
495 else
496 excludingFlags = 0;
497
498 for( int i = 0; i < m_matchedProxyModel->rowCount(); i++ )
499 {
500 QModelIndex idx = m_matchedProxyModel->index( i, 0 );
501 if( !matchedTreeView->isExpanded( idx ) )
502 continue;
503
504 int flags = idx.data( MatchedTracksModel::TupleFlagsRole ).toInt();
505 if( ( flags & excludingFlags ) == 0 )
506 matchedTreeView->collapse( idx );
507 }
508 }
509
510 void
openConfiguration()511 MatchedTracksPage::openConfiguration()
512 {
513 App *app = pApp;
514 if( app )
515 app->slotConfigAmarok( QStringLiteral("MetadataConfig") );
516 }
517
518 void
setHeaderSizePoliciesFromModel(QHeaderView * header,QAbstractItemModel * model)519 MatchedTracksPage::setHeaderSizePoliciesFromModel( QHeaderView *header, QAbstractItemModel *model )
520 {
521 for( int column = 0; column < model->columnCount(); column++ )
522 {
523 QVariant headerData = model->headerData( column, Qt::Horizontal,
524 CommonModel::ResizeModeRole );
525 QHeaderView::ResizeMode mode = QHeaderView::ResizeMode( headerData.toInt() );
526 header->setSectionResizeMode( column, mode );
527 }
528 }
529