mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-04 21:08:04 +03:00
Add first-class subscription groups
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Vendored
+39
-4
@@ -540,7 +540,8 @@ TRANSLATION = {
|
||||
},
|
||||
"Move Up": {
|
||||
"source": [
|
||||
"Furious.Widget.ServerTableView"
|
||||
"Furious.Widget.ServerTableView",
|
||||
"Furious.Widget.SubscriptionTableView"
|
||||
],
|
||||
"RU": "Переместить вверх",
|
||||
"ZH": "上移",
|
||||
@@ -548,7 +549,8 @@ TRANSLATION = {
|
||||
},
|
||||
"Move Down": {
|
||||
"source": [
|
||||
"Furious.Widget.ServerTableView"
|
||||
"Furious.Widget.ServerTableView",
|
||||
"Furious.Widget.SubscriptionTableView"
|
||||
],
|
||||
"RU": "Переместить вниз",
|
||||
"ZH": "下移",
|
||||
@@ -1977,6 +1979,7 @@ TRANSLATION = {
|
||||
"Disabled": {
|
||||
"source": [
|
||||
"Furious.Backends.Xray.RoutingWindow",
|
||||
"Furious.Widget.ServerTableView",
|
||||
"Furious.Widget.SubscriptionTableView"
|
||||
],
|
||||
"RU": "Отключено",
|
||||
@@ -2757,8 +2760,8 @@ TRANSLATION = {
|
||||
"source": [
|
||||
"Furious.Window.SubscriptionPage"
|
||||
],
|
||||
"RU": "Введите название подписки.",
|
||||
"ZH": "请输入订阅备注。",
|
||||
"RU": "Введите примечание к подписки.",
|
||||
"ZH": "请输入订阅别名。",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Please enter a valid subscription URL.": {
|
||||
@@ -2840,5 +2843,37 @@ TRANSLATION = {
|
||||
"RU": "Использовать URL подписки из буфера обмена?",
|
||||
"ZH": "是否使用剪贴板中的订阅 URL?",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"All Profiles": {
|
||||
"source": [
|
||||
"Furious.Window.HomePage"
|
||||
],
|
||||
"RU": "Все профили",
|
||||
"ZH": "全部配置",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Manual Profiles": {
|
||||
"source": [
|
||||
"Furious.Window.HomePage"
|
||||
],
|
||||
"RU": "Профили, добавленные вручную",
|
||||
"ZH": "手动添加的配置",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"View Profiles": {
|
||||
"source": [
|
||||
"Furious.Window.SubscriptionPage"
|
||||
],
|
||||
"RU": "Показать профили",
|
||||
"ZH": "查看配置",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Unknown Subscription": {
|
||||
"source": [
|
||||
"Furious.Widget.ServerTableView"
|
||||
],
|
||||
"RU": "Неизвестная подписка",
|
||||
"ZH": "未知订阅",
|
||||
"isReviewed": "True"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from .Configuration import ConfigFactory
|
||||
|
||||
@@ -32,6 +35,7 @@ __all__ = [
|
||||
'ServerProfile',
|
||||
'connectionOf',
|
||||
'ensureProfile',
|
||||
'profileConnectionFingerprint',
|
||||
]
|
||||
|
||||
|
||||
@@ -49,6 +53,9 @@ class ProfileMetadata:
|
||||
latency: str = ''
|
||||
speed: str = ''
|
||||
extras: dict[str, Any] = field(default_factory=dict)
|
||||
profileId: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
subscriptionManaged: bool = False
|
||||
subscriptionProfileKey: str = ''
|
||||
|
||||
@classmethod
|
||||
def fromMapping(cls, value: Mapping[str, Any] | None = None, **kwargs):
|
||||
@@ -68,19 +75,38 @@ class ProfileMetadata:
|
||||
if isinstance(favorite, str):
|
||||
favorite = favorite.strip().casefold() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
subscriptionSource = str(
|
||||
data.pop('subscriptionSource', data.pop('subsId', '')) or ''
|
||||
)
|
||||
subscriptionManaged = data.pop('subscriptionManaged', None)
|
||||
|
||||
if subscriptionManaged is None:
|
||||
# Before managed ownership was explicit, a non-empty subsId was
|
||||
# written only for profiles imported from that subscription.
|
||||
subscriptionManaged = bool(subscriptionSource)
|
||||
elif isinstance(subscriptionManaged, str):
|
||||
subscriptionManaged = subscriptionManaged.strip().casefold() in (
|
||||
'1',
|
||||
'true',
|
||||
'yes',
|
||||
'on',
|
||||
)
|
||||
|
||||
known = {
|
||||
'profileId': str(data.pop('profileId', '') or uuid.uuid4()),
|
||||
'displayName': data.pop('displayName', data.pop('remark', '')),
|
||||
'group': data.pop('group', ''),
|
||||
'tags': tags,
|
||||
'subscriptionSource': data.pop(
|
||||
'subscriptionSource', data.pop('subsId', '')
|
||||
),
|
||||
'subscriptionSource': subscriptionSource,
|
||||
'subscriptionManaged': bool(subscriptionManaged),
|
||||
'subscriptionProfileKey': str(data.pop('subscriptionProfileKey', '') or ''),
|
||||
'updatedAt': data.pop('updatedAt', ''),
|
||||
'annotations': data.pop('annotations', ''),
|
||||
'favorite': bool(favorite),
|
||||
'latency': data.pop('latency', data.pop('delayResult', '')),
|
||||
'speed': data.pop('speed', data.pop('speedResult', '')),
|
||||
}
|
||||
|
||||
extras = dict(nestedExtras) if isinstance(nestedExtras, Mapping) else {}
|
||||
extras.update(data)
|
||||
|
||||
@@ -89,10 +115,13 @@ class ProfileMetadata:
|
||||
def toMapping(self) -> dict[str, Any]:
|
||||
"""Return the normalized persisted metadata mapping."""
|
||||
return {
|
||||
'profileId': self.profileId,
|
||||
'displayName': self.displayName,
|
||||
'group': self.group,
|
||||
'tags': list(self.tags),
|
||||
'subscriptionSource': self.subscriptionSource,
|
||||
'subscriptionManaged': self.subscriptionManaged,
|
||||
'subscriptionProfileKey': self.subscriptionProfileKey,
|
||||
'updatedAt': self.updatedAt,
|
||||
'annotations': self.annotations,
|
||||
'favorite': self.favorite,
|
||||
@@ -119,6 +148,12 @@ class ProfileMetadata:
|
||||
)
|
||||
elif attribute == 'favorite' and isinstance(value, str):
|
||||
self.favorite = value.strip().casefold() in ('1', 'true', 'yes', 'on')
|
||||
elif attribute == 'subscriptionSource':
|
||||
self.subscriptionSource = str(value or '')
|
||||
|
||||
if not self.subscriptionSource:
|
||||
self.subscriptionManaged = False
|
||||
self.subscriptionProfileKey = ''
|
||||
elif attribute in self.__dataclass_fields__ and attribute != 'extras':
|
||||
setattr(self, attribute, value)
|
||||
else:
|
||||
@@ -183,6 +218,16 @@ class ServerProfile(MutableMapping[str, Any]):
|
||||
"""Return an independent profile copy."""
|
||||
return copy.deepcopy(self)
|
||||
|
||||
def independentCopy(self):
|
||||
"""Return a manual copy with a new identity and no source owner."""
|
||||
profile = self.deepcopy()
|
||||
profile.metadata.profileId = str(uuid.uuid4())
|
||||
profile.metadata.subscriptionSource = ''
|
||||
profile.metadata.subscriptionManaged = False
|
||||
profile.metadata.subscriptionProfileKey = ''
|
||||
|
||||
return profile
|
||||
|
||||
def replaceConnection(self, connection: ConfigFactory):
|
||||
"""Return this profile's metadata composed with a new connection."""
|
||||
return ServerProfile.fromConfiguration(
|
||||
@@ -259,6 +304,11 @@ class ServerProfile(MutableMapping[str, Any]):
|
||||
"""Return the subscription source identifier."""
|
||||
return self.metadata.subscriptionSource
|
||||
|
||||
@property
|
||||
def itemSubscriptionManaged(self) -> bool:
|
||||
"""Return whether a subscription synchronizer owns this profile."""
|
||||
return self.metadata.subscriptionManaged
|
||||
|
||||
@property
|
||||
def itemLatency(self) -> str:
|
||||
"""Return the last latency result."""
|
||||
@@ -284,3 +334,23 @@ def ensureProfile(value, **metadata) -> ServerProfile:
|
||||
return value
|
||||
|
||||
return ServerProfile.fromConfiguration(value, metadata)
|
||||
|
||||
|
||||
def profileConnectionFingerprint(value) -> str:
|
||||
"""Return a deterministic identity for a profile connection document."""
|
||||
connection = connectionOf(value)
|
||||
|
||||
try:
|
||||
serialized = json.dumps(
|
||||
connection,
|
||||
ensure_ascii=False,
|
||||
separators=(',', ':'),
|
||||
sort_keys=True,
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
serializer = getattr(connection, 'toJSONString', None)
|
||||
serialized = serializer(indent=0) if callable(serializer) else str(connection)
|
||||
|
||||
return hashlib.sha256(serialized.encode('utf-8')).hexdigest()
|
||||
|
||||
@@ -32,6 +32,7 @@ from .Profile import (
|
||||
ServerProfile,
|
||||
connectionOf,
|
||||
ensureProfile,
|
||||
profileConnectionFingerprint,
|
||||
)
|
||||
from .Protocol import Protocol
|
||||
|
||||
@@ -48,4 +49,5 @@ __all__ = [
|
||||
'UJSONEncoder',
|
||||
'connectionOf',
|
||||
'ensureProfile',
|
||||
'profileConnectionFingerprint',
|
||||
]
|
||||
|
||||
@@ -242,6 +242,7 @@ class SubscriptionItem:
|
||||
configuration: Optional[Mapping[str, Any]] = None
|
||||
name: str = ''
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
upstreamId: str = ''
|
||||
|
||||
def __post_init__(self):
|
||||
"""Require exactly one serialized or normalized profile value."""
|
||||
|
||||
@@ -24,7 +24,7 @@ from Furious.Interface import *
|
||||
from Furious.Models import ServerProfile
|
||||
from Furious.Repository.Routings import UserRoutings
|
||||
from Furious.Repository.Servers import UserServers
|
||||
from Furious.Repository.Subscriptions import UserSubs
|
||||
from Furious.Repository.Subscriptions import SubscriptionGroup, UserSubs
|
||||
from Furious.Repository.TunSettings import UserTUNSettings
|
||||
|
||||
from typing import Union
|
||||
@@ -89,6 +89,26 @@ class Storage:
|
||||
"""Return the user subs value."""
|
||||
return Storage._UserSubsStorage().data()
|
||||
|
||||
@staticmethod
|
||||
def SubscriptionGroups() -> tuple[SubscriptionGroup, ...]:
|
||||
"""Return first-class subscription groups in their display order."""
|
||||
return Storage._UserSubsStorage().groups()
|
||||
|
||||
@staticmethod
|
||||
def SubscriptionGroup(unique: str) -> SubscriptionGroup | None:
|
||||
"""Return one subscription group by stable ID."""
|
||||
return Storage._UserSubsStorage().group(unique)
|
||||
|
||||
@staticmethod
|
||||
def upsertSubscriptionGroup(group: SubscriptionGroup):
|
||||
"""Persist one subscription group through the shared repository."""
|
||||
Storage._UserSubsStorage().upsertGroup(group)
|
||||
|
||||
@staticmethod
|
||||
def removeSubscriptionGroup(unique: str) -> SubscriptionGroup | None:
|
||||
"""Remove one subscription group through the shared repository."""
|
||||
return Storage._UserSubsStorage().removeGroup(unique)
|
||||
|
||||
@staticmethod
|
||||
def UserTUNSettings() -> dict[str, str]:
|
||||
"""Return the user TUN settings value."""
|
||||
|
||||
@@ -23,16 +23,18 @@ from Furious.Frozenlib import *
|
||||
from Furious.Interface import *
|
||||
from Furious.Models.Encoding import *
|
||||
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
__all__ = ['UserSubs']
|
||||
__all__ = ['SubscriptionGroup', 'UserSubs']
|
||||
|
||||
registerAppSettings('CustomSubscription')
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserSubEntry:
|
||||
"""Describe one user sub entry."""
|
||||
class SubscriptionGroup:
|
||||
"""Describe a subscription source and the profiles that it owns."""
|
||||
|
||||
remark: str = ''
|
||||
webURL: str = ''
|
||||
@@ -42,12 +44,95 @@ class UserSubEntry:
|
||||
userAgent: str = ''
|
||||
filter: str = ''
|
||||
lastUpdated: str = ''
|
||||
id: str = ''
|
||||
sortOrder: int = 0
|
||||
lastDecoderId: str = ''
|
||||
lastSyncStatus: str = ''
|
||||
lastSyncError: str = ''
|
||||
profileCount: int = 0
|
||||
extras: dict[str, Any] = field(default_factory=dict, repr=False)
|
||||
|
||||
@classmethod
|
||||
def fromMapping(cls, unique: str, value: Mapping[str, Any] | None = None):
|
||||
"""Restore a group while preserving fields from newer installations."""
|
||||
data = dict(value or {})
|
||||
nestedExtras = data.pop('extras', {})
|
||||
known = {
|
||||
name: data.pop(name, default)
|
||||
for name, default in (
|
||||
('remark', ''),
|
||||
('webURL', ''),
|
||||
('enabled', True),
|
||||
('autoupdate', ''),
|
||||
('proxy', ''),
|
||||
('userAgent', ''),
|
||||
('filter', ''),
|
||||
('lastUpdated', ''),
|
||||
('sortOrder', 0),
|
||||
('lastDecoderId', data.pop('decoderId', '')),
|
||||
('lastSyncStatus', ''),
|
||||
('lastSyncError', ''),
|
||||
('profileCount', 0),
|
||||
)
|
||||
}
|
||||
|
||||
class UserSub:
|
||||
"""Represent user sub."""
|
||||
for name in (
|
||||
'remark',
|
||||
'webURL',
|
||||
'autoupdate',
|
||||
'proxy',
|
||||
'userAgent',
|
||||
'filter',
|
||||
'lastUpdated',
|
||||
'lastDecoderId',
|
||||
'lastSyncStatus',
|
||||
'lastSyncError',
|
||||
):
|
||||
known[name] = str(known[name] or '')
|
||||
|
||||
unique: dict[str, dict]
|
||||
if isinstance(known['enabled'], str):
|
||||
known['enabled'] = known['enabled'].strip().casefold() in (
|
||||
'1',
|
||||
'true',
|
||||
'yes',
|
||||
'on',
|
||||
)
|
||||
else:
|
||||
known['enabled'] = bool(known['enabled'])
|
||||
|
||||
for name in ('sortOrder', 'profileCount'):
|
||||
try:
|
||||
known[name] = max(0, int(known[name]))
|
||||
except (TypeError, ValueError):
|
||||
known[name] = 0
|
||||
|
||||
extras = dict(nestedExtras) if isinstance(nestedExtras, Mapping) else {}
|
||||
extras.update(data)
|
||||
|
||||
return cls(id=str(unique), **known, extras=extras)
|
||||
|
||||
def toMapping(self) -> dict[str, Any]:
|
||||
"""Return the backward-compatible persisted group mapping."""
|
||||
result = dict(self.extras)
|
||||
result.update(
|
||||
{
|
||||
'remark': self.remark,
|
||||
'webURL': self.webURL,
|
||||
'enabled': self.enabled,
|
||||
'autoupdate': self.autoupdate,
|
||||
'proxy': self.proxy,
|
||||
'userAgent': self.userAgent,
|
||||
'filter': self.filter,
|
||||
'lastUpdated': self.lastUpdated,
|
||||
'sortOrder': self.sortOrder,
|
||||
'lastDecoderId': self.lastDecoderId,
|
||||
'lastSyncStatus': self.lastSyncStatus,
|
||||
'lastSyncError': self.lastSyncError,
|
||||
'profileCount': self.profileCount,
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class UserSubs(Mixins.CleanupOnExit, StorageBackend):
|
||||
@@ -69,7 +154,19 @@ class UserSubs(Mixins.CleanupOnExit, StorageBackend):
|
||||
|
||||
return {}
|
||||
|
||||
self._data = restore()
|
||||
restored = restore()
|
||||
|
||||
self._data = restored if isinstance(restored, dict) else {}
|
||||
|
||||
# Normalize legacy URL-only entries into the current group schema. The
|
||||
# dictionary key remains the stable group ID used by existing profiles.
|
||||
for order, (unique, value) in enumerate(tuple(self._data.items())):
|
||||
group = SubscriptionGroup.fromMapping(unique, value)
|
||||
|
||||
if not group.sortOrder:
|
||||
group.sortOrder = order
|
||||
|
||||
self._data[unique] = group.toMapping()
|
||||
|
||||
def sync(self):
|
||||
"""Persist the current user subs data."""
|
||||
@@ -85,6 +182,45 @@ class UserSubs(Mixins.CleanupOnExit, StorageBackend):
|
||||
"""Return the data managed by the user subs."""
|
||||
return self._data
|
||||
|
||||
def groups(self) -> tuple[SubscriptionGroup, ...]:
|
||||
"""Return subscription groups ordered independently from table rows."""
|
||||
return tuple(
|
||||
sorted(
|
||||
(
|
||||
SubscriptionGroup.fromMapping(unique, value)
|
||||
for unique, value in self._data.items()
|
||||
),
|
||||
key=lambda group: (group.sortOrder, group.remark.casefold(), group.id),
|
||||
)
|
||||
)
|
||||
|
||||
def group(self, unique: str) -> SubscriptionGroup | None:
|
||||
"""Return one group by stable ID."""
|
||||
value = self._data.get(unique)
|
||||
|
||||
return (
|
||||
SubscriptionGroup.fromMapping(unique, value)
|
||||
if isinstance(value, Mapping)
|
||||
else None
|
||||
)
|
||||
|
||||
def upsertGroup(self, group: SubscriptionGroup):
|
||||
"""Insert or replace one group without changing its identity."""
|
||||
if not group.id:
|
||||
raise ValueError('subscription group ID must not be empty')
|
||||
|
||||
self._data[group.id] = group.toMapping()
|
||||
|
||||
def removeGroup(self, unique: str) -> SubscriptionGroup | None:
|
||||
"""Remove and return one group definition."""
|
||||
value = self._data.pop(unique, None)
|
||||
|
||||
return (
|
||||
SubscriptionGroup.fromMapping(unique, value)
|
||||
if isinstance(value, Mapping)
|
||||
else None
|
||||
)
|
||||
|
||||
def cleanup(self):
|
||||
"""Release resources owned by the user subs."""
|
||||
self.sync()
|
||||
|
||||
@@ -20,5 +20,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .Storage import Storage
|
||||
from .Subscriptions import SubscriptionGroup
|
||||
|
||||
__all__ = ['Storage']
|
||||
__all__ = ['Storage', 'SubscriptionGroup']
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Models import ServerProfile
|
||||
from Furious.Models import ServerProfile, profileConnectionFingerprint
|
||||
from Furious.Plugins import getPluginRegistry, profileFromAny
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
@@ -71,12 +71,14 @@ class SubscriptionImportService:
|
||||
|
||||
profiles = []
|
||||
rejected = 0
|
||||
identityOccurrences = {}
|
||||
|
||||
for item in result.items:
|
||||
value = item.configuration if item.configuration is not None else item.uri
|
||||
metadata = {
|
||||
**dict(item.metadata),
|
||||
'subscriptionSource': source.id,
|
||||
'subscriptionManaged': True,
|
||||
'updatedAt': source.updatedAt,
|
||||
}
|
||||
|
||||
@@ -89,6 +91,17 @@ class SubscriptionImportService:
|
||||
**metadata,
|
||||
)
|
||||
|
||||
baseIdentity = (
|
||||
f'upstream:{item.upstreamId}'
|
||||
if item.upstreamId
|
||||
else f'config:{profileConnectionFingerprint(profile)}'
|
||||
)
|
||||
occurrence = identityOccurrences.get(baseIdentity, 0)
|
||||
identityOccurrences[baseIdentity] = occurrence + 1
|
||||
profile.metadata.subscriptionProfileKey = (
|
||||
baseIdentity if occurrence == 0 else f'{baseIdentity}#{occurrence + 1}'
|
||||
)
|
||||
|
||||
if not self.registry.validateConfig(profile):
|
||||
profiles.append(profile)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# 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/>.
|
||||
|
||||
"""Reconcile subscription-owned profiles without affecting other groups."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Models import ServerProfile, profileConnectionFingerprint
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
__all__ = ['SubscriptionSyncResult', 'SubscriptionSynchronizer']
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionSyncResult:
|
||||
"""Describe one atomic subscription-group synchronization."""
|
||||
|
||||
groupId: str
|
||||
added: int
|
||||
updated: int
|
||||
removed: int
|
||||
profileIds: tuple[str, ...]
|
||||
removedProfileIds: tuple[str, ...]
|
||||
changedProfileIds: tuple[str, ...]
|
||||
|
||||
|
||||
class SubscriptionSynchronizer:
|
||||
"""Own stable, group-scoped profile reconciliation semantics."""
|
||||
|
||||
LocalMetadataFields = (
|
||||
'annotations',
|
||||
'favorite',
|
||||
'group',
|
||||
'latency',
|
||||
'speed',
|
||||
'tags',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _ensureKeys(profiles: list[ServerProfile], groupId: str):
|
||||
"""Migrate legacy group profiles to deterministic occurrence keys."""
|
||||
occurrences = {}
|
||||
|
||||
for profile in profiles:
|
||||
metadata = profile.metadata
|
||||
|
||||
if metadata.subscriptionSource != groupId:
|
||||
continue
|
||||
|
||||
if not metadata.subscriptionManaged:
|
||||
continue
|
||||
|
||||
if metadata.subscriptionProfileKey:
|
||||
continue
|
||||
|
||||
baseIdentity = f'config:{profileConnectionFingerprint(profile)}'
|
||||
occurrence = occurrences.get(baseIdentity, 0)
|
||||
occurrences[baseIdentity] = occurrence + 1
|
||||
metadata.subscriptionProfileKey = (
|
||||
baseIdentity if occurrence == 0 else f'{baseIdentity}#{occurrence + 1}'
|
||||
)
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
profiles: list[ServerProfile],
|
||||
incomingProfiles,
|
||||
groupId: str,
|
||||
) -> SubscriptionSyncResult:
|
||||
"""Atomically replace only profiles managed by *groupId*."""
|
||||
if not groupId:
|
||||
raise ValueError('subscription group ID must not be empty')
|
||||
|
||||
incoming = list(incomingProfiles)
|
||||
|
||||
self._ensureKeys(profiles, groupId)
|
||||
|
||||
managedIndexes = [
|
||||
index
|
||||
for index, profile in enumerate(profiles)
|
||||
if profile.itemSubscription == groupId and profile.itemSubscriptionManaged
|
||||
]
|
||||
insertionIndex = min(managedIndexes) if managedIndexes else len(profiles)
|
||||
existingByKey = {
|
||||
profiles[index].metadata.subscriptionProfileKey: profiles[index]
|
||||
for index in managedIndexes
|
||||
}
|
||||
synchronized = []
|
||||
updated = 0
|
||||
added = 0
|
||||
changedProfileIds = []
|
||||
|
||||
for profile in incoming:
|
||||
metadata = profile.metadata
|
||||
metadata.subscriptionSource = groupId
|
||||
metadata.subscriptionManaged = True
|
||||
|
||||
if not metadata.subscriptionProfileKey:
|
||||
metadata.subscriptionProfileKey = (
|
||||
f'config:{profileConnectionFingerprint(profile)}'
|
||||
)
|
||||
|
||||
existing = existingByKey.pop(metadata.subscriptionProfileKey, None)
|
||||
|
||||
if existing is None:
|
||||
added += 1
|
||||
synchronized.append(profile)
|
||||
|
||||
continue
|
||||
|
||||
oldFingerprint = profileConnectionFingerprint(existing)
|
||||
|
||||
for fieldName in self.LocalMetadataFields:
|
||||
setattr(metadata, fieldName, getattr(existing.metadata, fieldName))
|
||||
|
||||
metadata.profileId = existing.metadata.profileId
|
||||
|
||||
existing.connection = profile.connection
|
||||
existing.metadata = metadata
|
||||
existing.deleted = False
|
||||
|
||||
synchronized.append(existing)
|
||||
updated += 1
|
||||
|
||||
if oldFingerprint != profileConnectionFingerprint(existing):
|
||||
changedProfileIds.append(metadata.profileId)
|
||||
|
||||
removedProfiles = tuple(existingByKey.values())
|
||||
|
||||
for profile in removedProfiles:
|
||||
profile.deleted = True
|
||||
|
||||
unmanagedOrOther = [
|
||||
profile
|
||||
for profile in profiles
|
||||
if not (
|
||||
profile.itemSubscription == groupId and profile.itemSubscriptionManaged
|
||||
)
|
||||
]
|
||||
profiles[:] = (
|
||||
unmanagedOrOther[:insertionIndex]
|
||||
+ synchronized
|
||||
+ unmanagedOrOther[insertionIndex:]
|
||||
)
|
||||
|
||||
for index, profile in enumerate(profiles):
|
||||
profile.index = index
|
||||
profile.deleted = False
|
||||
|
||||
return SubscriptionSyncResult(
|
||||
groupId=groupId,
|
||||
added=added,
|
||||
updated=updated,
|
||||
removed=len(removedProfiles),
|
||||
profileIds=tuple(profile.metadata.profileId for profile in synchronized),
|
||||
removedProfileIds=tuple(
|
||||
profile.metadata.profileId for profile in removedProfiles
|
||||
),
|
||||
changedProfileIds=tuple(changedProfileIds),
|
||||
)
|
||||
@@ -47,6 +47,7 @@ from .SubscriptionImporter import (
|
||||
SubscriptionImportService,
|
||||
SubscriptionSource,
|
||||
)
|
||||
from .SubscriptionSync import SubscriptionSyncResult, SubscriptionSynchronizer
|
||||
from .TrafficStatsManager import (
|
||||
TrafficStatsSample,
|
||||
TrafficStatsManager,
|
||||
@@ -77,6 +78,8 @@ __all__ = [
|
||||
'SubscriptionImportResult',
|
||||
'SubscriptionImportService',
|
||||
'SubscriptionSource',
|
||||
'SubscriptionSyncResult',
|
||||
'SubscriptionSynchronizer',
|
||||
'TrafficStatsSample',
|
||||
'TrafficStatsManager',
|
||||
'UpdateManager',
|
||||
|
||||
@@ -35,6 +35,7 @@ from Furious.Service import (
|
||||
ConnectionManager,
|
||||
SubscriptionImportService,
|
||||
SubscriptionSource,
|
||||
SubscriptionSynchronizer,
|
||||
coreLogCallback,
|
||||
)
|
||||
from Furious.Widget.WaitingSpinner import *
|
||||
@@ -161,9 +162,10 @@ class SubscriptionManager(WebGETManager):
|
||||
super().__init__(parent, actionMessage=actionMessage, mustCallOnce=False)
|
||||
|
||||
self.importer = SubscriptionImportService()
|
||||
self.synchronizer = SubscriptionSynchronizer()
|
||||
|
||||
def handleItemDeletionAndInsertion(self, **kwargs):
|
||||
"""Handle item deletion and insertion."""
|
||||
def handleSynchronizationResults(self, **kwargs):
|
||||
"""Commit successful group-scoped synchronization results."""
|
||||
successArgs = kwargs.pop('successArgs', list())
|
||||
failureArgs = kwargs.pop('failureArgs', list())
|
||||
showMessageBox = kwargs.pop('showMessageBox', True)
|
||||
@@ -174,45 +176,22 @@ class SubscriptionManager(WebGETManager):
|
||||
parent = self.parent()
|
||||
|
||||
if isinstance(parent, ServerTableView):
|
||||
isConnected = APP().isSystemTrayConnected()
|
||||
|
||||
subsIndexes = list(
|
||||
index
|
||||
for index, server in enumerate(Storage.UserServers())
|
||||
if server.itemSubscription == unique
|
||||
result = parent.synchronizeSubscriptionProfiles(
|
||||
unique,
|
||||
profiles,
|
||||
self.synchronizer,
|
||||
)
|
||||
param['syncResult'] = result
|
||||
|
||||
subsGroupIndex = -1
|
||||
activatedIndex = Storage.UserActivatedItemIndex()
|
||||
group = Storage.SubscriptionGroup(unique)
|
||||
|
||||
if activatedIndex in subsIndexes:
|
||||
for index, server in enumerate(Storage.UserServers()):
|
||||
if index <= activatedIndex:
|
||||
if server.itemSubscription == unique:
|
||||
subsGroupIndex += 1
|
||||
else:
|
||||
break
|
||||
if group is not None:
|
||||
group.lastDecoderId = param.get('decoderId', '')
|
||||
group.lastSyncStatus = 'success'
|
||||
group.lastSyncError = ''
|
||||
group.profileCount = len(result.profileIds)
|
||||
|
||||
parent.deleteItemByIndex(
|
||||
subsIndexes,
|
||||
showTrayMessage=bool(subsGroupIndex < 0),
|
||||
showProgress=False,
|
||||
)
|
||||
|
||||
remaining = len(Storage.UserServers())
|
||||
|
||||
for profile in profiles:
|
||||
parent.appendNewItemByFactory(profile)
|
||||
|
||||
if subsGroupIndex >= 0:
|
||||
newIndex = remaining + subsGroupIndex
|
||||
|
||||
if newIndex < len(Storage.UserServers()):
|
||||
parent.activateItemByIndex(newIndex, True)
|
||||
|
||||
if isConnected and not APP().isSystemTrayConnected():
|
||||
# Trigger connect
|
||||
APP().systemTray.ConnectAction.trigger()
|
||||
Storage.upsertSubscriptionGroup(group)
|
||||
|
||||
if showMessageBox:
|
||||
mbox = MBoxUpdateSubsInfo(
|
||||
@@ -240,7 +219,7 @@ class SubscriptionManager(WebGETManager):
|
||||
depthMap['depth'] -= 1
|
||||
|
||||
if depthMap['depth'] == 0:
|
||||
self.handleItemDeletionAndInsertion(**kwargs)
|
||||
self.handleSynchronizationResults(**kwargs)
|
||||
|
||||
def successCallback(self, networkReply, **kwargs):
|
||||
"""Handle a successful network operation."""
|
||||
@@ -281,6 +260,13 @@ class SubscriptionManager(WebGETManager):
|
||||
|
||||
if result is None or not result.profiles:
|
||||
failureArgs.append({'error': 'UnsupportedSubscriptionFormat', **kwargs})
|
||||
|
||||
group = Storage.SubscriptionGroup(kwargs.get('unique', ''))
|
||||
|
||||
if group is not None:
|
||||
group.lastSyncStatus = 'error'
|
||||
group.lastSyncError = 'UnsupportedSubscriptionFormat'
|
||||
Storage.upsertSubscriptionGroup(group)
|
||||
else:
|
||||
logger.info(
|
||||
f'update subs ({remark}, {webURL}) success. '
|
||||
@@ -288,14 +274,27 @@ class SubscriptionManager(WebGETManager):
|
||||
f'rejected {result.rejectedItems}'
|
||||
)
|
||||
|
||||
successArgs.append({'profiles': result.profiles, **kwargs})
|
||||
successArgs.append(
|
||||
{
|
||||
**kwargs,
|
||||
'profiles': result.profiles,
|
||||
'decoderId': result.decoderId,
|
||||
}
|
||||
)
|
||||
|
||||
unique = kwargs.get('unique', '')
|
||||
|
||||
if unique in Storage.UserSubs():
|
||||
Storage.UserSubs()[unique]['lastUpdated'] = (
|
||||
datetime.datetime.now().astimezone().isoformat(timespec='seconds')
|
||||
)
|
||||
group = Storage.SubscriptionGroup(unique)
|
||||
|
||||
if group is not None:
|
||||
group.lastUpdated = (
|
||||
datetime.datetime.now()
|
||||
.astimezone()
|
||||
.isoformat(timespec='seconds')
|
||||
)
|
||||
group.lastDecoderId = result.decoderId
|
||||
Storage.upsertSubscriptionGroup(group)
|
||||
|
||||
def failureCallback(self, networkReply, **kwargs):
|
||||
"""Handle a failed network operation."""
|
||||
@@ -310,6 +309,13 @@ class SubscriptionManager(WebGETManager):
|
||||
|
||||
failureArgs.append({'error': networkReply.errorString(), **kwargs})
|
||||
|
||||
group = Storage.SubscriptionGroup(kwargs.get('unique', ''))
|
||||
|
||||
if group is not None:
|
||||
group.lastSyncStatus = 'error'
|
||||
group.lastSyncError = networkReply.errorString()
|
||||
Storage.upsertSubscriptionGroup(group)
|
||||
|
||||
def updateSubsByWebGET(self, **kwargs):
|
||||
"""Update subs by web get."""
|
||||
url = kwargs.get('webURL', '')
|
||||
@@ -335,9 +341,20 @@ class SubscriptionManager(WebGETManager):
|
||||
"""Update subs by unique."""
|
||||
subscription = Storage.UserSubs().get(unique)
|
||||
|
||||
if not subscription or not subscription.get('enabled', True):
|
||||
if (
|
||||
not subscription
|
||||
or not subscription.get('enabled', True)
|
||||
or not subscription.get('webURL')
|
||||
):
|
||||
return
|
||||
|
||||
group = Storage.SubscriptionGroup(unique)
|
||||
|
||||
if group is not None:
|
||||
group.lastSyncStatus = 'syncing'
|
||||
group.lastSyncError = ''
|
||||
Storage.upsertSubscriptionGroup(group)
|
||||
|
||||
depthMap = kwargs.get('depthMap', {'depth': 1})
|
||||
successArgs = kwargs.get('successArgs', list())
|
||||
failureArgs = kwargs.get('failureArgs', list())
|
||||
@@ -1083,9 +1100,19 @@ class ServerTableColumn:
|
||||
|
||||
def _subscriptionRemark(item: ServerProfile) -> str:
|
||||
"""Resolve a persisted subscription ID to its user-visible remark."""
|
||||
if not item.itemSubscriptionManaged:
|
||||
return ''
|
||||
|
||||
subscription = Storage.UserSubs().get(item.itemSubscription, {})
|
||||
|
||||
return subscription.get('remark', '')
|
||||
if not subscription:
|
||||
return _('Unknown Subscription')
|
||||
|
||||
remark = subscription.get('remark', '') or item.itemSubscription
|
||||
|
||||
return (
|
||||
remark if subscription.get('enabled', True) else f'{remark} ({_("Disabled")})'
|
||||
)
|
||||
|
||||
|
||||
class UserServersTableModel(QtCore.QAbstractTableModel):
|
||||
@@ -1299,6 +1326,7 @@ class UserServersSortFilterProxyModel(QtCore.QSortFilterProxyModel):
|
||||
self.searchCaseSensitive = False
|
||||
self.searchUseRegex = True
|
||||
self.searchRegex = None
|
||||
self.subscriptionFilter = None
|
||||
self.sortSuspended = False
|
||||
|
||||
self.setSortRole(UserServersTableModel.SortRole)
|
||||
@@ -1357,16 +1385,33 @@ class UserServersSortFilterProxyModel(QtCore.QSortFilterProxyModel):
|
||||
|
||||
self.invalidateFilter()
|
||||
|
||||
def setSubscriptionFilter(self, unique: str | None):
|
||||
"""Limit rows to manual profiles or one subscription group."""
|
||||
self.subscriptionFilter = unique
|
||||
self.invalidateFilter()
|
||||
|
||||
def filterAcceptsRow(self, sourceRow: int, sourceParent) -> bool:
|
||||
"""Filter accepts row."""
|
||||
if not self.searchPattern or self.searchRegex is None:
|
||||
return True
|
||||
|
||||
model = self.sourceModel()
|
||||
|
||||
if model is None:
|
||||
return True
|
||||
|
||||
if 0 <= sourceRow < len(Storage.UserServers()):
|
||||
profile = Storage.UserServers()[sourceRow]
|
||||
|
||||
if self.subscriptionFilter == '':
|
||||
if profile.itemSubscriptionManaged:
|
||||
return False
|
||||
elif (
|
||||
self.subscriptionFilter is not None
|
||||
and profile.itemSubscription != self.subscriptionFilter
|
||||
):
|
||||
return False
|
||||
|
||||
if not self.searchPattern or self.searchRegex is None:
|
||||
return True
|
||||
|
||||
searchableText = '\n'.join(
|
||||
str(
|
||||
model.data(
|
||||
@@ -1445,6 +1490,7 @@ class ServerTableView(
|
||||
self.setModel(self.proxyModel)
|
||||
|
||||
self.subsManager = SubscriptionManager(parent=self)
|
||||
self.subsManager.subscriptionsChanged.connect(self.flushAll)
|
||||
|
||||
self.downloadSpeedScheduler = DownloadSpeedTestScheduler(
|
||||
self,
|
||||
@@ -1972,6 +2018,10 @@ class ServerTableView(
|
||||
"""Clear search."""
|
||||
self.search('')
|
||||
|
||||
def filterBySubscription(self, unique: str | None):
|
||||
"""Show all, manual, or one subscription group's profiles."""
|
||||
self.proxyModel.setSubscriptionFilter(unique)
|
||||
|
||||
def addServerViaGui(
|
||||
self,
|
||||
protocol,
|
||||
@@ -2138,9 +2188,10 @@ class ServerTableView(
|
||||
|
||||
for index in indexes:
|
||||
if 0 <= index < len(Storage.UserServers()):
|
||||
deepcopy = Storage.UserServers()[index].deepcopy()
|
||||
deepcopy = Storage.UserServers()[index].independentCopy()
|
||||
|
||||
# Do not clone subsId
|
||||
# A duplicate is a new manual profile, not another profile
|
||||
# managed by the source subscription.
|
||||
self.appendNewItem(
|
||||
remark=deepcopy.itemRemark,
|
||||
config=deepcopy,
|
||||
@@ -2483,6 +2534,57 @@ class ServerTableView(
|
||||
self.subsManager.configureHttpProxy(httpProxy)
|
||||
self.subsManager.updateSubs(**kwargs)
|
||||
|
||||
def synchronizeSubscriptionProfiles(
|
||||
self,
|
||||
unique: str,
|
||||
profiles,
|
||||
synchronizer: SubscriptionSynchronizer | None = None,
|
||||
):
|
||||
"""Apply one group update without replacing unrelated profiles."""
|
||||
synchronizer = synchronizer or SubscriptionSynchronizer()
|
||||
servers = Storage.UserServers()
|
||||
activatedIndex = Storage.UserActivatedItemIndex()
|
||||
activeProfileId = ''
|
||||
activeWasManagedByGroup = False
|
||||
|
||||
if 0 <= activatedIndex < len(servers):
|
||||
active = servers[activatedIndex]
|
||||
activeProfileId = active.metadata.profileId
|
||||
activeWasManagedByGroup = (
|
||||
active.itemSubscription == unique and active.itemSubscriptionManaged
|
||||
)
|
||||
|
||||
wasConnected = APP().isSystemTrayConnected()
|
||||
|
||||
self.sourceModel.beginResetModel()
|
||||
|
||||
try:
|
||||
result = synchronizer.reconcile(servers, profiles, unique)
|
||||
finally:
|
||||
self.sourceModel.endResetModel()
|
||||
|
||||
newActivatedIndex = next(
|
||||
(
|
||||
index
|
||||
for index, profile in enumerate(servers)
|
||||
if profile.metadata.profileId == activeProfileId
|
||||
),
|
||||
-1,
|
||||
)
|
||||
|
||||
AppSettings.set('ActivatedItemIndex', str(newActivatedIndex))
|
||||
|
||||
self.sourceModel.refreshIndexes()
|
||||
self.proxyModel.invalidate()
|
||||
|
||||
if wasConnected and activeProfileId:
|
||||
if newActivatedIndex < 0 and activeWasManagedByGroup:
|
||||
APP().systemTray.ConnectAction.doDisconnect()
|
||||
elif activeProfileId in result.changedProfileIds:
|
||||
APP().systemTray.ConnectAction.doReconnect()
|
||||
|
||||
return result
|
||||
|
||||
def appendNewItemByFactory(self, factory: ConfigFactory | ServerProfile):
|
||||
"""Append new item by factory."""
|
||||
factory = ensureProfile(factory)
|
||||
|
||||
@@ -335,6 +335,7 @@ class UserSubsTableModel(QtCore.QAbstractTableModel):
|
||||
1
|
||||
for profile in Storage.UserServers()
|
||||
if profile.itemSubscription == unique
|
||||
and profile.itemSubscriptionManaged
|
||||
)
|
||||
)
|
||||
|
||||
@@ -468,6 +469,7 @@ _TRANSLATABLE_HEADERS = [
|
||||
class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
|
||||
"""Represent user subs Qt table view."""
|
||||
|
||||
groupsChanged = QtCore.Signal()
|
||||
RowHeight = 42
|
||||
|
||||
AutoUpdateOptions = {
|
||||
@@ -557,6 +559,15 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
|
||||
self.setDefaultDropAction(QtCore.Qt.DropAction.IgnoreAction)
|
||||
|
||||
contextMenuActions = [
|
||||
AppQAction(
|
||||
_('Move Up'),
|
||||
callback=lambda: self.moveSelectedGroup(-1),
|
||||
),
|
||||
AppQAction(
|
||||
_('Move Down'),
|
||||
callback=lambda: self.moveSelectedGroup(1),
|
||||
),
|
||||
AppQSeperator(),
|
||||
AppQAction(
|
||||
_('Delete'),
|
||||
callback=lambda: self.deleteSelectedItem(),
|
||||
@@ -614,7 +625,7 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
|
||||
deleteIndex,
|
||||
)
|
||||
|
||||
Storage.UserSubs().pop(deleteUnique)
|
||||
Storage.removeSubscriptionGroup(deleteUnique)
|
||||
|
||||
# Begin timer cleanup
|
||||
qtimer = self.timers[deleteIndex]
|
||||
@@ -642,7 +653,11 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
|
||||
if callable(self.deleteUniqueCallback):
|
||||
self.deleteUniqueCallback(deleteUnique)
|
||||
|
||||
for order, value in enumerate(Storage.UserSubs().values()):
|
||||
value['sortOrder'] = order
|
||||
|
||||
self.flushAll()
|
||||
self.groupsChanged.emit()
|
||||
else:
|
||||
# Do not delete
|
||||
pass
|
||||
@@ -668,6 +683,44 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
|
||||
# Show the MessageBox asynchronously
|
||||
mbox.open()
|
||||
|
||||
def moveSelectedGroup(self, offset: int):
|
||||
"""Move one group while preserving its stable ID and timer."""
|
||||
indexes = self.selectedIndex
|
||||
|
||||
if len(indexes) != 1 or offset not in (-1, 1):
|
||||
return
|
||||
|
||||
source = indexes[0]
|
||||
target = source + offset
|
||||
|
||||
items = list(Storage.UserSubs().items())
|
||||
|
||||
if target < 0 or target >= len(items):
|
||||
return
|
||||
|
||||
self.sourceModel.layoutAboutToBeChanged.emit()
|
||||
|
||||
item = items.pop(source)
|
||||
|
||||
items.insert(target, item)
|
||||
|
||||
Storage.UserSubs().clear()
|
||||
Storage.UserSubs().update(items)
|
||||
|
||||
timer = self.timers.pop(source)
|
||||
|
||||
timerConnected = self.timerConnected.pop(source)
|
||||
|
||||
self.timers.insert(target, timer)
|
||||
self.timerConnected.insert(target, timerConnected)
|
||||
|
||||
for order, (unique, value) in enumerate(items):
|
||||
value['sortOrder'] = order
|
||||
|
||||
self.sourceModel.layoutChanged.emit()
|
||||
self.selectRow(target)
|
||||
self.groupsChanged.emit()
|
||||
|
||||
def _configureAutoUpdate(self, row: int):
|
||||
"""Configure one subscription timer independently of table columns."""
|
||||
unique = list(Storage.UserSubs().keys())[row]
|
||||
@@ -800,23 +853,26 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
|
||||
kwargs.pop('lastUpdated', ''),
|
||||
)
|
||||
|
||||
# The internal subs object
|
||||
subsob = {
|
||||
unique: {
|
||||
'remark': remark,
|
||||
'webURL': webURL,
|
||||
'enabled': bool(enabled),
|
||||
'autoupdate': autoupdate,
|
||||
'proxy': proxy,
|
||||
'userAgent': userAgent,
|
||||
'filter': profileFilter,
|
||||
'lastUpdated': 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():
|
||||
row = list(Storage.UserSubs().keys()).index(unique)
|
||||
Storage.UserSubs().update(subsob)
|
||||
|
||||
Storage.upsertSubscriptionGroup(group)
|
||||
|
||||
self.flushRow(row, subsob[unique])
|
||||
else:
|
||||
row = self.sourceModel.rowCount()
|
||||
@@ -826,11 +882,15 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
|
||||
self.timerConnected.append(False)
|
||||
|
||||
self.sourceModel.beginInsertRows(QtCore.QModelIndex(), row, row)
|
||||
Storage.UserSubs().update(subsob)
|
||||
|
||||
Storage.upsertSubscriptionGroup(group)
|
||||
|
||||
self.sourceModel.endInsertRows()
|
||||
|
||||
self.flushRow(row, subsob[unique])
|
||||
|
||||
self.groupsChanged.emit()
|
||||
|
||||
@staticmethod
|
||||
def updateSubsByUnique(
|
||||
unique: str, httpProxy: Union[str, Callable, None], **kwargs
|
||||
|
||||
@@ -548,8 +548,11 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
|
||||
)
|
||||
|
||||
self.searchButton = SearchButton()
|
||||
self.subscriptionFilterComboBox = AppQComboBox()
|
||||
self.subscriptionFilterComboBox.setMinimumWidth(180)
|
||||
|
||||
self.searchLayout.addWidget(self.searchLineEdit)
|
||||
self.searchLayout.addWidget(self.subscriptionFilterComboBox)
|
||||
self.searchLayout.addWidget(self.searchButton)
|
||||
|
||||
self._layout.addLayout(self.searchLayout)
|
||||
@@ -558,11 +561,22 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
|
||||
self.searchButton.clicked.connect(
|
||||
lambda: self.userServersQTableWidget.search(self.searchLineEdit.text())
|
||||
)
|
||||
|
||||
self.searchLineEdit.returnPressed.connect(
|
||||
lambda: self.userServersQTableWidget.search(self.searchLineEdit.text())
|
||||
)
|
||||
self.searchLineEdit.textChanged.connect(self.handleUserServersSearchTextChanged)
|
||||
|
||||
self.subscriptionFilterComboBox.currentIndexChanged.connect(
|
||||
self.handleSubscriptionFilterChanged
|
||||
)
|
||||
|
||||
self.userServersQTableWidget.subsManager.subscriptionsChanged.connect(
|
||||
self.refreshSubscriptionFilter
|
||||
)
|
||||
|
||||
self.refreshSubscriptionFilter()
|
||||
|
||||
self.setCentralWidget(self._widget)
|
||||
|
||||
@QtCore.Slot(str)
|
||||
@@ -571,6 +585,42 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
|
||||
if not text:
|
||||
self.userServersQTableWidget.clearSearch()
|
||||
|
||||
@QtCore.Slot()
|
||||
def refreshSubscriptionFilter(self):
|
||||
"""Refresh the stable group selector without losing its selection."""
|
||||
selected = self.subscriptionFilterComboBox.currentData()
|
||||
|
||||
with Mixins.QBlockSignalContext(self.subscriptionFilterComboBox):
|
||||
self.subscriptionFilterComboBox.clear()
|
||||
self.subscriptionFilterComboBox.addItem(_('All Profiles'), None)
|
||||
self.subscriptionFilterComboBox.addItem(_('Manual Profiles'), '')
|
||||
|
||||
for group in Storage.SubscriptionGroups():
|
||||
self.subscriptionFilterComboBox.addItem(
|
||||
group.remark or group.webURL or group.id,
|
||||
group.id,
|
||||
)
|
||||
|
||||
index = self.subscriptionFilterComboBox.findData(selected)
|
||||
|
||||
self.subscriptionFilterComboBox.setCurrentIndex(max(0, index))
|
||||
|
||||
self.handleSubscriptionFilterChanged()
|
||||
|
||||
@QtCore.Slot()
|
||||
def handleSubscriptionFilterChanged(self):
|
||||
"""Apply the selected ownership group to the server table."""
|
||||
self.userServersQTableWidget.filterBySubscription(
|
||||
self.subscriptionFilterComboBox.currentData()
|
||||
)
|
||||
|
||||
def showSubscriptionGroup(self, unique: str):
|
||||
"""Select one group in the Home profile filter."""
|
||||
index = self.subscriptionFilterComboBox.findData(unique)
|
||||
|
||||
if index >= 0:
|
||||
self.subscriptionFilterComboBox.setCurrentIndex(index)
|
||||
|
||||
def updateSubsByUnique(self, unique: str, httpProxy: Union[str, None], **kwargs):
|
||||
"""Update subs by unique."""
|
||||
self.userServersQTableWidget.updateSubsByUnique(unique, httpProxy, **kwargs)
|
||||
|
||||
@@ -213,6 +213,7 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
|
||||
self.editButton = AppQPushButton(_('Edit'))
|
||||
self.deleteButton = AppQPushButton(_('Delete'))
|
||||
self.copyURLButton = AppQPushButton(_('Copy URL'))
|
||||
self.viewProfilesButton = AppQPushButton(_('View Profiles'))
|
||||
self.updateSelectedButton = AppQPushButton(_('Update Selected'))
|
||||
self.updateAllButton = AppQPushButton(_('Update All'))
|
||||
|
||||
@@ -221,6 +222,9 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
|
||||
parent=self,
|
||||
)
|
||||
self.table.doubleClicked.connect(lambda _index: self.editSelected())
|
||||
self.table.groupsChanged.connect(
|
||||
self.serverTable.subsManager.subscriptionsChanged.emit
|
||||
)
|
||||
|
||||
self.serverTable.subsManager.subscriptionsChanged.connect(self.table.flushAll)
|
||||
|
||||
@@ -228,6 +232,7 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
|
||||
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)
|
||||
|
||||
@@ -251,6 +256,7 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
|
||||
actions.addWidget(self.editButton)
|
||||
actions.addWidget(self.deleteButton)
|
||||
actions.addWidget(self.copyURLButton)
|
||||
actions.addWidget(self.viewProfilesButton)
|
||||
actions.addStretch(1)
|
||||
|
||||
content = QWidget()
|
||||
@@ -273,11 +279,19 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
|
||||
indexes = [
|
||||
index
|
||||
for index, server in enumerate(Storage.UserServers())
|
||||
if server.itemSubscription == unique
|
||||
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
|
||||
@@ -389,6 +403,26 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
|
||||
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 ''
|
||||
@@ -445,6 +479,7 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
|
||||
(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'),
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user