diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index c7405e7..da3b9ed 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -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" } } diff --git a/Furious/Models/Profile.py b/Furious/Models/Profile.py index 08923c5..428951b 100644 --- a/Furious/Models/Profile.py +++ b/Furious/Models/Profile.py @@ -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() diff --git a/Furious/Models/__init__.py b/Furious/Models/__init__.py index 701a718..4b0421d 100644 --- a/Furious/Models/__init__.py +++ b/Furious/Models/__init__.py @@ -32,6 +32,7 @@ from .Profile import ( ServerProfile, connectionOf, ensureProfile, + profileConnectionFingerprint, ) from .Protocol import Protocol @@ -48,4 +49,5 @@ __all__ = [ 'UJSONEncoder', 'connectionOf', 'ensureProfile', + 'profileConnectionFingerprint', ] diff --git a/Furious/Plugins/API.py b/Furious/Plugins/API.py index c3f4492..f84934c 100644 --- a/Furious/Plugins/API.py +++ b/Furious/Plugins/API.py @@ -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.""" diff --git a/Furious/Repository/Storage.py b/Furious/Repository/Storage.py index 0b75f59..73577b7 100644 --- a/Furious/Repository/Storage.py +++ b/Furious/Repository/Storage.py @@ -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.""" diff --git a/Furious/Repository/Subscriptions.py b/Furious/Repository/Subscriptions.py index e114496..a818977 100644 --- a/Furious/Repository/Subscriptions.py +++ b/Furious/Repository/Subscriptions.py @@ -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() diff --git a/Furious/Repository/__init__.py b/Furious/Repository/__init__.py index 4f51c4d..d0280a2 100644 --- a/Furious/Repository/__init__.py +++ b/Furious/Repository/__init__.py @@ -20,5 +20,6 @@ from __future__ import annotations from .Storage import Storage +from .Subscriptions import SubscriptionGroup -__all__ = ['Storage'] +__all__ = ['Storage', 'SubscriptionGroup'] diff --git a/Furious/Service/SubscriptionImporter.py b/Furious/Service/SubscriptionImporter.py index c1f0d77..eb8e8e9 100644 --- a/Furious/Service/SubscriptionImporter.py +++ b/Furious/Service/SubscriptionImporter.py @@ -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: diff --git a/Furious/Service/SubscriptionSync.py b/Furious/Service/SubscriptionSync.py new file mode 100644 index 0000000..483a225 --- /dev/null +++ b/Furious/Service/SubscriptionSync.py @@ -0,0 +1,174 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# 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 . + +"""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), + ) diff --git a/Furious/Service/__init__.py b/Furious/Service/__init__.py index 4cea47d..ca70d5d 100644 --- a/Furious/Service/__init__.py +++ b/Furious/Service/__init__.py @@ -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', diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index 1b2b074..7391a1e 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -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) diff --git a/Furious/Widget/SubscriptionTableView.py b/Furious/Widget/SubscriptionTableView.py index 518fc1c..797b05d 100644 --- a/Furious/Widget/SubscriptionTableView.py +++ b/Furious/Widget/SubscriptionTableView.py @@ -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 diff --git a/Furious/Window/HomePage.py b/Furious/Window/HomePage.py index 56825f0..82e301c 100644 --- a/Furious/Window/HomePage.py +++ b/Furious/Window/HomePage.py @@ -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) diff --git a/Furious/Window/SubscriptionPage.py b/Furious/Window/SubscriptionPage.py index e2b7eca..0c1d134 100644 --- a/Furious/Window/SubscriptionPage.py +++ b/Furious/Window/SubscriptionPage.py @@ -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'), ):