1 /*
2  * Bittorrent Client using Qt and libtorrent.
3  * Copyright (C) 2017  Vladimir Golovnev <glassez@yandex.ru>
4  * Copyright (C) 2010  Christophe Dumez <chris@qbittorrent.org>
5  *
6  * This program is free software; you can redistribute it and/or
7  * modify it under the terms of the GNU General Public License
8  * as published by the Free Software Foundation; either version 2
9  * of the License, or (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14  * GNU General Public License for more details.
15  *
16  * You should have received a copy of the GNU General Public License
17  * along with this program; if not, write to the Free Software
18  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19  *
20  * In addition, as a special exception, the copyright holders give permission to
21  * link this program with the OpenSSL project's "OpenSSL" library (or with
22  * modified versions of it that use the same license as the "OpenSSL" library),
23  * and distribute the linked executables. You must obey the GNU General Public
24  * License in all respects for all of the code used other than "OpenSSL".  If you
25  * modify file(s), you may extend this exception to your version of the file(s),
26  * but you are not obligated to do so. If you do not wish to do so, delete this
27  * exception statement from your version.
28  */
29 
30 #include "automatedrssdownloader.h"
31 
32 #include <QCursor>
33 #include <QFileDialog>
34 #include <QMenu>
35 #include <QMessageBox>
36 #include <QPair>
37 #include <QRegularExpression>
38 #include <QShortcut>
39 #include <QSignalBlocker>
40 #include <QString>
41 
42 #include "base/bittorrent/session.h"
43 #include "base/global.h"
44 #include "base/preferences.h"
45 #include "base/rss/rss_article.h"
46 #include "base/rss/rss_autodownloader.h"
47 #include "base/rss/rss_feed.h"
48 #include "base/rss/rss_folder.h"
49 #include "base/rss/rss_session.h"
50 #include "base/utils/fs.h"
51 #include "base/utils/string.h"
52 #include "gui/autoexpandabledialog.h"
53 #include "gui/torrentcategorydialog.h"
54 #include "gui/uithememanager.h"
55 #include "gui/utils.h"
56 #include "ui_automatedrssdownloader.h"
57 
58 const QString EXT_JSON {QStringLiteral(".json")};
59 const QString EXT_LEGACY {QStringLiteral(".rssrules")};
60 
AutomatedRssDownloader(QWidget * parent)61 AutomatedRssDownloader::AutomatedRssDownloader(QWidget *parent)
62     : QDialog(parent)
63     , m_formatFilterJSON(QString::fromLatin1("%1 (*%2)").arg(tr("Rules"), EXT_JSON))
64     , m_formatFilterLegacy(QString::fromLatin1("%1 (*%2)").arg(tr("Rules (legacy)"), EXT_LEGACY))
65     , m_ui(new Ui::AutomatedRssDownloader)
66     , m_currentRuleItem(nullptr)
67 {
68     m_ui->setupUi(this);
69     // Icons
70     m_ui->removeRuleBtn->setIcon(UIThemeManager::instance()->getIcon("list-remove"));
71     m_ui->addRuleBtn->setIcon(UIThemeManager::instance()->getIcon("list-add"));
72     m_ui->addCategoryBtn->setIcon(UIThemeManager::instance()->getIcon("list-add"));
73 
74     // Ui Settings
75     m_ui->listRules->setSortingEnabled(true);
76     m_ui->listRules->setSelectionMode(QAbstractItemView::ExtendedSelection);
77     m_ui->treeMatchingArticles->setSortingEnabled(true);
78     m_ui->treeMatchingArticles->sortByColumn(0, Qt::AscendingOrder);
79     m_ui->hsplitter->setCollapsible(0, false);
80     m_ui->hsplitter->setCollapsible(1, false);
81     m_ui->hsplitter->setCollapsible(2, true); // Only the preview list is collapsible
82     m_ui->lineSavePath->setDialogCaption(tr("Destination directory"));
83     m_ui->lineSavePath->setMode(FileSystemPathEdit::Mode::DirectorySave);
84 
85     connect(m_ui->checkRegex, &QAbstractButton::toggled, this, &AutomatedRssDownloader::updateFieldsToolTips);
86     connect(m_ui->listRules, &QWidget::customContextMenuRequested, this, &AutomatedRssDownloader::displayRulesListMenu);
87 
88     m_episodeRegex = new QRegularExpression("^(^\\d{1,4}x(\\d{1,4}(-(\\d{1,4})?)?;){1,}){1,1}"
89                                             , QRegularExpression::CaseInsensitiveOption);
90     QString tip = "<p>" + tr("Matches articles based on episode filter.") + "</p><p><b>" + tr("Example: ")
91                   + "1x2;8-15;5;30-;</b>" + tr(" will match 2, 5, 8 through 15, 30 and onward episodes of season one", "example X will match") + "</p>";
92     tip += "<p>" + tr("Episode filter rules: ") + "</p><ul><li>" + tr("Season number is a mandatory non-zero value") + "</li>"
93            + "<li>" + tr("Episode number is a mandatory positive value") + "</li>"
94            + "<li>" + tr("Filter must end with semicolon") + "</li>"
95            + "<li>" + tr("Three range types for episodes are supported: ") + "</li>" + "<li><ul>"
96            + "<li>" + tr("Single number: <b>1x25;</b> matches episode 25 of season one") + "</li>"
97            + "<li>" + tr("Normal range: <b>1x25-40;</b> matches episodes 25 through 40 of season one") + "</li>"
98            + "<li>" + tr("Infinite range: <b>1x25-;</b> matches episodes 25 and upward of season one, and all episodes of later seasons") + "</li>" + "</ul></li></ul>";
99     m_ui->lineEFilter->setToolTip(tip);
100 
101     initCategoryCombobox();
102     loadSettings();
103 
104     connect(RSS::AutoDownloader::instance(), &RSS::AutoDownloader::ruleAdded, this, &AutomatedRssDownloader::handleRuleAdded);
105     connect(RSS::AutoDownloader::instance(), &RSS::AutoDownloader::ruleRenamed, this, &AutomatedRssDownloader::handleRuleRenamed);
106     connect(RSS::AutoDownloader::instance(), &RSS::AutoDownloader::ruleChanged, this, &AutomatedRssDownloader::handleRuleChanged);
107     connect(RSS::AutoDownloader::instance(), &RSS::AutoDownloader::ruleAboutToBeRemoved, this, &AutomatedRssDownloader::handleRuleAboutToBeRemoved);
108 
109     // Update matching articles when necessary
110     connect(m_ui->lineContains, &QLineEdit::textEdited, this, &AutomatedRssDownloader::handleRuleDefinitionChanged);
111     connect(m_ui->lineContains, &QLineEdit::textEdited, this, &AutomatedRssDownloader::updateMustLineValidity);
112     connect(m_ui->lineNotContains, &QLineEdit::textEdited, this, &AutomatedRssDownloader::handleRuleDefinitionChanged);
113     connect(m_ui->lineNotContains, &QLineEdit::textEdited, this, &AutomatedRssDownloader::updateMustNotLineValidity);
114     connect(m_ui->lineEFilter, &QLineEdit::textEdited, this, &AutomatedRssDownloader::handleRuleDefinitionChanged);
115     connect(m_ui->lineEFilter, &QLineEdit::textEdited, this, &AutomatedRssDownloader::updateEpisodeFilterValidity);
116     connect(m_ui->checkRegex, &QCheckBox::stateChanged, this, &AutomatedRssDownloader::handleRuleDefinitionChanged);
117     connect(m_ui->checkRegex, &QCheckBox::stateChanged, this, &AutomatedRssDownloader::updateMustLineValidity);
118     connect(m_ui->checkRegex, &QCheckBox::stateChanged, this, &AutomatedRssDownloader::updateMustNotLineValidity);
119     connect(m_ui->checkSmart, &QCheckBox::stateChanged, this, &AutomatedRssDownloader::handleRuleDefinitionChanged);
120     connect(m_ui->spinIgnorePeriod, qOverload<int>(&QSpinBox::valueChanged)
121             , this, &AutomatedRssDownloader::handleRuleDefinitionChanged);
122 
123     connect(m_ui->listFeeds, &QListWidget::itemChanged, this, &AutomatedRssDownloader::handleFeedCheckStateChange);
124 
125     connect(m_ui->listRules, &QListWidget::itemSelectionChanged, this, &AutomatedRssDownloader::updateRuleDefinitionBox);
126     connect(m_ui->listRules, &QListWidget::itemChanged, this, &AutomatedRssDownloader::handleRuleCheckStateChange);
127 
128     const auto *editHotkey = new QShortcut(Qt::Key_F2, m_ui->listRules, nullptr, nullptr, Qt::WidgetShortcut);
129     connect(editHotkey, &QShortcut::activated, this, &AutomatedRssDownloader::renameSelectedRule);
130     const auto *deleteHotkey = new QShortcut(QKeySequence::Delete, m_ui->listRules, nullptr, nullptr, Qt::WidgetShortcut);
131     connect(deleteHotkey, &QShortcut::activated, this, &AutomatedRssDownloader::on_removeRuleBtn_clicked);
132 
133     connect(m_ui->listRules, &QAbstractItemView::doubleClicked, this, &AutomatedRssDownloader::renameSelectedRule);
134 
135     loadFeedList();
136 
137     m_ui->listRules->blockSignals(true);
138     for (const RSS::AutoDownloadRule &rule : asConst(RSS::AutoDownloader::instance()->rules()))
139         createRuleItem(rule);
140     m_ui->listRules->blockSignals(false);
141 
142     updateRuleDefinitionBox();
143 
144     if (RSS::AutoDownloader::instance()->isProcessingEnabled())
145         m_ui->labelWarn->hide();
146     connect(RSS::AutoDownloader::instance(), &RSS::AutoDownloader::processingStateChanged
147             , this, &AutomatedRssDownloader::handleProcessingStateChanged);
148 }
149 
~AutomatedRssDownloader()150 AutomatedRssDownloader::~AutomatedRssDownloader()
151 {
152     // Save current item on exit
153     saveEditedRule();
154     saveSettings();
155 
156     delete m_ui;
157     delete m_episodeRegex;
158 }
159 
loadSettings()160 void AutomatedRssDownloader::loadSettings()
161 {
162     const Preferences *const pref = Preferences::instance();
163     Utils::Gui::resize(this, pref->getRssGeometrySize());
164     m_ui->hsplitter->restoreState(pref->getRssHSplitterSizes());
165 }
166 
saveSettings()167 void AutomatedRssDownloader::saveSettings()
168 {
169     Preferences *const pref = Preferences::instance();
170     pref->setRssGeometrySize(size());
171     pref->setRssHSplitterSizes(m_ui->hsplitter->saveState());
172 }
173 
createRuleItem(const RSS::AutoDownloadRule & rule)174 void AutomatedRssDownloader::createRuleItem(const RSS::AutoDownloadRule &rule)
175 {
176     QListWidgetItem *item = new QListWidgetItem(rule.name(), m_ui->listRules);
177     m_itemsByRuleName.insert(rule.name(), item);
178     item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
179     item->setCheckState(rule.isEnabled() ? Qt::Checked : Qt::Unchecked);
180 }
181 
loadFeedList()182 void AutomatedRssDownloader::loadFeedList()
183 {
184     const QSignalBlocker feedListSignalBlocker(m_ui->listFeeds);
185 
186     for (const auto feed : asConst(RSS::Session::instance()->feeds()))
187     {
188         QListWidgetItem *item = new QListWidgetItem(feed->name(), m_ui->listFeeds);
189         item->setData(Qt::UserRole, feed->url());
190         item->setFlags(item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate);
191     }
192 
193     updateFeedList();
194 }
195 
updateFeedList()196 void AutomatedRssDownloader::updateFeedList()
197 {
198     const QSignalBlocker feedListSignalBlocker(m_ui->listFeeds);
199 
200     QList<QListWidgetItem *> selection;
201 
202     if (m_currentRuleItem)
203         selection << m_currentRuleItem;
204     else
205         selection = m_ui->listRules->selectedItems();
206 
207     bool enable = !selection.isEmpty();
208 
209     for (int i = 0; i < m_ui->listFeeds->count(); ++i)
210     {
211         QListWidgetItem *item = m_ui->listFeeds->item(i);
212         const QString feedURL = item->data(Qt::UserRole).toString();
213         item->setHidden(!enable);
214 
215         bool allEnabled = true;
216         bool anyEnabled = false;
217 
218         for (const QListWidgetItem *ruleItem : asConst(selection))
219         {
220             const auto rule = RSS::AutoDownloader::instance()->ruleByName(ruleItem->text());
221             if (rule.feedURLs().contains(feedURL))
222                 anyEnabled = true;
223             else
224                 allEnabled = false;
225         }
226 
227         if (anyEnabled && allEnabled)
228             item->setCheckState(Qt::Checked);
229         else if (anyEnabled)
230             item->setCheckState(Qt::PartiallyChecked);
231         else
232             item->setCheckState(Qt::Unchecked);
233     }
234 
235     m_ui->listFeeds->sortItems();
236     m_ui->lblListFeeds->setEnabled(enable);
237     m_ui->listFeeds->setEnabled(enable);
238 }
239 
updateRuleDefinitionBox()240 void AutomatedRssDownloader::updateRuleDefinitionBox()
241 {
242     const QList<QListWidgetItem *> selection = m_ui->listRules->selectedItems();
243     QListWidgetItem *currentRuleItem = ((selection.count() == 1) ? selection.first() : nullptr);
244     if (m_currentRuleItem != currentRuleItem)
245     {
246         saveEditedRule(); // Save previous rule first
247         m_currentRuleItem = currentRuleItem;
248         //m_ui->listRules->setCurrentItem(m_currentRuleItem);
249     }
250 
251     // Update rule definition box
252     if (m_currentRuleItem)
253     {
254         m_currentRule = RSS::AutoDownloader::instance()->ruleByName(m_currentRuleItem->text());
255 
256         m_ui->lineContains->setText(m_currentRule.mustContain());
257         m_ui->lineNotContains->setText(m_currentRule.mustNotContain());
258         if (!m_currentRule.episodeFilter().isEmpty())
259             m_ui->lineEFilter->setText(m_currentRule.episodeFilter());
260         else
261             m_ui->lineEFilter->clear();
262         m_ui->checkBoxSaveDiffDir->setChecked(!m_currentRule.savePath().isEmpty());
263         m_ui->lineSavePath->setSelectedPath(Utils::Fs::toNativePath(m_currentRule.savePath()));
264         m_ui->checkRegex->blockSignals(true);
265         m_ui->checkRegex->setChecked(m_currentRule.useRegex());
266         m_ui->checkRegex->blockSignals(false);
267         m_ui->checkSmart->blockSignals(true);
268         m_ui->checkSmart->setChecked(m_currentRule.useSmartFilter());
269         m_ui->checkSmart->blockSignals(false);
270         m_ui->comboCategory->setCurrentIndex(m_ui->comboCategory->findText(m_currentRule.assignedCategory()));
271         if (m_currentRule.assignedCategory().isEmpty())
272             m_ui->comboCategory->clearEditText();
273         int index = 0;
274         if (m_currentRule.addPaused().has_value())
275             index = (*m_currentRule.addPaused() ? 1 : 2);
276         m_ui->comboAddPaused->setCurrentIndex(index);
277         index = 0;
278         if (m_currentRule.torrentContentLayout())
279             index = static_cast<int>(*m_currentRule.torrentContentLayout()) + 1;
280         m_ui->comboContentLayout->setCurrentIndex(index);
281         m_ui->spinIgnorePeriod->setValue(m_currentRule.ignoreDays());
282         QDateTime dateTime = m_currentRule.lastMatch();
283         QString lMatch;
284         if (dateTime.isValid())
285             lMatch = tr("Last Match: %1 days ago").arg(dateTime.daysTo(QDateTime::currentDateTime()));
286         else
287             lMatch = tr("Last Match: Unknown");
288         m_ui->lblLastMatch->setText(lMatch);
289         updateMustLineValidity();
290         updateMustNotLineValidity();
291         updateEpisodeFilterValidity();
292 
293         updateFieldsToolTips(m_ui->checkRegex->isChecked());
294         m_ui->ruleDefBox->setEnabled(true);
295     }
296     else
297     {
298         m_currentRule = RSS::AutoDownloadRule();
299         clearRuleDefinitionBox();
300         m_ui->ruleDefBox->setEnabled(false);
301     }
302 
303     updateFeedList();
304     updateMatchingArticles();
305 }
306 
clearRuleDefinitionBox()307 void AutomatedRssDownloader::clearRuleDefinitionBox()
308 {
309     m_ui->lineContains->clear();
310     m_ui->lineNotContains->clear();
311     m_ui->lineEFilter->clear();
312     m_ui->checkBoxSaveDiffDir->setChecked(false);
313     m_ui->lineSavePath->clear();
314     m_ui->comboCategory->clearEditText();
315     m_ui->comboCategory->setCurrentIndex(-1);
316     m_ui->checkRegex->setChecked(false);
317     m_ui->checkSmart->setChecked(false);
318     m_ui->spinIgnorePeriod->setValue(0);
319     m_ui->comboAddPaused->clearEditText();
320     m_ui->comboAddPaused->setCurrentIndex(-1);
321     m_ui->comboContentLayout->clearEditText();
322     m_ui->comboContentLayout->setCurrentIndex(-1);
323     updateFieldsToolTips(m_ui->checkRegex->isChecked());
324     updateMustLineValidity();
325     updateMustNotLineValidity();
326     updateEpisodeFilterValidity();
327 }
328 
initCategoryCombobox()329 void AutomatedRssDownloader::initCategoryCombobox()
330 {
331     // Load torrent categories
332     QStringList categories = BitTorrent::Session::instance()->categories().keys();
333     std::sort(categories.begin(), categories.end(), Utils::String::naturalLessThan<Qt::CaseInsensitive>);
334     m_ui->comboCategory->addItem("");
335     m_ui->comboCategory->addItems(categories);
336 }
337 
updateEditedRule()338 void AutomatedRssDownloader::updateEditedRule()
339 {
340     if (!m_currentRuleItem || !m_ui->ruleDefBox->isEnabled()) return;
341 
342     m_currentRule.setEnabled(m_currentRuleItem->checkState() != Qt::Unchecked);
343     m_currentRule.setUseRegex(m_ui->checkRegex->isChecked());
344     m_currentRule.setUseSmartFilter(m_ui->checkSmart->isChecked());
345     m_currentRule.setMustContain(m_ui->lineContains->text());
346     m_currentRule.setMustNotContain(m_ui->lineNotContains->text());
347     m_currentRule.setEpisodeFilter(m_ui->lineEFilter->text());
348     m_currentRule.setSavePath(m_ui->checkBoxSaveDiffDir->isChecked() ? m_ui->lineSavePath->selectedPath() : "");
349     m_currentRule.setCategory(m_ui->comboCategory->currentText());
350     std::optional<bool> addPaused;
351     if (m_ui->comboAddPaused->currentIndex() == 1)
352         addPaused = true;
353     else if (m_ui->comboAddPaused->currentIndex() == 2)
354         addPaused = false;
355     m_currentRule.setAddPaused(addPaused);
356 
357     std::optional<BitTorrent::TorrentContentLayout> contentLayout;
358     if (m_ui->comboContentLayout->currentIndex() > 0)
359         contentLayout = static_cast<BitTorrent::TorrentContentLayout>(m_ui->comboContentLayout->currentIndex() - 1);
360     m_currentRule.setTorrentContentLayout(contentLayout);
361 
362     m_currentRule.setIgnoreDays(m_ui->spinIgnorePeriod->value());
363 }
364 
saveEditedRule()365 void AutomatedRssDownloader::saveEditedRule()
366 {
367     if (!m_currentRuleItem || !m_ui->ruleDefBox->isEnabled()) return;
368 
369     updateEditedRule();
370     RSS::AutoDownloader::instance()->insertRule(m_currentRule);
371 }
372 
on_addRuleBtn_clicked()373 void AutomatedRssDownloader::on_addRuleBtn_clicked()
374 {
375 //    saveEditedRule();
376 
377     // Ask for a rule name
378     const QString ruleName = AutoExpandableDialog::getText(
379                 this, tr("New rule name"), tr("Please type the name of the new download rule."));
380     if (ruleName.isEmpty()) return;
381 
382     // Check if this rule name already exists
383     if (RSS::AutoDownloader::instance()->hasRule(ruleName))
384     {
385         QMessageBox::warning(this, tr("Rule name conflict")
386                              , tr("A rule with this name already exists, please choose another name."));
387         return;
388     }
389 
390     RSS::AutoDownloader::instance()->insertRule(RSS::AutoDownloadRule(ruleName));
391 }
392 
on_removeRuleBtn_clicked()393 void AutomatedRssDownloader::on_removeRuleBtn_clicked()
394 {
395     const QList<QListWidgetItem *> selection = m_ui->listRules->selectedItems();
396     if (selection.isEmpty()) return;
397 
398     // Ask for confirmation
399     const QString confirmText = ((selection.count() == 1)
400                                  ? tr("Are you sure you want to remove the download rule named '%1'?")
401                                    .arg(selection.first()->text())
402                                  : tr("Are you sure you want to remove the selected download rules?"));
403     if (QMessageBox::question(this, tr("Rule deletion confirmation"), confirmText, QMessageBox::Yes, QMessageBox::No) != QMessageBox::Yes)
404         return;
405 
406     for (const QListWidgetItem *item : selection)
407         RSS::AutoDownloader::instance()->removeRule(item->text());
408 }
409 
on_addCategoryBtn_clicked()410 void AutomatedRssDownloader::on_addCategoryBtn_clicked()
411 {
412     const QString newCategoryName = TorrentCategoryDialog::createCategory(this);
413 
414     if (!newCategoryName.isEmpty())
415     {
416         m_ui->comboCategory->addItem(newCategoryName);
417         m_ui->comboCategory->setCurrentText(newCategoryName);
418     }
419 }
420 
on_exportBtn_clicked()421 void AutomatedRssDownloader::on_exportBtn_clicked()
422 {
423     if (RSS::AutoDownloader::instance()->rules().isEmpty())
424     {
425         QMessageBox::warning(this, tr("Invalid action")
426                              , tr("The list is empty, there is nothing to export."));
427         return;
428     }
429 
430     QString selectedFilter {m_formatFilterJSON};
431     QString path = QFileDialog::getSaveFileName(
432                 this, tr("Export RSS rules"), QDir::homePath()
433                 , QString::fromLatin1("%1;;%2").arg(m_formatFilterJSON, m_formatFilterLegacy), &selectedFilter);
434     if (path.isEmpty()) return;
435 
436     const RSS::AutoDownloader::RulesFileFormat format
437     {
438         (selectedFilter == m_formatFilterJSON)
439                 ? RSS::AutoDownloader::RulesFileFormat::JSON
440                 : RSS::AutoDownloader::RulesFileFormat::Legacy
441     };
442 
443     if (format == RSS::AutoDownloader::RulesFileFormat::JSON)
444     {
445         if (!path.endsWith(EXT_JSON, Qt::CaseInsensitive))
446             path += EXT_JSON;
447     }
448     else
449     {
450         if (!path.endsWith(EXT_LEGACY, Qt::CaseInsensitive))
451             path += EXT_LEGACY;
452     }
453 
454     QFile file {path};
455     if (!file.open(QFile::WriteOnly)
456             || (file.write(RSS::AutoDownloader::instance()->exportRules(format)) == -1))
457             {
458         QMessageBox::critical(
459                     this, tr("I/O Error")
460                     , tr("Failed to create the destination file. Reason: %1").arg(file.errorString()));
461     }
462 }
463 
on_importBtn_clicked()464 void AutomatedRssDownloader::on_importBtn_clicked()
465 {
466     QString selectedFilter {m_formatFilterJSON};
467     QString path = QFileDialog::getOpenFileName(
468                 this, tr("Import RSS rules"), QDir::homePath()
469                 , QString::fromLatin1("%1;;%2").arg(m_formatFilterJSON, m_formatFilterLegacy), &selectedFilter);
470     if (path.isEmpty() || !QFile::exists(path))
471         return;
472 
473     QFile file {path};
474     if (!file.open(QIODevice::ReadOnly))
475     {
476         QMessageBox::critical(
477                     this, tr("I/O Error")
478                     , tr("Failed to open the file. Reason: %1").arg(file.errorString()));
479         return;
480     }
481 
482     const RSS::AutoDownloader::RulesFileFormat format
483     {
484         (selectedFilter == m_formatFilterJSON)
485                 ? RSS::AutoDownloader::RulesFileFormat::JSON
486                 : RSS::AutoDownloader::RulesFileFormat::Legacy
487     };
488 
489     try
490     {
491         RSS::AutoDownloader::instance()->importRules(file.readAll(),format);
492     }
493     catch (const RSS::ParsingError &error)
494     {
495         QMessageBox::critical(
496                     this, tr("Import Error")
497                     , tr("Failed to import the selected rules file. Reason: %1").arg(error.message()));
498     }
499 }
500 
displayRulesListMenu()501 void AutomatedRssDownloader::displayRulesListMenu()
502 {
503     QMenu *menu = new QMenu(this);
504     menu->setAttribute(Qt::WA_DeleteOnClose);
505 
506     menu->addAction(UIThemeManager::instance()->getIcon("list-add"), tr("Add new rule...")
507         , this, &AutomatedRssDownloader::on_addRuleBtn_clicked);
508 
509     const QList<QListWidgetItem *> selection = m_ui->listRules->selectedItems();
510 
511     if (!selection.isEmpty())
512     {
513         if (selection.count() == 1)
514         {
515             menu->addAction(UIThemeManager::instance()->getIcon("list-remove"), tr("Delete rule")
516                 , this, &AutomatedRssDownloader::on_removeRuleBtn_clicked);
517             menu->addSeparator();
518             menu->addAction(UIThemeManager::instance()->getIcon("edit-rename"), tr("Rename rule...")
519                 , this, &AutomatedRssDownloader::renameSelectedRule);
520         }
521         else
522         {
523             menu->addAction(UIThemeManager::instance()->getIcon("list-remove"), tr("Delete selected rules")
524                 , this, &AutomatedRssDownloader::on_removeRuleBtn_clicked);
525         }
526 
527         menu->addSeparator();
528         menu->addAction(UIThemeManager::instance()->getIcon("edit-clear"), tr("Clear downloaded episodes...")
529             , this, &AutomatedRssDownloader::clearSelectedRuleDownloadedEpisodeList);
530     }
531 
532     menu->popup(QCursor::pos());
533 }
534 
renameSelectedRule()535 void AutomatedRssDownloader::renameSelectedRule()
536 {
537     const QList<QListWidgetItem *> selection = m_ui->listRules->selectedItems();
538     if (selection.isEmpty()) return;
539 
540     QListWidgetItem *item = selection.first();
541     forever
542     {
543         QString newName = AutoExpandableDialog::getText(
544                     this, tr("Rule renaming"), tr("Please type the new rule name")
545                     , QLineEdit::Normal, item->text());
546         newName = newName.trimmed();
547         if (newName.isEmpty()) return;
548 
549         if (RSS::AutoDownloader::instance()->hasRule(newName))
550         {
551             QMessageBox::warning(this, tr("Rule name conflict")
552                                  , tr("A rule with this name already exists, please choose another name."));
553         }
554         else
555         {
556             // Rename the rule
557             RSS::AutoDownloader::instance()->renameRule(item->text(), newName);
558             return;
559         }
560     }
561 }
562 
handleRuleCheckStateChange(QListWidgetItem * ruleItem)563 void AutomatedRssDownloader::handleRuleCheckStateChange(QListWidgetItem *ruleItem)
564 {
565     m_ui->listRules->setCurrentItem(ruleItem);
566 }
567 
clearSelectedRuleDownloadedEpisodeList()568 void AutomatedRssDownloader::clearSelectedRuleDownloadedEpisodeList()
569 {
570     const QMessageBox::StandardButton reply = QMessageBox::question(
571                 this,
572                 tr("Clear downloaded episodes"),
573                 tr("Are you sure you want to clear the list of downloaded episodes for the selected rule?"),
574                 QMessageBox::Yes | QMessageBox::No);
575 
576     if (reply == QMessageBox::Yes)
577     {
578         m_currentRule.setPreviouslyMatchedEpisodes(QStringList());
579         handleRuleDefinitionChanged();
580     }
581 }
582 
handleFeedCheckStateChange(QListWidgetItem * feedItem)583 void AutomatedRssDownloader::handleFeedCheckStateChange(QListWidgetItem *feedItem)
584 {
585     const QString feedURL = feedItem->data(Qt::UserRole).toString();
586     for (QListWidgetItem *ruleItem : asConst(m_ui->listRules->selectedItems()))
587     {
588         RSS::AutoDownloadRule rule = (ruleItem == m_currentRuleItem
589                                        ? m_currentRule
590                                        : RSS::AutoDownloader::instance()->ruleByName(ruleItem->text()));
591         QStringList affectedFeeds = rule.feedURLs();
592         if ((feedItem->checkState() == Qt::Checked) && !affectedFeeds.contains(feedURL))
593             affectedFeeds << feedURL;
594         else if ((feedItem->checkState() == Qt::Unchecked) && affectedFeeds.contains(feedURL))
595             affectedFeeds.removeOne(feedURL);
596 
597         rule.setFeedURLs(affectedFeeds);
598         if (ruleItem != m_currentRuleItem)
599             RSS::AutoDownloader::instance()->insertRule(rule);
600         else
601             m_currentRule = rule;
602     }
603 
604     handleRuleDefinitionChanged();
605 }
606 
updateMatchingArticles()607 void AutomatedRssDownloader::updateMatchingArticles()
608 {
609     m_ui->treeMatchingArticles->clear();
610 
611     for (const QListWidgetItem *ruleItem : asConst(m_ui->listRules->selectedItems()))
612     {
613         RSS::AutoDownloadRule rule = (ruleItem == m_currentRuleItem
614                                        ? m_currentRule
615                                        : RSS::AutoDownloader::instance()->ruleByName(ruleItem->text()));
616         for (const QString &feedURL : asConst(rule.feedURLs()))
617         {
618             auto feed = RSS::Session::instance()->feedByURL(feedURL);
619             if (!feed) continue; // feed doesn't exist
620 
621             QStringList matchingArticles;
622             for (const auto article : asConst(feed->articles()))
623                 if (rule.matches(article->data()))
624                     matchingArticles << article->title();
625             if (!matchingArticles.isEmpty())
626                 addFeedArticlesToTree(feed, matchingArticles);
627         }
628     }
629 
630     m_treeListEntries.clear();
631 }
632 
addFeedArticlesToTree(RSS::Feed * feed,const QStringList & articles)633 void AutomatedRssDownloader::addFeedArticlesToTree(RSS::Feed *feed, const QStringList &articles)
634 {
635     // Turn off sorting while inserting
636     m_ui->treeMatchingArticles->setSortingEnabled(false);
637 
638     // Check if this feed is already in the tree
639     QTreeWidgetItem *treeFeedItem = nullptr;
640     for (int i = 0; i < m_ui->treeMatchingArticles->topLevelItemCount(); ++i)
641     {
642         QTreeWidgetItem *item = m_ui->treeMatchingArticles->topLevelItem(i);
643         if (item->data(0, Qt::UserRole).toString() == feed->url())
644         {
645             treeFeedItem = item;
646             break;
647         }
648     }
649 
650     // If there is none, create it
651     if (!treeFeedItem)
652     {
653         treeFeedItem = new QTreeWidgetItem(QStringList() << feed->name());
654         treeFeedItem->setToolTip(0, feed->name());
655         QFont f = treeFeedItem->font(0);
656         f.setBold(true);
657         treeFeedItem->setFont(0, f);
658         treeFeedItem->setData(0, Qt::DecorationRole, UIThemeManager::instance()->getIcon("inode-directory"));
659         treeFeedItem->setData(0, Qt::UserRole, feed->url());
660         m_ui->treeMatchingArticles->addTopLevelItem(treeFeedItem);
661     }
662 
663     // Insert the articles
664     for (const QString &article : articles)
665     {
666         QPair<QString, QString> key(feed->name(), article);
667 
668         if (!m_treeListEntries.contains(key))
669         {
670             m_treeListEntries << key;
671             QTreeWidgetItem *item = new QTreeWidgetItem(QStringList() << article);
672             item->setToolTip(0, article);
673             treeFeedItem->addChild(item);
674         }
675     }
676 
677     m_ui->treeMatchingArticles->expandItem(treeFeedItem);
678     m_ui->treeMatchingArticles->sortItems(0, Qt::AscendingOrder);
679     m_ui->treeMatchingArticles->setSortingEnabled(true);
680 }
681 
updateFieldsToolTips(bool regex)682 void AutomatedRssDownloader::updateFieldsToolTips(bool regex)
683 {
684     QString tip;
685     if (regex)
686     {
687         tip = "<p>" + tr("Regex mode: use Perl-compatible regular expressions") + "</p>";
688     }
689     else
690     {
691         tip = "<p>" + tr("Wildcard mode: you can use") + "<ul>"
692               + "<li>" + tr("? to match any single character") + "</li>"
693               + "<li>" + tr("* to match zero or more of any characters") + "</li>"
694               + "<li>" + tr("Whitespaces count as AND operators (all words, any order)") + "</li>"
695               + "<li>" + tr("| is used as OR operator") + "</li></ul></p>"
696               + "<p>" + tr("If word order is important use * instead of whitespace.") + "</p>";
697     }
698 
699     // Whether regex or wildcard, warn about a potential gotcha for users.
700     // Explanatory string broken over multiple lines for readability (and multiple
701     // statements to prevent uncrustify indenting excessively.
702     tip += "<p>";
703     tip += tr("An expression with an empty %1 clause (e.g. %2)",
704               "We talk about regex/wildcards in the RSS filters section here."
705               " So a valid sentence would be: An expression with an empty | clause (e.g. expr|)"
706               ).arg("<tt>|</tt>", "<tt>expr|</tt>");
707     m_ui->lineContains->setToolTip(tip + tr(" will match all articles.") + "</p>");
708     m_ui->lineNotContains->setToolTip(tip + tr(" will exclude all articles.") + "</p>");
709 }
710 
updateMustLineValidity()711 void AutomatedRssDownloader::updateMustLineValidity()
712 {
713     const QString text = m_ui->lineContains->text();
714     bool isRegex = m_ui->checkRegex->isChecked();
715     bool valid = true;
716     QString error;
717 
718     if (!text.isEmpty())
719     {
720         QStringList tokens;
721         if (isRegex)
722             tokens << text;
723         else
724             for (const QString &token : asConst(text.split('|')))
725                 tokens << Utils::String::wildcardToRegex(token);
726 
727         for (const QString &token : asConst(tokens))
728         {
729             QRegularExpression reg(token, QRegularExpression::CaseInsensitiveOption);
730             if (!reg.isValid())
731             {
732                 if (isRegex)
733                     error = tr("Position %1: %2").arg(reg.patternErrorOffset()).arg(reg.errorString());
734                 valid = false;
735                 break;
736             }
737         }
738     }
739 
740     if (valid)
741     {
742         m_ui->lineContains->setStyleSheet("");
743         m_ui->labelMustStat->setPixmap(QPixmap());
744         m_ui->labelMustStat->setToolTip("");
745     }
746     else
747     {
748         m_ui->lineContains->setStyleSheet("QLineEdit { color: #ff0000; }");
749         m_ui->labelMustStat->setPixmap(UIThemeManager::instance()->getIcon("task-attention").pixmap(16, 16));
750         m_ui->labelMustStat->setToolTip(error);
751     }
752 }
753 
updateMustNotLineValidity()754 void AutomatedRssDownloader::updateMustNotLineValidity()
755 {
756     const QString text = m_ui->lineNotContains->text();
757     bool isRegex = m_ui->checkRegex->isChecked();
758     bool valid = true;
759     QString error;
760 
761     if (!text.isEmpty())
762     {
763         QStringList tokens;
764         if (isRegex)
765             tokens << text;
766         else
767             for (const QString &token : asConst(text.split('|')))
768                 tokens << Utils::String::wildcardToRegex(token);
769 
770         for (const QString &token : asConst(tokens))
771         {
772             QRegularExpression reg(token, QRegularExpression::CaseInsensitiveOption);
773             if (!reg.isValid())
774             {
775                 if (isRegex)
776                     error = tr("Position %1: %2").arg(reg.patternErrorOffset()).arg(reg.errorString());
777                 valid = false;
778                 break;
779             }
780         }
781     }
782 
783     if (valid)
784     {
785         m_ui->lineNotContains->setStyleSheet("");
786         m_ui->labelMustNotStat->setPixmap(QPixmap());
787         m_ui->labelMustNotStat->setToolTip("");
788     }
789     else
790     {
791         m_ui->lineNotContains->setStyleSheet("QLineEdit { color: #ff0000; }");
792         m_ui->labelMustNotStat->setPixmap(UIThemeManager::instance()->getIcon("task-attention").pixmap(16, 16));
793         m_ui->labelMustNotStat->setToolTip(error);
794     }
795 }
796 
updateEpisodeFilterValidity()797 void AutomatedRssDownloader::updateEpisodeFilterValidity()
798 {
799     const QString text = m_ui->lineEFilter->text();
800     bool valid = text.isEmpty() || m_episodeRegex->match(text).hasMatch();
801 
802     if (valid)
803     {
804         m_ui->lineEFilter->setStyleSheet("");
805         m_ui->labelEpFilterStat->setPixmap(QPixmap());
806     }
807     else
808     {
809         m_ui->lineEFilter->setStyleSheet("QLineEdit { color: #ff0000; }");
810         m_ui->labelEpFilterStat->setPixmap(UIThemeManager::instance()->getIcon("task-attention").pixmap(16, 16));
811     }
812 }
813 
handleRuleDefinitionChanged()814 void AutomatedRssDownloader::handleRuleDefinitionChanged()
815 {
816     updateEditedRule();
817     updateMatchingArticles();
818 }
819 
handleRuleAdded(const QString & ruleName)820 void AutomatedRssDownloader::handleRuleAdded(const QString &ruleName)
821 {
822     createRuleItem(RSS::AutoDownloadRule(ruleName));
823 }
824 
handleRuleRenamed(const QString & ruleName,const QString & oldRuleName)825 void AutomatedRssDownloader::handleRuleRenamed(const QString &ruleName, const QString &oldRuleName)
826 {
827     auto item = m_itemsByRuleName.take(oldRuleName);
828     m_itemsByRuleName.insert(ruleName, item);
829     if (m_currentRule.name() == oldRuleName)
830         m_currentRule.setName(ruleName);
831     item->setText(ruleName);
832 }
833 
handleRuleChanged(const QString & ruleName)834 void AutomatedRssDownloader::handleRuleChanged(const QString &ruleName)
835 {
836     auto item = m_itemsByRuleName.value(ruleName);
837     if (item && (item != m_currentRuleItem))
838         item->setCheckState(RSS::AutoDownloader::instance()->ruleByName(ruleName).isEnabled() ? Qt::Checked : Qt::Unchecked);
839 }
840 
handleRuleAboutToBeRemoved(const QString & ruleName)841 void AutomatedRssDownloader::handleRuleAboutToBeRemoved(const QString &ruleName)
842 {
843     m_currentRuleItem = nullptr;
844     delete m_itemsByRuleName.take(ruleName);
845 }
846 
handleProcessingStateChanged(bool enabled)847 void AutomatedRssDownloader::handleProcessingStateChanged(bool enabled)
848 {
849     m_ui->labelWarn->setVisible(!enabled);
850 }
851