mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-26 16:58:17 +03:00
586 lines
20 KiB
Python
586 lines
20 KiB
Python
# 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
|