Files
2026-09-19 13:45:52 +08:00

586 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# 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 <https://www.gnu.org/licenses/>.
"""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,
self.stopUpdatesButton,
) = (
AppQPushButton(_('Add')),
AppQPushButton(_('Edit')),
AppQPushButton(_('Delete')),
AppQPushButton(_('Copy URL')),
AppQPushButton(_('View Profiles')),
AppQPushButton(_('Update Selected')),
AppQPushButton(_('Update All')),
AppQPushButton(_('Stop Updates')),
)
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.serverTable.subsManager.subscriptionStateChanged.connect(
self._refreshUpdateActions
)
self.serverTable.subsManager.subscriptionsChanged.connect(
self._refreshUpdateActions
)
self.stopUpdatesButton.clicked.connect(self.serverTable.subsManager.stopUpdates)
self._refreshUpdateActions()
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)
actions.addWidget(self.stopUpdatesButton)
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()
def _refreshUpdateActions(self, *_args):
"""Derive cancellation availability from shared synchronization state."""
self.stopUpdatesButton.setEnabled(
any(
group.get('lastSyncStatus') == 'syncing'
for group in Storage.UserSubs().values()
)
)
@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'),
(self.stopUpdatesButton, 'stop-circle.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