Files
LorenEteval_Furious/Furious/Widget/SubscriptionTableView.py
T
2026-08-12 12:20:48 +08:00

705 lines
22 KiB
Python
Raw 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/>.
"""Provide widgets for user subs Qt table view."""
from __future__ import annotations
from Furious.Frozenlib import *
from Furious.Repository import *
from Furious.Qt import *
from Furious.Qt import gettext as _
from PySide6 import QtCore
from PySide6.QtGui import *
from PySide6.QtWidgets import *
from typing import Union, Callable
import logging
import functools
logger = logging.getLogger(__name__)
__all__ = ['SubscriptionTableView']
# Migrate legacy settings
registerAppSettings('SubscriptionWidgetSectionSizeTable')
registerAppSettings('UserSubsHeaderViewState')
class SubscriptionTableHorizontalHeader(AppQHeaderView):
"""Provide the user subs Qt table view horizontal table header."""
def __init__(self, *args, **kwargs):
"""Initialize the SubscriptionTableHorizontalHeader."""
super().__init__(QtCore.Qt.Orientation.Horizontal, *args, **kwargs)
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
class UserSubsAppQComboBox(AppQComboBox):
"""Represent user subs app q combo box."""
def __init__(self, *args, **kwargs):
"""Initialize the UserSubsAppQComboBox."""
super().__init__(*args, **kwargs)
def retranslate(self):
# Do not emit 'currentTextChanged' when retranslate
"""Refresh translated text for the user subs app q combo box."""
with Mixins.QBlockSignalContext(self):
super().retranslate()
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']:
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)
if role == QtCore.Qt.ItemDataRole.DisplayRole:
if self.itemKey[column] in ['autoupdate', 'proxy']:
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']:
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'),
]
class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
"""Represent user subs Qt table view."""
RowHeight = 42
AutoUpdateOptions = {
'': None,
'Never': None,
'Every 5 mins': 5 * 60 * 1000,
'Every 10 mins': 10 * 60 * 1000,
'Every 15 mins': 15 * 60 * 1000,
'Every 30 mins': 30 * 60 * 1000,
'Every 45 mins': 45 * 60 * 1000,
'Every 1 hour': 1 * 60 * 60 * 1000,
'Every 2 hours': 2 * 60 * 60 * 1000,
'Every 3 hours': 3 * 60 * 60 * 1000,
'Every 6 hours': 6 * 60 * 60 * 1000,
'Every 8 hours': 8 * 60 * 60 * 1000,
'Every 10 hours': 10 * 60 * 60 * 1000,
'Every 12 hours': 12 * 60 * 60 * 1000,
'Every 24 hours': 24 * 60 * 60 * 1000,
}
ProxyOptions = {
'': lambda: None,
'Use current proxy': lambda: Storage.Extras.UserHttpProxy(),
'Force proxy': lambda: '127.0.0.1:10809',
'No proxy': lambda: None,
}
Headers = [
SubscriptionTableColumn('Remark', lambda item: item.get('remark', '')),
SubscriptionTableColumn('URL', lambda item: item.get('webURL', '')),
SubscriptionTableColumn('Auto Update', lambda item: item.get('autoupdate', '')),
SubscriptionTableColumn(
'Auto Update Use Proxy', lambda item: item.get('proxy', '')
),
]
# Corresponds to 'Headers'
ItemKey = ['remark', 'webURL', 'autoupdate', 'proxy']
def __init__(self, *args, **kwargs):
"""Initialize the subscription table view."""
self.deleteUniqueCallback = kwargs.pop('deleteUniqueCallback', None)
super().__init__(*args, **kwargs)
self.timers = list(QtCore.QTimer() for i in range(len(Storage.UserSubs())))
self.timerConnected = list(False for i in range(len(Storage.UserSubs())))
self.sourceModel = UserSubsTableModel(self.Headers, self.ItemKey, parent=self)
self.setModel(self.sourceModel)
# Install custom header
self.setHorizontalHeader(
SubscriptionTableHorizontalHeader(
parent=self,
legacySectionSizeSettingsName='SubscriptionWidgetSectionSizeTable',
sectionSizeSettingsName='UserSubsHeaderViewState',
)
)
self.setVerticalHeader(SubscriptionTableVerticalHeader(self))
self.setDefaultRowHeight(self.RowHeight)
self.horizontalHeader().setCustomSectionResizeMode()
self.horizontalHeader().restoreSectionSize()
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)
contextMenuActions = [
AppQAction(
_('Delete'),
callback=lambda: self.deleteSelectedItem(),
),
]
self.contextMenu = AppQMenu(*contextMenuActions)
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()))
)
@QtCore.Slot(QtCore.QPoint)
def handleCustomContextMenuRequested(self, point):
"""Handle custom context menu requested."""
self.contextMenu.exec(self.viewport().mapToGlobal(point))
def deleteSelectedItem(self):
"""Delete selected item."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing to do
return
def handleResultCode(_indexes, code):
"""Handle result code."""
if code == PySide6Legacy.enumValueWrapper(
AppQMessageBox.StandardButton.Yes
):
for i in range(len(_indexes)):
deleteIndex = _indexes[i] - i
deleteUnique = list(Storage.UserSubs().keys())[deleteIndex]
self.sourceModel.beginRemoveRows(
QtCore.QModelIndex(),
deleteIndex,
deleteIndex,
)
Storage.UserSubs().pop(deleteUnique)
# Begin timer cleanup
qtimer = self.timers[deleteIndex]
assert isinstance(qtimer, QtCore.QTimer)
if self.timerConnected[deleteIndex]:
try:
qtimer.timeout.disconnect()
except Exception as ex:
# Any non-exit exceptions
# Disconnect all previous signals if possible
pass
qtimer.stop()
# End timer cleanup
# Remove timer from list
self.timers.pop(deleteIndex)
self.timerConnected.pop(deleteIndex)
self.sourceModel.endRemoveRows()
if callable(self.deleteUniqueCallback):
self.deleteUniqueCallback(deleteUnique)
self.flushAll()
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, indexes))
# Show the MessageBox asynchronously
mbox.open()
def handleAutoUpdateComboBoxCurrentTextChanged(self, text: str, row: int):
"""Handle auto update combo box current text changed."""
textEnglish = _(text, 'EN')
unique = list(Storage.UserSubs().keys())[row]
subsob = Storage.UserSubs()[unique]
remark, webURL = subsob['remark'], subsob['webURL']
try:
timems = self.AutoUpdateOptions[textEnglish]
except KeyError:
# Invalid key. Reset
logger.error(f'\'{textEnglish}\' is not in auto update options. Reset')
textEnglish = ''
# Write to subs object
subsob['autoupdate'] = textEnglish
timems = self.AutoUpdateOptions[textEnglish]
qtimer = self.timers[row]
assert isinstance(qtimer, QtCore.QTimer)
if timems is not None:
logger.info(
f'start auto update job for subscription ({remark}, {webURL}). '
f'Interval is {timems // (60 * 1000)} mins'
)
def getHttpProxy(_subsob):
"""Return HTTP proxy."""
return self.ProxyOptions[_subsob.get('proxy', '')]()
if self.timerConnected[row]:
try:
qtimer.timeout.disconnect()
except Exception as ex:
# Any non-exit exceptions
# Disconnect all previous signals if possible
pass
qtimer.timeout.connect(
functools.partial(
self.updateSubsByUnique,
unique=unique,
httpProxy=functools.partial(getHttpProxy, subsob),
)
)
self.timerConnected[row] = True
qtimer.start(timems)
else:
logger.info(f'stop auto update job for subscription ({remark}, {webURL})')
if self.timerConnected[row]:
try:
qtimer.timeout.disconnect()
except Exception as ex:
# Any non-exit exceptions
# Disconnect all previous signals if possible
pass
self.timerConnected[row] = False
qtimer.stop()
# Write to subs object
subsob['autoupdate'] = textEnglish
self.sourceModel.emitRowChanged(row, self.Headers.index('Auto Update'))
# Return potentially fixed value
return textEnglish
def handleProxyComboBoxCurrentTextChanged(self, text: str, row: int):
"""Handle proxy combo box current text changed."""
textEnglish = _(text, 'EN')
unique = list(Storage.UserSubs().keys())[row]
subsob = Storage.UserSubs()[unique]
try:
proxyFn = self.ProxyOptions[textEnglish]
except KeyError:
# Invalid key. Reset
logger.error(f'\'{textEnglish}\' is not in proxy options. Reset')
textEnglish = ''
# Write to subs object
subsob['proxy'] = textEnglish
proxyFn = self.ProxyOptions[textEnglish]
assert callable(proxyFn)
# Write to subs object
subsob['proxy'] = textEnglish
self.sourceModel.emitRowChanged(
row, self.Headers.index('Auto Update Use Proxy')
)
# Return potentially fixed value
return textEnglish
def flushItem(self, row, column, item):
"""Refresh item."""
if row < 0 or row >= self.sourceModel.rowCount():
return
index = self.sourceModel.index(row, column)
if column == self.Headers.index('Auto Update'):
header = self.Headers[column]
newItem = UserSubsAppQComboBox(parent=self.viewport())
newItem.addItems(list(_(key) for key in self.AutoUpdateOptions.keys()))
newItem.setFont(QFont(AppFontName()))
itemIndex = newItem.findText(
_(self.handleAutoUpdateComboBoxCurrentTextChanged(header(item), row))
)
if itemIndex >= 0:
newItem.setCurrentIndex(itemIndex)
newItem.currentTextChanged.connect(
functools.partial(
self.handleAutoUpdateComboBoxCurrentTextChanged,
row=row,
)
)
self.setIndexWidget(index, newItem)
self.sourceModel.emitRowChanged(row, column)
elif column == self.Headers.index('Auto Update Use Proxy'):
header = self.Headers[column]
newItem = UserSubsAppQComboBox(parent=self.viewport())
newItem.addItems(list(_(key) for key in self.ProxyOptions.keys()))
newItem.setFont(QFont(AppFontName()))
itemIndex = newItem.findText(
_(self.handleProxyComboBoxCurrentTextChanged(header(item), row))
)
if itemIndex >= 0:
newItem.setCurrentIndex(itemIndex)
newItem.currentTextChanged.connect(
functools.partial(
self.handleProxyComboBoxCurrentTextChanged,
row=row,
)
)
self.setIndexWidget(index, newItem)
self.sourceModel.emitRowChanged(row, column)
else:
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."""
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, autoupdate, proxy = (
kwargs.pop('unique', ''),
kwargs.pop('remark', ''),
kwargs.pop('webURL', ''),
# Below values are not filled when adding item via Gui
kwargs.pop('autoupdate', ''),
kwargs.pop('proxy', ''),
)
# The internal subs object
subsob = {
unique: {
'remark': remark,
'webURL': webURL,
'autoupdate': autoupdate,
'proxy': proxy,
}
}
if unique in Storage.UserSubs():
row = list(Storage.UserSubs().keys()).index(unique)
Storage.UserSubs().update(subsob)
self.flushRow(row, subsob[unique])
else:
row = self.sourceModel.rowCount()
# Add timer
self.timers.append(QtCore.QTimer())
self.timerConnected.append(False)
self.sourceModel.beginInsertRows(QtCore.QModelIndex(), row, row)
Storage.UserSubs().update(subsob)
self.sourceModel.endInsertRows()
self.flushRow(row, subsob[unique])
@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
APP().mainWindow.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,
)