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