/* * Copyright (c) 2024-2026 Fabian Freund. * * This file is part of WebLibre * (see https://weblibre.eu). * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import 'package:json_annotation/json_annotation.dart'; part 'ublock_asset.g.dart'; enum UBlockAssetGroup { @JsonValue('default') $default, @JsonValue('ads') ads, @JsonValue('privacy') privacy, @JsonValue('malware') malware, @JsonValue('annoyances') annoyances, @JsonValue('multipurpose') multipurpose, @JsonValue('regions') regions; String get label => switch (this) { $default => 'Default', ads => 'Ads', privacy => 'Privacy', malware => 'Malware', annoyances => 'Annoyances', multipurpose => 'Multipurpose', regions => 'Regions', }; static const displayOrder = UBlockAssetGroup.values; } enum UBlockAssetSubGroup { @JsonValue('cookies') cookies, @JsonValue('social') social; String get label => switch (this) { cookies => 'Cookie Notices', social => 'Social Widgets', }; } List _contentUrlFromJson(dynamic value) { if (value is List) return value.cast(); if (value is String) return [value]; return []; } dynamic _contentUrlToJson(List value) { if (value.length == 1) return value.first; return value; } @JsonSerializable() class UBlockAssetEntry { final String content; @JsonKey(includeIfNull: false) final UBlockAssetGroup? group; @JsonKey(includeIfNull: false) final UBlockAssetSubGroup? group2; @JsonKey(includeIfNull: false) final String? parent; @JsonKey(includeIfNull: false) final String? title; @JsonKey( includeIfNull: false, fromJson: _contentUrlFromJson, toJson: _contentUrlToJson, ) final List contentURL; @JsonKey(includeIfNull: false) final List? cdnURLs; @JsonKey(includeIfNull: false) final List? patchURLs; @JsonKey(includeIfNull: false) final String? supportURL; @JsonKey(includeIfNull: false) final String? instructionURL; @JsonKey(includeIfNull: false) final String? tags; @JsonKey(includeIfNull: false) final String? lang; @JsonKey(includeIfNull: false) final String? ua; @JsonKey(includeIfNull: false, defaultValue: false) final bool off; @JsonKey(includeIfNull: false, defaultValue: false) final bool preferred; @JsonKey(includeIfNull: false) final int? updateAfter; const UBlockAssetEntry({ required this.content, this.group, this.group2, this.parent, this.title, this.contentURL = const [], this.cdnURLs, this.patchURLs, this.supportURL, this.instructionURL, this.tags, this.lang, this.ua, this.off = false, this.preferred = false, this.updateAfter, }); bool get isFilterList => content == 'filters'; bool get isDefaultEnabled => !off; UBlockAssetGroup get effectiveGroup => group2?.toGroup() ?? group ?? UBlockAssetGroup.ads; factory UBlockAssetEntry.fromJson(Map json) => _$UBlockAssetEntryFromJson(json); Map toJson() => _$UBlockAssetEntryToJson(this); } extension on UBlockAssetSubGroup { UBlockAssetGroup toGroup() => switch (this) { UBlockAssetSubGroup.cookies => UBlockAssetGroup.annoyances, UBlockAssetSubGroup.social => UBlockAssetGroup.annoyances, }; } class UBlockAssetsRegistry { final Map _entries; UBlockAssetsRegistry(this._entries); Map get filterEntries => Map.fromEntries(_entries.entries.where((e) => e.value.isFilterList)); List get defaultEnabledTokens => _entries.entries .where((e) => e.value.isFilterList && e.value.isDefaultEnabled) .map((e) => e.key) .toList(); UBlockAssetEntry? operator [](String key) => _entries[key]; Map>> buildGroupedParentTree() { final result = >>{}; for (final group in UBlockAssetGroup.displayOrder) { final groupEntries = >{}; final processedKeys = {}; final groupSubGroups = UBlockAssetSubGroup.values .where((sg) => sg.toGroup() == group) .toList(); for (final subGroup in groupSubGroups) { for (final entry in _entries.entries.where( (e) => e.value.isFilterList && e.value.group2 == subGroup, )) { final parentKey = entry.value.parent; groupEntries.putIfAbsent(parentKey, () => []).add(entry.key); processedKeys.add(entry.key); } } for (final entry in _entries.entries.where( (e) => e.value.isFilterList && e.value.group == group && e.value.group2 == null && !processedKeys.contains(e.key), )) { final parentKey = entry.value.parent; groupEntries.putIfAbsent(parentKey, () => []).add(entry.key); } if (groupEntries.isNotEmpty) { result[group] = groupEntries; } } return result; } List tokensMatchingLocales(Iterable languageCodes) { final primaryCodes = languageCodes.map((code) { final parts = code.split('-'); return parts.first.toLowerCase(); }).toSet(); return _entries.entries .where((e) { if (!e.value.isFilterList || !e.value.off) return false; final lang = e.value.lang; if (lang == null) return false; final entryLangs = lang .split(RegExp(r'\s+')) .map((l) => l.toLowerCase()) .toSet(); return primaryCodes.intersection(entryLangs).isNotEmpty; }) .map((e) => e.key) .toList(); } int enabledCountInGroup(UBlockAssetGroup group, Set enabledTokens) { var count = 0; for (final entry in _entries.entries) { if (!entry.value.isFilterList) continue; if (entry.value.effectiveGroup != group) continue; if (enabledTokens.contains(entry.key)) count++; } return count; } int totalCountInGroup(UBlockAssetGroup group) { var count = 0; for (final entry in _entries.entries) { if (!entry.value.isFilterList) continue; if (entry.value.effectiveGroup != group) continue; count++; } return count; } static UBlockAssetsRegistry fromJson(Map json) { final entries = {}; for (final entry in json.entries) { if (entry.value is Map) { entries[entry.key] = UBlockAssetEntry.fromJson( entry.value as Map, ); } } return UBlockAssetsRegistry(entries); } }