# 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 ( AppQComboBox, AppQDialog, AppQDialogButtonBox, AppQLabel, AppQLineEdit, AppQMessageBox, AppQPushButton, AppQSwitch, AppQTransientDialog, AppStyleSheet, bootstrapIcon, bootstrapIconWhite, ) from Furious.Qt import gettext as _ from Furious.Qt.Signals import connectWeakly from Furious.Repository import Storage from Furious.Widget.SubscriptionTableView import SubscriptionTableView from PySide6 import QtCore from PySide6.QtWidgets import ( QApplication, QFrame, QGridLayout, QHBoxLayout, QMainWindow, QVBoxLayout, QWidget, ) 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, self.urlEdit, self.enabledSwitch, self.autoUpdateComboBox, self.proxyComboBox, self.userAgentEdit, self.filterEdit, ) = ( AppQLineEdit(subscription.get('remark', '')), AppQLineEdit(subscription.get('webURL', '')), AppQSwitch(), AppQComboBox(), AppQComboBox(), AppQLineEdit(subscription.get('userAgent', '')), AppQLineEdit(subscription.get('filter', '')), ) self.remarkEdit.setMinimumWidth(240) self.remarkEdit.setMaximumWidth(360) self.urlEdit.setMinimumWidth(420) self.enabledSwitch.syncChecked(subscription.get('enabled', True)) self.autoUpdateComboBox.setMinimumWidth(220) self.autoUpdateComboBox.setContentWidthAdjustable() self.proxyComboBox.setMinimumWidth(220) self.proxyComboBox.setContentWidthAdjustable() 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, proxyIndex = ( self.autoUpdateComboBox.findData(subscription.get('autoupdate', '')), 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, ) connectWeakly(self.buttons.accepted, self, 'accept') connectWeakly(self.buttons.rejected, 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.""" mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Warning, parent=self) mbox.setHeading(_('Invalid data')) mbox.setText(message) mbox.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.setContentWidthAdjustable() self.proxyComboBox.setMinimumWidth(160) ( self.addButton, self.editButton, self.deleteButton, self.copyURLButton, self.viewProfilesButton, self.updateSelectedButton, self.updateAllButton, ) = ( AppQPushButton(_('Add')), AppQPushButton(_('Edit')), AppQPushButton(_('Delete')), AppQPushButton(_('Copy URL')), AppQPushButton(_('View Profiles')), AppQPushButton(_('Update Selected')), AppQPushButton(_('Update All')), ) self.table = SubscriptionTableView( deleteUniqueCallback=self._deleteProfilesForSubscription, subscriptionManager=self.serverTable.subsManager, parent=self, ) self.table.doubleClicked.connect(self._editDoubleClicked) self.table.groupsChanged.connect( self.serverTable.subsManager.subscriptionsChanged.emit ) # The manager and table are persistent main-window children. This direct # connection intentionally shares their process-lifetime ownership. self.serverTable.subsManager.subscriptionStateChanged.connect( self.table.refreshSubscriptionState ) 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 mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Question, parent=self) mbox.setText(_('Use the subscription URL from the clipboard?')) mbox.setInformativeText(url) mbox.setStandardButtons( AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No ) mbox.setDefaultButton(AppQMessageBox.StandardButton.Yes) mbox.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() mbox.finished.connect(finished) mbox.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 self.serverTable.updateSubscriptions( keys, self._selectedProxy(), 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