1 /**
2 * \file filelist.cpp
3 * List of files to operate on.
4 *
5 * \b Project: Kid3
6 * \author Urs Fleisch
7 * \date 9 Jan 2003
8 *
9 * Copyright (C) 2003-2018 Urs Fleisch
10 *
11 * This file is part of Kid3.
12 *
13 * Kid3 is free software; you can redistribute it and/or modify
14 * it under the terms of the GNU General Public License as published by
15 * the Free Software Foundation; either version 2 of the License, or
16 * (at your option) any later version.
17 *
18 * Kid3 is distributed in the hope that it will be useful,
19 * but WITHOUT ANY WARRANTY; without even the implied warranty of
20 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 * GNU General Public License for more details.
22 *
23 * You should have received a copy of the GNU General Public License
24 * along with this program. If not, see <http://www.gnu.org/licenses/>.
25 */
26
27 #include "filelist.h"
28 #include <QFileInfo>
29 #include <QDir>
30 #include <QStringList>
31 #include <QUrl>
32 #include <QMenu>
33 #include <QHeaderView>
34 #include <QDesktopServices>
35 #include <QMouseEvent>
36 #include <QDialog>
37 #include <QLayout>
38 #include <QPushButton>
39 #include <QTextCursor>
40 #include <QTextEdit>
41 #include <QMessageBox>
42 #include <QCoreApplication>
43 #include "fileproxymodel.h"
44 #include "modeliterator.h"
45 #include "taggedfile.h"
46 #include "basemainwindow.h"
47 #include "useractionsconfig.h"
48 #include "guiconfig.h"
49 #include "playlistconfig.h"
50 #include "externalprocess.h"
51 #include "commandformatreplacer.h"
52 #include "config.h"
53
54 namespace {
55
56 /**
57 * Dialog to show output from external process.
58 */
59 class OutputViewer : public QDialog, public ExternalProcess::IOutputViewer {
60 public:
61 /**
62 * Constructor.
63 *
64 * @param parent parent widget
65 */
66 explicit OutputViewer(QWidget* parent);
67
68 /**
69 * Destructor.
70 */
71 virtual ~OutputViewer() override;
72
73 /**
74 * Set caption.
75 * @param title caption
76 */
77 virtual void setCaption(const QString& title) override;
78
79 /**
80 * Append text.
81 */
82 virtual void append(const QString& text) override;
83
84 /**
85 * Scroll text to bottom.
86 */
87 virtual void scrollToBottom() override;
88
89 private:
90 QTextEdit* m_textEdit;
91 };
92
93 /**
94 * Constructor.
95 *
96 * @param parent parent widget
97 */
OutputViewer(QWidget * parent)98 OutputViewer::OutputViewer(QWidget* parent) : QDialog(parent)
99 {
100 setObjectName(QLatin1String("OutputViewer"));
101 setModal(false);
102 auto vlayout = new QVBoxLayout(this);
103 m_textEdit = new QTextEdit(this);
104 m_textEdit->setReadOnly(true);
105 m_textEdit->setLineWrapMode(QTextEdit::NoWrap);
106 m_textEdit->setStyleSheet(QLatin1String("font-family: \"Courier\";"));
107 vlayout->addWidget(m_textEdit);
108 auto buttonLayout = new QHBoxLayout;
109 QPushButton* clearButton = new QPushButton(
110 QCoreApplication::translate("FileList", "C&lear"), this);
111 auto hspacer = new QSpacerItem(16, 0, QSizePolicy::Expanding,
112 QSizePolicy::Minimum);
113 QPushButton* closeButton = new QPushButton(
114 QCoreApplication::translate("FileList", "&Close"), this);
115 buttonLayout->addWidget(clearButton);
116 buttonLayout->addItem(hspacer);
117 buttonLayout->addWidget(closeButton);
118 connect(clearButton, &QAbstractButton::clicked, m_textEdit, &QTextEdit::clear);
119 connect(closeButton, &QAbstractButton::clicked, this, &QDialog::accept);
120 vlayout->addLayout(buttonLayout);
121 resize(600, 424);
122 }
123
124 /**
125 * Destructor.
126 */
~OutputViewer()127 OutputViewer::~OutputViewer()
128 {
129 // not inline or default to silence weak-vtables warning
130 }
131
132 /**
133 * Set caption.
134 * @param title caption
135 */
setCaption(const QString & title)136 void OutputViewer::setCaption(const QString& title)
137 {
138 setWindowTitle(title);
139 show();
140 raise();
141 }
142
143 /**
144 * Append text.
145 */
append(const QString & text)146 void OutputViewer::append(const QString& text)
147 {
148 if (text.isEmpty())
149 return;
150
151 QString txt(text);
152 txt.replace(QLatin1String("\r\n"), QLatin1String("\n"));
153 int startPos = 0;
154 int txtLen = txt.length();
155 while (startPos < txtLen) {
156 QChar ch;
157 int len;
158 int crLfPos = txt.indexOf(QRegularExpression(QLatin1String("[\\r\\n]")), startPos);
159 if (crLfPos >= startPos) {
160 ch = txt.at(crLfPos);
161 len = crLfPos - startPos;
162 } else {
163 ch = QChar();
164 len = -1;
165 }
166 QString line(txt.mid(startPos, len));
167 if (!m_textEdit->textCursor().atBlockEnd()) {
168 QTextCursor cursor = m_textEdit->textCursor();
169 cursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor,
170 line.length());
171 m_textEdit->setTextCursor(cursor);
172 }
173 m_textEdit->insertPlainText(line);
174 if (ch == QLatin1Char('\r')) {
175 m_textEdit->moveCursor(QTextCursor::StartOfLine);
176 } else if (ch == QLatin1Char('\n')) {
177 m_textEdit->moveCursor(QTextCursor::EndOfLine);
178 m_textEdit->insertPlainText(ch);
179 }
180 if (len == -1) {
181 break;
182 }
183 startPos = crLfPos + 1;
184 }
185 }
186
187 /**
188 * Scroll text to bottom.
189 */
scrollToBottom()190 void OutputViewer::scrollToBottom()
191 {
192 m_textEdit->moveCursor(QTextCursor::End);
193 }
194
195
196 /**
197 * Create a name for an action.
198 * @param text user action text
199 * @return name for user action.
200 */
nameForAction(const QString & text)201 QString nameForAction(const QString& text)
202 {
203 QString name;
204 for (auto cit = text.constBegin(); cit != text.constEnd(); ++cit) {
205 if (cit->toLatin1() == '\0') {
206 continue;
207 }
208 if (cit->isLetterOrNumber()) {
209 name.append(cit->toLower());
210 } else if (cit->isSpace()) {
211 name.append(QLatin1Char('_'));
212 }
213 }
214 if (!name.isEmpty()) {
215 name.prepend(QLatin1String("user_"));
216 }
217 return name;
218 }
219
220 }
221
222
223 /**
224 * Constructor.
225 * @param parent parent widget
226 * @param mainWin main window
227 */
FileList(QWidget * parent,BaseMainWindowImpl * mainWin)228 FileList::FileList(QWidget* parent, BaseMainWindowImpl* mainWin)
229 : ConfigurableTreeView(parent), m_mainWin(mainWin),
230 m_renameAction(nullptr), m_deleteAction(nullptr)
231 {
232 setObjectName(QLatin1String("FileList"));
233 setSelectionMode(ExtendedSelection);
234 setContextMenuPolicy(Qt::CustomContextMenu);
235 connect(this, &QWidget::customContextMenuRequested,
236 this, &FileList::customContextMenu);
237 connect(this, &QAbstractItemView::doubleClicked,
238 this, &FileList::onDoubleClicked);
239 }
240
241 /**
242 * Destructor.
243 */
~FileList()244 FileList::~FileList()
245 {
246 // Must not be inline because of forwared declared QScopedPointer.
247 }
248
249 /**
250 * Returns the recommended size for the widget.
251 * @return recommended size.
252 */
sizeHint() const253 QSize FileList::sizeHint() const
254 {
255 return QSize(fontMetrics().maxWidth() * 25,
256 QTreeView::sizeHint().height());
257 }
258
259 /**
260 * Enable dragging if the item is pressed at the left icon side.
261 * @param event mouse event
262 */
mousePressEvent(QMouseEvent * event)263 void FileList::mousePressEvent(QMouseEvent* event)
264 {
265 QPoint pos = event->pos();
266 if (pos.x() < 80) {
267 QModelIndex idx = indexAt(pos);
268 if (const auto fsModel =
269 qobject_cast<const FileProxyModel*>(idx.model())) {
270 if (!FileProxyModel::getTaggedFileOfIndex(idx)) {
271 // The file possibly dragged is not a tagged file, e.g. an image file.
272 // Make it the only draggable file in order to keep the selection of
273 // tagged files while still being able to drag an image file on them.
274 const_cast<FileProxyModel*>(fsModel)->setExclusiveDraggableIndex(idx);
275 setSelectionMode(MultiSelection);
276 } else {
277 const_cast<FileProxyModel*>(fsModel)->setExclusiveDraggableIndex(
278 QPersistentModelIndex());
279 setSelectionMode(ExtendedSelection);
280 }
281 }
282 setDragEnabled(true);
283 } else {
284 setDragEnabled(false);
285 setSelectionMode(ExtendedSelection);
286 }
287 ConfigurableTreeView::mousePressEvent(event);
288 }
289
290 /**
291 * Called when a drag operation is started.
292 * Reimplemented to close all tagged files before being dropped to another
293 * application, which would not be able to open them on Windows.
294 * @param supportedActions drop actions
295 */
startDrag(Qt::DropActions supportedActions)296 void FileList::startDrag(Qt::DropActions supportedActions)
297 {
298 const auto indexes = selectedIndexes();
299 for (const QModelIndex& index : indexes) {
300 const QAbstractItemModel* mdl = index.model();
301 if (index.column() == 0 &&
302 mdl && (mdl->flags(index) & Qt::ItemIsDragEnabled)) {
303 if (TaggedFile* tf = FileProxyModel::getTaggedFileOfIndex(index)) {
304 tf->closeFileHandle();
305 }
306 }
307 }
308 ConfigurableTreeView::startDrag(supportedActions);
309 }
310
311 /**
312 * Init the user actions for the context menu.
313 */
initUserActions()314 void FileList::initUserActions()
315 {
316 QMap<QString, QAction*> oldUserActions;
317 oldUserActions.swap(m_userActions);
318 int id = 0;
319 const QList<UserActionsConfig::MenuCommand> commands =
320 UserActionsConfig::instance().contextMenuCommands();
321 for (auto it = commands.constBegin(); it != commands.constEnd(); ++it) {
322 const QString text((*it).getName());
323 const QString name = nameForAction(text);
324 if (!name.isEmpty() && it->getCommand() != QLatin1String("@beginmenu")) {
325 QAction* action = oldUserActions.take(name);
326 if (!action) {
327 action = new QAction(text, this);
328 connect(action, &QAction::triggered, this, &FileList::executeSenderAction);
329 emit userActionAdded(name, action);
330 }
331 action->setData(id);
332 m_userActions.insert(name, action);
333 }
334 ++id;
335 }
336 for (auto it = oldUserActions.constBegin(); it != oldUserActions.constEnd(); ++it) {
337 emit userActionRemoved(it.key(), it.value());
338 }
339 }
340
341 /**
342 * Display a context menu with operations for selected files.
343 *
344 * @param index index of item
345 * @param pos position where context menu is drawn on screen
346 */
contextMenu(const QModelIndex & index,const QPoint & pos)347 void FileList::contextMenu(const QModelIndex& index, const QPoint& pos)
348 {
349 if (index.isValid()) {
350 QString path;
351 bool isPlaylist = false;
352 if (const auto model =
353 qobject_cast<const FileProxyModel*>(index.model())) {
354 path = model->filePath(index);
355 PlaylistConfig::formatFromFileExtension(path, &isPlaylist);
356 }
357 QMenu menu(this);
358 #if QT_VERSION >= 0x050600
359 menu.addAction(tr("&Expand all"), m_mainWin,
360 &BaseMainWindowImpl::expandFileList);
361 menu.addAction(tr("&Collapse all"), this, &QTreeView::collapseAll);
362 #else
363 menu.addAction(tr("&Expand all"), m_mainWin, SLOT(expandFileList()));
364 menu.addAction(tr("&Collapse all"), this, SLOT(collapseAll()));
365 #endif
366 if (m_renameAction) {
367 menu.addAction(m_renameAction);
368 }
369 if (m_deleteAction) {
370 menu.addAction(m_deleteAction);
371 }
372 #ifdef HAVE_QTMULTIMEDIA
373 #if QT_VERSION >= 0x050600
374 menu.addAction(tr("&Play"), m_mainWin, &BaseMainWindowImpl::slotPlayAudio);
375 #else
376 menu.addAction(tr("&Play"), m_mainWin, SLOT(slotPlayAudio()));
377 #endif
378 #endif
379 if (isPlaylist) {
380 QAction* editPlaylistAction = new QAction(tr("E&dit"), &menu);
381 editPlaylistAction->setData(path);
382 connect(editPlaylistAction, &QAction::triggered,
383 this, &FileList::editPlaylist);
384 menu.addAction(editPlaylistAction);
385 }
386 #if QT_VERSION >= 0x050600
387 menu.addAction(tr("&Open"), this, &FileList::openFile);
388 menu.addAction(tr("Open Containing &Folder"),
389 this, &FileList::openContainingFolder);
390 #else
391 menu.addAction(tr("&Open"), this, SLOT(openFile()));
392 menu.addAction(tr("Open Containing &Folder"),
393 this, SLOT(openContainingFolder()));
394 #endif
395 QMenu* userMenu = &menu;
396 QList<UserActionsConfig::MenuCommand> commands =
397 UserActionsConfig::instance().contextMenuCommands();
398 for (auto it = commands.constBegin(); it != commands.constEnd(); ++it) {
399 const QString text((*it).getName());
400 const QString name = nameForAction(text);
401 if (!text.isEmpty()) {
402 if (it->getCommand() == QLatin1String("@beginmenu")) {
403 userMenu = userMenu->addMenu(text);
404 } else if (QAction* action = m_userActions.value(name)) {
405 userMenu->addAction(action);
406 }
407 } else if (it->getCommand() == QLatin1String("@separator")) {
408 userMenu->addSeparator();
409 } else if (it->getCommand() == QLatin1String("@endmenu")) {
410 if (auto parentMenu = qobject_cast<QMenu*>(userMenu->parent())) {
411 userMenu = parentMenu;
412 }
413 }
414 }
415 menu.setMouseTracking(true);
416 menu.exec(pos);
417 }
418 }
419
420 /**
421 * Format a string list from the selected files.
422 * Supported format fields:
423 * Those supported by FrameFormatReplacer::getReplacement(),
424 * when prefixed with u, encoded as URL
425 * %f filename
426 * %F list of files
427 * %uf URL of single file
428 * %uF list of URLs
429 * %d directory name
430 * %b the web browser set in the configuration
431 * %q the base directory for QML files
432 *
433 * @todo %f and %F are full paths, which is inconsistent with the
434 * export format strings but compatible with .desktop files.
435 * %d is duration in export format.
436 * The export codes should be changed.
437 *
438 * @param format format specification
439 *
440 * @return formatted string list.
441 */
formatStringList(const QStringList & format)442 QStringList FileList::formatStringList(const QStringList& format)
443 {
444 QStringList files;
445 TaggedFile* firstSelectedFile = nullptr;
446 const QModelIndexList selItems(selectionModel()
447 ? selectionModel()->selectedRows() : QModelIndexList());
448 for (const QModelIndex& index : selItems) {
449 if (TaggedFile* taggedFile = FileProxyModel::getTaggedFileOfIndex(index)) {
450 if (!firstSelectedFile) {
451 firstSelectedFile = taggedFile;
452 }
453 files.append(taggedFile->getAbsFilename());
454 }
455 }
456
457 QString dirPath;
458 if (files.isEmpty() && !selItems.isEmpty()) {
459 dirPath = FileProxyModel::getPathIfIndexOfDir(selItems.first());
460 if (!dirPath.isNull()) {
461 files.append(dirPath);
462 firstSelectedFile = TaggedFileOfDirectoryIterator::first(selItems.first());
463 }
464 }
465
466 FrameCollection frames;
467 QStringList fmt;
468 for (auto it = format.constBegin(); it != format.constEnd(); ++it) {
469 if ((*it).indexOf(QLatin1Char('%')) == -1) {
470 fmt.push_back(*it);
471 } else {
472 if (*it == QLatin1String("%F") || *it == QLatin1String("%{files}")) {
473 // list of files
474 fmt += files;
475 } else if (*it == QLatin1String("%uF") || *it == QLatin1String("%{urls}")) {
476 // list of URLs or URL
477 QUrl url;
478 url.setScheme(QLatin1String("file"));
479 for (auto fit = files.constBegin(); fit != files.constEnd(); ++fit) {
480 url.setPath(*fit);
481 fmt.push_back(url.toString());
482 }
483 } else {
484 if (firstSelectedFile) {
485 // use merged tags to format string
486 frames.clear();
487 for (Frame::TagNumber tagNr : Frame::allTagNumbers()) {
488 if (frames.empty()) {
489 firstSelectedFile->getAllFrames(tagNr, frames);
490 } else {
491 FrameCollection frames1;
492 firstSelectedFile->getAllFrames(tagNr, frames1);
493 frames.merge(frames1);
494 }
495 }
496 }
497 QString str(*it);
498 str.replace(QLatin1String("%uf"), QLatin1String("%{url}"));
499 CommandFormatReplacer cfr(frames, str, files, !dirPath.isNull());
500 cfr.replacePercentCodes(FrameFormatReplacer::FSF_SupportUrlEncode);
501 fmt.push_back(cfr.getString());
502 }
503 }
504 }
505 return fmt;
506 }
507
508 /**
509 * Execute a context menu command.
510 *
511 * @param id command ID
512 */
executeContextCommand(int id)513 void FileList::executeContextCommand(int id)
514 {
515 if (id < static_cast<int>(
516 UserActionsConfig::instance().contextMenuCommands().size())) {
517 QStringList args;
518 const UserActionsConfig::MenuCommand& menuCmd =
519 UserActionsConfig::instance().contextMenuCommands().at(id);
520 QString cmd = menuCmd.getCommand();
521
522 int len = cmd.length();
523 int end = 0;
524 while (end < len) {
525 int begin = end;
526 while (begin < len && cmd[begin] == QLatin1Char(' ')) ++begin;
527 if (begin >= len) break;
528 if (cmd[begin] == QLatin1Char('"')) {
529 ++begin;
530 QString str;
531 while (begin < len) {
532 if (cmd[begin] == QLatin1Char('\\') && begin + 1 < len &&
533 (cmd[begin + 1] == QLatin1Char('\\') ||
534 cmd[begin + 1] == QLatin1Char('"'))) {
535 ++begin;
536 } else if (cmd[begin] == QLatin1Char('"')) {
537 break;
538 }
539 str += cmd[begin];
540 ++begin;
541 }
542 args.push_back(str);
543 end = begin;
544 } else {
545 end = cmd.indexOf(QLatin1Char(' '), begin + 1);
546 if (end == -1) end = len;
547 args.push_back(cmd.mid(begin, end - begin));
548 }
549 ++end;
550 }
551
552 args = formatStringList(args);
553
554 if (!m_process) {
555 m_process.reset(new ExternalProcess(m_mainWin->app(), this));
556 }
557 if (menuCmd.outputShown() && !m_process->outputViewer()) {
558 m_process->setOutputViewer(new OutputViewer(this));
559 }
560 if (menuCmd.mustBeConfirmed() && !args.isEmpty()) {
561 if (QMessageBox::question(
562 this, menuCmd.getName(),
563 tr("Execute ") + args.join(QLatin1String(" ")) + QLatin1Char('?'),
564 QMessageBox::Ok, QMessageBox::Cancel) != QMessageBox::Ok) {
565 return;
566 }
567 }
568 if (!m_process->launchCommand(menuCmd.getName(), args,
569 menuCmd.outputShown())) {
570 QMessageBox::warning(
571 this, menuCmd.getName(),
572 tr("Could not execute ") + args.join(QLatin1String(" ")),
573 QMessageBox::Ok, Qt::NoButton);
574 }
575 }
576 }
577
578 /**
579 * Execute a context menu action.
580 *
581 * @param action action of selected menu, 0 to use sender() action
582 */
executeAction(QAction * action)583 void FileList::executeAction(QAction* action)
584 {
585 if (!action) {
586 action = qobject_cast<QAction*>(sender());
587 }
588 if (action) {
589 bool ok;
590 int id = action->data().toInt(&ok);
591 if (ok) {
592 executeContextCommand(id);
593 return;
594 }
595
596 QString name = action->text().remove(QLatin1Char('&'));
597 id = 0;
598 QList<UserActionsConfig::MenuCommand> commands =
599 UserActionsConfig::instance().contextMenuCommands();
600 for (auto it = commands.constBegin(); it != commands.constEnd(); ++it) {
601 if (name == (*it).getName()) {
602 executeContextCommand(id);
603 break;
604 }
605 ++id;
606 }
607 }
608 }
609
610 /**
611 * Execute context menu action which sent signal.
612 * Same as executeAction() with default arguments, provided for functor-based
613 * connections.
614 */
executeSenderAction()615 void FileList::executeSenderAction()
616 {
617 executeAction(nullptr);
618 }
619
620 /**
621 * Display a custom context menu with operations for selected files.
622 *
623 * @param pos position where context menu is drawn on screen
624 */
customContextMenu(const QPoint & pos)625 void FileList::customContextMenu(const QPoint& pos)
626 {
627 contextMenu(currentIndex(), mapToGlobal(pos));
628 }
629
630 /**
631 * Handle double click to file.
632 *
633 * @param index model index of item
634 */
onDoubleClicked(const QModelIndex & index)635 void FileList::onDoubleClicked(const QModelIndex& index)
636 {
637 if (FileProxyModel::getTaggedFileOfIndex(index)) {
638 if (GuiConfig::instance().playOnDoubleClick()) {
639 m_mainWin->slotPlayAudio();
640 }
641 } else if (const auto model =
642 qobject_cast<const FileProxyModel*>(index.model())) {
643 QString path = model->filePath(index);
644 bool isPlaylist = false;
645 PlaylistConfig::formatFromFileExtension(path, &isPlaylist);
646 if (isPlaylist) {
647 m_mainWin->showPlaylistEditDialog(path);
648 }
649 }
650 }
651
652 /**
653 * Called when "Edit" action is called from context menu.
654 */
editPlaylist()655 void FileList::editPlaylist()
656 {
657 if (auto action = qobject_cast<QAction*>(sender())) {
658 m_mainWin->showPlaylistEditDialog(action->data().toString());
659 }
660 }
661
662 /**
663 * Set rename action.
664 * @param action rename action
665 */
setRenameAction(QAction * action)666 void FileList::setRenameAction(QAction* action)
667 {
668 if (m_renameAction) {
669 removeAction(m_renameAction);
670 }
671 m_renameAction = action;
672 if (m_renameAction) {
673 addAction(m_renameAction);
674 }
675 }
676
677 /**
678 * Set delete action.
679 * @param action delete action
680 */
setDeleteAction(QAction * action)681 void FileList::setDeleteAction(QAction* action)
682 {
683 if (m_deleteAction) {
684 removeAction(m_deleteAction);
685 }
686 m_deleteAction = action;
687 if (m_deleteAction) {
688 addAction(m_deleteAction);
689 }
690 }
691
692 /**
693 * Open with standard application.
694 */
openFile()695 void FileList::openFile()
696 {
697 if (QItemSelectionModel* selModel = selectionModel()) {
698 if (const auto fsModel =
699 qobject_cast<const FileProxyModel*>(selModel->model())) {
700 const auto indexes = selModel->selectedRows();
701 for (const QModelIndex& index : indexes) {
702 QDesktopServices::openUrl(
703 QUrl::fromLocalFile(fsModel->filePath(index)));
704 }
705 }
706 }
707 }
708
709 /**
710 * Open containing folder.
711 */
openContainingFolder()712 void FileList::openContainingFolder()
713 {
714 if (QItemSelectionModel* selModel = selectionModel()) {
715 QModelIndexList indexes = selModel->selectedRows();
716 if (!indexes.isEmpty()) {
717 const FileProxyModel* fsModel;
718 QModelIndex index = indexes.first().parent();
719 if (index.isValid() &&
720 (fsModel = qobject_cast<const FileProxyModel*>(index.model())) != nullptr &&
721 fsModel->isDir(index)) {
722 QDesktopServices::openUrl(
723 QUrl::fromLocalFile(fsModel->filePath(index)));
724 }
725 }
726 }
727 }
728