Skip to content

Commit

Permalink
feat: add appstream service
Browse files Browse the repository at this point in the history
  • Loading branch information
d-loose committed Sep 6, 2023
1 parent c504f7f commit ca54b38
Show file tree
Hide file tree
Showing 7 changed files with 483 additions and 0 deletions.
2 changes: 2 additions & 0 deletions lib/appstream.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export 'src/appstream/appstream_service.dart';
export 'src/appstream/appstream_utils.dart';
227 changes: 227 additions & 0 deletions lib/src/appstream/appstream_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import 'dart:collection';

import 'package:appstream/appstream.dart';
import 'package:flutter/foundation.dart';
import 'package:snowball_stemmer/snowball_stemmer.dart';

import '/l10n.dart';
import 'appstream_utils.dart';

extension _Tokenizer on String {
List<String> tokenize() => split(RegExp('\\W'));
}

class _CachedComponent {
final AppstreamComponent component;
final String id;
late String name;
late List<String> keywords;
late List<String> summary;
late List<String> description;
late String origin;
late String package;
late List<String> mediaTypes;

_CachedComponent(this.component) : id = component.id.toLowerCase() {
name = _getLocalizedComponentAttribute(component.name)?.toLowerCase() ?? '';
keywords = _getLocalizedComponentAttribute(component.keywords)
?.map((e) => e.toLowerCase())
.toList() ??
[];
summary = _getLocalizedComponentAttribute(component.summary)
?.toLowerCase()
.tokenize() ??
[];
description = _getLocalizedComponentAttribute(component.description)
?.toLowerCase()
.tokenize() ??
[];
origin = ''; // XXX: https://github.com/canonical/appstream.dart/issues/25
package = component.package?.toLowerCase() ?? '';
mediaTypes = [];
for (final provider in component.provides) {
if (provider is AppstreamProvidesMediatype) {
mediaTypes.add(provider.mediaType.toLowerCase());
}
}
}

static T? _getLocalizedComponentAttribute<T>(Map<String, T> attribute) {
final languageKey = bestLanguageKey(attribute.keys);
if (languageKey == null) return null;
return attribute[languageKey];
}

int match(List<String> tokens) {
int score = _MatchScore.none.value;
for (final token in tokens) {
if (id.toLowerCase().contains(token)) {
score |= _MatchScore.id.value;
}
if (name.contains(token)) {
score |= _MatchScore.name.value;
}
if (keywords.contains(token)) {
score |= _MatchScore.keyword.value;
}
if (summary.contains(token)) {
score |= _MatchScore.summary.value;
}
if (description.contains(token)) {
score |= _MatchScore.description.value;
}
if (origin.contains(token)) {
score |= _MatchScore.origin.value;
}
if (package.contains(token)) {
score |= _MatchScore.pkgName.value;
}
if (mediaTypes.any((e) => e.contains(token))) {
score |= _MatchScore.mediaType.value;
}
if (score == _MatchScore.all.value) break;
}
return score;
}

@override
bool operator ==(Object other) =>
other is _CachedComponent && component.id == other.component.id;

@override
int get hashCode => component.id.hashCode;
}

enum _MatchScore {
none(0),
mediaType(1 << 0),
pkgName(1 << 1),
origin(1 << 2),
description(1 << 3),
summary(1 << 4),
keyword(1 << 5),
name(1 << 6),
id(1 << 7),
all(1 << 0 | 1 << 1 | 1 << 2 | 1 << 3 | 1 << 4 | 1 << 5 | 1 << 6 | 1 << 7);

final int value;
const _MatchScore(this.value);
}

class _ScoredComponent {
final int score;
final AppstreamComponent component;
const _ScoredComponent(this.score, this.component);
}

