1 /*
2  * Copyright (C) by Klaas Freitag <freitag@owncloud.com>
3  *
4  * This program is free software; you can redistribute it and/or modify
5  * it under the terms of the GNU General Public License as published by
6  * the Free Software Foundation; either version 2 of the License, or
7  * (at your option) any later version.
8  *
9  * This program is distributed in the hope that it will be useful, but
10  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
11  * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
12  * for more details.
13  */
14 
15 #include <QtGui>
16 #include <QtWidgets>
17 
18 #include "issueswidget.h"
19 #include "configfile.h"
20 #include "syncresult.h"
21 #include "syncengine.h"
22 #include "logger.h"
23 #include "theme.h"
24 #include "folderman.h"
25 #include "syncfileitem.h"
26 #include "folder.h"
27 #include "openfilemanager.h"
28 #include "activityitemdelegate.h"
29 #include "protocolwidget.h"
30 #include "accountstate.h"
31 #include "account.h"
32 #include "accountmanager.h"
33 #include "common/syncjournalfilerecord.h"
34 #include "elidedlabel.h"
35 
36 
37 #include "ui_issueswidget.h"
38 
39 #include <climits>
40 
41 namespace OCC {
42 
43 /**
44  * If more issues are reported than this they will not show up
45  * to avoid performance issues around sorting this many issues.
46  */
47 static const int maxIssueCount = 50000;
48 
pathsWithIssuesKey(const ProtocolItem::ExtraData & data)49 static QPair<QString, QString> pathsWithIssuesKey(const ProtocolItem::ExtraData &data)
50 {
51     return qMakePair(data.folderName, data.path);
52 }
53 
IssuesWidget(QWidget * parent)54 IssuesWidget::IssuesWidget(QWidget *parent)
55     : QWidget(parent)
56     , _ui(new Ui::IssuesWidget)
57 {
58     _ui->setupUi(this);
59 
60     connect(ProgressDispatcher::instance(), &ProgressDispatcher::progressInfo,
61         this, &IssuesWidget::slotProgressInfo);
62     connect(ProgressDispatcher::instance(), &ProgressDispatcher::itemCompleted,
63         this, &IssuesWidget::slotItemCompleted);
64     connect(ProgressDispatcher::instance(), &ProgressDispatcher::syncError,
65         this, &IssuesWidget::addError);
66 
67     connect(_ui->_treeWidget, &QTreeWidget::itemActivated, this, &IssuesWidget::slotOpenFile);
68     connect(_ui->copyIssuesButton, &QAbstractButton::clicked, this, &IssuesWidget::copyToClipboard);
69 
70     _ui->_treeWidget->setContextMenuPolicy(Qt::CustomContextMenu);
71     connect(_ui->_treeWidget, &QTreeWidget::customContextMenuRequested, this, &IssuesWidget::slotItemContextMenu);
72 
73     connect(_ui->showIgnores, &QAbstractButton::toggled, this, &IssuesWidget::slotRefreshIssues);
74     connect(_ui->showWarnings, &QAbstractButton::toggled, this, &IssuesWidget::slotRefreshIssues);
75     connect(_ui->filterAccount, static_cast<void (QComboBox::*)(int)>(&QComboBox::currentIndexChanged), this, &IssuesWidget::slotRefreshIssues);
76     connect(_ui->filterAccount, static_cast<void (QComboBox::*)(int)>(&QComboBox::currentIndexChanged), this, &IssuesWidget::slotUpdateFolderFilters);
77     connect(_ui->filterFolder, static_cast<void (QComboBox::*)(int)>(&QComboBox::currentIndexChanged), this, &IssuesWidget::slotRefreshIssues);
78     for (auto account : AccountManager::instance()->accounts()) {
79         slotAccountAdded(account.data());
80     }
81     connect(AccountManager::instance(), &AccountManager::accountAdded,
82         this, &IssuesWidget::slotAccountAdded);
83     connect(AccountManager::instance(), &AccountManager::accountRemoved,
84         this, &IssuesWidget::slotAccountRemoved);
85     connect(FolderMan::instance(), &FolderMan::folderListChanged,
86         this, &IssuesWidget::slotUpdateFolderFilters);
87 
88 
89     // Adjust copyToClipboard() when making changes here!
90     QStringList header;
91     header << tr("Time");
92     header << tr("File");
93     header << tr("Folder");
94     header << tr("Issue");
95 
96     int timestampColumnExtra = 0;
97 #ifdef Q_OS_WIN
98     timestampColumnExtra = 20; // font metrics are broken on Windows, see #4721
99 #endif
100 
101     _ui->_treeWidget->setHeaderLabels(header);
102     int timestampColumnWidth =
103         ActivityItemDelegate::rowHeight() // icon
104         + _ui->_treeWidget->fontMetrics().boundingRect(ProtocolItem::timeString(QDateTime::currentDateTime())).width()
105         + timestampColumnExtra;
106     _ui->_treeWidget->setColumnWidth(0, timestampColumnWidth);
107     _ui->_treeWidget->setColumnWidth(1, 180);
108     _ui->_treeWidget->setColumnCount(4);
109     _ui->_treeWidget->setRootIsDecorated(false);
110     _ui->_treeWidget->setTextElideMode(Qt::ElideMiddle);
111     _ui->_treeWidget->header()->setObjectName("ActivityErrorListHeader");
112 #if defined(Q_OS_MAC)
113     _ui->_treeWidget->setMinimumWidth(400);
114 #endif
115 
116     _reenableSorting.setInterval(5000);
117     connect(&_reenableSorting, &QTimer::timeout, this,
118         [this]() { _ui->_treeWidget->setSortingEnabled(true); });
119 
120     _ui->_tooManyIssuesWarning->hide();
121     connect(this, &IssuesWidget::issueCountUpdated, this,
122         [this](int count) { _ui->_tooManyIssuesWarning->setVisible(count >= maxIssueCount); });
123 
124     _ui->_conflictHelp->hide();
125     _ui->_conflictHelp->setText(
126         tr("There were conflicts. <a href=\"%1\">Check the documentation on how to resolve them.</a>")
127             .arg(Theme::instance()->conflictHelpUrl()));
128 }
129 
~IssuesWidget()130 IssuesWidget::~IssuesWidget()
131 {
132     delete _ui;
133 }
134 
showEvent(QShowEvent * ev)135 void IssuesWidget::showEvent(QShowEvent *ev)
136 {
137     ConfigFile cfg;
138     cfg.restoreGeometryHeader(_ui->_treeWidget->header());
139 
140     // Sorting by section was newly enabled. But if we restore the header
141     // from a state where sorting was disabled, both of these flags will be
142     // false and sorting will be impossible!
143     _ui->_treeWidget->header()->setSectionsClickable(true);
144     _ui->_treeWidget->header()->setSortIndicatorShown(true);
145 
146     // Switch back to "first important, then by time" ordering
147     _ui->_treeWidget->sortByColumn(0, Qt::DescendingOrder);
148 
149     QWidget::showEvent(ev);
150 }
151 
hideEvent(QHideEvent * ev)152 void IssuesWidget::hideEvent(QHideEvent *ev)
153 {
154     ConfigFile cfg;
155     cfg.saveGeometryHeader(_ui->_treeWidget->header());
156     QWidget::hideEvent(ev);
157 }
158 
persistsUntilLocalDiscovery(QTreeWidgetItem * item)159 static bool persistsUntilLocalDiscovery(QTreeWidgetItem *item)
160 {
161     const auto data = ProtocolItem::extraData(item);
162     return data.status == SyncFileItem::Conflict
163         || (data.status == SyncFileItem::FileIgnored && data.direction == SyncFileItem::Up);
164 }
165 
cleanItems(const std::function<bool (QTreeWidgetItem *)> & shouldDelete)166 void IssuesWidget::cleanItems(const std::function<bool(QTreeWidgetItem *)> &shouldDelete)
167 {
168     _ui->_treeWidget->setSortingEnabled(false);
169 
170     // The issue list is a state, clear it and let the next sync fill it
171     // with ignored files and propagation errors.
172     int itemCnt = _ui->_treeWidget->topLevelItemCount();
173     for (int cnt = itemCnt - 1; cnt >= 0; cnt--) {
174         QTreeWidgetItem *item = _ui->_treeWidget->topLevelItem(cnt);
175         if (shouldDelete(item)) {
176             _pathsWithIssues.remove(pathsWithIssuesKey(ProtocolItem::extraData(item)));
177             delete item;
178         }
179     }
180 
181     _ui->_treeWidget->setSortingEnabled(true);
182 
183     // update the tabtext
184     emit(issueCountUpdated(_ui->_treeWidget->topLevelItemCount()));
185 }
186 
addItem(QTreeWidgetItem * item)187 void IssuesWidget::addItem(QTreeWidgetItem *item)
188 {
189     if (!item)
190         return;
191 
192     int count = _ui->_treeWidget->topLevelItemCount();
193     if (count >= maxIssueCount) {
194         delete item;
195         return;
196     }
197 
198     _ui->_treeWidget->setSortingEnabled(false);
199     _reenableSorting.start();
200 
201     // Insert item specific errors behind the others
202     int insertLoc = 0;
203     if (!item->text(1).isEmpty()) {
204         for (int i = 0; i < count; ++i) {
205             if (_ui->_treeWidget->topLevelItem(i)->text(1).isEmpty()) {
206                 insertLoc = i + 1;
207             } else {
208                 break;
209             }
210         }
211     }
212 
213     // Wipe any existing message for the same folder and path
214     auto newData = ProtocolItem::extraData(item);
215     if (_pathsWithIssues.contains(pathsWithIssuesKey(newData))) {
216         for (int i = 0; i < count; ++i) {
217             auto otherItem = _ui->_treeWidget->topLevelItem(i);
218             auto otherData = ProtocolItem::extraData(otherItem);
219             if (otherData.path == newData.path && otherData.folderName == newData.folderName) {
220                 delete otherItem;
221                 break;
222             }
223         }
224     }
225 
226     _ui->_treeWidget->insertTopLevelItem(insertLoc, item);
227     _pathsWithIssues.insert(pathsWithIssuesKey(newData));
228     item->setHidden(!shouldBeVisible(item, currentAccountFilter(), currentFolderFilter()));
229     emit issueCountUpdated(_ui->_treeWidget->topLevelItemCount());
230 }
231 
slotOpenFile(QTreeWidgetItem * item,int)232 void IssuesWidget::slotOpenFile(QTreeWidgetItem *item, int)
233 {
234     QString fileName = item->text(1);
235     if (Folder *folder = ProtocolItem::folder(item)) {
236         // folder->path() always comes back with trailing path
237         QString fullPath = folder->path() + fileName;
238         if (QFile(fullPath).exists()) {
239             showInFileManager(fullPath);
240         }
241     }
242 }
243 
slotProgressInfo(const QString & folder,const ProgressInfo & progress)244 void IssuesWidget::slotProgressInfo(const QString &folder, const ProgressInfo &progress)
245 {
246     if (progress.status() == ProgressInfo::Reconcile) {
247         // Wipe all non-persistent entries - as well as the persistent ones
248         // in cases where a local discovery was done.
249         auto f = FolderMan::instance()->folder(folder);
250         if (!f)
251             return;
252         const auto &engine = f->syncEngine();
253         const auto style = engine.lastLocalDiscoveryStyle();
254         cleanItems([&](QTreeWidgetItem *item) {
255             if (ProtocolItem::extraData(item).folderName != folder)
256                 return false;
257             if (style == LocalDiscoveryStyle::FilesystemOnly)
258                 return true;
259             if (!persistsUntilLocalDiscovery(item))
260                 return true;
261 
262             // Definitely wipe the entry if the file no longer exists
263             if (!QFileInfo(f->path() + ProtocolItem::extraData(item).path).exists())
264                 return true;
265 
266             auto path = QFileInfo(ProtocolItem::extraData(item).path).dir().path();
267             if (path == ".")
268                 path.clear();
269 
270             return engine.shouldDiscoverLocally(path);
271         });
272     }
273     if (progress.status() == ProgressInfo::Done) {
274         // We keep track very well of pending conflicts.
275         // Inform other components about them.
276         QStringList conflicts;
277         auto tree = _ui->_treeWidget;
278         for (int i = 0; i < tree->topLevelItemCount(); ++i) {
279             auto item = tree->topLevelItem(i);
280             auto data = ProtocolItem::extraData(item);
281             if (data.folderName == folder
282                 && data.status == SyncFileItem::Conflict) {
283                 conflicts.append(data.path);
284             }
285         }
286         emit ProgressDispatcher::instance()->folderConflicts(folder, conflicts);
287 
288         _ui->_conflictHelp->setHidden(Theme::instance()->conflictHelpUrl().isEmpty() || conflicts.isEmpty());
289     }
290 }
291 
slotItemCompleted(const QString & folder,const SyncFileItemPtr & item)292 void IssuesWidget::slotItemCompleted(const QString &folder, const SyncFileItemPtr &item)
293 {
294     if (!item->showInIssuesTab())
295         return;
296     QTreeWidgetItem *line = ProtocolItem::create(folder, *item);
297     if (!line)
298         return;
299     addItem(line);
300 }
301 
slotRefreshIssues()302 void IssuesWidget::slotRefreshIssues()
303 {
304     auto tree = _ui->_treeWidget;
305     auto filterFolderAlias = currentFolderFilter();
306     auto filterAccount = currentAccountFilter();
307 
308     for (int i = 0; i < tree->topLevelItemCount(); ++i) {
309         auto item = tree->topLevelItem(i);
310         item->setHidden(!shouldBeVisible(item, filterAccount, filterFolderAlias));
311     }
312 
313     _ui->_treeWidget->setColumnHidden(2, !filterFolderAlias.isEmpty());
314 }
315 
slotAccountAdded(AccountState * account)316 void IssuesWidget::slotAccountAdded(AccountState *account)
317 {
318     _ui->filterAccount->addItem(account->account()->displayName(), QVariant::fromValue(account));
319     updateAccountChoiceVisibility();
320 }
321 
slotAccountRemoved(AccountState * account)322 void IssuesWidget::slotAccountRemoved(AccountState *account)
323 {
324     for (int i = _ui->filterAccount->count() - 1; i >= 0; --i) {
325         if (account == _ui->filterAccount->itemData(i).value<AccountState *>())
326             _ui->filterAccount->removeItem(i);
327     }
328     updateAccountChoiceVisibility();
329 }
330 
slotItemContextMenu(const QPoint & pos)331 void IssuesWidget::slotItemContextMenu(const QPoint &pos)
332 {
333     auto item = _ui->_treeWidget->itemAt(pos);
334     if (!item)
335         return;
336     auto globalPos = _ui->_treeWidget->viewport()->mapToGlobal(pos);
337     ProtocolItem::openContextMenu(globalPos, item, this);
338 }
339 
updateAccountChoiceVisibility()340 void IssuesWidget::updateAccountChoiceVisibility()
341 {
342     bool visible = _ui->filterAccount->count() > 2;
343     _ui->filterAccount->setVisible(visible);
344     _ui->accountLabel->setVisible(visible);
345     slotUpdateFolderFilters();
346 }
347 
currentAccountFilter() const348 AccountState *IssuesWidget::currentAccountFilter() const
349 {
350     return _ui->filterAccount->currentData().value<AccountState *>();
351 }
352 
currentFolderFilter() const353 QString IssuesWidget::currentFolderFilter() const
354 {
355     return _ui->filterFolder->currentData().toString();
356 }
357 
shouldBeVisible(QTreeWidgetItem * item,AccountState * filterAccount,const QString & filterFolderAlias) const358 bool IssuesWidget::shouldBeVisible(QTreeWidgetItem *item, AccountState *filterAccount,
359     const QString &filterFolderAlias) const
360 {
361     bool visible = true;
362     auto data = ProtocolItem::extraData(item);
363     auto status = data.status;
364     visible &= (_ui->showIgnores->isChecked() || status != SyncFileItem::FileIgnored);
365     visible &= (_ui->showWarnings->isChecked()
366         || (status != SyncFileItem::SoftError
367                && status != SyncFileItem::Restoration));
368 
369     const auto &folderalias = data.folderName;
370     if (filterAccount) {
371         auto folder = FolderMan::instance()->folder(folderalias);
372         visible &= folder && folder->accountState() == filterAccount;
373     }
374     visible &= (filterFolderAlias.isEmpty() || filterFolderAlias == folderalias);
375 
376     return visible;
377 }
378 
slotUpdateFolderFilters()379 void IssuesWidget::slotUpdateFolderFilters()
380 {
381     auto account = _ui->filterAccount->currentData().value<AccountState *>();
382 
383     // If there is no account selector, show folders for the single
384     // available account
385     if (_ui->filterAccount->isHidden() && _ui->filterAccount->count() > 1) {
386         account = _ui->filterAccount->itemData(1).value<AccountState *>();
387     }
388 
389     if (!account) {
390         _ui->filterFolder->setCurrentIndex(0);
391     }
392     _ui->filterFolder->setEnabled(account != nullptr);
393 
394     for (int i = _ui->filterFolder->count() - 1; i >= 1; --i) {
395         _ui->filterFolder->removeItem(i);
396     }
397 
398     // Find all selectable folders while figuring out if we need a folder
399     // selector in the first place
400     bool anyAccountHasMultipleFolders = false;
401     QSet<AccountState *> accountsWithFolders;
402     for (auto folder : FolderMan::instance()->map().values()) {
403         if (accountsWithFolders.contains(folder->accountState()))
404             anyAccountHasMultipleFolders = true;
405         accountsWithFolders.insert(folder->accountState());
406 
407         if (folder->accountState() != account)
408             continue;
409         _ui->filterFolder->addItem(folder->shortGuiLocalPath(), folder->alias());
410     }
411 
412     // If we don't need the combo box, hide it.
413     _ui->filterFolder->setVisible(anyAccountHasMultipleFolders);
414     _ui->folderLabel->setVisible(anyAccountHasMultipleFolders);
415 
416     // If there's no choice, select the only folder and disable
417     if (_ui->filterFolder->count() == 2 && anyAccountHasMultipleFolders) {
418         _ui->filterFolder->setCurrentIndex(1);
419         _ui->filterFolder->setEnabled(false);
420     }
421 }
422 
storeSyncIssues(QTextStream & ts)423 void IssuesWidget::storeSyncIssues(QTextStream &ts)
424 {
425     int topLevelItems = _ui->_treeWidget->topLevelItemCount();
426 
427     for (int i = 0; i < topLevelItems; i++) {
428         QTreeWidgetItem *child = _ui->_treeWidget->topLevelItem(i);
429         if (child->isHidden())
430             continue;
431         ts << right
432            // time stamp
433            << qSetFieldWidth(20)
434            << child->data(0, Qt::DisplayRole).toString()
435            // separator
436            << qSetFieldWidth(0) << ","
437 
438            // file name
439            << qSetFieldWidth(64)
440            << child->data(1, Qt::DisplayRole).toString()
441            // separator
442            << qSetFieldWidth(0) << ","
443 
444            // folder
445            << qSetFieldWidth(30)
446            << child->data(2, Qt::DisplayRole).toString()
447            // separator
448            << qSetFieldWidth(0) << ","
449 
450            // action
451            << qSetFieldWidth(15)
452            << child->data(3, Qt::DisplayRole).toString()
453            << qSetFieldWidth(0)
454            << endl;
455     }
456 }
457 
showFolderErrors(const QString & folderAlias)458 void IssuesWidget::showFolderErrors(const QString &folderAlias)
459 {
460     auto folder = FolderMan::instance()->folder(folderAlias);
461     if (!folder)
462         return;
463 
464     _ui->filterAccount->setCurrentIndex(
465         qMax(0, _ui->filterAccount->findData(QVariant::fromValue(folder->accountState()))));
466     _ui->filterFolder->setCurrentIndex(
467         qMax(0, _ui->filterFolder->findData(folderAlias)));
468     _ui->showIgnores->setChecked(false);
469     _ui->showWarnings->setChecked(false);
470 }
471 
addError(const QString & folderAlias,const QString & message,ErrorCategory category)472 void IssuesWidget::addError(const QString &folderAlias, const QString &message,
473     ErrorCategory category)
474 {
475     auto folder = FolderMan::instance()->folder(folderAlias);
476     if (!folder)
477         return;
478 
479     QStringList columns;
480     QDateTime timestamp = QDateTime::currentDateTime();
481     const QString timeStr = ProtocolItem::timeString(timestamp);
482     const QString longTimeStr = ProtocolItem::timeString(timestamp, QLocale::LongFormat);
483 
484     columns << timeStr;
485     columns << ""; // no "File" entry
486     columns << folder->shortGuiLocalPath();
487     columns << message;
488 
489     QIcon icon = Theme::instance()->syncStateIcon(SyncResult::Error);
490 
491     QTreeWidgetItem *twitem = new ProtocolItem(columns);
492     twitem->setData(0, Qt::SizeHintRole, QSize(0, ActivityItemDelegate::rowHeight()));
493     twitem->setIcon(0, icon);
494     twitem->setToolTip(0, longTimeStr);
495     twitem->setToolTip(3, message);
496     ProtocolItem::ExtraData data;
497     data.timestamp = timestamp;
498     data.folderName = folderAlias;
499     data.status = SyncFileItem::NormalError;
500     ProtocolItem::setExtraData(twitem, data);
501 
502     addItem(twitem);
503     addErrorWidget(twitem, message, category);
504 }
505 
addErrorWidget(QTreeWidgetItem * item,const QString & message,ErrorCategory category)506 void IssuesWidget::addErrorWidget(QTreeWidgetItem *item, const QString &message, ErrorCategory category)
507 {
508     QWidget *widget = nullptr;
509     if (category == ErrorCategory::InsufficientRemoteStorage) {
510         widget = new QWidget;
511         auto layout = new QHBoxLayout;
512         widget->setLayout(layout);
513 
514         auto label = new ElidedLabel(message, widget);
515         label->setElideMode(Qt::ElideMiddle);
516         layout->addWidget(label);
517 
518         auto button = new QPushButton("Retry all uploads", widget);
519         button->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Expanding);
520         auto folderAlias = ProtocolItem::extraData(item).folderName;
521         connect(button, &QPushButton::clicked,
522             this, [this, folderAlias]() { retryInsufficentRemoteStorageErrors(folderAlias); });
523         layout->addWidget(button);
524     }
525 
526     if (widget) {
527         item->setText(3, QString());
528     }
529     _ui->_treeWidget->setItemWidget(item, 3, widget);
530 }
531 
retryInsufficentRemoteStorageErrors(const QString & folderAlias)532 void IssuesWidget::retryInsufficentRemoteStorageErrors(const QString &folderAlias)
533 {
534     auto folderman = FolderMan::instance();
535     auto folder = folderman->folder(folderAlias);
536     if (!folder)
537         return;
538 
539     folder->journalDb()->wipeErrorBlacklistCategory(SyncJournalErrorBlacklistRecord::InsufficientRemoteStorage);
540     folderman->scheduleFolderNext(folder);
541 }
542 }
543