small web feature initial

This commit is contained in:
Fabian Freund
2026-03-23 11:13:39 +01:00
parent 1e56bb3e3a
commit b9669635d9
61 changed files with 10455 additions and 233 deletions
@@ -0,0 +1,137 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:rxdart/rxdart.dart';
import 'package:weblibre/features/small_web/data/database/definitions.drift.dart';
import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart';
import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart';
import 'package:weblibre/features/small_web/data/providers.dart';
import 'package:weblibre/features/small_web/domain/services/kagi_source_service.dart';
import 'package:weblibre/features/small_web/domain/services/small_web_discover_service.dart';
import 'package:weblibre/features/small_web/domain/services/wander_source_service.dart';
part 'providers.g.dart';
@Riverpod(keepAlive: true)
Future<KagiSourceService> kagiSourceService(Ref ref) async {
final categories = await ref.watch(kagiCategoriesProvider.future);
return KagiSourceService(
ref.watch(smallWebDatabaseProvider),
categories.remap,
);
}
@Riverpod(keepAlive: true)
WanderSourceService wanderSourceService(Ref ref) {
return WanderSourceService(ref.watch(smallWebDatabaseProvider));
}
@Riverpod(keepAlive: true)
Future<SmallWebDiscoverService> smallWebDiscoverService(Ref ref) async {
final kagiService = await ref.watch(kagiSourceServiceProvider.future);
return SmallWebDiscoverService(
ref.watch(smallWebDatabaseProvider),
kagiService,
ref.watch(wanderSourceServiceProvider),
);
}
@Riverpod()
Stream<List<GetRecentVisitsResult>> smallWebRecentVisits(
Ref ref,
SmallWebSourceKind sourceKind,
KagiSmallWebMode? mode,
) {
final db = ref.watch(smallWebDatabaseProvider);
return db.smallWebVisitDao
.getRecentVisits(sourceKind: sourceKind, mode: mode)
.watch();
}
@Riverpod()
Stream<Map<KagiSmallWebMode, int>> smallWebAllModeItemCounts(Ref ref) {
final db = ref.watch(smallWebDatabaseProvider);
return db.definitionsDrift.getAllModeItemCounts().watch().map((rows) {
final counts = <KagiSmallWebMode, int>{};
for (final row in rows) {
if (row.mode == null) continue;
final mode = KagiSmallWebMode.values
.where((m) => m.name == row.mode)
.firstOrNull;
if (mode != null) counts[mode] = (counts[mode] ?? 0) + row.c;
}
return counts;
});
}
@Riverpod()
Stream<({int linkedConsoles, int pages})> wanderConsoleStats(
Ref ref,
Uri consoleUrl,
) {
final db = ref.watch(smallWebDatabaseProvider);
final linkedConsoles = db.definitionsDrift
.getConsoleNeighborCount(consoleUrl: consoleUrl.toString())
.watchSingle();
final pages = db.definitionsDrift
.getWanderPagesForConsole(
sourceKind: SmallWebSourceKind.wander,
consoleUrl: consoleUrl.toString(),
)
.watch();
return CombineLatestStream.combine2(
linkedConsoles,
pages,
(a, b) => (linkedConsoles: a, pages: b.length),
);
}
@Riverpod()
Stream<List<GetNeighborConsolesWithPageCountsResult>> wanderNeighborConsoles(
Ref ref,
Uri consoleUrl,
) {
final db = ref.watch(smallWebDatabaseProvider);
return db.definitionsDrift
.getNeighborConsolesWithPageCounts(
sourceKind: SmallWebSourceKind.wander,
sourceConsoleUrl: consoleUrl.toString(),
)
.watch();
}
@Riverpod()
Stream<List<GetAllConsolesWithPageCountsResult>> wanderAllConsoles(
Ref ref,
String query,
) {
final db = ref.watch(smallWebDatabaseProvider);
return db.definitionsDrift
.getAllConsolesWithPageCounts(
sourceKind: SmallWebSourceKind.wander,
query: query,
)
.watch();
}
@@ -0,0 +1,511 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(kagiSourceService)
final kagiSourceServiceProvider = KagiSourceServiceProvider._();
final class KagiSourceServiceProvider
extends
$FunctionalProvider<
AsyncValue<KagiSourceService>,
KagiSourceService,
FutureOr<KagiSourceService>
>
with
$FutureModifier<KagiSourceService>,
$FutureProvider<KagiSourceService> {
KagiSourceServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'kagiSourceServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$kagiSourceServiceHash();
@$internal
@override
$FutureProviderElement<KagiSourceService> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<KagiSourceService> create(Ref ref) {
return kagiSourceService(ref);
}
}
String _$kagiSourceServiceHash() => r'7f0cca556d22cc65356660f3e59c976a35d40d37';
@ProviderFor(wanderSourceService)
final wanderSourceServiceProvider = WanderSourceServiceProvider._();
final class WanderSourceServiceProvider
extends
$FunctionalProvider<
WanderSourceService,
WanderSourceService,
WanderSourceService
>
with $Provider<WanderSourceService> {
WanderSourceServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'wanderSourceServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$wanderSourceServiceHash();
@$internal
@override
$ProviderElement<WanderSourceService> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
WanderSourceService create(Ref ref) {
return wanderSourceService(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(WanderSourceService value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<WanderSourceService>(value),
);
}
}
String _$wanderSourceServiceHash() =>
r'80574ea0c51f56325940edb0fa7e849784ba7421';
@ProviderFor(smallWebDiscoverService)
final smallWebDiscoverServiceProvider = SmallWebDiscoverServiceProvider._();
final class SmallWebDiscoverServiceProvider
extends
$FunctionalProvider<
AsyncValue<SmallWebDiscoverService>,
SmallWebDiscoverService,
FutureOr<SmallWebDiscoverService>
>
with
$FutureModifier<SmallWebDiscoverService>,
$FutureProvider<SmallWebDiscoverService> {
SmallWebDiscoverServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'smallWebDiscoverServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$smallWebDiscoverServiceHash();
@$internal
@override
$FutureProviderElement<SmallWebDiscoverService> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SmallWebDiscoverService> create(Ref ref) {
return smallWebDiscoverService(ref);
}
}
String _$smallWebDiscoverServiceHash() =>
r'14fba9eb1c2aa51fa1fac2c2c764ea400603e8ca';
@ProviderFor(smallWebRecentVisits)
final smallWebRecentVisitsProvider = SmallWebRecentVisitsFamily._();
final class SmallWebRecentVisitsProvider
extends
$FunctionalProvider<
AsyncValue<List<GetRecentVisitsResult>>,
List<GetRecentVisitsResult>,
Stream<List<GetRecentVisitsResult>>
>
with
$FutureModifier<List<GetRecentVisitsResult>>,
$StreamProvider<List<GetRecentVisitsResult>> {
SmallWebRecentVisitsProvider._({
required SmallWebRecentVisitsFamily super.from,
required (SmallWebSourceKind, KagiSmallWebMode?) super.argument,
}) : super(
retry: null,
name: r'smallWebRecentVisitsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$smallWebRecentVisitsHash();
@override
String toString() {
return r'smallWebRecentVisitsProvider'
''
'$argument';
}
@$internal
@override
$StreamProviderElement<List<GetRecentVisitsResult>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<List<GetRecentVisitsResult>> create(Ref ref) {
final argument = this.argument as (SmallWebSourceKind, KagiSmallWebMode?);
return smallWebRecentVisits(ref, argument.$1, argument.$2);
}
@override
bool operator ==(Object other) {
return other is SmallWebRecentVisitsProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$smallWebRecentVisitsHash() =>
r'ec7d837523b856a6e7e828c0f26a97913e2af34a';
final class SmallWebRecentVisitsFamily extends $Family
with
$FunctionalFamilyOverride<
Stream<List<GetRecentVisitsResult>>,
(SmallWebSourceKind, KagiSmallWebMode?)
> {
SmallWebRecentVisitsFamily._()
: super(
retry: null,
name: r'smallWebRecentVisitsProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
SmallWebRecentVisitsProvider call(
SmallWebSourceKind sourceKind,
KagiSmallWebMode? mode,
) => SmallWebRecentVisitsProvider._(argument: (sourceKind, mode), from: this);
@override
String toString() => r'smallWebRecentVisitsProvider';
}
@ProviderFor(smallWebAllModeItemCounts)
final smallWebAllModeItemCountsProvider = SmallWebAllModeItemCountsProvider._();
final class SmallWebAllModeItemCountsProvider
extends
$FunctionalProvider<
AsyncValue<Map<KagiSmallWebMode, int>>,
Map<KagiSmallWebMode, int>,
Stream<Map<KagiSmallWebMode, int>>
>
with
$FutureModifier<Map<KagiSmallWebMode, int>>,
$StreamProvider<Map<KagiSmallWebMode, int>> {
SmallWebAllModeItemCountsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'smallWebAllModeItemCountsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$smallWebAllModeItemCountsHash();
@$internal
@override
$StreamProviderElement<Map<KagiSmallWebMode, int>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<Map<KagiSmallWebMode, int>> create(Ref ref) {
return smallWebAllModeItemCounts(ref);
}
}
String _$smallWebAllModeItemCountsHash() =>
r'64d50f9fe581003d6f1e7df61b859118a5ad31fa';
@ProviderFor(wanderConsoleStats)
final wanderConsoleStatsProvider = WanderConsoleStatsFamily._();
final class WanderConsoleStatsProvider
extends
$FunctionalProvider<
AsyncValue<({int linkedConsoles, int pages})>,
({int linkedConsoles, int pages}),
Stream<({int linkedConsoles, int pages})>
>
with
$FutureModifier<({int linkedConsoles, int pages})>,
$StreamProvider<({int linkedConsoles, int pages})> {
WanderConsoleStatsProvider._({
required WanderConsoleStatsFamily super.from,
required Uri super.argument,
}) : super(
retry: null,
name: r'wanderConsoleStatsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$wanderConsoleStatsHash();
@override
String toString() {
return r'wanderConsoleStatsProvider'
''
'($argument)';
}
@$internal
@override
$StreamProviderElement<({int linkedConsoles, int pages})> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<({int linkedConsoles, int pages})> create(Ref ref) {
final argument = this.argument as Uri;
return wanderConsoleStats(ref, argument);
}
@override
bool operator ==(Object other) {
return other is WanderConsoleStatsProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$wanderConsoleStatsHash() =>
r'201d2b0669b878762c3d535f39f6d04e030b8112';
final class WanderConsoleStatsFamily extends $Family
with
$FunctionalFamilyOverride<
Stream<({int linkedConsoles, int pages})>,
Uri
> {
WanderConsoleStatsFamily._()
: super(
retry: null,
name: r'wanderConsoleStatsProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
WanderConsoleStatsProvider call(Uri consoleUrl) =>
WanderConsoleStatsProvider._(argument: consoleUrl, from: this);
@override
String toString() => r'wanderConsoleStatsProvider';
}
@ProviderFor(wanderNeighborConsoles)
final wanderNeighborConsolesProvider = WanderNeighborConsolesFamily._();
final class WanderNeighborConsolesProvider
extends
$FunctionalProvider<
AsyncValue<List<GetNeighborConsolesWithPageCountsResult>>,
List<GetNeighborConsolesWithPageCountsResult>,
Stream<List<GetNeighborConsolesWithPageCountsResult>>
>
with
$FutureModifier<List<GetNeighborConsolesWithPageCountsResult>>,
$StreamProvider<List<GetNeighborConsolesWithPageCountsResult>> {
WanderNeighborConsolesProvider._({
required WanderNeighborConsolesFamily super.from,
required Uri super.argument,
}) : super(
retry: null,
name: r'wanderNeighborConsolesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$wanderNeighborConsolesHash();
@override
String toString() {
return r'wanderNeighborConsolesProvider'
''
'($argument)';
}
@$internal
@override
$StreamProviderElement<List<GetNeighborConsolesWithPageCountsResult>>
$createElement($ProviderPointer pointer) => $StreamProviderElement(pointer);
@override
Stream<List<GetNeighborConsolesWithPageCountsResult>> create(Ref ref) {
final argument = this.argument as Uri;
return wanderNeighborConsoles(ref, argument);
}
@override
bool operator ==(Object other) {
return other is WanderNeighborConsolesProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$wanderNeighborConsolesHash() =>
r'08393800b649cb88e21281dad02a7ed6c745797e';
final class WanderNeighborConsolesFamily extends $Family
with
$FunctionalFamilyOverride<
Stream<List<GetNeighborConsolesWithPageCountsResult>>,
Uri
> {
WanderNeighborConsolesFamily._()
: super(
retry: null,
name: r'wanderNeighborConsolesProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
WanderNeighborConsolesProvider call(Uri consoleUrl) =>
WanderNeighborConsolesProvider._(argument: consoleUrl, from: this);
@override
String toString() => r'wanderNeighborConsolesProvider';
}
@ProviderFor(wanderAllConsoles)
final wanderAllConsolesProvider = WanderAllConsolesFamily._();
final class WanderAllConsolesProvider
extends
$FunctionalProvider<
AsyncValue<List<GetAllConsolesWithPageCountsResult>>,
List<GetAllConsolesWithPageCountsResult>,
Stream<List<GetAllConsolesWithPageCountsResult>>
>
with
$FutureModifier<List<GetAllConsolesWithPageCountsResult>>,
$StreamProvider<List<GetAllConsolesWithPageCountsResult>> {
WanderAllConsolesProvider._({
required WanderAllConsolesFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'wanderAllConsolesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$wanderAllConsolesHash();
@override
String toString() {
return r'wanderAllConsolesProvider'
''
'($argument)';
}
@$internal
@override
$StreamProviderElement<List<GetAllConsolesWithPageCountsResult>>
$createElement($ProviderPointer pointer) => $StreamProviderElement(pointer);
@override
Stream<List<GetAllConsolesWithPageCountsResult>> create(Ref ref) {
final argument = this.argument as String;
return wanderAllConsoles(ref, argument);
}
@override
bool operator ==(Object other) {
return other is WanderAllConsolesProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$wanderAllConsolesHash() => r'27d87bbad8fb9e993de36c70649b324a98c8bf87';
final class WanderAllConsolesFamily extends $Family
with
$FunctionalFamilyOverride<
Stream<List<GetAllConsolesWithPageCountsResult>>,
String
> {
WanderAllConsolesFamily._()
: super(
retry: null,
name: r'wanderAllConsolesProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
WanderAllConsolesProvider call(String query) =>
WanderAllConsolesProvider._(argument: query, from: this);
@override
String toString() => r'wanderAllConsolesProvider';
}
@@ -0,0 +1,231 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'dart:isolate';
import 'package:drift/drift.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:rss_dart/dart_rss.dart';
import 'package:uuid/enums.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/uuid.dart';
import 'package:weblibre/extensions/http_encoding.dart';
import 'package:weblibre/features/small_web/data/database/database.dart';
import 'package:weblibre/features/small_web/data/database/definitions.drift.dart';
import 'package:weblibre/features/small_web/data/models/kagi_feed_entry.dart';
import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart';
import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart';
const _staleDuration = Duration(hours: 3);
typedef _KagiFeedFetchRequest = ({
RootIsolateToken token,
String url,
String mode,
Map<String, String> categoryRemap,
});
class KagiSourceService {
final SmallWebDatabase _db;
final Map<String, String> _categoryRemap;
KagiSourceService(this._db, this._categoryRemap);
Future<bool> needsRefresh(KagiSmallWebMode mode) async {
final latestFetch = await _db.smallWebItemDao
.getLatestFetchedAt(SmallWebSourceKind.kagi, mode)
.getSingleOrNull();
if (latestFetch == null) return true;
return DateTime.now().difference(latestFetch) > _staleDuration;
}
Future<void> fetchAndIngest(KagiSmallWebMode mode) async {
final request = (
token: ServicesBinding.rootIsolateToken!,
url: mode.feedUrl.toString(),
mode: mode.name,
categoryRemap: Map<String, String>.from(_categoryRemap),
);
final List<KagiFeedEntry> entries;
try {
entries = await _runKagiFeedFetch(request);
} catch (e, st) {
logger.e(
'Failed to fetch/parse Kagi feed for $mode',
error: e,
stackTrace: st,
);
rethrow;
}
final now = DateTime.now();
await _db.batch((batch) {
for (final entry in entries) {
final itemId = uuid.v5(Namespace.url.value, entry.url.toString());
batch.insert(
_db.smallWebItems,
SmallWebItemsCompanion.insert(
id: itemId,
url: entry.url,
title: Value(entry.title),
domain: entry.url.host,
author: Value(entry.author),
summary: Value(entry.summary),
publishedAt: Value(entry.publishedAt),
createdAt: Value(now),
updatedAt: Value(now),
),
onConflict: DoUpdate(
(old) => SmallWebItemsCompanion(
title: entry.title != null
? Value(entry.title)
: const Value.absent(),
author: entry.author != null
? Value(entry.author)
: const Value.absent(),
summary: entry.summary != null
? Value(entry.summary)
: const Value.absent(),
publishedAt: entry.publishedAt != null
? Value(entry.publishedAt)
: const Value.absent(),
updatedAt: Value(now),
),
target: [_db.smallWebItems.url],
),
);
final membershipId = uuid.v5(
Namespace.url.value,
'${SmallWebSourceKind.kagi.name}:${mode.name}:${entry.url}',
);
batch.insert(
_db.smallWebMemberships,
SmallWebMembershipsCompanion.insert(
id: membershipId,
itemId: itemId,
sourceKind: SmallWebSourceKind.kagi,
mode: Value(mode.name),
consoleUrl: const Value(null),
categories: Value(entry.categories),
fetchedAt: Value(now),
),
onConflict: DoUpdate(
(old) => SmallWebMembershipsCompanion(
categories: Value(entry.categories),
fetchedAt: Value(now),
),
target: [_db.smallWebMemberships.id],
),
);
}
});
}
}
Future<List<KagiFeedEntry>> _runKagiFeedFetch(_KagiFeedFetchRequest request) {
return Isolate.run(_createKagiFeedFetchTask(request));
}
Future<List<KagiFeedEntry>> Function() _createKagiFeedFetchTask(
_KagiFeedFetchRequest request,
) {
return () => _fetchAndParseFeed(
request.token,
Uri.parse(request.url),
request.mode,
request.categoryRemap,
);
}
Future<List<KagiFeedEntry>> _fetchAndParseFeed(
RootIsolateToken token,
Uri url,
String mode,
Map<String, String> categoryRemap,
) async {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
final client = http.Client();
try {
final response = await client.get(url).timeout(const Duration(seconds: 30));
if (response.statusCode != 200) {
throw Exception(
'Kagi feed request failed with status ${response.statusCode}',
);
}
final xmlString = response.bodyUnicodeFallback;
final feed = AtomFeed.parse(xmlString);
return feed.items
.map((item) {
final link = item.links
.where((l) => l.rel == 'alternate' || l.rel == null)
.map((l) => l.href)
.firstOrNull;
final href = link ?? item.links.firstOrNull?.href;
final parsedUrl = href != null ? Uri.tryParse(href) : null;
if (parsedUrl == null) return null;
if (mode == 'videos' && parsedUrl.path.contains('/shorts/')) {
return null;
}
const kagiScheme = 'https://kagi.com/smallweb/categories';
final categories = item.categories
.where(
(c) =>
c.scheme == kagiScheme &&
c.term != null &&
c.term!.isNotEmpty,
)
.map((c) => categoryRemap[c.term!] ?? c.term!)
.toList();
final author = item.authors
.where((a) => a.name != null && a.name!.isNotEmpty)
.map((a) => a.name!)
.firstOrNull;
return KagiFeedEntry(
url: parsedUrl,
title: item.title,
author: author,
summary: item.summary,
publishedAt: item.updated != null
? DateTime.tryParse(item.updated!)
: null,
categories: categories,
);
})
.nonNulls
.toList();
} finally {
client.close();
}
}
@@ -0,0 +1,205 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'dart:math';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:weblibre/core/uuid.dart';
import 'package:weblibre/features/small_web/data/database/database.dart';
import 'package:weblibre/features/small_web/data/database/definitions.drift.dart';
import 'package:weblibre/features/small_web/data/models/kagi_small_web_mode.dart';
import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart';
import 'package:weblibre/features/small_web/data/models/wander_console_source.dart';
import 'package:weblibre/features/small_web/domain/services/kagi_source_service.dart';
import 'package:weblibre/features/small_web/domain/services/wander_source_service.dart';
final _random = Random.secure();
class WanderDiscoverResult with FastEquatable {
final SmallWebItem item;
final Uri consoleUrl;
WanderDiscoverResult({required this.item, required this.consoleUrl});
@override
List<Object?> get hashParameters => [item, consoleUrl];
}
class SmallWebDiscoverService {
final SmallWebDatabase _db;
final KagiSourceService _kagiService;
final WanderSourceService _wanderService;
SmallWebDiscoverService(this._db, this._kagiService, this._wanderService);
Future<void> recordVisit({
required String itemId,
required SmallWebSourceKind sourceKind,
required KagiSmallWebMode? mode,
Uri? consoleUrl,
}) async {
await _db.smallWebVisitDao.insertVisit(
SmallWebVisit(
id: uuid.v4(),
itemId: itemId,
sourceKind: sourceKind,
mode: mode?.name,
consoleUrl: consoleUrl,
visitedAt: DateTime.now(),
),
);
}
Future<SmallWebItem?> discoverKagi({
required KagiSmallWebMode mode,
String? category,
}) async {
if (await _kagiService.needsRefresh(mode)) {
await _kagiService.fetchAndIngest(mode);
}
final items = await _db.smallWebItemDao
.getDiscoverableKagiItems(mode, category)
.get();
if (items.isEmpty) return null;
final picked = items[_random.nextInt(items.length)];
await recordVisit(
itemId: picked.id,
sourceKind: SmallWebSourceKind.kagi,
mode: mode,
);
return picked;
}
Future<WanderDiscoverResult?> discoverWander({
Uri? currentConsoleUrl,
bool forceNewConsole = false,
}) async {
await _wanderService.syncSeeds();
final recentItemIds =
(await _db.smallWebVisitDao
.getRecentItemIds(
sourceKind: SmallWebSourceKind.wander,
mode: null,
)
.get())
.toSet();
// Pick a console to explore
Uri consoleUrl;
if (currentConsoleUrl != null && !forceNewConsole) {
consoleUrl = currentConsoleUrl;
} else {
final consoleUrls = await _wanderService.getDiscoveredConsoleUrls();
if (forceNewConsole && currentConsoleUrl != null) {
consoleUrls.remove(currentConsoleUrl);
}
if (consoleUrls.isEmpty) return null;
consoleUrl = consoleUrls[_random.nextInt(consoleUrls.length)];
}
final pages = await _refreshAndGetPages(consoleUrl, forceRetry: true);
final unvisitedPages = pages
.where((page) => !recentItemIds.contains(page.id))
.toList();
if (unvisitedPages.isNotEmpty) {
return _pickAndRecord(unvisitedPages, consoleUrl);
}
// No unvisited pages on this console — try alternatives
final result = await _tryAlternativeConsoles(consoleUrl, recentItemIds);
if (result != null) return result;
// Last resort: revisit a page from the original console
if (pages.isEmpty) return null;
return _pickAndRecord(pages, consoleUrl);
}
Future<void> updateItemTitle(String itemId, String title) {
return _db.smallWebItemDao.updateTitle(itemId, title);
}
Future<List<SmallWebItem>> _refreshAndGetPages(
Uri consoleUrl, {
bool forceRetry = false,
}) async {
if (await _wanderService.shouldRefreshConsole(
consoleUrl,
forceRetry: forceRetry,
)) {
await _wanderService.fetchAndIngestConsole(
consoleUrl,
source: WanderConsoleSource.discovered,
);
}
return _wanderService.getPagesForConsole(consoleUrl);
}
Future<WanderDiscoverResult?> _tryAlternativeConsoles(
Uri excludeConsole,
Set<String> recentItemIds,
) async {
final allConsoles = await _wanderService.getDiscoveredConsoleUrls()
..remove(excludeConsole)
..shuffle(_random);
for (final altConsole in allConsoles.take(10)) {
try {
final altPages = await _refreshAndGetPages(altConsole);
final unvisited = altPages
.where((p) => !recentItemIds.contains(p.id))
.toList();
final candidates = unvisited.isNotEmpty ? unvisited : altPages;
if (candidates.isNotEmpty) {
return _pickAndRecord(candidates, altConsole);
}
} catch (_) {
// Skip consoles that fail to fetch; continue trying others.
}
}
return null;
}
Future<WanderDiscoverResult> _pickAndRecord(
List<SmallWebItem> candidates,
Uri consoleUrl,
) async {
final picked = candidates[_random.nextInt(candidates.length)];
await recordVisit(
itemId: picked.id,
sourceKind: SmallWebSourceKind.wander,
mode: null,
consoleUrl: consoleUrl,
);
return WanderDiscoverResult(item: picked, consoleUrl: consoleUrl);
}
}
@@ -0,0 +1,80 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:weblibre/extensions/uri.dart';
final _lineCommentPattern = RegExp(r'(?<!:)//.*$', multiLine: true);
final _blockCommentPattern = RegExp(r'/\*[\s\S]*?\*/');
final _consolesPattern = RegExp(r'consoles\s*:\s*\[([\s\S]*?)\]', dotAll: true);
final _pagesPattern = RegExp(r'pages\s*:\s*\[([\s\S]*?)\]', dotAll: true);
// ignore: unnecessary_raw_strings
final _stringPattern = RegExp(r'''(?:["'`])([^"'`]+)(?:["'`])''');
List<String> _extractArray(String source, RegExp pattern) {
final match = pattern.firstMatch(source);
if (match == null) return [];
final arrayContent = match.group(1) ?? '';
return _stringPattern
.allMatches(arrayContent)
.map((m) => m.group(1)!)
.toList();
}
Uri _normalizeUrl(String url) {
var normalized = url;
if (normalized.endsWith('/index.html')) {
normalized = normalized.substring(
0,
normalized.length - 'index.html'.length,
);
}
return Uri.parse(normalized);
}
class WanderJsResult {
final List<Uri> consoles;
final List<Uri> pages;
const WanderJsResult({required this.consoles, required this.pages});
factory WanderJsResult.parse(String jsSource) {
final cleaned = jsSource
.replaceAll(_blockCommentPattern, '')
.replaceAll(_lineCommentPattern, '');
final consoles = _extractArray(cleaned, _consolesPattern);
final pages = _extractArray(cleaned, _pagesPattern);
return WanderJsResult(
consoles: consoles
.map(_normalizeUrl)
.where((uri) => uri.isHttpOrHttps)
.toList(),
pages: pages
.map(_normalizeUrl)
.where((uri) => uri.isHttpOrHttps)
.toList(),
);
}
}
@@ -0,0 +1,341 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'dart:isolate';
import 'package:drift/drift.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:uuid/enums.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/uuid.dart';
import 'package:weblibre/features/small_web/data/database/database.dart';
import 'package:weblibre/features/small_web/data/database/definitions.drift.dart';
import 'package:weblibre/features/small_web/data/models/small_web_source_kind.dart';
import 'package:weblibre/features/small_web/data/models/wander_console_source.dart';
import 'package:weblibre/features/small_web/data/wander_seed_consoles.dart';
import 'package:weblibre/features/small_web/domain/services/wander_js_parser.dart';
const _staleDuration = Duration(hours: 3);
const _retryAfterError = Duration(minutes: 30);
typedef _WanderJsFetchRequest = ({RootIsolateToken token, String url});
class WanderSourceService {
final SmallWebDatabase _db;
WanderSourceService(this._db);
Future<bool> shouldRefreshConsole(
Uri consoleUrl, {
bool forceRetry = false,
}) async {
final console = await _db.wanderConsoleDao
.getConsole(consoleUrl)
.getSingleOrNull();
if (console == null || console.lastFetchedAt == null) return true;
final age = DateTime.now().difference(console.lastFetchedAt!);
if (console.lastFetchFailed == true) {
return forceRetry || age > _retryAfterError;
}
return age > _staleDuration;
}
Future<void> syncSeeds() async {
final now = DateTime.now();
await _db.batch((batch) {
for (final seedUrl in wanderSeedConsoles) {
final url = Uri.parse(seedUrl);
batch.insert(
_db.wanderConsoles,
WanderConsolesCompanion.insert(
url: url,
wanderJsUrl: url.resolve('wander.js'),
source: WanderConsoleSource.seed,
createdAt: Value(now),
),
onConflict: DoNothing(),
);
}
});
}
Future<WanderJsResult?> fetchAndIngestConsole(
Uri consoleUrl, {
required WanderConsoleSource source,
}) async {
final wanderJsUrl = consoleUrl.resolve('wander.js');
final now = DateTime.now();
try {
final result = await _runWanderJsFetch((
token: ServicesBinding.rootIsolateToken!,
url: wanderJsUrl.toString(),
));
if (result == null) {
await _saveConsoleWithError(consoleUrl, wanderJsUrl, now, source);
return null;
}
final existingUrls = await _db.wanderConsoleDao
.getExistingConsoleUrls(result.consoles)
.get();
await _db.transaction(() async {
await _db.wanderConsoleDao.upsertConsole(
WanderConsole(
url: consoleUrl,
wanderJsUrl: wanderJsUrl,
lastFetchedAt: now,
lastFetchFailed: false,
source: source,
createdAt: now,
),
);
await _db.batch((batch) {
for (final neighborUrl in result.consoles) {
batch.insert(
_db.wanderConsoleNeighbors,
WanderConsoleNeighborsCompanion.insert(
sourceConsoleUrl: consoleUrl.toString(),
targetConsoleUrl: neighborUrl.toString(),
discoveredAt: Value(now),
),
onConflict: DoNothing(),
);
if (!existingUrls.contains(neighborUrl)) {
batch.insert(
_db.wanderConsoles,
WanderConsolesCompanion.insert(
url: neighborUrl,
wanderJsUrl: neighborUrl.resolve('wander.js'),
discoveredFromUrl: Value(consoleUrl),
source: WanderConsoleSource.discovered,
createdAt: Value(now),
),
onConflict: DoNothing(),
);
}
}
for (final pageUrl in result.pages) {
final itemId = uuid.v5(Namespace.url.value, pageUrl.toString());
batch.insert(
_db.smallWebItems,
SmallWebItemsCompanion.insert(
id: itemId,
url: pageUrl,
domain: pageUrl.host,
createdAt: Value(now),
updatedAt: Value(now),
),
onConflict: DoUpdate(
(old) => SmallWebItemsCompanion(updatedAt: Value(now)),
target: [_db.smallWebItems.url],
),
);
final membershipId = uuid.v5(
Namespace.url.value,
'${SmallWebSourceKind.wander.name}:$consoleUrl:$pageUrl',
);
batch.insert(
_db.smallWebMemberships,
SmallWebMembershipsCompanion.insert(
id: membershipId,
itemId: itemId,
sourceKind: SmallWebSourceKind.wander,
consoleUrl: Value(consoleUrl),
fetchedAt: Value(now),
),
onConflict: DoUpdate(
(old) => SmallWebMembershipsCompanion(fetchedAt: Value(now)),
target: [_db.smallWebMemberships.id],
),
);
}
});
});
return result;
} catch (e, st) {
logger.e(
'Failed to fetch wander.js from $wanderJsUrl',
error: e,
stackTrace: st,
);
await _saveConsoleWithError(consoleUrl, wanderJsUrl, now, source);
rethrow;
}
}
/// Normalizes a user-input URL to a wander console URL.
///
/// Accepts URLs like:
/// - `https://example.com/wander/` → kept as-is
/// - `https://example.com/wander` → trailing slash added
/// - `https://example.com` → `/wander/` appended
/// - `https://example.com/` → `wander/` appended
///
/// Returns the normalized console URL (always ends with `/wander/`).
static Uri normalizeConsoleUrl(Uri url) {
var path = url.path;
// Strip trailing wander.js if someone pasted the full JS URL
if (path.endsWith('/wander.js')) {
path = path.substring(0, path.length - 'wander.js'.length);
}
// Ensure the path ends with /wander/
if (!path.endsWith('/wander/')) {
if (path.endsWith('/wander')) {
path = '$path/';
} else {
if (!path.endsWith('/')) {
path = '$path/';
}
path = '${path}wander/';
}
}
return url.replace(path: path);
}
/// Checks if a console URL already exists in the database.
Future<bool> consoleExists(Uri consoleUrl) async {
final console = await _db.wanderConsoleDao
.getConsole(consoleUrl)
.getSingleOrNull();
return console != null;
}
/// Validates that a URL points to a valid wander console by fetching its
/// wander.js and checking it contains valid consoles or pages data.
///
/// Returns the parsed [WanderJsResult] if valid, or throws with a
/// descriptive error message.
Future<WanderJsResult> validateConsole(Uri consoleUrl) async {
final wanderJsUrl = consoleUrl.resolve('wander.js');
final result = await _runWanderJsFetch((
token: ServicesBinding.rootIsolateToken!,
url: wanderJsUrl.toString(),
));
if (result == null) {
throw Exception('Could not fetch wander.js from $wanderJsUrl');
}
if (result.consoles.isEmpty && result.pages.isEmpty) {
throw Exception('The wander.js file contains no consoles or pages');
}
return result;
}
/// Validates and adds a user-provided console URL.
///
/// The URL is normalized, checked for duplicates, validated by fetching
/// wander.js, then ingested into the database.
///
/// Returns the normalized console URL.
Future<Uri> addConsoleFromUrl(Uri rawUrl) async {
final consoleUrl = normalizeConsoleUrl(rawUrl);
if (await consoleExists(consoleUrl)) {
throw Exception('This console has already been added');
}
// Validate by fetching wander.js
await validateConsole(consoleUrl);
// Now do the full ingest
await fetchAndIngestConsole(consoleUrl, source: WanderConsoleSource.manual);
return consoleUrl;
}
Future<List<Uri>> getDiscoveredConsoleUrls() {
return _db.wanderConsoleDao.getDiscoveredConsoleUrls().get();
}
Future<List<SmallWebItem>> getPagesForConsole(Uri consoleUrl) {
return _db.definitionsDrift
.getWanderPagesForConsole(
sourceKind: SmallWebSourceKind.wander,
consoleUrl: consoleUrl.toString(),
)
.get();
}
Future<void> _saveConsoleWithError(
Uri consoleUrl,
Uri wanderJsUrl,
DateTime now,
WanderConsoleSource source,
) async {
await _db.wanderConsoleDao.upsertConsole(
WanderConsole(
url: consoleUrl,
wanderJsUrl: wanderJsUrl,
lastFetchedAt: now,
lastFetchFailed: true,
source: source,
createdAt: now,
),
);
}
}
Future<WanderJsResult?> _runWanderJsFetch(_WanderJsFetchRequest request) {
return Isolate.run(_createWanderJsFetchTask(request));
}
Future<WanderJsResult?> Function() _createWanderJsFetchTask(
_WanderJsFetchRequest request,
) {
return () => _fetchAndParseWanderJs(request.token, Uri.parse(request.url));
}
Future<WanderJsResult?> _fetchAndParseWanderJs(
RootIsolateToken token,
Uri url,
) async {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
final client = http.Client();
try {
final response = await client.get(url).timeout(const Duration(seconds: 15));
if (response.statusCode != 200) return null;
return WanderJsResult.parse(response.body);
} finally {
client.close();
}
}