class AppstreamService {
final AppstreamPool _pool;
late final Future<void> _loader = _pool.load().then((_) => _populateCache());

// TODO: cache AppstreamPool
AppstreamService({@visibleForTesting AppstreamPool? pool})
: _pool = pool ?? AppstreamPool() {
PlatformDispatcher.instance.onLocaleChanged = () async {
await _loader;
_populateCache();
};
}

final HashSet<_CachedComponent> _cache = HashSet<_CachedComponent>();
@visibleForTesting
int get cacheSize => _cache.length;

void _populateCache() {
_cache.clear();
for (final component in _pool.components) {
_cache.add(_CachedComponent(component));
}
}

List<String> get _greylist =>
lookupAppLocalizations(PlatformDispatcher.instance.locale)
.appstreamSearchGreylist
.split(';');

Future<void> init() async => _loader;

static final stemmersMap = <String, Algorithm>{
'ar': Algorithm.arabic,
'hy': Algorithm.armenian,
'eu': Algorithm.basque,
'ca': Algorithm.catalan,
'da': Algorithm.danish,
'nl': Algorithm.dutch,
'en': Algorithm.english,
'fi': Algorithm.finnish,
'fr': Algorithm.french,
'de': Algorithm.german,
'el': Algorithm.greek,
'hi': Algorithm.hindi,
'hu': Algorithm.hungarian,
'id': Algorithm.indonesian,
'ga': Algorithm.irish,
'it': Algorithm.italian,
'lt': Algorithm.lithuanian,
'ne': Algorithm.nepali,
'nb': Algorithm.norwegian,
'pt': Algorithm.portuguese,
'ro': Algorithm.romanian,
'ru': Algorithm.russian,
'sr': Algorithm.serbian,
'es': Algorithm.spanish,
'sv': Algorithm.swedish,
'ta': Algorithm.tamil,
'tr': Algorithm.turkish,
'yi': Algorithm.yiddish,
};

// Re-implementation of as_pool_build_search_tokens()
// (https://www.freedesktop.org/software/appstream/docs/api/appstream-AsPool.html#as-pool-build-search-tokens)
List<String> _buildSearchTokens(String search) {
final words = search.toLowerCase().split(RegExp(r'\s'));
// Filter out too generic search terms
words.removeWhere((element) => _greylist.contains(element));
if (words.isEmpty) {
words.addAll(search.toLowerCase().split(RegExp(r'\s')));
}
// Filter out short tokens, and those containing markup
words.removeWhere(
(element) => element.length <= 1 || element.contains(RegExp(r'[<>()]')),
);
// Extract only the common stems from the tokens
final algorithm =
stemmersMap[PlatformDispatcher.instance.locale.languageCode];
if (algorithm != null) {
final stemmer = SnowballStemmer(algorithm);
return words.map((element) => stemmer.stem(element)).toSet().toList();
} else {
return words;
}
}

// Re-implementation of as_pool_search()
// (https://www.freedesktop.org/software/appstream/docs/api/appstream-AsPool.html#as-pool-search)
Future<List<AppstreamComponent>> search(String search) async {
final tokens = _buildSearchTokens(search);
await _loader;
if (tokens.isEmpty) {
if (search.length <= 1) {
// Search query too broad, matching everything
return _pool.components;
} else {
// No valid search tokens
return [];
}
}
final scored = <_ScoredComponent>[];
for (final entry in _cache) {
final score = entry.match(tokens);
if (score > 0) {
scored.add(_ScoredComponent(score, entry.component));
}
}
scored.sort((a, b) => b.score.compareTo(a.score));
return scored.map((e) => e.component).toList();
}
}
66 changes: 66 additions & 0 deletions lib/src/appstream/appstream_utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import 'dart:io';
import 'dart:ui';

import 'package:appstream/appstream.dart';
import 'package:collection/collection.dart';

// TODO: uncomment once we re-add packagekit
// import 'package:packagekit/packagekit.dart';
// import 'package:ubuntu_service/ubuntu_service.dart';

String? bestLanguageKey(Iterable<String> keys, {Locale? locale}) {
locale ??= PlatformDispatcher.instance.locale;
if (locale.toLanguageTag() != 'und') {
var key = '${locale.languageCode}_${locale.countryCode}';
if (keys.contains(key)) return key;
key = locale.languageCode;
if (keys.contains(key)) return key;
}
const fallback = 'C';
if (keys.contains(fallback)) return fallback;
return null;
}

extension LocalizedComponent on AppstreamComponent {
String localizedName({Locale? locale}) =>
name[bestLanguageKey(name.keys, locale: locale)] ?? '';
String localizedSummary({Locale? locale}) =>
summary[bestLanguageKey(summary.keys, locale: locale)] ?? '';
String localizedDescription({Locale? locale}) =>
description[bestLanguageKey(description.keys, locale: locale)] ?? '';
}

// TODO: uncomment once we re-add packagekit
// extension PackageKitId on AppstreamComponent {
// Future<PackageKitPackageId> get packageKitId =>
// getService<PackageService>().resolve(package ?? id);
// }

int _sizeComparison(int? a, int? b) => a?.compareTo(b ?? 0) ?? 0;

extension Icons on AppstreamComponent {
String? get icon {
final cached = icons.whereType<AppstreamCachedIcon>().toList();
cached.sort((a, b) => _sizeComparison(b.width, a.width));
//for (final icon in cached) {
// XXX: we need the origin to determine if a cached icon exists on disk
// (https://github.com/canonical/appstream.dart/issues/25)
//}

final local = icons.whereType<AppstreamLocalIcon>().toList();
local.sort((a, b) => _sizeComparison(b.width, a.width));
for (final icon in local) {
if (File(icon.filename).existsSync()) {
return icon.filename;
}
}

final stock = icons.whereType<AppstreamStockIcon>().firstOrNull;
if (stock != null) {
// TODO: check whether the stock icon exists on disk, and return it
}

final remote = icons.whereType<AppstreamRemoteIcon>().firstOrNull;
return remote?.url;
}
}
1 change: 1 addition & 0 deletions lib/src/l10n/app_en.arb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"appstreamSearchGreylist": "app;application;package;program;programme;suite;tool",
"detailPageChannelLabel": "Channel",
"detailPageConfinementLabel": "Confinement",
"detailPageContactPublisherLabel": "Contact {publisher}",
Expand Down
2 changes: 2 additions & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ environment:
flutter: '^3.10.6'

dependencies:
appstream: ^0.2.8
args: ^2.4.2
cached_network_image: ^3.2.3
collection: ^1.17.0
Expand All @@ -30,6 +31,7 @@ dependencies:
shimmer: ^3.0.0
snapcraft_launcher: ^0.1.0
snapd: ^0.4.11
snowball_stemmer: ^0.1.0
ubuntu_localizations: ^0.3.3
ubuntu_service: ^0.2.2
ubuntu_test: ^0.1.0-0
Expand Down
Loading

0 comments on commit ca54b38

Please sign in to comment.