# 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 . """Persist subscription definitions.""" from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * from Furious.Models.Encoding import * from collections.abc import Mapping from dataclasses import dataclass, field from typing import Any import logging __all__ = ['SubscriptionGroup', 'UserSubs'] logger = logging.getLogger(__name__) registerAppSettings('CustomSubscription') @dataclass class SubscriptionGroup: """Describe a subscription source and the profiles that it owns.""" remark: str = '' webURL: str = '' enabled: bool = True autoupdate: str = '' proxy: str = '' userAgent: str = '' filter: str = '' lastUpdated: str = '' id: str = '' sortOrder: int = 0 lastDecoderId: str = '' lastSyncStatus: str = '' lastSyncError: str = '' profileCount: int = 0 subscriptionUpload: int = 0 subscriptionDownload: int = 0 subscriptionTotal: int = 0 subscriptionExpire: 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), ('subscriptionUpload', 0), ('subscriptionDownload', 0), ('subscriptionTotal', 0), ('subscriptionExpire', 0), ) } for name in ( 'remark', 'webURL', 'autoupdate', 'proxy', 'userAgent', 'filter', 'lastUpdated', 'lastDecoderId', 'lastSyncStatus', 'lastSyncError', ): known[name] = str(known[name] or '') 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 for name in ( 'subscriptionUpload', 'subscriptionDownload', 'subscriptionTotal', 'subscriptionExpire', ): try: known[name] = min(max(0, int(known[name])), (1 << 63) - 1) 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, 'subscriptionUpload': self.subscriptionUpload, 'subscriptionDownload': self.subscriptionDownload, 'subscriptionTotal': self.subscriptionTotal, 'subscriptionExpire': self.subscriptionExpire, } ) return result class UserSubs(Mixins.CleanupOnExit, StorageBackend): # unique: { remark, webURL } """Manage the persisted subscription collection.""" def __init__(self, *args, **kwargs): """Initialize the UserSubs.""" super().__init__(*args, **kwargs) self._restoreFailed = False def restore(): """Restore the user subs.""" raw = AppSettings.get('CustomSubscription') if raw is None: return {} try: data = UJSONEncoder.decode(PyBase64Encoder.decode(raw)) if isinstance(data, dict): return data raise TypeError('subscription repository root must be an object') except Exception: # Any non-exit exceptions self._restoreFailed = True logger.exception('failed to restore persisted subscriptions') return {} restored = restore() self._data = {} normalized = {} # Publish only a completely hydrated collection. Failed startup cleanup # must not serialize a partly normalized document over recoverable input. try: for order, (unique, value) in enumerate(restored.items()): if not isinstance(value, Mapping): raise TypeError('subscription repository records must be objects') group = SubscriptionGroup.fromMapping(unique, value) if not group.sortOrder: group.sortOrder = order normalized[unique] = group.toMapping() except Exception as ex: # Any non-exit exceptions self._restoreFailed = True logger.error( 'failed to restore subscription records (%s)', type(ex).__name__ ) else: self._data = normalized def sync(self): """Persist the current user subs data.""" AppSettings.set( 'CustomSubscription', PyBase64Encoder.encode( UJSONEncoder.encode(self._data).encode(), ), ) self._restoreFailed = False def data(self) -> dict[str, dict]: """Return the live mutable collection managed by this repository.""" 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 moveGroups(self, groupIds, position: str) -> bool: """Move selected groups one position while preserving relative order.""" selected = {str(groupId) for groupId in groupIds} items = list(self._data.items()) selected.intersection_update(unique for unique, _value in items) if not selected or position not in ('up', 'down'): return False originalOrder = list(items) def isSelected(item): """Return whether the group belongs to the selected identity set.""" return item[0] in selected if position == 'up': for index in range(1, len(items)): if isSelected(items[index]) and not isSelected(items[index - 1]): items[index - 1], items[index] = items[index], items[index - 1] else: for index in range(len(items) - 2, -1, -1): if isSelected(items[index]) and not isSelected(items[index + 1]): items[index], items[index + 1] = items[index + 1], items[index] if items == originalOrder: return False self._data.clear() self._data.update(items) for order, value in enumerate(self._data.values()): value['sortOrder'] = order return True def upsertGroup(self, group: SubscriptionGroup): """Insert or replace one group without changing its identity.""" self.upsertGroups((group,)) def upsertGroups(self, groups): """Atomically insert or replace a batch of validated groups.""" staged = {} for group in groups: if not isinstance(group, SubscriptionGroup): raise TypeError('subscription group must be a SubscriptionGroup') if not group.id: raise ValueError('subscription group ID must not be empty') staged[group.id] = group.toMapping() self._data.update(staged) 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.""" if self._restoreFailed and not self._data: logger.warning( 'preserving unreadable persisted subscriptions during cleanup' ) return self.sync()