# Copyright (C) 2024–present Loren Eteval & contributors # # This file is part of Furious. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Present subscription definitions and synchronization as a dedicated page.""" from __future__ import annotations from Furious.Frozenlib import APP, Mixins, PySide6Legacy from Furious.Qt import * from Furious.Qt import gettext as _ from Furious.Repository import Storage from Furious.Widget.SubscriptionTableView import SubscriptionTableView from PySide6 import QtCore from PySide6.QtWidgets import * from urllib.parse import urlsplit import uuid __all__ = ['SubscriptionPage'] def _validSubscriptionURL(value) -> str: """Return a normalized HTTP(S) subscription URL or an empty string.""" url = str(value or '').strip() if not url or any(character.isspace() for character in url): return '' try: parsed = urlsplit(url) hostname = parsed.hostname except ValueError: return '' if parsed.scheme.casefold() not in ('http', 'https') or not hostname: return '' return url class _SubscriptionEditorDialog(AppQTransientDialog): """Edit one complete subscription definition with validation.""" def __init__(self, subscription=None, parent=None): """Initialize fields from an existing definition or defaults.""" super().__init__(parent) subscription = dict(subscription or {}) self.remarkEdit = AppQLineEdit(subscription.get('remark', '')) self.urlEdit = AppQLineEdit(subscription.get('webURL', '')) self.enabledSwitch = AppQSwitch() self.enabledSwitch.syncChecked(subscription.get('enabled', True)) self.autoUpdateComboBox = AppQComboBox() self.proxyComboBox = AppQComboBox() self.userAgentEdit = AppQLineEdit(subscription.get('userAgent', '')) self.filterEdit = AppQLineEdit(subscription.get('filter', '')) self.remarkEdit.setMinimumWidth(240) self.remarkEdit.setMaximumWidth(360) self.urlEdit.setMinimumWidth(420) self.autoUpdateComboBox.setMinimumWidth(220) self.autoUpdateComboBox.setMaximumWidth(320) self.proxyComboBox.setMinimumWidth(220) self.proxyComboBox.setMaximumWidth(320) self.userAgentEdit.setMinimumWidth(260) self.filterEdit.setMinimumWidth(260) for value in SubscriptionTableView.AutoUpdateOptions: self.autoUpdateComboBox.addItem(_(value), value) for value in SubscriptionTableView.ProxyOptions: self.proxyComboBox.addItem(_(value), value) autoIndex = self.autoUpdateComboBox.findData(subscription.get('autoupdate', '')) proxyIndex = self.proxyComboBox.findData(subscription.get('proxy', '')) self.autoUpdateComboBox.setCurrentIndex(max(autoIndex, 0)) self.proxyComboBox.setCurrentIndex(max(proxyIndex, 0)) self.buttons = AppQDialogButtonBox(QtCore.Qt.Orientation.Horizontal) self.buttons.addButton(_('OK'), AppQDialogButtonBox.ButtonRole.AcceptRole) self.buttons.addButton( _('Cancel'), AppQDialogButtonBox.ButtonRole.RejectRole, ) self.buttons.accepted.connect(self.accept) self.buttons.rejected.connect(self.reject) formWidget = QFrame() formWidget.setObjectName('SubscriptionEditorForm') form = QGridLayout(formWidget) form.setContentsMargins(20, 18, 20, 18) form.setHorizontalSpacing(16) form.setVerticalSpacing(14) form.setColumnStretch(1, 1) form.setColumnStretch(3, 1) ( self.remarkLabel, self.urlLabel, self.enabledLabel, self.autoUpdateLabel, self.proxyLabel, self.userAgentLabel, self.filterLabel, ) = ( AppQLabel(_('Remark')), AppQLabel(_('URL')), AppQLabel(_('Enabled')), AppQLabel(_('Auto Update')), AppQLabel(_('Auto Update Use Proxy')), AppQLabel(_('User Agent')), AppQLabel(_('Profile Filter (Regex)')), ) enabledLayout = QHBoxLayout() enabledLayout.setContentsMargins(0, 0, 0, 0) enabledLayout.setSpacing(10) enabledLayout.addWidget(self.enabledLabel) enabledLayout.addWidget(self.enabledSwitch) enabledLayout.addStretch(1) form.addWidget(self.remarkLabel, 0, 0) form.addWidget(self.remarkEdit, 0, 1) form.addLayout(enabledLayout, 0, 2, 1, 2) form.addWidget(self.urlLabel, 1, 0) form.addWidget(self.urlEdit, 1, 1, 1, 3) form.addWidget(self.autoUpdateLabel, 2, 0) form.addWidget(self.autoUpdateComboBox, 2, 1) form.addWidget(self.proxyLabel, 2, 2) form.addWidget(self.proxyComboBox, 2, 3) form.addWidget(self.userAgentLabel, 3, 0) form.addWidget(self.userAgentEdit, 3, 1) form.addWidget(self.filterLabel, 3, 2) form.addWidget(self.filterEdit, 3, 3) buttonsLayout = QHBoxLayout() buttonsLayout.setContentsMargins(0, 0, 0, 0) buttonsLayout.addStretch(1) buttonsLayout.addWidget(self.buttons) layout = QVBoxLayout(self) layout.setContentsMargins(20, 20, 20, 18) layout.setSpacing(16) layout.addWidget(formWidget) layout.addLayout(buttonsLayout) self.setWindowTitle( _('Edit Subscription') if subscription else _('Add Subscription') ) self.resize(900, 330) def _showValidationError(self, message: str): """Show one non-blocking validation message owned by this dialog.""" messageBox = AppQMessageBox( icon=AppQMessageBox.Icon.Warning, parent=self, ) messageBox.setWindowTitle(_('Invalid data')) messageBox.setText(message) messageBox.open() def accept(self): """Validate required fields before accepting the definition.""" remark, url = ( self.remarkEdit.text().strip(), _validSubscriptionURL(self.urlEdit.text()), ) if not remark: self._showValidationError(_('Please enter a subscription remark.')) return if not url: self._showValidationError(_('Please enter a valid subscription URL.')) return super().accept() def subscription(self): """Return the normalized values entered by the user.""" return { 'remark': self.remarkEdit.text().strip(), 'webURL': self.urlEdit.text().strip(), 'enabled': self.enabledSwitch.isChecked(), 'autoupdate': self.autoUpdateComboBox.currentData() or '', 'proxy': self.proxyComboBox.currentData() or '', 'userAgent': self.userAgentEdit.text().strip(), 'filter': self.filterEdit.text().strip(), } class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow): """Own subscription editing, grouping, and synchronization workflows.""" def __init__(self, serverTable, parent=None): """Initialize around the existing profile synchronization backend.""" super().__init__(parent) self.setObjectName('SubscriptionPage') self.serverTable = serverTable self.pageTitleLabel = AppQLabel(_('Subscriptions')) self.pageTitleLabel.setObjectName('SubscriptionPageTitle') self.proxyLabel = AppQLabel(_('Update Using')) self.proxyComboBox = AppQComboBox() self.proxyComboBox.setMinimumWidth(160) self.addButton = AppQPushButton(_('Add')) self.editButton = AppQPushButton(_('Edit')) self.deleteButton = AppQPushButton(_('Delete')) self.copyURLButton = AppQPushButton(_('Copy URL')) self.viewProfilesButton = AppQPushButton(_('View Profiles')) self.updateSelectedButton = AppQPushButton(_('Update Selected')) self.updateAllButton = AppQPushButton(_('Update All')) self.table = SubscriptionTableView( deleteUniqueCallback=self._deleteProfilesForSubscription, parent=self, ) self.table.doubleClicked.connect(self._editDoubleClicked) self.table.groupsChanged.connect( self.serverTable.subsManager.subscriptionsChanged.emit ) self.serverTable.subsManager.subscriptionsChanged.connect(self.table.flushAll) self.addButton.clicked.connect(self.addSubscription) self.editButton.clicked.connect(self.editSelected) self.deleteButton.clicked.connect(self.table.deleteSelectedItem) self.copyURLButton.clicked.connect(self.copySelectedURL) self.viewProfilesButton.clicked.connect(self.viewSelectedProfiles) self.updateSelectedButton.clicked.connect(self.updateSelected) self.updateAllButton.clicked.connect(self.updateAll) for key in SubscriptionTableView.ProxyOptions: self.proxyComboBox.addItem(_(key), key) controls = QHBoxLayout() controls.setContentsMargins(0, 0, 0, 0) controls.setSpacing(8) controls.addWidget(self.pageTitleLabel) controls.addStretch(1) controls.addWidget(self.proxyLabel) controls.addWidget(self.proxyComboBox) controls.addWidget(self.updateSelectedButton) controls.addWidget(self.updateAllButton) actions = QHBoxLayout() actions.setContentsMargins(0, 0, 0, 0) actions.setSpacing(8) actions.addWidget(self.addButton) actions.addWidget(self.editButton) actions.addWidget(self.deleteButton) actions.addWidget(self.copyURLButton) actions.addWidget(self.viewProfilesButton) actions.addStretch(1) content = QWidget() content.setObjectName('SubscriptionPageContent') layout = QVBoxLayout(content) layout.setContentsMargins(20, 18, 20, 20) layout.setSpacing(12) layout.addLayout(controls) layout.addLayout(actions) layout.addWidget(self.table, 1) self.setCentralWidget(content) self.setIconsByTheme(APP().theme()) self.retranslate() @QtCore.Slot(QtCore.QModelIndex) def _editDoubleClicked(self, _index): """Edit the selected subscription after a table double-click.""" self.editSelected() def _deleteProfilesForSubscription(self, unique: str): """Remove profiles belonging to a deleted subscription group.""" indexes = [ index for index, server in enumerate(Storage.UserServers()) if server.itemSubscription == unique and server.itemSubscriptionManaged ] self.serverTable.deleteItemByIndex(indexes, showProgress=False) for server in Storage.UserServers(): if server.itemSubscription == unique: server.metadata.subscriptionSource = '' server.metadata.subscriptionManaged = False server.metadata.subscriptionProfileKey = '' self.serverTable.flushAll() def _selectedUnique(self): """Return the first selected subscription ID, if any.""" selected = self.table.selectedUniques return selected[0] if selected else None def _openEditor(self, unique=None, initial=None): """Open and retain an asynchronous add/edit dialog.""" source = Storage.UserSubs().get(unique, initial or {}) dialog = _SubscriptionEditorDialog(source, parent=self) dialog.setWindowTitle( _('Edit Subscription') if unique else _('Add Subscription') ) dialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal) def finished(code): """Persist accepted values through the existing table model.""" if code != PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted): return subscriptionUnique = unique or str(uuid.uuid4()) existing = Storage.UserSubs().get(subscriptionUnique, {}) self.table.appendNewItem( unique=subscriptionUnique, lastUpdated=existing.get('lastUpdated', ''), **dialog.subscription(), ) self.table.selectRow(list(Storage.UserSubs()).index(subscriptionUnique)) dialog.finished.connect(finished) dialog.open() return dialog @QtCore.Slot() def addSubscription(self): """Offer a valid clipboard URL before opening the normal editor.""" url = _validSubscriptionURL(QApplication.clipboard().text()) if not url: self._openEditor() return messageBox = AppQMessageBox( icon=AppQMessageBox.Icon.Question, parent=self, ) messageBox.setWindowTitle(_('Add Subscription')) messageBox.setText(_('Use the subscription URL from the clipboard?')) messageBox.setInformativeText(url) messageBox.setStandardButtons( AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No ) messageBox.setDefaultButton(AppQMessageBox.StandardButton.Yes) messageBox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) def finished(code): """Continue with clipboard assistance or the regular workflow.""" if code == PySide6Legacy.enumValueWrapper( AppQMessageBox.StandardButton.Yes ): self.addFromClipboard(url) else: self._openEditor() messageBox.finished.connect(finished) messageBox.open() def addFromClipboard(self, clipboardURL=None): """Seed a subscription from an HTTP(S) URL on the clipboard.""" if clipboardURL is None: clipboardURL = QApplication.clipboard().text() url = _validSubscriptionURL(clipboardURL) if not url: self._openEditor() return parsed = urlsplit(url) self._openEditor( initial={ 'remark': parsed.hostname or _('Subscription'), 'webURL': url, 'enabled': True, } ) @QtCore.Slot() def editSelected(self): """Edit the selected subscription without relying on inline cells.""" unique = self._selectedUnique() if unique is not None: self._openEditor(unique) @QtCore.Slot() def copySelectedURL(self): """Copy the selected subscription URL for sharing/export.""" unique = self._selectedUnique() if unique is not None: QApplication.clipboard().setText( str(Storage.UserSubs()[unique].get('webURL', '')) ) @QtCore.Slot() def viewSelectedProfiles(self): """Open Home filtered to the selected subscription group.""" unique = self._selectedUnique() if unique is None: return mainWindow = self.window() homePage = getattr(mainWindow, 'homePage', None) if homePage is not None: homePage.showSubscriptionGroup(unique) showPage = getattr(mainWindow, 'showPage', None) if callable(showPage): showPage('home') def _selectedProxy(self): """Resolve the proxy policy selected for manual synchronization.""" key = self.proxyComboBox.currentData() or '' resolver = SubscriptionTableView.ProxyOptions.get(key) return resolver() if callable(resolver) else None @QtCore.Slot() def updateSelected(self): """Synchronize selected enabled subscription groups together.""" keys = tuple( key for key in self.table.selectedUniques if Storage.UserSubs().get(key, {}).get('enabled', True) and Storage.UserSubs().get(key, {}).get('webURL') ) if not keys: return depthMap = {'depth': len(keys)} successArgs = [] failureArgs = [] httpProxy = self._selectedProxy() for key in keys: self.serverTable.updateSubsByUnique( key, httpProxy, depthMap=depthMap, successArgs=successArgs, failureArgs=failureArgs, showMessageBox=True, parent=self, ) @QtCore.Slot() def updateAll(self): """Synchronize every enabled subscription with one proxy policy.""" self.serverTable.updateSubs(self._selectedProxy(), parent=self) def updateSubsByUnique(self, unique: str, httpProxy, **kwargs): """Preserve the established application subscription-update API.""" self.serverTable.updateSubsByUnique(unique, httpProxy, **kwargs) def setIconsByTheme(self, theme: str): """Apply theme-aware Fluent icons to subscription commands.""" iconFactory = ( bootstrapIconWhite if theme == AppStyleSheet.Dark else bootstrapIcon ) for button, iconName in ( (self.addButton, 'plus-lg.svg'), (self.editButton, 'pencil-square.svg'), (self.deleteButton, 'trash.svg'), (self.copyURLButton, 'link-45deg.svg'), (self.viewProfilesButton, 'funnel.svg'), (self.updateSelectedButton, 'arrow-repeat.svg'), (self.updateAllButton, 'cloud-arrow-down.svg'), ): button.setIcon(iconFactory(iconName)) def themeChangedCallback(self, theme: str): """Refresh command icons after a theme change.""" self.setIconsByTheme(theme) def showEvent(self, event): """Refresh persisted subscription data whenever the page is shown.""" super().showEvent(event) self.table.flushAll() def retranslate(self): """Handle page-level dynamic translation state.""" pass