33 #include <QFileDialog>
35 #include <QMessageBox>
36 #include <QRegularExpression>
38 #include <QSignalBlocker>
57 #include "ui_automatedrssdownloader.h"
59 const QString
EXT_JSON {QStringLiteral(
".json")};
64 , m_formatFilterJSON(QString::fromLatin1(
"%1 (*%2)").arg(tr(
"Rules"),
EXT_JSON))
65 , m_formatFilterLegacy(QString::fromLatin1(
"%1 (*%2)").arg(tr(
"Rules (legacy)"),
EXT_LEGACY))
67 , m_currentRuleItem(nullptr)
76 m_ui->listRules->setSortingEnabled(
true);
77 m_ui->listRules->setSelectionMode(QAbstractItemView::ExtendedSelection);
78 m_ui->treeMatchingArticles->setSortingEnabled(
true);
79 m_ui->treeMatchingArticles->sortByColumn(0, Qt::AscendingOrder);
80 m_ui->hsplitter->setCollapsible(0,
false);
81 m_ui->hsplitter->setCollapsible(1,
false);
82 m_ui->hsplitter->setCollapsible(2,
true);
83 m_ui->lineSavePath->setDialogCaption(tr(
"Destination directory"));
89 m_episodeRegex =
new QRegularExpression(
"^(^\\d{1,4}x(\\d{1,4}(-(\\d{1,4})?)?;){1,}){1,1}"
90 , QRegularExpression::CaseInsensitiveOption);
91 QString tip =
"<p>" + tr(
"Matches articles based on episode filter.") +
"</p><p><b>" + tr(
"Example: ")
92 +
"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>";
93 tip +=
"<p>" + tr(
"Episode filter rules: ") +
"</p><ul><li>" + tr(
"Season number is a mandatory non-zero value") +
"</li>"
94 +
"<li>" + tr(
"Episode number is a mandatory positive value") +
"</li>"
95 +
"<li>" + tr(
"Filter must end with semicolon") +
"</li>"
96 +
"<li>" + tr(
"Three range types for episodes are supported: ") +
"</li>" +
"<li><ul>"
97 +
"<li>" + tr(
"Single number: <b>1x25;</b> matches episode 25 of season one") +
"</li>"
98 +
"<li>" + tr(
"Normal range: <b>1x25-40;</b> matches episodes 25 through 40 of season one") +
"</li>"
99 +
"<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>";
100 m_ui->lineEFilter->setToolTip(tip);
121 connect(
m_ui->spinIgnorePeriod, qOverload<int>(&QSpinBox::valueChanged)
129 const auto *editHotkey =
new QShortcut(Qt::Key_F2,
m_ui->listRules,
nullptr,
nullptr, Qt::WidgetShortcut);
131 const auto *deleteHotkey =
new QShortcut(QKeySequence::Delete,
m_ui->listRules,
nullptr,
nullptr, Qt::WidgetShortcut);
138 m_ui->listRules->blockSignals(
true);
141 m_ui->listRules->blockSignals(
false);
146 m_ui->labelWarn->hide();
177 QListWidgetItem *item =
new QListWidgetItem(rule.
name(),
m_ui->listRules);
179 item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
180 item->setCheckState(rule.
isEnabled() ? Qt::Checked : Qt::Unchecked);
185 const QSignalBlocker feedListSignalBlocker(
m_ui->listFeeds);
189 QListWidgetItem *item =
new QListWidgetItem(feed->name(),
m_ui->listFeeds);
190 item->setData(Qt::UserRole, feed->url());
191 item->setFlags(item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate);
199 const QSignalBlocker feedListSignalBlocker(
m_ui->listFeeds);
201 QList<QListWidgetItem *> selection;
206 selection =
m_ui->listRules->selectedItems();
208 bool enable = !selection.isEmpty();
210 for (
int i = 0; i <
m_ui->listFeeds->count(); ++i)
212 QListWidgetItem *item =
m_ui->listFeeds->item(i);
213 const QString feedURL = item->data(Qt::UserRole).toString();
214 item->setHidden(!enable);
216 bool allEnabled =
true;
217 bool anyEnabled =
false;
219 for (
const QListWidgetItem *ruleItem :
asConst(selection))
222 if (rule.feedURLs().contains(feedURL))
228 if (anyEnabled && allEnabled)
229 item->setCheckState(Qt::Checked);
231 item->setCheckState(Qt::PartiallyChecked);
233 item->setCheckState(Qt::Unchecked);
236 m_ui->listFeeds->sortItems();
237 m_ui->lblListFeeds->setEnabled(enable);
238 m_ui->listFeeds->setEnabled(enable);
243 const QList<QListWidgetItem *> selection =
m_ui->listRules->selectedItems();
244 QListWidgetItem *currentRuleItem = ((selection.count() == 1) ? selection.first() :
nullptr);
262 m_ui->lineEFilter->clear();
265 m_ui->checkRegex->blockSignals(
true);
267 m_ui->checkRegex->blockSignals(
false);
268 m_ui->checkSmart->blockSignals(
true);
270 m_ui->checkSmart->blockSignals(
false);
273 m_ui->comboCategory->clearEditText();
277 m_ui->comboAddPaused->setCurrentIndex(index);
281 m_ui->comboContentLayout->setCurrentIndex(index);
285 if (dateTime.isValid())
286 lMatch = tr(
"Last Match: %1 days ago").arg(dateTime.daysTo(QDateTime::currentDateTime()));
288 lMatch = tr(
"Last Match: Unknown");
289 m_ui->lblLastMatch->setText(lMatch);
295 m_ui->ruleDefBox->setEnabled(
true);
301 m_ui->ruleDefBox->setEnabled(
false);
310 m_ui->lineContains->clear();
311 m_ui->lineNotContains->clear();
312 m_ui->lineEFilter->clear();
313 m_ui->checkBoxSaveDiffDir->setChecked(
false);
314 m_ui->lineSavePath->clear();
315 m_ui->comboCategory->clearEditText();
316 m_ui->comboCategory->setCurrentIndex(-1);
317 m_ui->checkRegex->setChecked(
false);
318 m_ui->checkSmart->setChecked(
false);
319 m_ui->spinIgnorePeriod->setValue(0);
320 m_ui->comboAddPaused->clearEditText();
321 m_ui->comboAddPaused->setCurrentIndex(-1);
322 m_ui->comboContentLayout->clearEditText();
323 m_ui->comboContentLayout->setCurrentIndex(-1);
335 m_ui->comboCategory->addItem(
"");
336 m_ui->comboCategory->addItems(categories);
351 std::optional<bool> addPaused;
352 if (
m_ui->comboAddPaused->currentIndex() == 1)
354 else if (
m_ui->comboAddPaused->currentIndex() == 2)
358 std::optional<BitTorrent::TorrentContentLayout> contentLayout;
359 if (
m_ui->comboContentLayout->currentIndex() > 0)
380 this, tr(
"New rule name"), tr(
"Please type the name of the new download rule."));
381 if (ruleName.isEmpty())
return;
386 QMessageBox::warning(
this, tr(
"Rule name conflict")
387 , tr(
"A rule with this name already exists, please choose another name."));
396 const QList<QListWidgetItem *> selection =
m_ui->listRules->selectedItems();
397 if (selection.isEmpty())
return;
400 const QString confirmText = ((selection.count() == 1)
401 ? tr(
"Are you sure you want to remove the download rule named '%1'?")
402 .arg(selection.first()->text())
403 : tr(
"Are you sure you want to remove the selected download rules?"));
404 if (QMessageBox::question(
this, tr(
"Rule deletion confirmation"), confirmText, QMessageBox::Yes, QMessageBox::No) != QMessageBox::Yes)
407 for (
const QListWidgetItem *item : selection)
415 if (!newCategoryName.isEmpty())
417 m_ui->comboCategory->addItem(newCategoryName);
418 m_ui->comboCategory->setCurrentText(newCategoryName);
426 QMessageBox::warning(
this, tr(
"Invalid action")
427 , tr(
"The list is empty, there is nothing to export."));
432 QString path = QFileDialog::getSaveFileName(
433 this, tr(
"Export RSS rules"), QDir::homePath()
435 if (path.isEmpty())
return;
446 if (!path.endsWith(
EXT_JSON, Qt::CaseInsensitive))
451 if (!path.endsWith(
EXT_LEGACY, Qt::CaseInsensitive))
459 QMessageBox::critical(
this, tr(
"I/O Error")
460 , tr(
"Failed to create the destination file. Reason: %1").arg(result.error()));
467 QString path = QFileDialog::getOpenFileName(
468 this, tr(
"Import RSS rules"), QDir::homePath()
470 if (path.isEmpty() || !QFile::exists(path))
474 if (!
file.open(QIODevice::ReadOnly))
476 QMessageBox::critical(
477 this, tr(
"I/O Error")
478 , tr(
"Failed to open the file. Reason: %1").arg(
file.errorString()));
495 QMessageBox::critical(
496 this, tr(
"Import Error")
497 , tr(
"Failed to import the selected rules file. Reason: %1").arg(error.
message()));
503 QMenu *menu =
new QMenu(
this);
504 menu->setAttribute(Qt::WA_DeleteOnClose);
509 const QList<QListWidgetItem *> selection =
m_ui->listRules->selectedItems();
511 if (!selection.isEmpty())
513 if (selection.count() == 1)
517 menu->addSeparator();
527 menu->addSeparator();
532 menu->popup(QCursor::pos());
537 const QList<QListWidgetItem *> selection =
m_ui->listRules->selectedItems();
538 if (selection.isEmpty())
return;
540 QListWidgetItem *item = selection.first();
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;
551 QMessageBox::warning(
this, tr(
"Rule name conflict")
552 , tr(
"A rule with this name already exists, please choose another name."));
565 m_ui->listRules->setCurrentItem(ruleItem);
570 const QMessageBox::StandardButton reply = QMessageBox::question(
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);
576 if (reply == QMessageBox::Yes)
585 const QString feedURL = feedItem->data(Qt::UserRole).toString();
586 for (QListWidgetItem *ruleItem :
asConst(
m_ui->listRules->selectedItems()))
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);
609 m_ui->treeMatchingArticles->clear();
611 for (
const QListWidgetItem *ruleItem :
asConst(
m_ui->listRules->selectedItems()))
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())
636 m_ui->treeMatchingArticles->setSortingEnabled(
false);
639 QTreeWidgetItem *treeFeedItem =
nullptr;
640 for (
int i = 0; i <
m_ui->treeMatchingArticles->topLevelItemCount(); ++i)
642 QTreeWidgetItem *item =
m_ui->treeMatchingArticles->topLevelItem(i);
643 if (item->data(0, Qt::UserRole).toString() == feed->
url())
653 treeFeedItem =
new QTreeWidgetItem(QStringList() << feed->
name());
654 treeFeedItem->setToolTip(0, feed->
name());
655 QFont
f = treeFeedItem->font(0);
657 treeFeedItem->setFont(0,
f);
659 treeFeedItem->setData(0, Qt::UserRole, feed->
url());
660 m_ui->treeMatchingArticles->addTopLevelItem(treeFeedItem);
664 for (
const QString &article : articles)
666 const std::pair<QString, QString> key(feed->
name(), article);
671 QTreeWidgetItem *item =
new QTreeWidgetItem(QStringList() << article);
672 item->setToolTip(0, article);
673 treeFeedItem->addChild(item);
677 m_ui->treeMatchingArticles->expandItem(treeFeedItem);
678 m_ui->treeMatchingArticles->sortItems(0, Qt::AscendingOrder);
679 m_ui->treeMatchingArticles->setSortingEnabled(
true);
687 tip =
"<p>" + tr(
"Regex mode: use Perl-compatible regular expressions") +
"</p>";
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>";
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>");
713 const QString text =
m_ui->lineContains->text();
714 bool isRegex =
m_ui->checkRegex->isChecked();
727 for (
const QString &token :
asConst(text.split(
'|')))
731 for (
const QString &token :
asConst(tokens))
733 QRegularExpression reg(token, QRegularExpression::CaseInsensitiveOption);
737 error = tr(
"Position %1: %2").arg(reg.patternErrorOffset()).arg(reg.errorString());
746 m_ui->lineContains->setStyleSheet(
"");
747 m_ui->labelMustStat->setPixmap(QPixmap());
748 m_ui->labelMustStat->setToolTip(
"");
752 m_ui->lineContains->setStyleSheet(
"QLineEdit { color: #ff0000; }");
754 m_ui->labelMustStat->setToolTip(error);
760 const QString text =
m_ui->lineNotContains->text();
761 bool isRegex =
m_ui->checkRegex->isChecked();
774 for (
const QString &token :
asConst(text.split(
'|')))
778 for (
const QString &token :
asConst(tokens))
780 QRegularExpression reg(token, QRegularExpression::CaseInsensitiveOption);
784 error = tr(
"Position %1: %2").arg(reg.patternErrorOffset()).arg(reg.errorString());
793 m_ui->lineNotContains->setStyleSheet(
"");
794 m_ui->labelMustNotStat->setPixmap(QPixmap());
795 m_ui->labelMustNotStat->setToolTip(
"");
799 m_ui->lineNotContains->setStyleSheet(
"QLineEdit { color: #ff0000; }");
801 m_ui->labelMustNotStat->setToolTip(error);
807 const QString text =
m_ui->lineEFilter->text();
808 bool valid = text.isEmpty() ||
m_episodeRegex->match(text).hasMatch();
812 m_ui->lineEFilter->setStyleSheet(
"");
813 m_ui->labelEpFilterStat->setPixmap(QPixmap());
817 m_ui->lineEFilter->setStyleSheet(
"QLineEdit { color: #ff0000; }");
839 item->setText(ruleName);
857 m_ui->labelWarn->setVisible(!enabled);
static QString getText(QWidget *parent, const QString &title, const QString &label, QLineEdit::EchoMode mode=QLineEdit::Normal, const QString &text={}, bool *ok=nullptr, bool excludeExtension=false, Qt::InputMethodHints inputMethodHints=Qt::ImhNone)
static Session * instance()
QStringList categories() const
QString message() const noexcept
@ DirectorySave
selecting directories for saving
static Preferences * instance()
void setRssGeometrySize(const QSize &geometry)
QSize getRssGeometrySize() const
void setRssHSplitterSizes(const QByteArray &sizes)
QByteArray getRssHSplitterSizes() const
void setTorrentContentLayout(std::optional< BitTorrent::TorrentContentLayout > contentLayout)
bool useSmartFilter() const
void setEpisodeFilter(const QString &e)
std::optional< BitTorrent::TorrentContentLayout > torrentContentLayout() const
void setPreviouslyMatchedEpisodes(const QStringList &previouslyMatchedEpisodes)
QString assignedCategory() const
QString mustContain() const
void setEnabled(bool enable)
void setUseRegex(bool enabled)
void setMustContain(const QString &tokens)
void setSavePath(const QString &savePath)
std::optional< bool > addPaused() const
void setCategory(const QString &category)
QString mustNotContain() const
QDateTime lastMatch() const
void setIgnoreDays(int d)
void setUseSmartFilter(bool enabled)
void setFeedURLs(const QStringList &urls)
void setName(const QString &name)
QStringList feedURLs() const
bool matches(const QVariantHash &articleData) const
QString episodeFilter() const
void setMustNotContain(const QString &tokens)
void setAddPaused(std::optional< bool > addPaused)
void ruleChanged(const QString &ruleName)
void removeRule(const QString &ruleName)
void processingStateChanged(bool enabled)
bool renameRule(const QString &ruleName, const QString &newRuleName)
static AutoDownloader * instance()
void ruleAdded(const QString &ruleName)
void ruleAboutToBeRemoved(const QString &ruleName)
void ruleRenamed(const QString &ruleName, const QString &oldRuleName)
AutoDownloadRule ruleByName(const QString &ruleName) const
QByteArray exportRules(RulesFileFormat format=RulesFileFormat::JSON) const
void insertRule(const AutoDownloadRule &rule)
void importRules(const QByteArray &data, RulesFileFormat format=RulesFileFormat::JSON)
static Session * instance()
Feed * feedByURL(const QString &url) const
static QString createCategory(QWidget *parent, const QString &parentCategoryName={})
static UIThemeManager * instance()
constexpr std::add_const_t< T > & asConst(T &t) noexcept
QString toNativePath(const QString &path)
void resize(QWidget *widget, const QSize &newSize={})
nonstd::expected< void, QString > saveToFile(const QString &path, const QByteArray &data)
QString wildcardToRegexPattern(const QString &pattern)
file(GLOB QBT_TS_FILES "${qBittorrent_SOURCE_DIR}/src/lang/*.ts") set_source_files_properties($