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