mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
970 lines
30 KiB
Python
970 lines
30 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/>.
|
||
|
||
"""Provide widgets for user subs Qt table view."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from Furious.Frozenlib import (
|
||
PLATFORM,
|
||
AppFontName,
|
||
AppMainWindow,
|
||
AppSettings,
|
||
Mixins,
|
||
PySide6Legacy,
|
||
registerAppSettings,
|
||
)
|
||
from Furious.Models import UJSONEncoder
|
||
from Furious.Repository import Storage, SubscriptionGroup
|
||
from Furious.Service import (
|
||
SUBSCRIPTION_AUTO_UPDATE_OPTIONS,
|
||
SUBSCRIPTION_PROXY_OPTIONS,
|
||
formatTrafficUsage,
|
||
resolveSubscriptionProxy,
|
||
)
|
||
from Furious.Qt import (
|
||
MBoxQuestionDelete,
|
||
AppQAction,
|
||
AppQHeaderView,
|
||
AppQMenu,
|
||
AppQMessageBox,
|
||
AppQTableView,
|
||
)
|
||
from Furious.Qt import gettext as _
|
||
|
||
from PySide6 import QtCore
|
||
from PySide6.QtGui import QFont, QStandardItemModel
|
||
from PySide6.QtWidgets import QAbstractItemView, QHeaderView, QTableView
|
||
|
||
from typing import Union, Callable
|
||
|
||
import datetime
|
||
import functools
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
__all__ = ['SubscriptionTableView']
|
||
|
||
# Keep the original setting names. The primary value now stores semantic widths
|
||
# instead of QHeaderView's schema-dependent opaque state.
|
||
LEGACY_SUBSCRIPTION_SECTION_SIZE_SETTING = 'SubscriptionWidgetSectionSizeTable'
|
||
SUBSCRIPTION_HEADER_STATE_SETTING = 'UserSubsHeaderViewState'
|
||
|
||
registerAppSettings(LEGACY_SUBSCRIPTION_SECTION_SIZE_SETTING)
|
||
registerAppSettings(SUBSCRIPTION_HEADER_STATE_SETTING)
|
||
|
||
|
||
class SubscriptionTableHorizontalHeader(AppQHeaderView):
|
||
"""Provide the user subs Qt table view horizontal table header."""
|
||
|
||
ColumnKeys = (
|
||
'remark',
|
||
'webURL',
|
||
'enabled',
|
||
'lastSyncStatus',
|
||
'lastUpdated',
|
||
'subscriptionInfo',
|
||
'profiles',
|
||
)
|
||
DefaultSectionSizes = (260, 520, 120, 150, 220, 220, 140)
|
||
LegacyColumnKeys = ('remark', 'webURL', 'autoupdate', 'proxy')
|
||
|
||
# Format discriminator for the semantic JSON stored under
|
||
# UserSubsHeaderViewState. The namespace and version let us reject
|
||
# unrelated strings or a future incompatible semantic format.
|
||
StateFormat = 'Furious.SubscriptionHeaderWidths/1'
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
"""Initialize the SubscriptionTableHorizontalHeader."""
|
||
super().__init__(QtCore.Qt.Orientation.Horizontal, *args, **kwargs)
|
||
|
||
def _configureInteractiveSections(self):
|
||
"""Normalize interaction without changing the logical column order."""
|
||
self.setSectionsMovable(False)
|
||
self.setCascadingSectionResizes(False)
|
||
self.setMinimumSectionSize(80)
|
||
self.setStretchLastSection(False)
|
||
self.setCustomSectionResizeMode()
|
||
|
||
def _applyDefaultSectionSizes(self):
|
||
"""Apply balanced widths for the concise subscription table."""
|
||
self.setStretchLastSection(False)
|
||
|
||
for section, size in enumerate(self.DefaultSectionSizes):
|
||
if section < self.count():
|
||
self.resizeSection(section, size)
|
||
|
||
@classmethod
|
||
def _semanticWidths(cls, state) -> dict[str, int]:
|
||
"""Decode the schema-independent header format, if present."""
|
||
if not isinstance(state, str):
|
||
return {}
|
||
|
||
try:
|
||
decoded = UJSONEncoder.decode(state)
|
||
except Exception:
|
||
# Any non-exit exceptions
|
||
|
||
return {}
|
||
|
||
if not isinstance(decoded, dict) or decoded.get('format') != cls.StateFormat:
|
||
return {}
|
||
|
||
widths = decoded.get('widths')
|
||
|
||
if not isinstance(widths, dict):
|
||
return {}
|
||
|
||
return {
|
||
key: width
|
||
for key, width in widths.items()
|
||
if key in cls.ColumnKeys
|
||
and isinstance(width, (int, float))
|
||
and not isinstance(width, bool)
|
||
and width > 0
|
||
}
|
||
|
||
@classmethod
|
||
def _legacyHeaderWidths(cls, state) -> dict[str, int]:
|
||
"""Extract widths from a legacy Qt state without touching this header."""
|
||
if state is None or isinstance(state, str):
|
||
return {}
|
||
|
||
probe = QTableView()
|
||
probeModel = QStandardItemModel(0, 1, probe)
|
||
probe.setModel(probeModel)
|
||
probeHeader = probe.horizontalHeader()
|
||
|
||
# restoreState() is intentionally used only on this disposable probe
|
||
# as a read-only, one-time compatibility path for the original
|
||
# four-column UserSubsHeaderViewState. Applying the opaque Qt state to
|
||
# the live five-column header could recreate phantom sections, legacy
|
||
# ordering, hidden state, and incompatible resize behavior.
|
||
try:
|
||
if not probeHeader.restoreState(state):
|
||
return {}
|
||
except Exception:
|
||
# Any non-exit exceptions
|
||
|
||
return {}
|
||
|
||
if probeHeader.count() != len(cls.LegacyColumnKeys):
|
||
return {}
|
||
|
||
for section in range(probeHeader.count()):
|
||
probeHeader.showSection(section)
|
||
|
||
return {
|
||
key: probeHeader.sectionSize(section)
|
||
for section, key in enumerate(cls.LegacyColumnKeys)
|
||
if key in cls.ColumnKeys and section < probeHeader.count()
|
||
}
|
||
|
||
@classmethod
|
||
def _oldestSectionWidths(cls) -> dict[str, int]:
|
||
"""Read the original index-based width table as a final fallback."""
|
||
try:
|
||
widths = UJSONEncoder.decode(
|
||
AppSettings.get(LEGACY_SUBSCRIPTION_SECTION_SIZE_SETTING)
|
||
)
|
||
except Exception:
|
||
# Any non-exit exceptions
|
||
|
||
return {}
|
||
|
||
if not isinstance(widths, dict):
|
||
return {}
|
||
|
||
result = {}
|
||
|
||
for section, key in enumerate(cls.LegacyColumnKeys):
|
||
width = widths.get(str(section))
|
||
|
||
if (
|
||
key in cls.ColumnKeys
|
||
and isinstance(width, (int, float))
|
||
and not isinstance(width, bool)
|
||
and width > 0
|
||
):
|
||
result[key] = width
|
||
|
||
return result
|
||
|
||
def _applyWidths(self, widths: dict[str, int]):
|
||
"""Apply semantic widths to the matching logical sections."""
|
||
for section, key in enumerate(self.ColumnKeys):
|
||
width = widths.get(key)
|
||
|
||
if width is not None:
|
||
self.resizeSection(
|
||
section,
|
||
max(self.minimumSectionSize(), round(width)),
|
||
)
|
||
|
||
def configureSections(self):
|
||
"""Restore semantic widths or safely read either historical format."""
|
||
self._configureInteractiveSections()
|
||
self._applyDefaultSectionSizes()
|
||
|
||
state = AppSettings.get(SUBSCRIPTION_HEADER_STATE_SETTING)
|
||
widths = self._semanticWidths(state) or self._legacyHeaderWidths(state)
|
||
|
||
if not widths:
|
||
widths = self._oldestSectionWidths()
|
||
|
||
self._applyWidths(widths)
|
||
|
||
# Saved Qt state may contain moved, hidden, or non-interactive sections.
|
||
# It is never restored onto the live header; the current schema owns
|
||
# order and interaction behavior.
|
||
for section in range(self.count()):
|
||
self.showSection(section)
|
||
|
||
self._configureInteractiveSections()
|
||
|
||
def cleanup(self):
|
||
"""Persist semantic widths under the established setting name."""
|
||
# Do not use QHeaderView.saveState() here. Its opaque binary data also
|
||
# records the section count, order, visibility, and resize modes, so it
|
||
# is unsafe to restore after the table schema changes. Persist only
|
||
# widths keyed by stable field names instead.
|
||
AppSettings.set(
|
||
SUBSCRIPTION_HEADER_STATE_SETTING,
|
||
UJSONEncoder.encode(
|
||
{
|
||
'format': self.StateFormat,
|
||
'widths': {
|
||
key: self.sectionSize(section)
|
||
for section, key in enumerate(self.ColumnKeys)
|
||
if section < self.count()
|
||
},
|
||
}
|
||
),
|
||
)
|
||
|
||
|
||
class SubscriptionTableVerticalHeader(AppQHeaderView):
|
||
"""Provide the user subs Qt table view vertical table header."""
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
"""Initialize the SubscriptionTableVerticalHeader."""
|
||
super().__init__(QtCore.Qt.Orientation.Vertical, *args, **kwargs)
|
||
|
||
|
||
class SubscriptionTableColumn:
|
||
"""Describe and render user subs Qt table view table columns."""
|
||
|
||
def __init__(self, name: str, func: Callable[[dict], str] = None):
|
||
"""Initialize the SubscriptionTableColumn."""
|
||
self.name = name
|
||
self.func = func
|
||
|
||
def __call__(self, item: dict) -> str:
|
||
"""Invoke the user subs Qt table view headers as a callable."""
|
||
if callable(self.func):
|
||
return self.func(item)
|
||
else:
|
||
return ''
|
||
|
||
def __eq__(self, other):
|
||
"""Compare the user subs Qt table view headers with another value."""
|
||
return str(self) == str(other)
|
||
|
||
def __str__(self):
|
||
"""Return the display text for the user subs Qt table view headers."""
|
||
return self.name
|
||
|
||
|
||
def subscriptionSyncStatusText(item: dict) -> str:
|
||
"""Return one concise, localized subscription synchronization state."""
|
||
return {
|
||
'syncing': _('Updating...'),
|
||
'success': _('Updated'),
|
||
'error': _('Update Failed'),
|
||
}.get(str(item.get('lastSyncStatus', '')), _('Never'))
|
||
|
||
|
||
def _nonnegativeInteger(value) -> int:
|
||
"""Return a safe non-negative integer from persisted presentation input."""
|
||
try:
|
||
return max(int(value), 0)
|
||
except (TypeError, ValueError, OverflowError):
|
||
return 0
|
||
|
||
|
||
def subscriptionInfoText(item: dict) -> str:
|
||
"""Return compact provider-reported traffic usage and expiry metadata."""
|
||
parts = []
|
||
total = _nonnegativeInteger(item.get('subscriptionTotal', 0))
|
||
|
||
if total:
|
||
used = _nonnegativeInteger(
|
||
item.get('subscriptionUpload', 0)
|
||
) + _nonnegativeInteger(item.get('subscriptionDownload', 0))
|
||
|
||
parts.append(f'{formatTrafficUsage(used)} / {formatTrafficUsage(total)}')
|
||
|
||
expiresAt = _nonnegativeInteger(item.get('subscriptionExpire', 0))
|
||
|
||
if expiresAt:
|
||
try:
|
||
expires = datetime.datetime.fromtimestamp(
|
||
expiresAt, tz=datetime.timezone.utc
|
||
).date()
|
||
except (OverflowError, OSError, ValueError):
|
||
pass
|
||
else:
|
||
parts.append(expires.isoformat())
|
||
|
||
return ' · '.join(parts)
|
||
|
||
|
||
class UserSubsTableModel(QtCore.QAbstractTableModel):
|
||
"""Expose user subs table data through a Qt item model."""
|
||
|
||
def __init__(
|
||
self,
|
||
headers: list[SubscriptionTableColumn],
|
||
itemKey: list[str],
|
||
parent=None,
|
||
):
|
||
"""Initialize the UserSubsTableModel."""
|
||
super().__init__(parent)
|
||
|
||
self.headers = headers
|
||
self.itemKey = itemKey
|
||
|
||
def rowCount(self, parent=QtCore.QModelIndex()) -> int:
|
||
"""Return the number of rows exposed by the model."""
|
||
if parent.isValid():
|
||
return 0
|
||
|
||
return len(Storage.UserSubs())
|
||
|
||
def columnCount(self, parent=QtCore.QModelIndex()) -> int:
|
||
"""Return the number of columns exposed by the model."""
|
||
if parent.isValid():
|
||
return 0
|
||
|
||
return len(self.headers)
|
||
|
||
def flags(self, index):
|
||
"""Return the Qt item flags for a model index."""
|
||
if not index.isValid():
|
||
return QtCore.Qt.ItemFlag.NoItemFlags
|
||
|
||
flags = QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsSelectable
|
||
|
||
if self.itemKey[index.column()] not in [
|
||
'autoupdate',
|
||
'proxy',
|
||
'enabled',
|
||
'lastSyncStatus',
|
||
'lastUpdated',
|
||
'subscriptionInfo',
|
||
'profiles',
|
||
]:
|
||
flags |= QtCore.Qt.ItemFlag.ItemIsEditable
|
||
|
||
return flags
|
||
|
||
def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole):
|
||
"""Return the data managed by the user subs table model."""
|
||
if not index.isValid():
|
||
return None
|
||
|
||
row = index.row()
|
||
column = index.column()
|
||
|
||
if row < 0 or row >= len(Storage.UserSubs()):
|
||
return None
|
||
|
||
if column < 0 or column >= len(self.headers):
|
||
return None
|
||
|
||
subsob = self.subsObjectByRow(row)
|
||
text = self.headers[column](subsob)
|
||
mapped = self.itemKey[column]
|
||
|
||
if mapped == 'profiles':
|
||
unique = self.uniqueByRow(row)
|
||
text = str(
|
||
sum(
|
||
1
|
||
for profile in Storage.UserServers()
|
||
if profile.itemSubscription == unique
|
||
and profile.itemSubscriptionManaged
|
||
)
|
||
)
|
||
|
||
if role == QtCore.Qt.ItemDataRole.DisplayRole:
|
||
if mapped in ['autoupdate', 'proxy', 'enabled']:
|
||
return _(text)
|
||
|
||
return text
|
||
|
||
if role == QtCore.Qt.ItemDataRole.EditRole:
|
||
return text
|
||
|
||
if role == QtCore.Qt.ItemDataRole.ToolTipRole:
|
||
return text
|
||
|
||
if role == QtCore.Qt.ItemDataRole.FontRole:
|
||
return QFont(AppFontName())
|
||
|
||
return None
|
||
|
||
def setData(self, index, value, role=QtCore.Qt.ItemDataRole.EditRole) -> bool:
|
||
"""Update model data for the requested role."""
|
||
if role != QtCore.Qt.ItemDataRole.EditRole or not index.isValid():
|
||
return False
|
||
|
||
row = index.row()
|
||
column = index.column()
|
||
|
||
if row < 0 or row >= len(Storage.UserSubs()):
|
||
return False
|
||
|
||
mapped = self.itemKey[column]
|
||
|
||
if mapped in [
|
||
'autoupdate',
|
||
'proxy',
|
||
'enabled',
|
||
'lastSyncStatus',
|
||
'lastUpdated',
|
||
'subscriptionInfo',
|
||
'profiles',
|
||
]:
|
||
return False
|
||
|
||
self.subsObjectByRow(row)[mapped] = str(value)
|
||
self.dataChanged.emit(index, index, [])
|
||
|
||
return True
|
||
|
||
def headerData(
|
||
self,
|
||
section: int,
|
||
orientation: QtCore.Qt.Orientation,
|
||
role=QtCore.Qt.ItemDataRole.DisplayRole,
|
||
):
|
||
"""Return display data for a table header section."""
|
||
if role != QtCore.Qt.ItemDataRole.DisplayRole:
|
||
return None
|
||
|
||
if orientation == QtCore.Qt.Orientation.Horizontal:
|
||
if 0 <= section < len(self.headers):
|
||
return _(str(self.headers[section]))
|
||
|
||
return None
|
||
|
||
return section + 1
|
||
|
||
@staticmethod
|
||
def uniqueByRow(row: int) -> str:
|
||
"""Return the unique by row value used by the user subs table model."""
|
||
return list(Storage.UserSubs().keys())[row]
|
||
|
||
@classmethod
|
||
def subsObjectByRow(cls, row: int) -> dict:
|
||
"""Return the subs object by row value used by the user subs table model."""
|
||
return Storage.UserSubs()[cls.uniqueByRow(row)]
|
||
|
||
def emitRowChanged(self, row: int, column: Union[int, None] = None):
|
||
"""Handle emit row changed for the user subs table model."""
|
||
if row < 0 or row >= self.rowCount():
|
||
return
|
||
|
||
if column is None:
|
||
left = self.index(row, 0)
|
||
right = self.index(row, self.columnCount() - 1)
|
||
else:
|
||
left = self.index(row, column)
|
||
right = left
|
||
|
||
self.dataChanged.emit(left, right, [])
|
||
|
||
def emitAllChanged(self):
|
||
"""Handle emit all changed for the user subs table model."""
|
||
if self.rowCount() == 0 or self.columnCount() == 0:
|
||
return
|
||
|
||
self.dataChanged.emit(
|
||
self.index(0, 0),
|
||
self.index(self.rowCount() - 1, self.columnCount() - 1),
|
||
[],
|
||
)
|
||
|
||
|
||
# Headers VALUE
|
||
_TRANSLATABLE_HEADERS = [
|
||
_('Never'),
|
||
_('Every 5 mins'),
|
||
_('Every 10 mins'),
|
||
_('Every 15 mins'),
|
||
_('Every 30 mins'),
|
||
_('Every 45 mins'),
|
||
_('Every 1 hour'),
|
||
_('Every 2 hours'),
|
||
_('Every 3 hours'),
|
||
_('Every 6 hours'),
|
||
_('Every 8 hours'),
|
||
_('Every 10 hours'),
|
||
_('Every 12 hours'),
|
||
_('Every 24 hours'),
|
||
_('Use current proxy'),
|
||
_('Force proxy'),
|
||
_('No proxy'),
|
||
_('Remark'),
|
||
_('Auto Update'),
|
||
_('Auto Update Use Proxy'),
|
||
_('Enabled'),
|
||
_('Disabled'),
|
||
_('Sync Status'),
|
||
_('Updating...'),
|
||
_('Updated'),
|
||
_('Update Failed'),
|
||
_('Last Updated'),
|
||
_('Usage / Expiry'),
|
||
_('Profiles'),
|
||
]
|
||
|
||
|
||
class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
|
||
"""Represent user subs Qt table view."""
|
||
|
||
groupsChanged = QtCore.Signal()
|
||
RowHeight = 42
|
||
|
||
AutoUpdateOptions = SUBSCRIPTION_AUTO_UPDATE_OPTIONS
|
||
ProxyOptions = {
|
||
option: functools.partial(resolveSubscriptionProxy, option)
|
||
for option in SUBSCRIPTION_PROXY_OPTIONS
|
||
}
|
||
|
||
Headers = [
|
||
SubscriptionTableColumn('Remark', lambda item: item.get('remark', '')),
|
||
SubscriptionTableColumn('URL', lambda item: item.get('webURL', '')),
|
||
SubscriptionTableColumn(
|
||
'Enabled',
|
||
lambda item: 'Enabled' if item.get('enabled', True) else 'Disabled',
|
||
),
|
||
SubscriptionTableColumn('Sync Status', subscriptionSyncStatusText),
|
||
SubscriptionTableColumn(
|
||
'Last Updated',
|
||
lambda item: item.get('lastUpdated', ''),
|
||
),
|
||
SubscriptionTableColumn('Usage / Expiry', subscriptionInfoText),
|
||
SubscriptionTableColumn('Profiles'),
|
||
]
|
||
|
||
# Corresponds to 'Headers'
|
||
ItemKey = [
|
||
'remark',
|
||
'webURL',
|
||
'enabled',
|
||
'lastSyncStatus',
|
||
'lastUpdated',
|
||
'subscriptionInfo',
|
||
'profiles',
|
||
]
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
"""Initialize the subscription table view."""
|
||
self.deleteUniqueCallback, self.subsManager = (
|
||
kwargs.pop('deleteUniqueCallback', None),
|
||
kwargs.pop('subscriptionManager', None),
|
||
)
|
||
|
||
super().__init__(*args, **kwargs)
|
||
|
||
self.sourceModel = UserSubsTableModel(self.Headers, self.ItemKey, parent=self)
|
||
self.setModel(self.sourceModel)
|
||
|
||
# Install custom header
|
||
self.setHorizontalHeader(
|
||
SubscriptionTableHorizontalHeader(
|
||
parent=self,
|
||
sectionSizeSettingsName=SUBSCRIPTION_HEADER_STATE_SETTING,
|
||
)
|
||
)
|
||
self.setVerticalHeader(SubscriptionTableVerticalHeader(self))
|
||
self.setDefaultRowHeight(self.RowHeight)
|
||
|
||
header = self.horizontalHeader()
|
||
header.configureSections()
|
||
|
||
self.setSortingEnabled(False)
|
||
|
||
# Selection
|
||
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||
|
||
# No drag and drop
|
||
self.setDragEnabled(False)
|
||
self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop)
|
||
self.setDropIndicatorShown(False)
|
||
self.setDefaultDropAction(QtCore.Qt.DropAction.IgnoreAction)
|
||
|
||
self.moveUpActionRef = AppQAction(
|
||
_('Move Up'),
|
||
callback=lambda: self.moveSelectedGroups('up'),
|
||
shortcut=QtCore.QKeyCombination(
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
QtCore.Qt.Key.Key_Up,
|
||
),
|
||
parent=self,
|
||
)
|
||
self.moveDownActionRef = AppQAction(
|
||
_('Move Down'),
|
||
callback=lambda: self.moveSelectedGroups('down'),
|
||
shortcut=QtCore.QKeyCombination(
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
QtCore.Qt.Key.Key_Down,
|
||
),
|
||
parent=self,
|
||
)
|
||
|
||
self.contextMenu = AppQMenu(
|
||
self.moveUpActionRef,
|
||
self.moveDownActionRef,
|
||
parent=self,
|
||
)
|
||
|
||
for action in (self.moveUpActionRef, self.moveDownActionRef):
|
||
action.setShortcutContext(QtCore.Qt.ShortcutContext.WidgetShortcut)
|
||
|
||
self.addAction(action)
|
||
|
||
self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
|
||
|
||
# Signals
|
||
self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested)
|
||
|
||
# Flush all data to table
|
||
self.flushAll()
|
||
|
||
@property
|
||
def selectedIndex(self):
|
||
"""Return the selected index value."""
|
||
return sorted(
|
||
list(set(index.row() for index in self.selectionModel().selectedRows()))
|
||
)
|
||
|
||
@property
|
||
def selectedUniques(self):
|
||
"""Return stable subscription IDs for the selected rows."""
|
||
keys = tuple(Storage.UserSubs())
|
||
|
||
return tuple(keys[row] for row in self.selectedIndex if row < len(keys))
|
||
|
||
def _currentUnique(self):
|
||
"""Return the stable subscription ID represented by the current row."""
|
||
row = self.currentIndex().row()
|
||
keys = tuple(Storage.UserSubs())
|
||
|
||
return keys[row] if 0 <= row < len(keys) else None
|
||
|
||
def _restoreGroupSelection(self, selectedUniques, currentUnique):
|
||
"""Restore logical selection, current item, and keyboard focus by ID."""
|
||
rows = {unique: row for row, unique in enumerate(Storage.UserSubs())}
|
||
|
||
selection = self.selectionModel()
|
||
selection.clearSelection()
|
||
flags = (
|
||
QtCore.QItemSelectionModel.SelectionFlag.Select
|
||
| QtCore.QItemSelectionModel.SelectionFlag.Rows
|
||
)
|
||
|
||
for unique in selectedUniques:
|
||
row = rows.get(unique)
|
||
|
||
if row is not None:
|
||
selection.select(self.sourceModel.index(row, 0), flags)
|
||
|
||
currentRow = rows.get(currentUnique)
|
||
|
||
if currentRow is not None:
|
||
selection.setCurrentIndex(
|
||
self.sourceModel.index(currentRow, 0),
|
||
QtCore.QItemSelectionModel.SelectionFlag.NoUpdate,
|
||
)
|
||
|
||
self.setFocus()
|
||
|
||
@QtCore.Slot(QtCore.QPoint)
|
||
def handleCustomContextMenuRequested(self, point):
|
||
"""Handle custom context menu requested."""
|
||
self.contextMenu.exec(self.viewport().mapToGlobal(point))
|
||
|
||
def deleteSelectedItem(self):
|
||
"""Confirm deletion of the selected stable subscription IDs."""
|
||
indexes = self.selectedIndex
|
||
|
||
if len(indexes) == 0:
|
||
# Nothing to do
|
||
return
|
||
|
||
uniques = self.selectedUniques
|
||
|
||
def handleResultCode(_uniques, code):
|
||
"""Resolve captured IDs against the current repository after confirmation."""
|
||
if code == PySide6Legacy.enumValueWrapper(
|
||
AppQMessageBox.StandardButton.Yes
|
||
):
|
||
removedAny = False
|
||
|
||
for deleteUnique in _uniques:
|
||
currentUniques = tuple(Storage.UserSubs())
|
||
|
||
if deleteUnique not in currentUniques:
|
||
continue
|
||
|
||
deleteIndex = currentUniques.index(deleteUnique)
|
||
|
||
self.sourceModel.beginRemoveRows(
|
||
QtCore.QModelIndex(),
|
||
deleteIndex,
|
||
deleteIndex,
|
||
)
|
||
|
||
Storage.removeSubscriptionGroup(deleteUnique)
|
||
|
||
if self.subsManager is not None:
|
||
self.subsManager.removeAutoUpdate(deleteUnique)
|
||
|
||
self.sourceModel.endRemoveRows()
|
||
|
||
removedAny = True
|
||
|
||
if callable(self.deleteUniqueCallback):
|
||
self.deleteUniqueCallback(deleteUnique)
|
||
|
||
if not removedAny:
|
||
return
|
||
|
||
for order, value in enumerate(Storage.UserSubs().values()):
|
||
value['sortOrder'] = order
|
||
|
||
self.flushAll()
|
||
|
||
self.groupsChanged.emit()
|
||
else:
|
||
# Do not delete
|
||
pass
|
||
|
||
if PLATFORM == 'Windows':
|
||
# Windows
|
||
mbox = MBoxQuestionDelete(icon=AppQMessageBox.Icon.Question)
|
||
else:
|
||
# macOS & linux
|
||
mbox = MBoxQuestionDelete(
|
||
icon=AppQMessageBox.Icon.Question, parent=self.parent()
|
||
)
|
||
mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
|
||
|
||
mbox.isMulti = bool(len(indexes) > 1)
|
||
mbox.possibleRemark = self.sourceModel.data(
|
||
self.sourceModel.index(indexes[0], 0),
|
||
QtCore.Qt.ItemDataRole.DisplayRole,
|
||
)
|
||
mbox.setText(mbox.customText())
|
||
mbox.finished.connect(functools.partial(handleResultCode, uniques))
|
||
|
||
# Show the MessageBox asynchronously
|
||
mbox.open()
|
||
|
||
def moveSelectedGroups(self, position: str):
|
||
"""Move selected groups while preserving identity, selection, and timers."""
|
||
selectedUniques = self.selectedUniques
|
||
|
||
if not selectedUniques or position not in ('up', 'down'):
|
||
return
|
||
|
||
currentUnique = self._currentUnique()
|
||
|
||
self.sourceModel.layoutAboutToBeChanged.emit()
|
||
|
||
changed = Storage.moveSubscriptionGroups(selectedUniques, position)
|
||
|
||
self.sourceModel.layoutChanged.emit()
|
||
|
||
self._restoreGroupSelection(selectedUniques, currentUnique)
|
||
|
||
if changed:
|
||
self.groupsChanged.emit()
|
||
|
||
@QtCore.Slot(object)
|
||
def refreshSubscriptionState(self, uniques):
|
||
"""Repaint metadata cells for stable subscription IDs only."""
|
||
rows = {unique: row for row, unique in enumerate(Storage.UserSubs())}
|
||
|
||
firstColumn, lastColumn = (
|
||
self.ItemKey.index('lastSyncStatus'),
|
||
self.ItemKey.index('profiles'),
|
||
)
|
||
|
||
changedRows = sorted(
|
||
row
|
||
for unique in dict.fromkeys(uniques)
|
||
if (row := rows.get(unique)) is not None
|
||
)
|
||
|
||
if not changedRows:
|
||
return
|
||
|
||
firstRow = previousRow = changedRows[0]
|
||
|
||
for row in (*changedRows[1:], None):
|
||
if row is not None and row == previousRow + 1:
|
||
previousRow = row
|
||
|
||
continue
|
||
|
||
self.sourceModel.dataChanged.emit(
|
||
self.sourceModel.index(firstRow, firstColumn),
|
||
self.sourceModel.index(previousRow, lastColumn),
|
||
[],
|
||
)
|
||
|
||
if row is not None:
|
||
firstRow = previousRow = row
|
||
|
||
def flushItem(self, row, column, item):
|
||
"""Refresh item."""
|
||
if row < 0 or row >= self.sourceModel.rowCount():
|
||
return
|
||
|
||
self.sourceModel.emitRowChanged(row, column)
|
||
|
||
def flushRow(self, row, item):
|
||
"""Refresh row."""
|
||
for column in list(range(self.sourceModel.columnCount())):
|
||
self.flushItem(row, column, item)
|
||
|
||
def flushAll(self):
|
||
"""Refresh all."""
|
||
for item in Storage.UserSubs().values():
|
||
item.setdefault('enabled', True)
|
||
item.setdefault('userAgent', '')
|
||
item.setdefault('filter', '')
|
||
item.setdefault('lastUpdated', '')
|
||
item.setdefault('lastSyncStatus', '')
|
||
|
||
self.sourceModel.emitAllChanged()
|
||
|
||
for index, key in enumerate(Storage.UserSubs()):
|
||
self.flushRow(index, Storage.UserSubs()[key])
|
||
|
||
def appendNewItem(self, **kwargs):
|
||
"""Append new item."""
|
||
(
|
||
unique,
|
||
remark,
|
||
webURL,
|
||
enabled,
|
||
autoupdate,
|
||
proxy,
|
||
userAgent,
|
||
profileFilter,
|
||
lastUpdated,
|
||
) = (
|
||
kwargs.pop('unique', ''),
|
||
kwargs.pop('remark', ''),
|
||
kwargs.pop('webURL', ''),
|
||
kwargs.pop('enabled', True),
|
||
kwargs.pop('autoupdate', ''),
|
||
kwargs.pop('proxy', ''),
|
||
kwargs.pop('userAgent', ''),
|
||
kwargs.pop('filter', ''),
|
||
kwargs.pop('lastUpdated', ''),
|
||
)
|
||
|
||
group = Storage.SubscriptionGroup(unique) or SubscriptionGroup(
|
||
id=unique,
|
||
sortOrder=len(Storage.UserSubs()),
|
||
)
|
||
group.remark = remark
|
||
group.webURL = webURL
|
||
group.enabled = bool(enabled)
|
||
group.autoupdate = autoupdate
|
||
group.proxy = proxy
|
||
group.userAgent = userAgent
|
||
group.filter = profileFilter
|
||
group.lastUpdated = lastUpdated
|
||
|
||
subsob = {unique: group.toMapping()}
|
||
|
||
if unique in Storage.UserSubs():
|
||
if self.subsManager is not None:
|
||
self.subsManager.cancelUpdates(unique)
|
||
|
||
row = list(Storage.UserSubs().keys()).index(unique)
|
||
|
||
Storage.upsertSubscriptionGroup(group)
|
||
|
||
self.flushRow(row, subsob[unique])
|
||
else:
|
||
row = self.sourceModel.rowCount()
|
||
|
||
self.sourceModel.beginInsertRows(QtCore.QModelIndex(), row, row)
|
||
Storage.upsertSubscriptionGroup(group)
|
||
self.sourceModel.endInsertRows()
|
||
|
||
self.flushRow(row, subsob[unique])
|
||
|
||
if self.subsManager is not None:
|
||
self.subsManager.configureAutoUpdate(unique)
|
||
|
||
self.groupsChanged.emit()
|
||
|
||
@staticmethod
|
||
def updateSubsByUnique(
|
||
unique: str, httpProxy: Union[str, Callable, None], **kwargs
|
||
):
|
||
"""Update subs by unique."""
|
||
showMessageBox = kwargs.pop('showMessageBox', False)
|
||
|
||
if callable(httpProxy):
|
||
try:
|
||
realHttpProxy = httpProxy()
|
||
except Exception as ex:
|
||
# Any non-exit exceptions
|
||
|
||
logger.error(
|
||
f'error while configuring http proxy: {ex}. '
|
||
f'Update subscription uses no proxy'
|
||
)
|
||
|
||
realHttpProxy = None
|
||
else:
|
||
realHttpProxy = httpProxy
|
||
|
||
AppMainWindow().updateSubsByUnique(
|
||
unique, realHttpProxy, showMessageBox=showMessageBox, **kwargs
|
||
)
|
||
|
||
def retranslate(self):
|
||
"""Refresh translated text for the user subs Qt table view."""
|
||
self.sourceModel.headerDataChanged.emit(
|
||
QtCore.Qt.Orientation.Horizontal,
|
||
0,
|
||
len(self.Headers) - 1,
|
||
)
|