From 2a2311253b0d4a1264e6c77ff4b8f73e2d9e113b Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Fri, 28 Feb 2025 14:31:07 +0100 Subject: [PATCH] web feed intermediate --- .../database/converters/feed_authors.dart | 21 + .../database/converters/feed_categories.dart | 24 + .../data/database/converters/feed_data.dart | 16 + .../data/database/converters/feed_links.dart | 21 + .../web_feed/data/database/daos/article.dart | 119 + .../data/database/daos/article.g.dart | 6 + .../web_feed/data/database/daos/feed.dart | 38 + .../web_feed/data/database/daos/feed.g.dart | 6 + .../web_feed/data/database/database.dart | 36 + .../web_feed/data/database/database.drift | 116 + .../web_feed/data/database/database.g.dart | 2214 +++++++++++++++++ .../web_feed/data/models/feed_article.dart | 116 + .../web_feed/data/models/feed_article.g.dart | 60 + .../models/feed_article_query_result.dart | 29 + .../web_feed/data/models/feed_author.dart | 23 + .../web_feed/data/models/feed_author.g.dart | 13 + .../web_feed/data/models/feed_category.dart | 23 + .../web_feed/data/models/feed_category.g.dart | 13 + .../web_feed/data/models/feed_filter.dart | 19 + .../web_feed/data/models/feed_filter.g.dart | 76 + .../web_feed/data/models/feed_link.dart | 41 + .../web_feed/data/models/feed_link.g.dart | 27 + .../data/models/feed_parse_result.dart | 20 + .../data/models/feed_parse_result.g.dart | 24 + app/lib/features/web_feed/data/providers.dart | 46 + .../features/web_feed/data/providers.g.dart | 26 + .../features/web_feed/domain/providers.dart | 38 + .../features/web_feed/domain/providers.g.dart | 433 ++++ .../domain/providers/article_filter.dart | 26 + .../domain/providers/article_filter.g.dart | 27 + .../domain/repositories/feed_repository.dart | 117 + .../repositories/feed_repository.g.dart | 26 + .../web_feed/domain/services/feed_reader.dart | 45 + .../domain/services/feed_reader.g.dart | 24 + .../features/web_feed/extensions/atom.dart | 57 + .../web_feed/extensions/feed_article.dart | 17 + app/lib/features/web_feed/extensions/rss.dart | 24 + .../extensions/web_feed_converter.dart | 50 + .../controllers/fetch_articles.dart | 50 + .../controllers/fetch_articles.g.dart | 30 + .../presentation/screens/feed_article.dart | 261 ++ .../screens/feed_article_list.dart | 103 + .../presentation/screens/feed_edit.dart | 204 ++ .../presentation/screens/feed_list.dart | 44 + .../widgets/authors_horizontal_list.dart | 41 + .../widgets/feed_article_card.dart | 122 + .../presentation/widgets/feed_card.dart | 132 + .../presentation/widgets/tag_field.dart | 89 + .../widgets/tags_horizontal_list.dart | 54 + .../features/web_feed/utils/feed_finder.dart | 95 + .../features/web_feed/utils/feed_parser.dart | 221 ++ 51 files changed, 5503 insertions(+) create mode 100644 app/lib/features/web_feed/data/database/converters/feed_authors.dart create mode 100644 app/lib/features/web_feed/data/database/converters/feed_categories.dart create mode 100644 app/lib/features/web_feed/data/database/converters/feed_data.dart create mode 100644 app/lib/features/web_feed/data/database/converters/feed_links.dart create mode 100644 app/lib/features/web_feed/data/database/daos/article.dart create mode 100644 app/lib/features/web_feed/data/database/daos/article.g.dart create mode 100644 app/lib/features/web_feed/data/database/daos/feed.dart create mode 100644 app/lib/features/web_feed/data/database/daos/feed.g.dart create mode 100644 app/lib/features/web_feed/data/database/database.dart create mode 100644 app/lib/features/web_feed/data/database/database.drift create mode 100644 app/lib/features/web_feed/data/database/database.g.dart create mode 100644 app/lib/features/web_feed/data/models/feed_article.dart create mode 100644 app/lib/features/web_feed/data/models/feed_article.g.dart create mode 100644 app/lib/features/web_feed/data/models/feed_article_query_result.dart create mode 100644 app/lib/features/web_feed/data/models/feed_author.dart create mode 100644 app/lib/features/web_feed/data/models/feed_author.g.dart create mode 100644 app/lib/features/web_feed/data/models/feed_category.dart create mode 100644 app/lib/features/web_feed/data/models/feed_category.g.dart create mode 100644 app/lib/features/web_feed/data/models/feed_filter.dart create mode 100644 app/lib/features/web_feed/data/models/feed_filter.g.dart create mode 100644 app/lib/features/web_feed/data/models/feed_link.dart create mode 100644 app/lib/features/web_feed/data/models/feed_link.g.dart create mode 100644 app/lib/features/web_feed/data/models/feed_parse_result.dart create mode 100644 app/lib/features/web_feed/data/models/feed_parse_result.g.dart create mode 100644 app/lib/features/web_feed/data/providers.dart create mode 100644 app/lib/features/web_feed/data/providers.g.dart create mode 100644 app/lib/features/web_feed/domain/providers.dart create mode 100644 app/lib/features/web_feed/domain/providers.g.dart create mode 100644 app/lib/features/web_feed/domain/providers/article_filter.dart create mode 100644 app/lib/features/web_feed/domain/providers/article_filter.g.dart create mode 100644 app/lib/features/web_feed/domain/repositories/feed_repository.dart create mode 100644 app/lib/features/web_feed/domain/repositories/feed_repository.g.dart create mode 100644 app/lib/features/web_feed/domain/services/feed_reader.dart create mode 100644 app/lib/features/web_feed/domain/services/feed_reader.g.dart create mode 100644 app/lib/features/web_feed/extensions/atom.dart create mode 100644 app/lib/features/web_feed/extensions/feed_article.dart create mode 100644 app/lib/features/web_feed/extensions/rss.dart create mode 100644 app/lib/features/web_feed/extensions/web_feed_converter.dart create mode 100644 app/lib/features/web_feed/presentation/controllers/fetch_articles.dart create mode 100644 app/lib/features/web_feed/presentation/controllers/fetch_articles.g.dart create mode 100644 app/lib/features/web_feed/presentation/screens/feed_article.dart create mode 100644 app/lib/features/web_feed/presentation/screens/feed_article_list.dart create mode 100644 app/lib/features/web_feed/presentation/screens/feed_edit.dart create mode 100644 app/lib/features/web_feed/presentation/screens/feed_list.dart create mode 100644 app/lib/features/web_feed/presentation/widgets/authors_horizontal_list.dart create mode 100644 app/lib/features/web_feed/presentation/widgets/feed_article_card.dart create mode 100644 app/lib/features/web_feed/presentation/widgets/feed_card.dart create mode 100644 app/lib/features/web_feed/presentation/widgets/tag_field.dart create mode 100644 app/lib/features/web_feed/presentation/widgets/tags_horizontal_list.dart create mode 100644 app/lib/features/web_feed/utils/feed_finder.dart create mode 100644 app/lib/features/web_feed/utils/feed_parser.dart diff --git a/app/lib/features/web_feed/data/database/converters/feed_authors.dart b/app/lib/features/web_feed/data/database/converters/feed_authors.dart new file mode 100644 index 00000000..2f220f43 --- /dev/null +++ b/app/lib/features/web_feed/data/database/converters/feed_authors.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:lensai/features/web_feed/data/models/feed_author.dart'; + +class FeedAuthorsConverter extends TypeConverter, String> { + const FeedAuthorsConverter(); + + @override + List fromSql(String fromDb) { + final authors = jsonDecode(fromDb) as List; + return authors + .map((author) => FeedAuthor.fromJson(author as Map)) + .toList(); + } + + @override + String toSql(List value) { + return jsonEncode(value.map((author) => author.toJson()).toList()); + } +} diff --git a/app/lib/features/web_feed/data/database/converters/feed_categories.dart b/app/lib/features/web_feed/data/database/converters/feed_categories.dart new file mode 100644 index 00000000..cd46bc08 --- /dev/null +++ b/app/lib/features/web_feed/data/database/converters/feed_categories.dart @@ -0,0 +1,24 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:lensai/features/web_feed/data/models/feed_category.dart'; + +class FeedCategoriesConverter + extends TypeConverter, String> { + const FeedCategoriesConverter(); + + @override + List fromSql(String fromDb) { + final categories = jsonDecode(fromDb) as List; + return categories + .map( + (category) => FeedCategory.fromJson(category as Map), + ) + .toList(); + } + + @override + String toSql(List value) { + return jsonEncode(value.map((category) => category.toJson()).toList()); + } +} diff --git a/app/lib/features/web_feed/data/database/converters/feed_data.dart b/app/lib/features/web_feed/data/database/converters/feed_data.dart new file mode 100644 index 00000000..b9d317f2 --- /dev/null +++ b/app/lib/features/web_feed/data/database/converters/feed_data.dart @@ -0,0 +1,16 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:lensai/features/web_feed/data/database/database.dart'; + +class FeedDataConverter extends JsonConverter> { + const FeedDataConverter(); + + @override + FeedData fromJson(Map json) { + return FeedData.fromJson(json); + } + + @override + Map toJson(FeedData object) { + return object.toJson(); + } +} diff --git a/app/lib/features/web_feed/data/database/converters/feed_links.dart b/app/lib/features/web_feed/data/database/converters/feed_links.dart new file mode 100644 index 00000000..336f9474 --- /dev/null +++ b/app/lib/features/web_feed/data/database/converters/feed_links.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:lensai/features/web_feed/data/models/feed_link.dart'; + +class FeedLinksConverter extends TypeConverter, String> { + const FeedLinksConverter(); + + @override + List fromSql(String fromDb) { + final links = jsonDecode(fromDb) as List; + return links + .map((link) => FeedLink.fromJson(link as Map)) + .toList(); + } + + @override + String toSql(List value) { + return jsonEncode(value.map((link) => link.toJson()).toList()); + } +} diff --git a/app/lib/features/web_feed/data/database/daos/article.dart b/app/lib/features/web_feed/data/database/daos/article.dart new file mode 100644 index 00000000..4b8761d0 --- /dev/null +++ b/app/lib/features/web_feed/data/database/daos/article.dart @@ -0,0 +1,119 @@ +import 'package:drift/drift.dart'; +import 'package:lensai/features/web_feed/data/database/database.dart'; +import 'package:lensai/features/web_feed/data/models/feed_article.dart'; +import 'package:lensai/features/web_feed/data/models/feed_article_query_result.dart'; + +part 'article.g.dart'; + +@DriftAccessor() +class ArticleDao extends DatabaseAccessor with _$ArticleDaoMixin { + ArticleDao(super.attachedDatabase); + + Selectable getFeedArticles(Uri? url) { + final select = db.article.select(); + + if (url != null) { + select.where((article) => article.feedId.equalsValue(url)); + } + + return select..orderBy([ + (row) => OrderingTerm( + expression: coalesce([row.updated, row.created]), + mode: OrderingMode.desc, + ), + ]); + } + + SingleOrNullSelectable getArticleById(String articleId) { + return db.article.select()..where((row) => row.id.equals(articleId)); + } + + Future upsertArticles(List articles) { + return db.transaction(() async { + await Future.wait( + articles + .map( + (article) => db.article.insertOne( + article, + onConflict: DoUpdate( + (old) { + return ArticleCompanion( + authors: Value(article.authors), + contentMarkdown: Value(article.contentMarkdown), + contentPlain: Value(article.contentPlain), + links: Value(article.links), + summaryMarkdown: Value(article.summaryMarkdown), + summaryPlain: Value(article.summaryPlain), + tags: Value(article.tags), + title: Value(article.title), + updated: Value(article.updated), + ); + }, + where: + (old) => + old.updated.isNotNull() & + old.updated.isSmallerThanValue( + article.updated ?? DateTime(0), + ), + ), + ), + ) + .toList(), + ); + }); + } + + Future updateArticleRead(String articleId, DateTime? read) { + final statement = + db.article.update()..where((article) => article.id.equals(articleId)); + + return statement.write(ArticleCompanion(lastRead: Value(read))); + } + + Selectable<(String, int)> getUnreadArticleCount() { + final count = countAll(); + + final countByFeed = + db.article.selectOnly() + ..addColumns([db.article.feedId, count]) + ..where( + db.article.lastRead.isNull() | + (db.article.updated.isNotNull() & + db.article.lastRead.isSmallerThan(db.article.lastRead)), + ) + ..groupBy([db.article.feedId]); + + return countByFeed.map( + (result) => (result.read(db.article.feedId)!, result.read(count)!), + ); + } + + Selectable queryArticles({ + required String matchPrefix, + required String matchSuffix, + required String ellipsis, + required int snippetLength, + required String searchString, + required Uri? feedId, + }) { + final ftsQuery = db.buildFtsQuery(searchString); + + if (ftsQuery.isNotEmpty) { + return db.queryArticlesFullContent( + feedId: feedId?.toString(), + query: ftsQuery, + snippetLength: snippetLength, + beforeMatch: matchPrefix, + afterMatch: matchSuffix, + ellipsis: ellipsis, + ); + } else { + return db.queryArticlesBasic( + feedId: feedId?.toString(), + query: db.buildLikeQuery(searchString), + beforeMatch: matchPrefix, + afterMatch: matchSuffix, + ); + } + } +} diff --git a/app/lib/features/web_feed/data/database/daos/article.g.dart b/app/lib/features/web_feed/data/database/daos/article.g.dart new file mode 100644 index 00000000..a2904668 --- /dev/null +++ b/app/lib/features/web_feed/data/database/daos/article.g.dart @@ -0,0 +1,6 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'article.dart'; + +// ignore_for_file: type=lint +mixin _$ArticleDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/web_feed/data/database/daos/feed.dart b/app/lib/features/web_feed/data/database/daos/feed.dart new file mode 100644 index 00000000..8fdafd67 --- /dev/null +++ b/app/lib/features/web_feed/data/database/daos/feed.dart @@ -0,0 +1,38 @@ +import 'package:drift/drift.dart'; +import 'package:lensai/features/web_feed/data/database/database.dart'; + +part 'feed.g.dart'; + +@DriftAccessor() +class FeedDao extends DatabaseAccessor with _$FeedDaoMixin { + FeedDao(super.attachedDatabase); + + Selectable getFeeds() { + return db.feed.select(); + } + + Future updateFeedFetched(Uri url, DateTime fetched) { + final statement = + db.feed.update()..where((feed) => feed.url.equalsValue(url)); + + return statement.write(FeedCompanion(lastFetched: Value(fetched))); + } + + Future deleteFeed(Uri url) { + return db.feed.deleteWhere((feed) => feed.url.equals(url.toString())); + } + + Future upsertFeed(FeedData feedData) { + return db.feed.insertOne( + feedData, + onConflict: DoUpdate((old) { + return FeedCompanion( + authors: Value(feedData.authors), + title: Value(feedData.title), + description: Value(feedData.description), + tags: Value(feedData.tags), + ); + }), + ); + } +} diff --git a/app/lib/features/web_feed/data/database/daos/feed.g.dart b/app/lib/features/web_feed/data/database/daos/feed.g.dart new file mode 100644 index 00000000..95d0ecec --- /dev/null +++ b/app/lib/features/web_feed/data/database/daos/feed.g.dart @@ -0,0 +1,6 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed.dart'; + +// ignore_for_file: type=lint +mixin _$FeedDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/web_feed/data/database/database.dart b/app/lib/features/web_feed/data/database/database.dart new file mode 100644 index 00000000..7b307ef7 --- /dev/null +++ b/app/lib/features/web_feed/data/database/database.dart @@ -0,0 +1,36 @@ +import 'package:drift/drift.dart'; +import 'package:lensai/data/database/converters/uri.dart'; +import 'package:lensai/features/search/domain/fts_tokenizer.dart'; +import 'package:lensai/features/web_feed/data/database/converters/feed_authors.dart'; +import 'package:lensai/features/web_feed/data/database/converters/feed_categories.dart'; +import 'package:lensai/features/web_feed/data/database/converters/feed_links.dart'; +import 'package:lensai/features/web_feed/data/database/daos/article.dart'; +import 'package:lensai/features/web_feed/data/database/daos/feed.dart'; +import 'package:lensai/features/web_feed/data/models/feed_article.dart'; +import 'package:lensai/features/web_feed/data/models/feed_article_query_result.dart'; +import 'package:lensai/features/web_feed/data/models/feed_author.dart'; +import 'package:lensai/features/web_feed/data/models/feed_category.dart'; +import 'package:lensai/features/web_feed/data/models/feed_link.dart'; + +part 'database.g.dart'; + +@DriftDatabase(include: {'database.drift'}, daos: [ArticleDao, FeedDao]) +class FeedDatabase extends _$FeedDatabase with TrigramQueryBuilderMixin { + @override + final int schemaVersion = 1; + + @override + final int ftsTokenLimit = 10; + @override + final int ftsMinTokenLength = 3; + + @override + MigrationStrategy get migration => MigrationStrategy( + beforeOpen: (details) async { + await customStatement('PRAGMA foreign_keys = ON;'); + await optimizeFtsIndex(); + }, + ); + + FeedDatabase(super.e); +} diff --git a/app/lib/features/web_feed/data/database/database.drift b/app/lib/features/web_feed/data/database/database.drift new file mode 100644 index 00000000..e0cf1aa0 --- /dev/null +++ b/app/lib/features/web_feed/data/database/database.drift @@ -0,0 +1,116 @@ +import 'package:lensai/data/database/converters/uri.dart'; +import 'package:lensai/features/web_feed/data/models/feed_article_query_result.dart'; +import 'package:lensai/features/web_feed/data/database/converters/feed_authors.dart'; +import 'package:lensai/features/web_feed/data/database/converters/feed_categories.dart'; +import 'package:lensai/features/web_feed/data/database/converters/feed_links.dart'; +import 'package:lensai/features/web_feed/data/models/feed_article.dart'; + +CREATE TABLE feed ( + url TEXT PRIMARY KEY NOT NULL MAPPED BY `const UriConverter()`, + title TEXT, + description TEXT, + authors TEXT MAPPED BY `const FeedAuthorsConverter()`, + tags TEXT MAPPED BY `const FeedCategoriesConverter()`, + last_fetched DATETIME +); + +CREATE TABLE article ( + id TEXT PRIMARY KEY NOT NULL, + feed_id TEXT NOT NULL MAPPED BY `const UriConverter()` REFERENCES feed ("url") ON DELETE CASCADE, + fetched DATETIME NOT NULL, + created DATETIME, + updated DATETIME, + last_read DATETIME, + title TEXT, + authors TEXT MAPPED BY `const FeedAuthorsConverter()`, + tags TEXT MAPPED BY `const FeedCategoriesConverter()`, + links TEXT MAPPED BY `const FeedLinksConverter()`, + summaryMarkdown TEXT, + summaryPlain TEXT, + contentMarkdown TEXT, + contentPlain TEXT +) WITH FeedArticle; + +CREATE INDEX article_feed_id ON article (feed_id); + +CREATE VIRTUAL TABLE article_fts + USING fts5( + title, + summaryPlain, + contentPlain, + content=article, + tokenize="trigram" + ); + +-- Triggers to keep the FTS index up to date. +CREATE TRIGGER article_after_insert AFTER INSERT ON article BEGIN + INSERT INTO + article_fts(rowid, title, summaryPlain, contentPlain) + VALUES (new.rowid, new.title, new.summaryPlain, new.contentPlain); +END; +CREATE TRIGGER article_after_delete AFTER DELETE ON article BEGIN + INSERT INTO + article_fts(article_fts, rowid, title, summaryPlain, contentPlain) + VALUES('delete', old.rowid, old.title, old.summaryPlain, old.contentPlain); +END; +CREATE TRIGGER article_after_update AFTER UPDATE ON article BEGIN + INSERT INTO + article_fts(article_fts, rowid, title, summaryPlain, contentPlain) + VALUES('delete', old.rowid, old.title, old.summaryPlain, old.contentPlain); + INSERT INTO + article_fts(rowid, title, summaryPlain, contentPlain) + VALUES (new.rowid, new.title, new.summaryPlain, new.contentPlain); +END; + +optimizeFtsIndex: + INSERT INTO article_fts(article_fts) VALUES ('optimize'); + +queryArticlesBasic(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult: + WITH weights AS ( + SELECT + -- Customize these weights (higher = more important) + 1.0 as title_weight -- Title matches are most important + ) + SELECT + a.*, + highlight(article_fts, 0, :beforeMatch, :afterMatch) AS title, + ( + bm25(article_fts, weights.title_weight) + ) AS weighted_rank + FROM article_fts(:query) fts + INNER JOIN + article a ON a.rowid = fts.rowid + CROSS JOIN weights + WHERE + fts.title LIKE :query AND + :feed_id IS NULL OR a.feed_id = :feed_id + ORDER BY + weighted_rank ASC, + a.created DESC NULLS LAST; + +queryArticlesFullContent(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult: + WITH weights AS ( + SELECT + -- Customize these weights (higher = more important) + 5.0 as title_weight, -- Title matches are most important + 2.0 as summary_weight, -- Summary matches are quite important + 1.0 as content_weight -- Content matches are basic + ) + SELECT + a.*, + highlight(article_fts, 0, :beforeMatch, :afterMatch) AS title, + snippet(article_fts, 1, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS summary, + snippet(article_fts, 2, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS content, + ( + bm25(article_fts, weights.title_weight, weights.summary_weight, + weights.content_weight) + ) AS weighted_rank + FROM article_fts(:query) fts + INNER JOIN + article a ON a.rowid = fts.rowid + CROSS JOIN weights + WHERE + :feed_id IS NULL OR a.feed_id = :feed_id + ORDER BY + weighted_rank ASC, + a.created DESC NULLS LAST; \ No newline at end of file diff --git a/app/lib/features/web_feed/data/database/database.g.dart b/app/lib/features/web_feed/data/database/database.g.dart new file mode 100644 index 00000000..1baaaf83 --- /dev/null +++ b/app/lib/features/web_feed/data/database/database.g.dart @@ -0,0 +1,2214 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class Feed extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Feed(this.attachedDatabase, [this._alias]); + late final GeneratedColumnWithTypeConverter url = + GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ).withConverter(Feed.$converterurl); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumnWithTypeConverter?, String> + authors = GeneratedColumn( + 'authors', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter?>(Feed.$converterauthorsn); + late final GeneratedColumnWithTypeConverter?, String> + tags = GeneratedColumn( + 'tags', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter?>(Feed.$convertertagsn); + late final GeneratedColumn lastFetched = GeneratedColumn( + 'last_fetched', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + url, + title, + description, + authors, + tags, + lastFetched, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'feed'; + @override + Set get $primaryKey => {url}; + @override + FeedData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return FeedData( + url: Feed.$converterurl.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + )!, + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + description: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + ), + authors: Feed.$converterauthorsn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}authors'], + ), + ), + tags: Feed.$convertertagsn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tags'], + ), + ), + lastFetched: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_fetched'], + ), + ); + } + + @override + Feed createAlias(String alias) { + return Feed(attachedDatabase, alias); + } + + static TypeConverter $converterurl = const UriConverter(); + static TypeConverter, String> $converterauthors = + const FeedAuthorsConverter(); + static TypeConverter?, String?> $converterauthorsn = + NullAwareTypeConverter.wrap($converterauthors); + static TypeConverter, String> $convertertags = + const FeedCategoriesConverter(); + static TypeConverter?, String?> $convertertagsn = + NullAwareTypeConverter.wrap($convertertags); + @override + bool get dontWriteConstraints => true; +} + +class FeedData extends DataClass implements Insertable { + final Uri url; + final String? title; + final String? description; + final List? authors; + final List? tags; + final DateTime? lastFetched; + const FeedData({ + required this.url, + this.title, + this.description, + this.authors, + this.tags, + this.lastFetched, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + { + map['url'] = Variable(Feed.$converterurl.toSql(url)); + } + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || description != null) { + map['description'] = Variable(description); + } + if (!nullToAbsent || authors != null) { + map['authors'] = Variable(Feed.$converterauthorsn.toSql(authors)); + } + if (!nullToAbsent || tags != null) { + map['tags'] = Variable(Feed.$convertertagsn.toSql(tags)); + } + if (!nullToAbsent || lastFetched != null) { + map['last_fetched'] = Variable(lastFetched); + } + return map; + } + + factory FeedData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return FeedData( + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + description: serializer.fromJson(json['description']), + authors: serializer.fromJson?>(json['authors']), + tags: serializer.fromJson?>(json['tags']), + lastFetched: serializer.fromJson(json['last_fetched']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'description': serializer.toJson(description), + 'authors': serializer.toJson?>(authors), + 'tags': serializer.toJson?>(tags), + 'last_fetched': serializer.toJson(lastFetched), + }; + } + + FeedData copyWith({ + Uri? url, + Value title = const Value.absent(), + Value description = const Value.absent(), + Value?> authors = const Value.absent(), + Value?> tags = const Value.absent(), + Value lastFetched = const Value.absent(), + }) => FeedData( + url: url ?? this.url, + title: title.present ? title.value : this.title, + description: description.present ? description.value : this.description, + authors: authors.present ? authors.value : this.authors, + tags: tags.present ? tags.value : this.tags, + lastFetched: lastFetched.present ? lastFetched.value : this.lastFetched, + ); + FeedData copyWithCompanion(FeedCompanion data) { + return FeedData( + url: data.url.present ? data.url.value : this.url, + title: data.title.present ? data.title.value : this.title, + description: + data.description.present ? data.description.value : this.description, + authors: data.authors.present ? data.authors.value : this.authors, + tags: data.tags.present ? data.tags.value : this.tags, + lastFetched: + data.lastFetched.present ? data.lastFetched.value : this.lastFetched, + ); + } + + @override + String toString() { + return (StringBuffer('FeedData(') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('description: $description, ') + ..write('authors: $authors, ') + ..write('tags: $tags, ') + ..write('lastFetched: $lastFetched') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(url, title, description, authors, tags, lastFetched); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is FeedData && + other.url == this.url && + other.title == this.title && + other.description == this.description && + other.authors == this.authors && + other.tags == this.tags && + other.lastFetched == this.lastFetched); +} + +class FeedCompanion extends UpdateCompanion { + final Value url; + final Value title; + final Value description; + final Value?> authors; + final Value?> tags; + final Value lastFetched; + final Value rowid; + const FeedCompanion({ + this.url = const Value.absent(), + this.title = const Value.absent(), + this.description = const Value.absent(), + this.authors = const Value.absent(), + this.tags = const Value.absent(), + this.lastFetched = const Value.absent(), + this.rowid = const Value.absent(), + }); + FeedCompanion.insert({ + required Uri url, + this.title = const Value.absent(), + this.description = const Value.absent(), + this.authors = const Value.absent(), + this.tags = const Value.absent(), + this.lastFetched = const Value.absent(), + this.rowid = const Value.absent(), + }) : url = Value(url); + static Insertable custom({ + Expression? url, + Expression? title, + Expression? description, + Expression? authors, + Expression? tags, + Expression? lastFetched, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (url != null) 'url': url, + if (title != null) 'title': title, + if (description != null) 'description': description, + if (authors != null) 'authors': authors, + if (tags != null) 'tags': tags, + if (lastFetched != null) 'last_fetched': lastFetched, + if (rowid != null) 'rowid': rowid, + }); + } + + FeedCompanion copyWith({ + Value? url, + Value? title, + Value? description, + Value?>? authors, + Value?>? tags, + Value? lastFetched, + Value? rowid, + }) { + return FeedCompanion( + url: url ?? this.url, + title: title ?? this.title, + description: description ?? this.description, + authors: authors ?? this.authors, + tags: tags ?? this.tags, + lastFetched: lastFetched ?? this.lastFetched, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (url.present) { + map['url'] = Variable(Feed.$converterurl.toSql(url.value)); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (authors.present) { + map['authors'] = Variable( + Feed.$converterauthorsn.toSql(authors.value), + ); + } + if (tags.present) { + map['tags'] = Variable(Feed.$convertertagsn.toSql(tags.value)); + } + if (lastFetched.present) { + map['last_fetched'] = Variable(lastFetched.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('FeedCompanion(') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('description: $description, ') + ..write('authors: $authors, ') + ..write('tags: $tags, ') + ..write('lastFetched: $lastFetched, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Article extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Article(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumnWithTypeConverter feedId = + GeneratedColumn( + 'feed_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL REFERENCES feed(url)ON DELETE CASCADE', + ).withConverter(Article.$converterfeedId); + late final GeneratedColumn fetched = GeneratedColumn( + 'fetched', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn created = GeneratedColumn( + 'created', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn updated = GeneratedColumn( + 'updated', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn lastRead = GeneratedColumn( + 'last_read', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumnWithTypeConverter?, String> + authors = GeneratedColumn( + 'authors', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter?>(Article.$converterauthorsn); + late final GeneratedColumnWithTypeConverter?, String> + tags = GeneratedColumn( + 'tags', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter?>(Article.$convertertagsn); + late final GeneratedColumnWithTypeConverter?, String> links = + GeneratedColumn( + 'links', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter?>(Article.$converterlinksn); + late final GeneratedColumn summaryMarkdown = GeneratedColumn( + 'summaryMarkdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn summaryPlain = GeneratedColumn( + 'summaryPlain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn contentMarkdown = GeneratedColumn( + 'contentMarkdown', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumn contentPlain = GeneratedColumn( + 'contentPlain', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + @override + List get $columns => [ + id, + feedId, + fetched, + created, + updated, + lastRead, + title, + authors, + tags, + links, + summaryMarkdown, + summaryPlain, + contentMarkdown, + contentPlain, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'article'; + @override + Set get $primaryKey => {id}; + @override + FeedArticle map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return FeedArticle( + id: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + feedId: Article.$converterfeedId.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}feed_id'], + )!, + ), + fetched: + attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}fetched'], + )!, + created: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created'], + ), + updated: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated'], + ), + lastRead: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_read'], + ), + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + authors: Article.$converterauthorsn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}authors'], + ), + ), + tags: Article.$convertertagsn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tags'], + ), + ), + links: Article.$converterlinksn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}links'], + ), + ), + summaryMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}summaryMarkdown'], + ), + summaryPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}summaryPlain'], + ), + contentMarkdown: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}contentMarkdown'], + ), + contentPlain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}contentPlain'], + ), + ); + } + + @override + Article createAlias(String alias) { + return Article(attachedDatabase, alias); + } + + static TypeConverter $converterfeedId = const UriConverter(); + static TypeConverter, String> $converterauthors = + const FeedAuthorsConverter(); + static TypeConverter?, String?> $converterauthorsn = + NullAwareTypeConverter.wrap($converterauthors); + static TypeConverter, String> $convertertags = + const FeedCategoriesConverter(); + static TypeConverter?, String?> $convertertagsn = + NullAwareTypeConverter.wrap($convertertags); + static TypeConverter, String> $converterlinks = + const FeedLinksConverter(); + static TypeConverter?, String?> $converterlinksn = + NullAwareTypeConverter.wrap($converterlinks); + @override + bool get dontWriteConstraints => true; +} + +class ArticleCompanion extends UpdateCompanion { + final Value id; + final Value feedId; + final Value fetched; + final Value created; + final Value updated; + final Value lastRead; + final Value title; + final Value?> authors; + final Value?> tags; + final Value?> links; + final Value summaryMarkdown; + final Value summaryPlain; + final Value contentMarkdown; + final Value contentPlain; + final Value rowid; + const ArticleCompanion({ + this.id = const Value.absent(), + this.feedId = const Value.absent(), + this.fetched = const Value.absent(), + this.created = const Value.absent(), + this.updated = const Value.absent(), + this.lastRead = const Value.absent(), + this.title = const Value.absent(), + this.authors = const Value.absent(), + this.tags = const Value.absent(), + this.links = const Value.absent(), + this.summaryMarkdown = const Value.absent(), + this.summaryPlain = const Value.absent(), + this.contentMarkdown = const Value.absent(), + this.contentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + ArticleCompanion.insert({ + required String id, + required Uri feedId, + required DateTime fetched, + this.created = const Value.absent(), + this.updated = const Value.absent(), + this.lastRead = const Value.absent(), + this.title = const Value.absent(), + this.authors = const Value.absent(), + this.tags = const Value.absent(), + this.links = const Value.absent(), + this.summaryMarkdown = const Value.absent(), + this.summaryPlain = const Value.absent(), + this.contentMarkdown = const Value.absent(), + this.contentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + feedId = Value(feedId), + fetched = Value(fetched); + static Insertable custom({ + Expression? id, + Expression? feedId, + Expression? fetched, + Expression? created, + Expression? updated, + Expression? lastRead, + Expression? title, + Expression? authors, + Expression? tags, + Expression? links, + Expression? summaryMarkdown, + Expression? summaryPlain, + Expression? contentMarkdown, + Expression? contentPlain, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (feedId != null) 'feed_id': feedId, + if (fetched != null) 'fetched': fetched, + if (created != null) 'created': created, + if (updated != null) 'updated': updated, + if (lastRead != null) 'last_read': lastRead, + if (title != null) 'title': title, + if (authors != null) 'authors': authors, + if (tags != null) 'tags': tags, + if (links != null) 'links': links, + if (summaryMarkdown != null) 'summaryMarkdown': summaryMarkdown, + if (summaryPlain != null) 'summaryPlain': summaryPlain, + if (contentMarkdown != null) 'contentMarkdown': contentMarkdown, + if (contentPlain != null) 'contentPlain': contentPlain, + if (rowid != null) 'rowid': rowid, + }); + } + + ArticleCompanion copyWith({ + Value? id, + Value? feedId, + Value? fetched, + Value? created, + Value? updated, + Value? lastRead, + Value? title, + Value?>? authors, + Value?>? tags, + Value?>? links, + Value? summaryMarkdown, + Value? summaryPlain, + Value? contentMarkdown, + Value? contentPlain, + Value? rowid, + }) { + return ArticleCompanion( + id: id ?? this.id, + feedId: feedId ?? this.feedId, + fetched: fetched ?? this.fetched, + created: created ?? this.created, + updated: updated ?? this.updated, + lastRead: lastRead ?? this.lastRead, + title: title ?? this.title, + authors: authors ?? this.authors, + tags: tags ?? this.tags, + links: links ?? this.links, + summaryMarkdown: summaryMarkdown ?? this.summaryMarkdown, + summaryPlain: summaryPlain ?? this.summaryPlain, + contentMarkdown: contentMarkdown ?? this.contentMarkdown, + contentPlain: contentPlain ?? this.contentPlain, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (feedId.present) { + map['feed_id'] = Variable( + Article.$converterfeedId.toSql(feedId.value), + ); + } + if (fetched.present) { + map['fetched'] = Variable(fetched.value); + } + if (created.present) { + map['created'] = Variable(created.value); + } + if (updated.present) { + map['updated'] = Variable(updated.value); + } + if (lastRead.present) { + map['last_read'] = Variable(lastRead.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (authors.present) { + map['authors'] = Variable( + Article.$converterauthorsn.toSql(authors.value), + ); + } + if (tags.present) { + map['tags'] = Variable(Article.$convertertagsn.toSql(tags.value)); + } + if (links.present) { + map['links'] = Variable( + Article.$converterlinksn.toSql(links.value), + ); + } + if (summaryMarkdown.present) { + map['summaryMarkdown'] = Variable(summaryMarkdown.value); + } + if (summaryPlain.present) { + map['summaryPlain'] = Variable(summaryPlain.value); + } + if (contentMarkdown.present) { + map['contentMarkdown'] = Variable(contentMarkdown.value); + } + if (contentPlain.present) { + map['contentPlain'] = Variable(contentPlain.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ArticleCompanion(') + ..write('id: $id, ') + ..write('feedId: $feedId, ') + ..write('fetched: $fetched, ') + ..write('created: $created, ') + ..write('updated: $updated, ') + ..write('lastRead: $lastRead, ') + ..write('title: $title, ') + ..write('authors: $authors, ') + ..write('tags: $tags, ') + ..write('links: $links, ') + ..write('summaryMarkdown: $summaryMarkdown, ') + ..write('summaryPlain: $summaryPlain, ') + ..write('contentMarkdown: $contentMarkdown, ') + ..write('contentPlain: $contentPlain, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class ArticleFts extends Table + with + TableInfo, + VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ArticleFts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: '', + ); + late final GeneratedColumn summaryPlain = GeneratedColumn( + 'summaryPlain', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: '', + ); + late final GeneratedColumn contentPlain = GeneratedColumn( + 'contentPlain', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: '', + ); + @override + List get $columns => [title, summaryPlain, contentPlain]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'article_fts'; + @override + Set get $primaryKey => const {}; + @override + ArticleFt map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ArticleFt( + title: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + summaryPlain: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}summaryPlain'], + )!, + contentPlain: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}contentPlain'], + )!, + ); + } + + @override + ArticleFts createAlias(String alias) { + return ArticleFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(title, summaryPlain, contentPlain, content=article, tokenize="trigram")'; +} + +class ArticleFt extends DataClass implements Insertable { + final String title; + final String summaryPlain; + final String contentPlain; + const ArticleFt({ + required this.title, + required this.summaryPlain, + required this.contentPlain, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['title'] = Variable(title); + map['summaryPlain'] = Variable(summaryPlain); + map['contentPlain'] = Variable(contentPlain); + return map; + } + + factory ArticleFt.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ArticleFt( + title: serializer.fromJson(json['title']), + summaryPlain: serializer.fromJson(json['summaryPlain']), + contentPlain: serializer.fromJson(json['contentPlain']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'title': serializer.toJson(title), + 'summaryPlain': serializer.toJson(summaryPlain), + 'contentPlain': serializer.toJson(contentPlain), + }; + } + + ArticleFt copyWith({ + String? title, + String? summaryPlain, + String? contentPlain, + }) => ArticleFt( + title: title ?? this.title, + summaryPlain: summaryPlain ?? this.summaryPlain, + contentPlain: contentPlain ?? this.contentPlain, + ); + ArticleFt copyWithCompanion(ArticleFtsCompanion data) { + return ArticleFt( + title: data.title.present ? data.title.value : this.title, + summaryPlain: + data.summaryPlain.present + ? data.summaryPlain.value + : this.summaryPlain, + contentPlain: + data.contentPlain.present + ? data.contentPlain.value + : this.contentPlain, + ); + } + + @override + String toString() { + return (StringBuffer('ArticleFt(') + ..write('title: $title, ') + ..write('summaryPlain: $summaryPlain, ') + ..write('contentPlain: $contentPlain') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(title, summaryPlain, contentPlain); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ArticleFt && + other.title == this.title && + other.summaryPlain == this.summaryPlain && + other.contentPlain == this.contentPlain); +} + +class ArticleFtsCompanion extends UpdateCompanion { + final Value title; + final Value summaryPlain; + final Value contentPlain; + final Value rowid; + const ArticleFtsCompanion({ + this.title = const Value.absent(), + this.summaryPlain = const Value.absent(), + this.contentPlain = const Value.absent(), + this.rowid = const Value.absent(), + }); + ArticleFtsCompanion.insert({ + required String title, + required String summaryPlain, + required String contentPlain, + this.rowid = const Value.absent(), + }) : title = Value(title), + summaryPlain = Value(summaryPlain), + contentPlain = Value(contentPlain); + static Insertable custom({ + Expression? title, + Expression? summaryPlain, + Expression? contentPlain, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (title != null) 'title': title, + if (summaryPlain != null) 'summaryPlain': summaryPlain, + if (contentPlain != null) 'contentPlain': contentPlain, + if (rowid != null) 'rowid': rowid, + }); + } + + ArticleFtsCompanion copyWith({ + Value? title, + Value? summaryPlain, + Value? contentPlain, + Value? rowid, + }) { + return ArticleFtsCompanion( + title: title ?? this.title, + summaryPlain: summaryPlain ?? this.summaryPlain, + contentPlain: contentPlain ?? this.contentPlain, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (title.present) { + map['title'] = Variable(title.value); + } + if (summaryPlain.present) { + map['summaryPlain'] = Variable(summaryPlain.value); + } + if (contentPlain.present) { + map['contentPlain'] = Variable(contentPlain.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ArticleFtsCompanion(') + ..write('title: $title, ') + ..write('summaryPlain: $summaryPlain, ') + ..write('contentPlain: $contentPlain, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$FeedDatabase extends GeneratedDatabase { + _$FeedDatabase(QueryExecutor e) : super(e); + $FeedDatabaseManager get managers => $FeedDatabaseManager(this); + late final Feed feed = Feed(this); + late final Article article = Article(this); + late final Index articleFeedId = Index( + 'article_feed_id', + 'CREATE INDEX article_feed_id ON article (feed_id)', + ); + late final ArticleFts articleFts = ArticleFts(this); + late final Trigger articleAfterInsert = Trigger( + 'CREATE TRIGGER article_after_insert AFTER INSERT ON article BEGIN INSERT INTO article_fts ("rowid", title, summaryPlain, contentPlain) VALUES (new."rowid", new.title, new.summaryPlain, new.contentPlain);END', + 'article_after_insert', + ); + late final Trigger articleAfterDelete = Trigger( + 'CREATE TRIGGER article_after_delete AFTER DELETE ON article BEGIN INSERT INTO article_fts (article_fts, "rowid", title, summaryPlain, contentPlain) VALUES (\'delete\', old."rowid", old.title, old.summaryPlain, old.contentPlain);END', + 'article_after_delete', + ); + late final Trigger articleAfterUpdate = Trigger( + 'CREATE TRIGGER article_after_update AFTER UPDATE ON article BEGIN INSERT INTO article_fts (article_fts, "rowid", title, summaryPlain, contentPlain) VALUES (\'delete\', old."rowid", old.title, old.summaryPlain, old.contentPlain);INSERT INTO article_fts ("rowid", title, summaryPlain, contentPlain) VALUES (new."rowid", new.title, new.summaryPlain, new.contentPlain);END', + 'article_after_update', + ); + late final ArticleDao articleDao = ArticleDao(this as FeedDatabase); + late final FeedDao feedDao = FeedDao(this as FeedDatabase); + Future optimizeFtsIndex() { + return customInsert( + 'INSERT INTO article_fts (article_fts) VALUES (\'optimize\')', + variables: [], + updates: {articleFts}, + ); + } + + Selectable queryArticlesBasic({ + required String beforeMatch, + required String afterMatch, + required String query, + String? feedId, + }) { + return customSelect( + 'WITH weights AS (SELECT 1.0 AS title_weight) SELECT a.*, highlight(article_fts, 0, ?1, ?2) AS title,(bm25(article_fts, weights.title_weight))AS weighted_rank FROM article_fts(?3)AS fts INNER JOIN article AS a ON a."rowid" = fts."rowid" CROSS JOIN weights WHERE fts.title LIKE ?3 AND ?4 IS NULL OR a.feed_id = ?4 ORDER BY weighted_rank ASC, a.created DESC NULLS LAST', + variables: [ + Variable(beforeMatch), + Variable(afterMatch), + Variable(query), + Variable(feedId), + ], + readsFrom: {articleFts, article}, + ).map( + (QueryRow row) => FeedArticleQueryResult( + id: row.read('id'), + feedId: Article.$converterfeedId.fromSql(row.read('feed_id')), + fetched: row.read('fetched'), + weightedRank: row.read('weighted_rank'), + created: row.readNullable('created'), + updated: row.readNullable('updated'), + lastRead: row.readNullable('last_read'), + title: row.readNullable('title'), + authors: NullAwareTypeConverter.wrapFromSql( + Article.$converterauthors, + row.readNullable('authors'), + ), + tags: NullAwareTypeConverter.wrapFromSql( + Article.$convertertags, + row.readNullable('tags'), + ), + links: NullAwareTypeConverter.wrapFromSql( + Article.$converterlinks, + row.readNullable('links'), + ), + summaryMarkdown: row.readNullable('summaryMarkdown'), + summaryPlain: row.readNullable('summaryPlain'), + contentMarkdown: row.readNullable('contentMarkdown'), + contentPlain: row.readNullable('contentPlain'), + ), + ); + } + + Selectable queryArticlesFullContent({ + required String beforeMatch, + required String afterMatch, + required String ellipsis, + required int snippetLength, + required String query, + String? feedId, + }) { + return customSelect( + 'WITH weights AS (SELECT 5.0 AS title_weight, 2.0 AS summary_weight, 1.0 AS content_weight) SELECT a.*, highlight(article_fts, 0, ?1, ?2) AS title, snippet(article_fts, 1, ?1, ?2, ?3, ?4) AS summary, snippet(article_fts, 2, ?1, ?2, ?3, ?4) AS content,(bm25(article_fts, weights.title_weight, weights.summary_weight, weights.content_weight))AS weighted_rank FROM article_fts(?5)AS fts INNER JOIN article AS a ON a."rowid" = fts."rowid" CROSS JOIN weights WHERE ?6 IS NULL OR a.feed_id = ?6 ORDER BY weighted_rank ASC, a.created DESC NULLS LAST', + variables: [ + Variable(beforeMatch), + Variable(afterMatch), + Variable(ellipsis), + Variable(snippetLength), + Variable(query), + Variable(feedId), + ], + readsFrom: {articleFts, article}, + ).map( + (QueryRow row) => FeedArticleQueryResult( + id: row.read('id'), + feedId: Article.$converterfeedId.fromSql(row.read('feed_id')), + fetched: row.read('fetched'), + weightedRank: row.read('weighted_rank'), + created: row.readNullable('created'), + updated: row.readNullable('updated'), + lastRead: row.readNullable('last_read'), + title: row.readNullable('title'), + authors: NullAwareTypeConverter.wrapFromSql( + Article.$converterauthors, + row.readNullable('authors'), + ), + tags: NullAwareTypeConverter.wrapFromSql( + Article.$convertertags, + row.readNullable('tags'), + ), + links: NullAwareTypeConverter.wrapFromSql( + Article.$converterlinks, + row.readNullable('links'), + ), + summaryMarkdown: row.readNullable('summaryMarkdown'), + summaryPlain: row.readNullable('summaryPlain'), + contentMarkdown: row.readNullable('contentMarkdown'), + contentPlain: row.readNullable('contentPlain'), + ), + ); + } + + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + feed, + article, + articleFeedId, + articleFts, + articleAfterInsert, + articleAfterDelete, + articleAfterUpdate, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'feed', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('article', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'article', + limitUpdateKind: UpdateKind.insert, + ), + result: [TableUpdate('article_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'article', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('article_fts', kind: UpdateKind.insert)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'article', + limitUpdateKind: UpdateKind.update, + ), + result: [TableUpdate('article_fts', kind: UpdateKind.insert)], + ), + ]); +} + +typedef $FeedCreateCompanionBuilder = + FeedCompanion Function({ + required Uri url, + Value title, + Value description, + Value?> authors, + Value?> tags, + Value lastFetched, + Value rowid, + }); +typedef $FeedUpdateCompanionBuilder = + FeedCompanion Function({ + Value url, + Value title, + Value description, + Value?> authors, + Value?> tags, + Value lastFetched, + Value rowid, + }); + +final class $FeedReferences + extends BaseReferences<_$FeedDatabase, Feed, FeedData> { + $FeedReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey> _articleRefsTable( + _$FeedDatabase db, + ) => MultiTypedResultKey.fromTable( + db.article, + aliasName: $_aliasNameGenerator(db.feed.url, db.article.feedId), + ); + + $ArticleProcessedTableManager get articleRefs { + final manager = $ArticleTableManager( + $_db, + $_db.article, + ).filter((f) => f.feedId.url.sqlEquals($_itemColumn('url')!)); + + final cache = $_typedResult.readTableOrNull(_articleRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $FeedFilterComposer extends Composer<_$FeedDatabase, Feed> { + $FeedFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnWithTypeConverterFilters get url => + $composableBuilder( + column: $table.url, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters?, List, String> + get authors => $composableBuilder( + column: $table.authors, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters< + List?, + List, + String + > + get tags => $composableBuilder( + column: $table.tags, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get lastFetched => $composableBuilder( + column: $table.lastFetched, + builder: (column) => ColumnFilters(column), + ); + + Expression articleRefs( + Expression Function($ArticleFilterComposer f) f, + ) { + final $ArticleFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.url, + referencedTable: $db.article, + getReferencedColumn: (t) => t.feedId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $ArticleFilterComposer( + $db: $db, + $table: $db.article, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $FeedOrderingComposer extends Composer<_$FeedDatabase, Feed> { + $FeedOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get authors => $composableBuilder( + column: $table.authors, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get tags => $composableBuilder( + column: $table.tags, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastFetched => $composableBuilder( + column: $table.lastFetched, + builder: (column) => ColumnOrderings(column), + ); +} + +class $FeedAnnotationComposer extends Composer<_$FeedDatabase, Feed> { + $FeedAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumnWithTypeConverter get url => + $composableBuilder(column: $table.url, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter?, String> get authors => + $composableBuilder(column: $table.authors, builder: (column) => column); + + GeneratedColumnWithTypeConverter?, String> get tags => + $composableBuilder(column: $table.tags, builder: (column) => column); + + GeneratedColumn get lastFetched => $composableBuilder( + column: $table.lastFetched, + builder: (column) => column, + ); + + Expression articleRefs( + Expression Function($ArticleAnnotationComposer a) f, + ) { + final $ArticleAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.url, + referencedTable: $db.article, + getReferencedColumn: (t) => t.feedId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $ArticleAnnotationComposer( + $db: $db, + $table: $db.article, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $FeedTableManager + extends + RootTableManager< + _$FeedDatabase, + Feed, + FeedData, + $FeedFilterComposer, + $FeedOrderingComposer, + $FeedAnnotationComposer, + $FeedCreateCompanionBuilder, + $FeedUpdateCompanionBuilder, + (FeedData, $FeedReferences), + FeedData, + PrefetchHooks Function({bool articleRefs}) + > { + $FeedTableManager(_$FeedDatabase db, Feed table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: + () => $FeedFilterComposer($db: db, $table: table), + createOrderingComposer: + () => $FeedOrderingComposer($db: db, $table: table), + createComputedFieldComposer: + () => $FeedAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value url = const Value.absent(), + Value title = const Value.absent(), + Value description = const Value.absent(), + Value?> authors = const Value.absent(), + Value?> tags = const Value.absent(), + Value lastFetched = const Value.absent(), + Value rowid = const Value.absent(), + }) => FeedCompanion( + url: url, + title: title, + description: description, + authors: authors, + tags: tags, + lastFetched: lastFetched, + rowid: rowid, + ), + createCompanionCallback: + ({ + required Uri url, + Value title = const Value.absent(), + Value description = const Value.absent(), + Value?> authors = const Value.absent(), + Value?> tags = const Value.absent(), + Value lastFetched = const Value.absent(), + Value rowid = const Value.absent(), + }) => FeedCompanion.insert( + url: url, + title: title, + description: description, + authors: authors, + tags: tags, + lastFetched: lastFetched, + rowid: rowid, + ), + withReferenceMapper: + (p0) => + p0 + .map( + (e) => ( + e.readTable(table), + $FeedReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({articleRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [if (articleRefs) db.article], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (articleRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $FeedReferences._articleRefsTable(db), + managerFromTypedResult: + (p0) => $FeedReferences(db, table, p0).articleRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.feedId == item.url, + ), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $FeedProcessedTableManager = + ProcessedTableManager< + _$FeedDatabase, + Feed, + FeedData, + $FeedFilterComposer, + $FeedOrderingComposer, + $FeedAnnotationComposer, + $FeedCreateCompanionBuilder, + $FeedUpdateCompanionBuilder, + (FeedData, $FeedReferences), + FeedData, + PrefetchHooks Function({bool articleRefs}) + >; +typedef $ArticleCreateCompanionBuilder = + ArticleCompanion Function({ + required String id, + required Uri feedId, + required DateTime fetched, + Value created, + Value updated, + Value lastRead, + Value title, + Value?> authors, + Value?> tags, + Value?> links, + Value summaryMarkdown, + Value summaryPlain, + Value contentMarkdown, + Value contentPlain, + Value rowid, + }); +typedef $ArticleUpdateCompanionBuilder = + ArticleCompanion Function({ + Value id, + Value feedId, + Value fetched, + Value created, + Value updated, + Value lastRead, + Value title, + Value?> authors, + Value?> tags, + Value?> links, + Value summaryMarkdown, + Value summaryPlain, + Value contentMarkdown, + Value contentPlain, + Value rowid, + }); + +final class $ArticleReferences + extends BaseReferences<_$FeedDatabase, Article, FeedArticle> { + $ArticleReferences(super.$_db, super.$_table, super.$_typedResult); + + static Feed _feedIdTable(_$FeedDatabase db) => + db.feed.createAlias($_aliasNameGenerator(db.article.feedId, db.feed.url)); + + $FeedProcessedTableManager get feedId { + final $_column = $_itemColumn('feed_id')!; + + final manager = $FeedTableManager( + $_db, + $_db.feed, + ).filter((f) => f.url.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_feedIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $ArticleFilterComposer extends Composer<_$FeedDatabase, Article> { + $ArticleFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get fetched => $composableBuilder( + column: $table.fetched, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get created => $composableBuilder( + column: $table.created, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updated => $composableBuilder( + column: $table.updated, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastRead => $composableBuilder( + column: $table.lastRead, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters?, List, String> + get authors => $composableBuilder( + column: $table.authors, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters< + List?, + List, + String + > + get tags => $composableBuilder( + column: $table.tags, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters?, List, String> + get links => $composableBuilder( + column: $table.links, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get summaryMarkdown => $composableBuilder( + column: $table.summaryMarkdown, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get summaryPlain => $composableBuilder( + column: $table.summaryPlain, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get contentMarkdown => $composableBuilder( + column: $table.contentMarkdown, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get contentPlain => $composableBuilder( + column: $table.contentPlain, + builder: (column) => ColumnFilters(column), + ); + + $FeedFilterComposer get feedId { + final $FeedFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.feedId, + referencedTable: $db.feed, + getReferencedColumn: (t) => t.url, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $FeedFilterComposer( + $db: $db, + $table: $db.feed, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $ArticleOrderingComposer extends Composer<_$FeedDatabase, Article> { + $ArticleOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get fetched => $composableBuilder( + column: $table.fetched, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get created => $composableBuilder( + column: $table.created, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updated => $composableBuilder( + column: $table.updated, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastRead => $composableBuilder( + column: $table.lastRead, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get authors => $composableBuilder( + column: $table.authors, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get tags => $composableBuilder( + column: $table.tags, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get links => $composableBuilder( + column: $table.links, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get summaryMarkdown => $composableBuilder( + column: $table.summaryMarkdown, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get summaryPlain => $composableBuilder( + column: $table.summaryPlain, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get contentMarkdown => $composableBuilder( + column: $table.contentMarkdown, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get contentPlain => $composableBuilder( + column: $table.contentPlain, + builder: (column) => ColumnOrderings(column), + ); + + $FeedOrderingComposer get feedId { + final $FeedOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.feedId, + referencedTable: $db.feed, + getReferencedColumn: (t) => t.url, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $FeedOrderingComposer( + $db: $db, + $table: $db.feed, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $ArticleAnnotationComposer extends Composer<_$FeedDatabase, Article> { + $ArticleAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get fetched => + $composableBuilder(column: $table.fetched, builder: (column) => column); + + GeneratedColumn get created => + $composableBuilder(column: $table.created, builder: (column) => column); + + GeneratedColumn get updated => + $composableBuilder(column: $table.updated, builder: (column) => column); + + GeneratedColumn get lastRead => + $composableBuilder(column: $table.lastRead, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumnWithTypeConverter?, String> get authors => + $composableBuilder(column: $table.authors, builder: (column) => column); + + GeneratedColumnWithTypeConverter?, String> get tags => + $composableBuilder(column: $table.tags, builder: (column) => column); + + GeneratedColumnWithTypeConverter?, String> get links => + $composableBuilder(column: $table.links, builder: (column) => column); + + GeneratedColumn get summaryMarkdown => $composableBuilder( + column: $table.summaryMarkdown, + builder: (column) => column, + ); + + GeneratedColumn get summaryPlain => $composableBuilder( + column: $table.summaryPlain, + builder: (column) => column, + ); + + GeneratedColumn get contentMarkdown => $composableBuilder( + column: $table.contentMarkdown, + builder: (column) => column, + ); + + GeneratedColumn get contentPlain => $composableBuilder( + column: $table.contentPlain, + builder: (column) => column, + ); + + $FeedAnnotationComposer get feedId { + final $FeedAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.feedId, + referencedTable: $db.feed, + getReferencedColumn: (t) => t.url, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $FeedAnnotationComposer( + $db: $db, + $table: $db.feed, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $ArticleTableManager + extends + RootTableManager< + _$FeedDatabase, + Article, + FeedArticle, + $ArticleFilterComposer, + $ArticleOrderingComposer, + $ArticleAnnotationComposer, + $ArticleCreateCompanionBuilder, + $ArticleUpdateCompanionBuilder, + (FeedArticle, $ArticleReferences), + FeedArticle, + PrefetchHooks Function({bool feedId}) + > { + $ArticleTableManager(_$FeedDatabase db, Article table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: + () => $ArticleFilterComposer($db: db, $table: table), + createOrderingComposer: + () => $ArticleOrderingComposer($db: db, $table: table), + createComputedFieldComposer: + () => $ArticleAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value feedId = const Value.absent(), + Value fetched = const Value.absent(), + Value created = const Value.absent(), + Value updated = const Value.absent(), + Value lastRead = const Value.absent(), + Value title = const Value.absent(), + Value?> authors = const Value.absent(), + Value?> tags = const Value.absent(), + Value?> links = const Value.absent(), + Value summaryMarkdown = const Value.absent(), + Value summaryPlain = const Value.absent(), + Value contentMarkdown = const Value.absent(), + Value contentPlain = const Value.absent(), + Value rowid = const Value.absent(), + }) => ArticleCompanion( + id: id, + feedId: feedId, + fetched: fetched, + created: created, + updated: updated, + lastRead: lastRead, + title: title, + authors: authors, + tags: tags, + links: links, + summaryMarkdown: summaryMarkdown, + summaryPlain: summaryPlain, + contentMarkdown: contentMarkdown, + contentPlain: contentPlain, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required Uri feedId, + required DateTime fetched, + Value created = const Value.absent(), + Value updated = const Value.absent(), + Value lastRead = const Value.absent(), + Value title = const Value.absent(), + Value?> authors = const Value.absent(), + Value?> tags = const Value.absent(), + Value?> links = const Value.absent(), + Value summaryMarkdown = const Value.absent(), + Value summaryPlain = const Value.absent(), + Value contentMarkdown = const Value.absent(), + Value contentPlain = const Value.absent(), + Value rowid = const Value.absent(), + }) => ArticleCompanion.insert( + id: id, + feedId: feedId, + fetched: fetched, + created: created, + updated: updated, + lastRead: lastRead, + title: title, + authors: authors, + tags: tags, + links: links, + summaryMarkdown: summaryMarkdown, + summaryPlain: summaryPlain, + contentMarkdown: contentMarkdown, + contentPlain: contentPlain, + rowid: rowid, + ), + withReferenceMapper: + (p0) => + p0 + .map( + (e) => ( + e.readTable(table), + $ArticleReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({feedId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (feedId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.feedId, + referencedTable: $ArticleReferences._feedIdTable( + db, + ), + referencedColumn: + $ArticleReferences._feedIdTable(db).url, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $ArticleProcessedTableManager = + ProcessedTableManager< + _$FeedDatabase, + Article, + FeedArticle, + $ArticleFilterComposer, + $ArticleOrderingComposer, + $ArticleAnnotationComposer, + $ArticleCreateCompanionBuilder, + $ArticleUpdateCompanionBuilder, + (FeedArticle, $ArticleReferences), + FeedArticle, + PrefetchHooks Function({bool feedId}) + >; +typedef $ArticleFtsCreateCompanionBuilder = + ArticleFtsCompanion Function({ + required String title, + required String summaryPlain, + required String contentPlain, + Value rowid, + }); +typedef $ArticleFtsUpdateCompanionBuilder = + ArticleFtsCompanion Function({ + Value title, + Value summaryPlain, + Value contentPlain, + Value rowid, + }); + +class $ArticleFtsFilterComposer extends Composer<_$FeedDatabase, ArticleFts> { + $ArticleFtsFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get summaryPlain => $composableBuilder( + column: $table.summaryPlain, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get contentPlain => $composableBuilder( + column: $table.contentPlain, + builder: (column) => ColumnFilters(column), + ); +} + +class $ArticleFtsOrderingComposer extends Composer<_$FeedDatabase, ArticleFts> { + $ArticleFtsOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get summaryPlain => $composableBuilder( + column: $table.summaryPlain, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get contentPlain => $composableBuilder( + column: $table.contentPlain, + builder: (column) => ColumnOrderings(column), + ); +} + +class $ArticleFtsAnnotationComposer + extends Composer<_$FeedDatabase, ArticleFts> { + $ArticleFtsAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get summaryPlain => $composableBuilder( + column: $table.summaryPlain, + builder: (column) => column, + ); + + GeneratedColumn get contentPlain => $composableBuilder( + column: $table.contentPlain, + builder: (column) => column, + ); +} + +class $ArticleFtsTableManager + extends + RootTableManager< + _$FeedDatabase, + ArticleFts, + ArticleFt, + $ArticleFtsFilterComposer, + $ArticleFtsOrderingComposer, + $ArticleFtsAnnotationComposer, + $ArticleFtsCreateCompanionBuilder, + $ArticleFtsUpdateCompanionBuilder, + (ArticleFt, BaseReferences<_$FeedDatabase, ArticleFts, ArticleFt>), + ArticleFt, + PrefetchHooks Function() + > { + $ArticleFtsTableManager(_$FeedDatabase db, ArticleFts table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: + () => $ArticleFtsFilterComposer($db: db, $table: table), + createOrderingComposer: + () => $ArticleFtsOrderingComposer($db: db, $table: table), + createComputedFieldComposer: + () => $ArticleFtsAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value title = const Value.absent(), + Value summaryPlain = const Value.absent(), + Value contentPlain = const Value.absent(), + Value rowid = const Value.absent(), + }) => ArticleFtsCompanion( + title: title, + summaryPlain: summaryPlain, + contentPlain: contentPlain, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String title, + required String summaryPlain, + required String contentPlain, + Value rowid = const Value.absent(), + }) => ArticleFtsCompanion.insert( + title: title, + summaryPlain: summaryPlain, + contentPlain: contentPlain, + rowid: rowid, + ), + withReferenceMapper: + (p0) => + p0 + .map( + (e) => ( + e.readTable(table), + BaseReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $ArticleFtsProcessedTableManager = + ProcessedTableManager< + _$FeedDatabase, + ArticleFts, + ArticleFt, + $ArticleFtsFilterComposer, + $ArticleFtsOrderingComposer, + $ArticleFtsAnnotationComposer, + $ArticleFtsCreateCompanionBuilder, + $ArticleFtsUpdateCompanionBuilder, + (ArticleFt, BaseReferences<_$FeedDatabase, ArticleFts, ArticleFt>), + ArticleFt, + PrefetchHooks Function() + >; + +class $FeedDatabaseManager { + final _$FeedDatabase _db; + $FeedDatabaseManager(this._db); + $FeedTableManager get feed => $FeedTableManager(_db, _db.feed); + $ArticleTableManager get article => $ArticleTableManager(_db, _db.article); + $ArticleFtsTableManager get articleFts => + $ArticleFtsTableManager(_db, _db.articleFts); +} diff --git a/app/lib/features/web_feed/data/models/feed_article.dart b/app/lib/features/web_feed/data/models/feed_article.dart new file mode 100644 index 00000000..f1ca4895 --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_article.dart @@ -0,0 +1,116 @@ +import 'package:drift/drift.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:lensai/features/web_feed/data/database/database.dart'; +import 'package:lensai/features/web_feed/data/models/feed_author.dart'; +import 'package:lensai/features/web_feed/data/models/feed_category.dart'; +import 'package:lensai/features/web_feed/data/models/feed_link.dart'; + +part 'feed_article.g.dart'; + +@JsonSerializable() +class FeedArticle with FastEquatable implements Insertable { + final String id; + final Uri feedId; + final DateTime fetched; + final DateTime? created; + final DateTime? updated; + final DateTime? lastRead; + final String? title; + final List? authors; + final List? tags; + final List? links; + final String? summaryMarkdown; + final String? summaryPlain; + final String? contentMarkdown; + final String? contentPlain; + + FeedArticle({ + required this.id, + required this.feedId, + required this.fetched, + this.created, + this.updated, + this.lastRead, + this.title, + this.authors, + this.tags, + this.links, + this.summaryMarkdown, + this.summaryPlain, + this.contentMarkdown, + this.contentPlain, + }); + + factory FeedArticle.fromJson(Map json) => + _$FeedArticleFromJson(json); + + Map toJson() => _$FeedArticleToJson(this); + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + { + map['feed_id'] = Variable(Article.$converterfeedId.toSql(feedId)); + } + map['fetched'] = Variable(fetched); + if (!nullToAbsent || created != null) { + map['created'] = Variable(created); + } + if (!nullToAbsent || updated != null) { + map['updated'] = Variable(updated); + } + if (!nullToAbsent || lastRead != null) { + map['last_read'] = Variable(lastRead); + } + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || authors != null) { + map['authors'] = Variable( + Article.$converterauthorsn.toSql(authors), + ); + } + if (!nullToAbsent || tags != null) { + map['tags'] = Variable(Article.$convertertagsn.toSql(tags)); + } + if (!nullToAbsent || links != null) { + map['links'] = Variable(Article.$converterlinksn.toSql(links)); + } + if (!nullToAbsent || summaryMarkdown != null) { + map['summaryMarkdown'] = Variable(summaryMarkdown); + } + if (!nullToAbsent || summaryPlain != null) { + map['summaryPlain'] = Variable(summaryPlain); + } + if (!nullToAbsent || contentMarkdown != null) { + map['contentMarkdown'] = Variable(contentMarkdown); + } + if (!nullToAbsent || contentPlain != null) { + map['contentPlain'] = Variable(contentPlain); + } + return map; + } + + @override + bool get cacheHash => true; + + @override + List get hashParameters => [ + id, + feedId, + fetched, + created, + updated, + lastRead, + title, + authors, + tags, + links, + summaryMarkdown, + summaryPlain, + contentMarkdown, + contentPlain, + ]; +} diff --git a/app/lib/features/web_feed/data/models/feed_article.g.dart b/app/lib/features/web_feed/data/models/feed_article.g.dart new file mode 100644 index 00000000..1d28976a --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_article.g.dart @@ -0,0 +1,60 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed_article.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FeedArticle _$FeedArticleFromJson(Map json) => FeedArticle( + id: json['id'] as String, + feedId: Uri.parse(json['feedId'] as String), + fetched: DateTime.parse(json['fetched'] as String), + created: + json['created'] == null + ? null + : DateTime.parse(json['created'] as String), + updated: + json['updated'] == null + ? null + : DateTime.parse(json['updated'] as String), + lastRead: + json['lastRead'] == null + ? null + : DateTime.parse(json['lastRead'] as String), + title: json['title'] as String?, + authors: + (json['authors'] as List?) + ?.map((e) => FeedAuthor.fromJson(e as Map)) + .toList(), + tags: + (json['tags'] as List?) + ?.map((e) => FeedCategory.fromJson(e as Map)) + .toList(), + links: + (json['links'] as List?) + ?.map((e) => FeedLink.fromJson(e as Map)) + .toList(), + summaryMarkdown: json['summaryMarkdown'] as String?, + summaryPlain: json['summaryPlain'] as String?, + contentMarkdown: json['contentMarkdown'] as String?, + contentPlain: json['contentPlain'] as String?, +); + +Map _$FeedArticleToJson(FeedArticle instance) => + { + 'id': instance.id, + 'feedId': instance.feedId.toString(), + 'fetched': instance.fetched.toIso8601String(), + 'created': instance.created?.toIso8601String(), + 'updated': instance.updated?.toIso8601String(), + 'lastRead': instance.lastRead?.toIso8601String(), + 'title': instance.title, + 'authors': instance.authors?.map((e) => e.toJson()).toList(), + 'tags': instance.tags?.map((e) => e.toJson()).toList(), + 'links': instance.links?.map((e) => e.toJson()).toList(), + 'summaryMarkdown': instance.summaryMarkdown, + 'summaryPlain': instance.summaryPlain, + 'contentMarkdown': instance.contentMarkdown, + 'contentPlain': instance.contentPlain, + }; diff --git a/app/lib/features/web_feed/data/models/feed_article_query_result.dart b/app/lib/features/web_feed/data/models/feed_article_query_result.dart new file mode 100644 index 00000000..2f6cc8a9 --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_article_query_result.dart @@ -0,0 +1,29 @@ +import 'package:lensai/features/web_feed/data/models/feed_article.dart'; + +class FeedArticleQueryResult extends FeedArticle { + final double weightedRank; + + FeedArticleQueryResult({ + required super.id, + required super.feedId, + required super.fetched, + required this.weightedRank, + super.created, + super.updated, + super.lastRead, + super.title, + super.authors, + super.tags, + super.links, + super.summaryMarkdown, + super.summaryPlain, + super.contentMarkdown, + super.contentPlain, + }); + + @override + bool get cacheHash => true; + + @override + List get hashParameters => [...super.hashParameters, weightedRank]; +} diff --git a/app/lib/features/web_feed/data/models/feed_author.dart b/app/lib/features/web_feed/data/models/feed_author.dart new file mode 100644 index 00000000..aa33b696 --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_author.dart @@ -0,0 +1,23 @@ +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'feed_author.g.dart'; + +@JsonSerializable() +class FeedAuthor with FastEquatable { + final String? name; + final String? email; + + FeedAuthor({this.name, this.email}); + + factory FeedAuthor.fromJson(Map json) => + _$FeedAuthorFromJson(json); + + Map toJson() => _$FeedAuthorToJson(this); + + @override + bool get cacheHash => true; + + @override + List get hashParameters => [name, email]; +} diff --git a/app/lib/features/web_feed/data/models/feed_author.g.dart b/app/lib/features/web_feed/data/models/feed_author.g.dart new file mode 100644 index 00000000..09bed8d5 --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_author.g.dart @@ -0,0 +1,13 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed_author.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FeedAuthor _$FeedAuthorFromJson(Map json) => + FeedAuthor(name: json['name'] as String?, email: json['email'] as String?); + +Map _$FeedAuthorToJson(FeedAuthor instance) => + {'name': instance.name, 'email': instance.email}; diff --git a/app/lib/features/web_feed/data/models/feed_category.dart b/app/lib/features/web_feed/data/models/feed_category.dart new file mode 100644 index 00000000..3f6aa987 --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_category.dart @@ -0,0 +1,23 @@ +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'feed_category.g.dart'; + +@JsonSerializable() +class FeedCategory with FastEquatable { + final String id; + final String? title; + + FeedCategory({required this.id, this.title}); + + factory FeedCategory.fromJson(Map json) => + _$FeedCategoryFromJson(json); + + Map toJson() => _$FeedCategoryToJson(this); + + @override + bool get cacheHash => true; + + @override + List get hashParameters => [id, title]; +} diff --git a/app/lib/features/web_feed/data/models/feed_category.g.dart b/app/lib/features/web_feed/data/models/feed_category.g.dart new file mode 100644 index 00000000..524c9c44 --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_category.g.dart @@ -0,0 +1,13 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed_category.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FeedCategory _$FeedCategoryFromJson(Map json) => + FeedCategory(id: json['id'] as String, title: json['title'] as String?); + +Map _$FeedCategoryToJson(FeedCategory instance) => + {'id': instance.id, 'title': instance.title}; diff --git a/app/lib/features/web_feed/data/models/feed_filter.dart b/app/lib/features/web_feed/data/models/feed_filter.dart new file mode 100644 index 00000000..f7a384cd --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_filter.dart @@ -0,0 +1,19 @@ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; + +part 'feed_filter.g.dart'; + +@CopyWith() +class FeedFilter with FastEquatable { + final Uri? feedId; + final String? query; + final Set? tags; + + FeedFilter({this.feedId, this.query, this.tags}); + + @override + bool get cacheHash => true; + + @override + List get hashParameters => [feedId, query, tags]; +} diff --git a/app/lib/features/web_feed/data/models/feed_filter.g.dart b/app/lib/features/web_feed/data/models/feed_filter.g.dart new file mode 100644 index 00000000..0c258dd5 --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_filter.g.dart @@ -0,0 +1,76 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed_filter.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$FeedFilterCWProxy { + FeedFilter feedId(Uri? feedId); + + FeedFilter query(String? query); + + FeedFilter tags(Set? tags); + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `FeedFilter(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// FeedFilter(...).copyWith(id: 12, name: "My name") + /// ```` + FeedFilter call({Uri? feedId, String? query, Set? tags}); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfFeedFilter.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfFeedFilter.copyWith.fieldName(...)` +class _$FeedFilterCWProxyImpl implements _$FeedFilterCWProxy { + const _$FeedFilterCWProxyImpl(this._value); + + final FeedFilter _value; + + @override + FeedFilter feedId(Uri? feedId) => this(feedId: feedId); + + @override + FeedFilter query(String? query) => this(query: query); + + @override + FeedFilter tags(Set? tags) => this(tags: tags); + + @override + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `FeedFilter(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// FeedFilter(...).copyWith(id: 12, name: "My name") + /// ```` + FeedFilter call({ + Object? feedId = const $CopyWithPlaceholder(), + Object? query = const $CopyWithPlaceholder(), + Object? tags = const $CopyWithPlaceholder(), + }) { + return FeedFilter( + feedId: + feedId == const $CopyWithPlaceholder() + ? _value.feedId + // ignore: cast_nullable_to_non_nullable + : feedId as Uri?, + query: + query == const $CopyWithPlaceholder() + ? _value.query + // ignore: cast_nullable_to_non_nullable + : query as String?, + tags: + tags == const $CopyWithPlaceholder() + ? _value.tags + // ignore: cast_nullable_to_non_nullable + : tags as Set?, + ); + } +} + +extension $FeedFilterCopyWith on FeedFilter { + /// Returns a callable class that can be used as follows: `instanceOfFeedFilter.copyWith(...)` or like so:`instanceOfFeedFilter.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$FeedFilterCWProxy get copyWith => _$FeedFilterCWProxyImpl(this); +} diff --git a/app/lib/features/web_feed/data/models/feed_link.dart b/app/lib/features/web_feed/data/models/feed_link.dart new file mode 100644 index 00000000..0391b32a --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_link.dart @@ -0,0 +1,41 @@ +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'feed_link.g.dart'; + +enum FeedLinkRelation { + ///an alternate representation of the entry or feed, for example a permalink to the html version of the entry, or the front page of the weblog. + alternate, + + ///a related resource which is potentially large in size and might require special handling, for example an audio or video recording. + enclosure, + + ///an document related to the entry or feed. + related, + + ///the feed itself. + self, + + ///the source of the information provided in the entry. + via, +} + +@JsonSerializable() +class FeedLink with FastEquatable { + final Uri uri; + final FeedLinkRelation? relation; + final String? title; + + FeedLink({required this.uri, this.relation, this.title}); + + factory FeedLink.fromJson(Map json) => + _$FeedLinkFromJson(json); + + Map toJson() => _$FeedLinkToJson(this); + + @override + bool get cacheHash => true; + + @override + List get hashParameters => [uri, relation, title]; +} diff --git a/app/lib/features/web_feed/data/models/feed_link.g.dart b/app/lib/features/web_feed/data/models/feed_link.g.dart new file mode 100644 index 00000000..3df8ce30 --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_link.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed_link.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FeedLink _$FeedLinkFromJson(Map json) => FeedLink( + uri: Uri.parse(json['uri'] as String), + relation: $enumDecodeNullable(_$FeedLinkRelationEnumMap, json['relation']), + title: json['title'] as String?, +); + +Map _$FeedLinkToJson(FeedLink instance) => { + 'uri': instance.uri.toString(), + 'relation': _$FeedLinkRelationEnumMap[instance.relation], + 'title': instance.title, +}; + +const _$FeedLinkRelationEnumMap = { + FeedLinkRelation.alternate: 'alternate', + FeedLinkRelation.enclosure: 'enclosure', + FeedLinkRelation.related: 'related', + FeedLinkRelation.self: 'self', + FeedLinkRelation.via: 'via', +}; diff --git a/app/lib/features/web_feed/data/models/feed_parse_result.dart b/app/lib/features/web_feed/data/models/feed_parse_result.dart new file mode 100644 index 00000000..7044164c --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_parse_result.dart @@ -0,0 +1,20 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:lensai/features/web_feed/data/database/converters/feed_data.dart'; +import 'package:lensai/features/web_feed/data/database/database.dart'; +import 'package:lensai/features/web_feed/data/models/feed_article.dart'; + +part 'feed_parse_result.g.dart'; + +@JsonSerializable() +class FeedParseResult { + @FeedDataConverter() + final FeedData feedData; + final List articleData; + + FeedParseResult({required this.feedData, required this.articleData}); + + factory FeedParseResult.fromJson(Map json) => + _$FeedParseResultFromJson(json); + + Map toJson() => _$FeedParseResultToJson(this); +} diff --git a/app/lib/features/web_feed/data/models/feed_parse_result.g.dart b/app/lib/features/web_feed/data/models/feed_parse_result.g.dart new file mode 100644 index 00000000..13c142ab --- /dev/null +++ b/app/lib/features/web_feed/data/models/feed_parse_result.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed_parse_result.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FeedParseResult _$FeedParseResultFromJson(Map json) => + FeedParseResult( + feedData: const FeedDataConverter().fromJson( + json['feedData'] as Map, + ), + articleData: + (json['articleData'] as List) + .map((e) => FeedArticle.fromJson(e as Map)) + .toList(), + ); + +Map _$FeedParseResultToJson(FeedParseResult instance) => + { + 'feedData': const FeedDataConverter().toJson(instance.feedData), + 'articleData': instance.articleData.map((e) => e.toJson()).toList(), + }; diff --git a/app/lib/features/web_feed/data/providers.dart b/app/lib/features/web_feed/data/providers.dart new file mode 100644 index 00000000..3d6274c8 --- /dev/null +++ b/app/lib/features/web_feed/data/providers.dart @@ -0,0 +1,46 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lensai/features/web_feed/data/database/database.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart' as path_provider; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sqlite3/sqlite3.dart'; +import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; + +part 'providers.g.dart'; + +@Riverpod(keepAlive: true) +FeedDatabase feedDatabase(Ref ref) { + final db = FeedDatabase( + LazyDatabase(() async { + // put the database file, called db.sqlite here, into the documents folder + // for your app. + final dbFolder = await path_provider.getApplicationDocumentsDirectory(); + final file = File(p.join(dbFolder.path, 'feed.db')); + + // Also work around limitations on old Android versions + if (Platform.isAndroid) { + await applyWorkaroundToOpenSqlite3OnOldAndroidVersions(); + } + + // Make sqlite3 pick a more suitable location for temporary files - the + // one from the system may be inaccessible due to sandboxing. + final cachebase = (await path_provider.getTemporaryDirectory()).path; + // We can't access /tmp on Android, which sqlite3 would try by default. + // Explicitly tell it about the correct temporary directory. + sqlite3.tempDirectory = cachebase; + + return NativeDatabase.createInBackground(file); + }), + ); + + ref.onDispose(() { + unawaited(db.close()); + }); + + return db; +} diff --git a/app/lib/features/web_feed/data/providers.g.dart b/app/lib/features/web_feed/data/providers.g.dart new file mode 100644 index 00000000..2afc7afa --- /dev/null +++ b/app/lib/features/web_feed/data/providers.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$feedDatabaseHash() => r'a4a1ffa36b73c2aecbe17bf096e30fa9b9ad23c9'; + +/// See also [feedDatabase]. +@ProviderFor(feedDatabase) +final feedDatabaseProvider = Provider.internal( + feedDatabase, + name: r'feedDatabaseProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$feedDatabaseHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef FeedDatabaseRef = ProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/web_feed/domain/providers.dart b/app/lib/features/web_feed/domain/providers.dart new file mode 100644 index 00000000..5f77687e --- /dev/null +++ b/app/lib/features/web_feed/domain/providers.dart @@ -0,0 +1,38 @@ +import 'package:lensai/features/web_feed/data/database/database.dart'; +import 'package:lensai/features/web_feed/data/models/feed_filter.dart'; +import 'package:lensai/features/web_feed/data/models/feed_article.dart'; +import 'package:lensai/features/web_feed/domain/repositories/feed_repository.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'providers.g.dart'; + +@Riverpod() +Stream> feedList(Ref ref) { + final repository = ref.watch(feedRepositoryProvider.notifier); + return repository.watchFeeds(); +} + +@Riverpod() +Stream> feedArticleList(Ref ref, FeedFilter filter) { + final repository = ref.watch(feedRepositoryProvider.notifier); + return repository.watchFeedArticles(filter); +} + +@Riverpod() +Stream feedArticle(Ref ref, String articleId) { + final repository = ref.watch(feedRepositoryProvider.notifier); + return repository.watchArticle(articleId); +} + +@Riverpod() +Raw>> _unreadArticleCount(Ref ref) { + final repository = ref.watch(feedRepositoryProvider.notifier); + return repository.watchUnreadFeedArticleCount(); +} + +@Riverpod() +Stream unreadFeedArticleCount(Ref ref, Uri feedId) { + final stream = ref.watch(_unreadArticleCountProvider); + return stream.map((counts) => counts[feedId.toString()]); +} diff --git a/app/lib/features/web_feed/domain/providers.g.dart b/app/lib/features/web_feed/domain/providers.g.dart new file mode 100644 index 00000000..ca6c9d12 --- /dev/null +++ b/app/lib/features/web_feed/domain/providers.g.dart @@ -0,0 +1,433 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$feedListHash() => r'0076186437354768c39fb1d7c8bcfcf7b94c7dd1'; + +/// See also [feedList]. +@ProviderFor(feedList) +final feedListProvider = AutoDisposeStreamProvider>.internal( + feedList, + name: r'feedListProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$feedListHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef FeedListRef = AutoDisposeStreamProviderRef>; +String _$feedArticleListHash() => r'64e834a1b69d913f1c860b38956b69af9e89a833'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [feedArticleList]. +@ProviderFor(feedArticleList) +const feedArticleListProvider = FeedArticleListFamily(); + +/// See also [feedArticleList]. +class FeedArticleListFamily extends Family>> { + /// See also [feedArticleList]. + const FeedArticleListFamily(); + + /// See also [feedArticleList]. + FeedArticleListProvider call(FeedFilter filter) { + return FeedArticleListProvider(filter); + } + + @override + FeedArticleListProvider getProviderOverride( + covariant FeedArticleListProvider provider, + ) { + return call(provider.filter); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'feedArticleListProvider'; +} + +/// See also [feedArticleList]. +class FeedArticleListProvider + extends AutoDisposeStreamProvider> { + /// See also [feedArticleList]. + FeedArticleListProvider(FeedFilter filter) + : this._internal( + (ref) => feedArticleList(ref as FeedArticleListRef, filter), + from: feedArticleListProvider, + name: r'feedArticleListProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$feedArticleListHash, + dependencies: FeedArticleListFamily._dependencies, + allTransitiveDependencies: + FeedArticleListFamily._allTransitiveDependencies, + filter: filter, + ); + + FeedArticleListProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.filter, + }) : super.internal(); + + final FeedFilter filter; + + @override + Override overrideWith( + Stream> Function(FeedArticleListRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: FeedArticleListProvider._internal( + (ref) => create(ref as FeedArticleListRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + filter: filter, + ), + ); + } + + @override + AutoDisposeStreamProviderElement> createElement() { + return _FeedArticleListProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is FeedArticleListProvider && other.filter == filter; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, filter.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin FeedArticleListRef on AutoDisposeStreamProviderRef> { + /// The parameter `filter` of this provider. + FeedFilter get filter; +} + +class _FeedArticleListProviderElement + extends AutoDisposeStreamProviderElement> + with FeedArticleListRef { + _FeedArticleListProviderElement(super.provider); + + @override + FeedFilter get filter => (origin as FeedArticleListProvider).filter; +} + +String _$feedArticleHash() => r'b1670f2ce11636f42fc0595befe42ada19319880'; + +/// See also [feedArticle]. +@ProviderFor(feedArticle) +const feedArticleProvider = FeedArticleFamily(); + +/// See also [feedArticle]. +class FeedArticleFamily extends Family> { + /// See also [feedArticle]. + const FeedArticleFamily(); + + /// See also [feedArticle]. + FeedArticleProvider call(String articleId) { + return FeedArticleProvider(articleId); + } + + @override + FeedArticleProvider getProviderOverride( + covariant FeedArticleProvider provider, + ) { + return call(provider.articleId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'feedArticleProvider'; +} + +/// See also [feedArticle]. +class FeedArticleProvider extends AutoDisposeStreamProvider { + /// See also [feedArticle]. + FeedArticleProvider(String articleId) + : this._internal( + (ref) => feedArticle(ref as FeedArticleRef, articleId), + from: feedArticleProvider, + name: r'feedArticleProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$feedArticleHash, + dependencies: FeedArticleFamily._dependencies, + allTransitiveDependencies: FeedArticleFamily._allTransitiveDependencies, + articleId: articleId, + ); + + FeedArticleProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.articleId, + }) : super.internal(); + + final String articleId; + + @override + Override overrideWith( + Stream Function(FeedArticleRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: FeedArticleProvider._internal( + (ref) => create(ref as FeedArticleRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + articleId: articleId, + ), + ); + } + + @override + AutoDisposeStreamProviderElement createElement() { + return _FeedArticleProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is FeedArticleProvider && other.articleId == articleId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, articleId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin FeedArticleRef on AutoDisposeStreamProviderRef { + /// The parameter `articleId` of this provider. + String get articleId; +} + +class _FeedArticleProviderElement + extends AutoDisposeStreamProviderElement + with FeedArticleRef { + _FeedArticleProviderElement(super.provider); + + @override + String get articleId => (origin as FeedArticleProvider).articleId; +} + +String _$unreadArticleCountHash() => + r'6fb96215fb3b7739a6cbd594a0358415ced04fca'; + +/// See also [_unreadArticleCount]. +@ProviderFor(_unreadArticleCount) +final _unreadArticleCountProvider = + AutoDisposeProvider>>>.internal( + _unreadArticleCount, + name: r'_unreadArticleCountProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$unreadArticleCountHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef _UnreadArticleCountRef = + AutoDisposeProviderRef>>>; +String _$unreadFeedArticleCountHash() => + r'f8573674477f0d9813b42c82d75e8196c1c1445b'; + +/// See also [unreadFeedArticleCount]. +@ProviderFor(unreadFeedArticleCount) +const unreadFeedArticleCountProvider = UnreadFeedArticleCountFamily(); + +/// See also [unreadFeedArticleCount]. +class UnreadFeedArticleCountFamily extends Family> { + /// See also [unreadFeedArticleCount]. + const UnreadFeedArticleCountFamily(); + + /// See also [unreadFeedArticleCount]. + UnreadFeedArticleCountProvider call(Uri feedId) { + return UnreadFeedArticleCountProvider(feedId); + } + + @override + UnreadFeedArticleCountProvider getProviderOverride( + covariant UnreadFeedArticleCountProvider provider, + ) { + return call(provider.feedId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'unreadFeedArticleCountProvider'; +} + +/// See also [unreadFeedArticleCount]. +class UnreadFeedArticleCountProvider extends AutoDisposeStreamProvider { + /// See also [unreadFeedArticleCount]. + UnreadFeedArticleCountProvider(Uri feedId) + : this._internal( + (ref) => + unreadFeedArticleCount(ref as UnreadFeedArticleCountRef, feedId), + from: unreadFeedArticleCountProvider, + name: r'unreadFeedArticleCountProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$unreadFeedArticleCountHash, + dependencies: UnreadFeedArticleCountFamily._dependencies, + allTransitiveDependencies: + UnreadFeedArticleCountFamily._allTransitiveDependencies, + feedId: feedId, + ); + + UnreadFeedArticleCountProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.feedId, + }) : super.internal(); + + final Uri feedId; + + @override + Override overrideWith( + Stream Function(UnreadFeedArticleCountRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: UnreadFeedArticleCountProvider._internal( + (ref) => create(ref as UnreadFeedArticleCountRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + feedId: feedId, + ), + ); + } + + @override + AutoDisposeStreamProviderElement createElement() { + return _UnreadFeedArticleCountProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is UnreadFeedArticleCountProvider && other.feedId == feedId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, feedId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin UnreadFeedArticleCountRef on AutoDisposeStreamProviderRef { + /// The parameter `feedId` of this provider. + Uri get feedId; +} + +class _UnreadFeedArticleCountProviderElement + extends AutoDisposeStreamProviderElement + with UnreadFeedArticleCountRef { + _UnreadFeedArticleCountProviderElement(super.provider); + + @override + Uri get feedId => (origin as UnreadFeedArticleCountProvider).feedId; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/web_feed/domain/providers/article_filter.dart b/app/lib/features/web_feed/domain/providers/article_filter.dart new file mode 100644 index 00000000..7a53b5f6 --- /dev/null +++ b/app/lib/features/web_feed/domain/providers/article_filter.dart @@ -0,0 +1,26 @@ +import 'package:lensai/extensions/nullable.dart'; +import 'package:lensai/features/web_feed/data/models/feed_filter.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'article_filter.g.dart'; + +@Riverpod(keepAlive: true) +class ArticleFilter extends _$ArticleFilter { + void addTag(String tagId) { + final tags = {...?state.tags, tagId}; + + state = state.copyWith.tags(tags); + } + + void removeTag(String tagId) { + if (state.tags.isNotEmpty) { + final tags = {...state.tags!}..remove(tagId); + state = state.copyWith.tags(tags); + } + } + + @override + FeedFilter build() { + return FeedFilter(); + } +} diff --git a/app/lib/features/web_feed/domain/providers/article_filter.g.dart b/app/lib/features/web_feed/domain/providers/article_filter.g.dart new file mode 100644 index 00000000..b9747502 --- /dev/null +++ b/app/lib/features/web_feed/domain/providers/article_filter.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'article_filter.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$articleFilterHash() => r'dfc997af8a33cbcb995288ef633ff321e95bae8d'; + +/// See also [ArticleFilter]. +@ProviderFor(ArticleFilter) +final articleFilterProvider = + NotifierProvider.internal( + ArticleFilter.new, + name: r'articleFilterProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$articleFilterHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ArticleFilter = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/web_feed/domain/repositories/feed_repository.dart b/app/lib/features/web_feed/domain/repositories/feed_repository.dart new file mode 100644 index 00000000..cbc659c1 --- /dev/null +++ b/app/lib/features/web_feed/domain/repositories/feed_repository.dart @@ -0,0 +1,117 @@ +import 'package:lensai/extensions/nullable.dart'; +import 'package:lensai/features/web_feed/data/database/database.dart'; +import 'package:lensai/features/web_feed/data/models/feed_filter.dart'; +import 'package:lensai/features/web_feed/data/models/feed_article.dart'; +import 'package:lensai/features/web_feed/data/providers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'feed_repository.g.dart'; + +@Riverpod(keepAlive: true) +class FeedRepository extends _$FeedRepository { + Future> getAllFeeds() { + return ref.read(feedDatabaseProvider).feedDao.getFeeds().get(); + } + + Future touchFeedFetched(Uri url) { + return ref + .read(feedDatabaseProvider) + .feedDao + .updateFeedFetched(url, DateTime.now()); + } + + Future upsertFeed(FeedData feedData) { + return ref.read(feedDatabaseProvider).feedDao.upsertFeed(feedData); + } + + Future upsertArticles(List articles) { + return ref.read(feedDatabaseProvider).articleDao.upsertArticles(articles); + } + + Future deleteFeed(Uri url) { + return ref.read(feedDatabaseProvider).feedDao.deleteFeed(url); + } + + Future touchArticleRead(String articleId) { + return ref + .read(feedDatabaseProvider) + .articleDao + .updateArticleRead(articleId, DateTime.now()); + } + + Future unsetArticleRead(String articleId) { + return ref + .read(feedDatabaseProvider) + .articleDao + .updateArticleRead(articleId, null); + } + + Stream> watchFeeds() { + return ref.read(feedDatabaseProvider).feedDao.getFeeds().watch(); + } + + Stream> watchFeedArticles( + FeedFilter filter, { + int snippetLength = 120, + String matchPrefix = '***', + String matchSuffix = '***', + String ellipsis = '…', + }) { + final stream = + filter.query.isNotEmpty + ? ref + .read(feedDatabaseProvider) + .articleDao + .queryArticles( + matchPrefix: matchPrefix, + matchSuffix: matchSuffix, + ellipsis: ellipsis, + snippetLength: snippetLength, + searchString: filter.query!, + feedId: filter.feedId, + ) + .watch() + : ref + .read(feedDatabaseProvider) + .articleDao + .getFeedArticles(filter.feedId) + .watch(); + + if (filter.tags.isNotEmpty) { + return stream.map( + (articles) => + articles + .where( + (article) => + article.tags?.toSet().containsAll(filter.tags!) ?? false, + ) + .toList(), + ); + } else { + return stream; + } + } + + Stream watchArticle(String articleId) { + return ref + .read(feedDatabaseProvider) + .articleDao + .getArticleById(articleId) + .watchSingleOrNull(); + } + + Stream> watchUnreadFeedArticleCount() { + return ref + .read(feedDatabaseProvider) + .articleDao + .getUnreadArticleCount() + .watch() + .map( + (results) => + Map.fromEntries(results.map((e) => MapEntry(e.$1, e.$2))), + ); + } + + @override + void build() {} +} diff --git a/app/lib/features/web_feed/domain/repositories/feed_repository.g.dart b/app/lib/features/web_feed/domain/repositories/feed_repository.g.dart new file mode 100644 index 00000000..aafa5cbb --- /dev/null +++ b/app/lib/features/web_feed/domain/repositories/feed_repository.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$feedRepositoryHash() => r'6a050933a6a4eafbe76a66262e1b64bd838a1078'; + +/// See also [FeedRepository]. +@ProviderFor(FeedRepository) +final feedRepositoryProvider = NotifierProvider.internal( + FeedRepository.new, + name: r'feedRepositoryProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$feedRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$FeedRepository = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/web_feed/domain/services/feed_reader.dart b/app/lib/features/web_feed/domain/services/feed_reader.dart new file mode 100644 index 00000000..fc5aa8df --- /dev/null +++ b/app/lib/features/web_feed/domain/services/feed_reader.dart @@ -0,0 +1,45 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:lensai/features/web_feed/data/models/feed_parse_result.dart'; +import 'package:lensai/features/web_feed/utils/feed_parser.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'feed_reader.g.dart'; + +@Riverpod(keepAlive: true) +class FeedReader extends _$FeedReader { + Future parseFeed(Uri url) async { + final rootIsolateToken = ServicesBinding.rootIsolateToken!; + + final result = await compute((args) async { + // Initialize BackgroundIsolateBinaryMessenger with the token + BackgroundIsolateBinaryMessenger.ensureInitialized( + args['token']! as RootIsolateToken, + ); + + final client = http.Client(); + try { + final url = Uri.parse(args['url']! as String); + final response = await client + .get(url) + .timeout(const Duration(seconds: 30)); + + final parser = FeedParser.parse(url: url, xmlString: response.body); + final result = FeedParseResult( + feedData: parser.readGeneralData(), + articleData: await parser.readArticles(), + ); + + return result.toJson(); + } finally { + client.close(); + } + }, {'token': rootIsolateToken, 'url': url.toString()}); + + return FeedParseResult.fromJson(result); + } + + @override + void build() {} +} diff --git a/app/lib/features/web_feed/domain/services/feed_reader.g.dart b/app/lib/features/web_feed/domain/services/feed_reader.g.dart new file mode 100644 index 00000000..0e66af89 --- /dev/null +++ b/app/lib/features/web_feed/domain/services/feed_reader.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed_reader.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$feedReaderHash() => r'67b09485e40c522108709ca32b9c624b4db2446b'; + +/// See also [FeedReader]. +@ProviderFor(FeedReader) +final feedReaderProvider = NotifierProvider.internal( + FeedReader.new, + name: r'feedReaderProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$feedReaderHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$FeedReader = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/web_feed/extensions/atom.dart b/app/lib/features/web_feed/extensions/atom.dart new file mode 100644 index 00000000..55db93e9 --- /dev/null +++ b/app/lib/features/web_feed/extensions/atom.dart @@ -0,0 +1,57 @@ +import 'package:collection/collection.dart'; +import 'package:lensai/extensions/nullable.dart'; +import 'package:lensai/features/web_feed/data/models/feed_author.dart'; +import 'package:lensai/features/web_feed/data/models/feed_category.dart'; +import 'package:lensai/features/web_feed/data/models/feed_link.dart'; +import 'package:rss_dart/dart_rss.dart'; + +extension ParseAtomLink on List { + List toFeedLinks() { + return map((link) { + final uri = link.href.mapNotNull(Uri.tryParse); + if (uri == null) { + return null; + } + + final relation = link.rel.mapNotNull( + (rel) => FeedLinkRelation.values.firstWhereOrNull((e) => e.name == rel), + ); + + return FeedLink( + uri: uri, + relation: relation, + title: link.title.whenNotEmpty, + ); + }).nonNulls.toList(); + } +} + +extension ParseAtomCategory on List { + List toFeedCategories() { + return where((category) => category.term.isNotEmpty) + .map( + (category) => FeedCategory( + id: category.term!, + title: category.label.whenNotEmpty, + ), + ) + .toList(); + } +} + +extension ParseAtomPerson on List { + List toFeedAuthors() { + return where( + (author) => + author.name.whenNotEmpty != null || + author.email.whenNotEmpty != null, + ) + .map( + (author) => FeedAuthor( + name: author.name.whenNotEmpty, + email: author.email.whenNotEmpty, + ), + ) + .toList(); + } +} diff --git a/app/lib/features/web_feed/extensions/feed_article.dart b/app/lib/features/web_feed/extensions/feed_article.dart new file mode 100644 index 00000000..92b0e5a9 --- /dev/null +++ b/app/lib/features/web_feed/extensions/feed_article.dart @@ -0,0 +1,17 @@ +import 'package:collection/collection.dart'; +import 'package:lensai/extensions/nullable.dart'; +import 'package:lensai/features/web_feed/data/models/feed_article.dart'; +import 'package:lensai/features/web_feed/data/models/feed_link.dart'; + +extension FeedArticleX on FeedArticle { + String get displayTitle => + title ?? + links + ?.firstWhereOrNull( + (link) => link.relation == FeedLinkRelation.alternate, + ) + .mapNotNull( + (link) => link.title.whenNotEmpty ?? link.uri.toString(), + ) ?? + 'Unnamed Article'; +} diff --git a/app/lib/features/web_feed/extensions/rss.dart b/app/lib/features/web_feed/extensions/rss.dart new file mode 100644 index 00000000..8f725263 --- /dev/null +++ b/app/lib/features/web_feed/extensions/rss.dart @@ -0,0 +1,24 @@ +import 'package:lensai/extensions/nullable.dart'; +import 'package:lensai/features/web_feed/data/models/feed_category.dart'; +import 'package:rss_dart/dart_rss.dart'; +import 'package:rss_dart/domain/dublin_core/dublin_core.dart'; + +extension ParseDublinCategory on DublinCore { + List toFeedCategories() { + return subjects + .where((subject) => subject.isNotEmpty) + .map((subject) => FeedCategory(id: subject)) + .toList(); + } +} + +extension ParseRssCategory on List { + List toFeedCategories() { + return where((category) => category.value.isNotEmpty) + .map( + (category) => + FeedCategory(id: '${category.domain} ${category.value}'.trim()), + ) + .toList(); + } +} diff --git a/app/lib/features/web_feed/extensions/web_feed_converter.dart b/app/lib/features/web_feed/extensions/web_feed_converter.dart new file mode 100644 index 00000000..233da046 --- /dev/null +++ b/app/lib/features/web_feed/extensions/web_feed_converter.dart @@ -0,0 +1,50 @@ +import 'package:rss_dart/dart_rss.dart'; + +extension WebFeedJson on WebFeed { + Map toJson() { + return { + 'title': title, + 'description': description, + 'links': links.where((link) => link != null).toList(), + 'items': items.map((item) => item.toJson()).toList(), + }; + } + + static WebFeed fromJson(Map json) { + return WebFeed( + title: json['title'] as String, + description: json['description'] as String, + links: (json['links'] as List).cast(), + items: + (json['items'] as List) + .map( + (item) => + WebFeedItemJson.fromJson(item as Map), + ) + .toList(), + ); + } +} + +extension WebFeedItemJson on WebFeedItem { + Map toJson() { + return { + 'title': title, + 'body': body, + 'links': links.where((link) => link != null).toList(), + 'updated': updated?.toIso8601String(), + }; + } + + static WebFeedItem fromJson(Map json) { + return WebFeedItem( + title: json['title'] as String, + body: json['body'] as String, + links: (json['links'] as List).cast(), + updated: + json['updated'] != null + ? DateTime.parse(json['updated'] as String) + : null, + ); + } +} diff --git a/app/lib/features/web_feed/presentation/controllers/fetch_articles.dart b/app/lib/features/web_feed/presentation/controllers/fetch_articles.dart new file mode 100644 index 00000000..ca3ea0b3 --- /dev/null +++ b/app/lib/features/web_feed/presentation/controllers/fetch_articles.dart @@ -0,0 +1,50 @@ +import 'package:lensai/features/web_feed/domain/repositories/feed_repository.dart'; +import 'package:lensai/features/web_feed/domain/services/feed_reader.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'fetch_articles.g.dart'; + +@Riverpod() +class FetchArticlesController extends _$FetchArticlesController { + Future fetchAllArticles() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final feeds = + await ref.read(feedRepositoryProvider.notifier).getAllFeeds(); + + await Future.wait( + feeds.map((feed) async { + final result = await ref + .read(feedReaderProvider.notifier) + .parseFeed(feed.url); + + await ref + .read(feedRepositoryProvider.notifier) + .upsertArticles(result.articleData); + + await ref + .read(feedRepositoryProvider.notifier) + .touchFeedFetched(feed.url); + }).toList(), + ); + }); + } + + Future fetchFeedArticles(Uri uri) async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final result = await ref.read(feedReaderProvider.notifier).parseFeed(uri); + + await ref + .read(feedRepositoryProvider.notifier) + .upsertArticles(result.articleData); + + await ref.read(feedRepositoryProvider.notifier).touchFeedFetched(uri); + }); + } + + @override + AsyncValue build() { + return const AsyncData(null); + } +} diff --git a/app/lib/features/web_feed/presentation/controllers/fetch_articles.g.dart b/app/lib/features/web_feed/presentation/controllers/fetch_articles.g.dart new file mode 100644 index 00000000..aa9000ae --- /dev/null +++ b/app/lib/features/web_feed/presentation/controllers/fetch_articles.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'fetch_articles.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$fetchArticlesControllerHash() => + r'63da7f6ba9eab28aebff4c60d98e2f2a404c5138'; + +/// See also [FetchArticlesController]. +@ProviderFor(FetchArticlesController) +final fetchArticlesControllerProvider = AutoDisposeNotifierProvider< + FetchArticlesController, + AsyncValue +>.internal( + FetchArticlesController.new, + name: r'fetchArticlesControllerProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$fetchArticlesControllerHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$FetchArticlesController = AutoDisposeNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/web_feed/presentation/screens/feed_article.dart b/app/lib/features/web_feed/presentation/screens/feed_article.dart new file mode 100644 index 00000000..3101c19f --- /dev/null +++ b/app/lib/features/web_feed/presentation/screens/feed_article.dart @@ -0,0 +1,261 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lensai/core/providers/format.dart'; +import 'package:lensai/core/routing/routes.dart'; +import 'package:lensai/extensions/nullable.dart'; +import 'package:lensai/features/geckoview/domain/repositories/tab.dart'; +import 'package:lensai/features/web_feed/data/models/feed_link.dart'; +import 'package:lensai/features/web_feed/domain/providers.dart'; +import 'package:lensai/features/web_feed/extensions/feed_article.dart'; +import 'package:lensai/features/web_feed/presentation/widgets/authors_horizontal_list.dart'; +import 'package:lensai/features/web_feed/presentation/widgets/tags_horizontal_list.dart'; +import 'package:lensai/presentation/widgets/failure_widget.dart'; +import 'package:lensai/utils/markdown/image_extractor.dart'; +import 'package:lensai/utils/ui_helper.dart'; + +enum _Pages { summary, content } + +class FeedArticleScreen extends HookConsumerWidget { + final String articleId; + + const FeedArticleScreen({super.key, required this.articleId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final articleAsync = ref.watch(feedArticleProvider(articleId)); + + return Scaffold( + body: articleAsync.when( + data: (article) { + if (article == null) { + return SizedBox.shrink(); + } + + return HookBuilder( + builder: (context) { + final hasArticleCreated = article.created != null; + final hasArticleUpdated = + article.updated != null && article.updated != article.created; + final hasAuthors = article.authors.isNotEmpty; + final hasTags = article.tags.isNotEmpty; + + final tabs = useMemoized( + () => [ + if (article.summaryMarkdown.isNotEmpty) _Pages.summary, + if (article.contentMarkdown.isNotEmpty) _Pages.content, + ], + ); + + final tabController = useTabController( + initialLength: tabs.length, + ); + + final articleLink = useMemoized( + () => article.links?.firstWhereOrNull( + (link) => link.relation == FeedLinkRelation.alternate, + ), + ); + + final articleImages = useMemoized( + () => (article.contentMarkdown ?? article.summaryMarkdown) + .mapNotNull(extractImagesFromMarkdown), + ); + + final bottomHeight = useMemoized(() { + var height = 0.0; + + if (hasArticleCreated) { + height += 20; + } + if (hasArticleUpdated) { + height += 20; + } + if (hasAuthors) { + height += 56; + } + if (hasTags) { + height += 56; + } + + return height; + }); + + return NestedScrollView( + headerSliverBuilder: + (context, innerBoxIsScrolled) => [ + SliverAppBar.large( + pinned: false, + flexibleSpace: FlexibleSpaceBar( + centerTitle: false, + titlePadding: EdgeInsetsDirectional.only( + start: 72, + end: 72, + bottom: bottomHeight + 16, + ), + title: Text( + article.displayTitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + background: articleImages?.firstOrNull.mapNotNull( + (img) => Image.network( + img.toString(), + fit: BoxFit.cover, + color: Theme.of( + context, + ).colorScheme.surface.withAlpha(200), + colorBlendMode: BlendMode.darken, + ), + ), + ), + bottom: PreferredSize( + preferredSize: Size(double.infinity, bottomHeight), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(), + Text( + 'Published: ${hasArticleCreated ? ref.read(formatProvider.notifier).fullDateTimeWithTimezone(article.created!) : 'N/A'}', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(fontStyle: FontStyle.italic), + ), + if (hasArticleUpdated) + Text( + 'Updated: ${ref.read(formatProvider.notifier).fullDateTimeWithTimezone(article.updated!)}', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(fontStyle: FontStyle.italic), + ), + if (hasAuthors) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Row( + children: [ + const Text('Authors:'), + const SizedBox(width: 8), + Expanded( + child: AuthorsHorizontalList( + authors: article.authors!, + ), + ), + ], + ), + ), + if (hasTags) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Row( + children: [ + const Text('Tags:'), + const SizedBox(width: 8), + Expanded( + child: TagsHorizontalList( + tags: article.tags!, + ), + ), + ], + ), + ), + ], + ), + ), + ), + actions: [ + if (articleLink != null) + IconButton( + onPressed: () async { + await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: articleLink.uri); + + if (context.mounted) { + context.go(BrowserRoute().location); + } + }, + icon: const Icon(Icons.open_in_browser), + ), + ], + ), + // SliverToBoxAdapter( + // child: Padding( + // padding: const EdgeInsets.all(8.0), + // child: TabBar( + // controller: tabController, + // tabs: [ + // ...tabs.map( + // (tab) => switch (tab) { + // _Pages.summary => const Tab(text: 'Summary'), + // _Pages.content => const Tab(text: 'Article'), + // }, + // ), + // ], + // ), + // ), + // ), + ], + body: TabBarView( + controller: tabController, + children: [ + ...tabs.map( + (tab) => Markdown( + selectable: true, + onTapLink: (text, href, title) async { + if (href.mapNotNull(Uri.tryParse) + case final Uri url) { + await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: url); + + if (context.mounted) { + showTabOpenedMessage( + context, + tabName: title.whenNotEmpty, + onShow: () { + context.go(BrowserRoute().location); + }, + ); + } + } + }, + styleSheet: MarkdownStyleSheet.fromTheme( + Theme.of(context), + ).copyWith( + blockquoteDecoration: BoxDecoration( + color: + Theme.of( + context, + ).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(2.0), + ), + ), + data: switch (tab) { + _Pages.summary => article.summaryMarkdown!, + _Pages.content => article.contentMarkdown!, + }, + ), + ), + ], + ), + ); + }, + ); + }, + error: + (error, stackTrace) => Center( + child: FailureWidget( + title: 'Failed reading article', + exception: error, + ), + ), + loading: () => const Center(child: CircularProgressIndicator()), + ), + ); + } +} diff --git a/app/lib/features/web_feed/presentation/screens/feed_article_list.dart b/app/lib/features/web_feed/presentation/screens/feed_article_list.dart new file mode 100644 index 00000000..2e1d13dc --- /dev/null +++ b/app/lib/features/web_feed/presentation/screens/feed_article_list.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lensai/domain/entities/equatable_iterable.dart'; +import 'package:lensai/extensions/nullable.dart'; +import 'package:lensai/features/web_feed/data/models/feed_filter.dart'; +import 'package:lensai/features/web_feed/domain/providers.dart'; +import 'package:lensai/features/web_feed/domain/providers/article_filter.dart'; +import 'package:lensai/features/web_feed/presentation/controllers/fetch_articles.dart'; +import 'package:lensai/features/web_feed/presentation/widgets/feed_article_card.dart'; +import 'package:lensai/presentation/widgets/failure_widget.dart'; + +class FeedArticleListScreen extends HookConsumerWidget { + final Uri? feedId; + + const FeedArticleListScreen({required this.feedId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final articlesAsync = ref.watch( + // ignore: provider_parameters + feedArticleListProvider(FeedFilter(feedId: feedId)), + ); + + return Scaffold( + body: NestedScrollView( + floatHeaderSlivers: true, + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + Consumer( + builder: (context, ref, child) { + final tags = ref.watch(articleFilterProvider); + + return SliverAppBar(floating: true, title: Text('Articles')); + }, + ), + ]; + }, + body: articlesAsync.when( + data: (articles) { + return RefreshIndicator( + onRefresh: () async { + if (feedId != null) { + await ref + .read(fetchArticlesControllerProvider.notifier) + .fetchFeedArticles(feedId!); + } else { + await ref + .read(fetchArticlesControllerProvider.notifier) + .fetchAllArticles(); + } + }, + child: ListView.builder( + itemCount: articles.length, + itemBuilder: (context, i) { + final article = articles[i]; + + return Consumer( + key: ValueKey(article.id), + builder: (context, ref, child) { + final tags = ref.watch( + articleFilterProvider.select( + (value) => EquatableCollection( + value.tags ?? const {}, + immutable: false, + ), + ), + ); + + return FeedArticleCard( + selectedTags: tags.collection, + onTagSelected: (tagId, value) { + if (value) { + ref + .read(articleFilterProvider.notifier) + .addTag(tagId); + } else { + ref + .read(articleFilterProvider.notifier) + .removeTag(tagId); + } + }, + article: article, + ); + }, + ); + }, + ), + ); + }, + error: + (error, stackTrace) => Center( + child: FailureWidget( + title: 'Failed to load Articles', + exception: error, + ), + ), + loading: () => const SizedBox.shrink(), + ), + ), + ); + } +} diff --git a/app/lib/features/web_feed/presentation/screens/feed_edit.dart b/app/lib/features/web_feed/presentation/screens/feed_edit.dart new file mode 100644 index 00000000..e9fb897c --- /dev/null +++ b/app/lib/features/web_feed/presentation/screens/feed_edit.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lensai/extensions/nullable.dart'; +import 'package:lensai/extensions/uri.dart'; +import 'package:lensai/features/web_feed/data/database/database.dart'; +import 'package:lensai/features/web_feed/data/models/feed_category.dart'; +import 'package:lensai/features/web_feed/domain/repositories/feed_repository.dart'; +import 'package:lensai/features/web_feed/presentation/widgets/tag_field.dart'; +import 'package:lensai/presentation/widgets/url_icon.dart'; + +enum _DialogMode { create, edit } + +class FeedEditScreen extends HookConsumerWidget { + final _DialogMode _mode; + + final FeedData initialFeed; + + const FeedEditScreen._({required _DialogMode mode, required this.initialFeed}) + : _mode = mode; + + factory FeedEditScreen.create({required FeedData initialFeed}) { + return FeedEditScreen._(mode: _DialogMode.create, initialFeed: initialFeed); + } + + factory FeedEditScreen.edit({required FeedData initialFeed}) { + return FeedEditScreen._(mode: _DialogMode.edit, initialFeed: initialFeed); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formKey = useMemoized(() => GlobalKey()); + final initialTags = useMemoized( + () => initialFeed.tags?.map((tag) => tag.id).toSet(), + ); + final tags = useRef(initialTags ?? {}); + + final titleTextController = useTextEditingController( + text: initialFeed.title ?? initialFeed.url.host, + ); + final descriptionTextController = useTextEditingController( + text: initialFeed.description, + ); + final urlTextController = useTextEditingController( + text: initialFeed.url.toString(), + ); + + return Scaffold( + appBar: AppBar( + title: Text(switch (_mode) { + _DialogMode.create => 'New Feed', + _DialogMode.edit => 'Edit Feed', + }), + actions: [ + IconButton( + onPressed: () async { + if (formKey.currentState?.validate() ?? false) { + final feedData = FeedData( + url: Uri.parse(urlTextController.text), + authors: initialFeed.authors, + description: descriptionTextController.text.whenNotEmpty, + tags: tags.value.map((tag) => FeedCategory(id: tag)).toList(), + title: titleTextController.text.whenNotEmpty, + ); + + await ref + .read(feedRepositoryProvider.notifier) + .upsertFeed(feedData); + + if (context.mounted) { + context.pop(); + } + } + }, + icon: const Icon(Icons.check), + ), + ], + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + Expanded( + child: Form( + key: formKey, + child: ListView( + children: [ + TextFormField( + decoration: InputDecoration( + prefixIcon: Padding( + padding: const EdgeInsets.all(10.0), + child: UrlIcon( + initialFeed.url.base, + iconSize: 24.0, + ), + ), + label: const Text('Title'), + ), + controller: titleTextController, + ), + TextFormField( + decoration: const InputDecoration( + label: Text('Description'), + ), + minLines: 1, + maxLines: 3, + controller: descriptionTextController, + ), + const SizedBox(height: 16), + TagField( + initialTags: tags.value, + onTagsUpdate: (newTags) { + tags.value = newTags; + }, + ), + const SizedBox(height: 16), + TextFormField( + decoration: const InputDecoration( + label: Text('Address'), + ), + keyboardType: TextInputType.url, + controller: urlTextController, + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (value) { + if (value.isEmpty) { + return 'Address must be provided'; + } + + if (Uri.tryParse(value!) case final Uri url) { + if (url.isScheme('https') || + url.isScheme('http') && + url.authority.isNotEmpty) { + return null; + } + } + + return 'Inavlid URL'; + }, + ), + ], + ), + ), + ), + if (_mode == _DialogMode.edit) + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: BorderSide( + color: Theme.of(context).colorScheme.error, + ), + foregroundColor: Theme.of(context).colorScheme.error, + iconColor: Theme.of(context).colorScheme.error, + ), + label: const Text('Delete'), + icon: const Icon(Icons.delete), + onPressed: () async { + final result = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Delete Feed'), + content: const Text( + 'Are you sure you want to delete this feed and delete all related articles?', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context, false); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context, true); + }, + child: const Text('Delete'), + ), + ], + ); + }, + ); + + if (result == true) { + await ref + .read(feedRepositoryProvider.notifier) + .deleteFeed(initialFeed.url); + + if (context.mounted) { + context.pop(); + } + } + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/web_feed/presentation/screens/feed_list.dart b/app/lib/features/web_feed/presentation/screens/feed_list.dart new file mode 100644 index 00000000..d995ec0d --- /dev/null +++ b/app/lib/features/web_feed/presentation/screens/feed_list.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lensai/features/web_feed/domain/providers.dart'; +import 'package:lensai/features/web_feed/presentation/controllers/fetch_articles.dart'; +import 'package:lensai/features/web_feed/presentation/widgets/feed_card.dart'; +import 'package:lensai/presentation/widgets/failure_widget.dart'; + +class FeedListScreen extends HookConsumerWidget { + const FeedListScreen(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final feeds = ref.watch(feedListProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Feeds')), + body: feeds.when( + data: (feeds) { + return RefreshIndicator( + onRefresh: () async { + await ref + .read(fetchArticlesControllerProvider.notifier) + .fetchAllArticles(); + }, + child: ListView.builder( + itemCount: feeds.length, + itemBuilder: (context, i) { + return FeedCard(feed: feeds[i]); + }, + ), + ); + }, + error: + (error, stackTrace) => Center( + child: FailureWidget( + title: 'Failed to load Feeds', + exception: error, + ), + ), + loading: () => const SizedBox.shrink(), + ), + ); + } +} diff --git a/app/lib/features/web_feed/presentation/widgets/authors_horizontal_list.dart b/app/lib/features/web_feed/presentation/widgets/authors_horizontal_list.dart new file mode 100644 index 00000000..5dcf9d3a --- /dev/null +++ b/app/lib/features/web_feed/presentation/widgets/authors_horizontal_list.dart @@ -0,0 +1,41 @@ +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:lensai/extensions/nullable.dart'; +import 'package:lensai/features/web_feed/data/models/feed_author.dart'; + +class AuthorsHorizontalList extends StatelessWidget { + late final List _authors; + + AuthorsHorizontalList({required List authors}) { + _authors = + authors + .map( + (author) => Chip( + label: Text( + '${author.name ?? ''} ${author.email.mapNotNull((email) => '($email)') ?? ''}' + .trim(), + ), + ), + ) + .toList(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 48, + child: FadingScroll( + fadingSize: 15, + builder: (context, controller) { + return ListView.builder( + itemCount: _authors.length, + controller: controller, + shrinkWrap: true, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) => _authors[index], + ); + }, + ), + ); + } +} diff --git a/app/lib/features/web_feed/presentation/widgets/feed_article_card.dart b/app/lib/features/web_feed/presentation/widgets/feed_article_card.dart new file mode 100644 index 00000000..d134925b --- /dev/null +++ b/app/lib/features/web_feed/presentation/widgets/feed_article_card.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lensai/core/routing/routes.dart'; +import 'package:lensai/extensions/nullable.dart'; +import 'package:lensai/extensions/uri.dart'; +import 'package:lensai/features/web_feed/data/models/feed_article.dart'; +import 'package:lensai/features/web_feed/domain/repositories/feed_repository.dart'; +import 'package:lensai/features/web_feed/extensions/feed_article.dart'; +import 'package:lensai/features/web_feed/presentation/widgets/authors_horizontal_list.dart'; +import 'package:lensai/features/web_feed/presentation/widgets/tags_horizontal_list.dart'; +import 'package:lensai/presentation/widgets/url_icon.dart'; +import 'package:timeago/timeago.dart' as timeago; + +class FeedArticleCard extends HookConsumerWidget { + final FeedArticle article; + + final Set selectedTags; + final void Function(String tagId, bool value)? onTagSelected; + + const FeedArticleCard({ + super.key, + required this.article, + this.onTagSelected, + this.selectedTags = const {}, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () async { + await ref + .read(feedRepositoryProvider.notifier) + .touchArticleRead(article.id); + + if (context.mounted) { + await context.push( + FeedArticleRoute(articleId: article.id).location, + extra: article, + ); + } + }, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + UrlIcon(article.feedId.base, iconSize: 34.0), + const SizedBox(width: 12.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + article.displayTitle, + style: theme.textTheme.titleMedium, + ), + if (article.summaryPlain != null) + Text( + article.summaryPlain!, + style: theme.textTheme.bodySmall, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (article.lastRead != null) + IconButton( + onPressed: () async { + await ref + .read(feedRepositoryProvider.notifier) + .unsetArticleRead(article.id); + }, + icon: const Icon(Icons.visibility), + ), + ], + ), + if (article.authors.isNotEmpty || article.tags.isNotEmpty) ...[ + const SizedBox(height: 8), + if (article.authors.isNotEmpty) + AuthorsHorizontalList(authors: article.authors!), + if (article.tags.isNotEmpty) + TagsHorizontalList( + tags: article.tags!, + selectedTags: selectedTags, + onTagSelected: onTagSelected, + ), + const Divider(), + ], + Row( + children: [ + Text( + 'Published: ${(article.created != null) ? timeago.format(article.created!) : 'N/A'}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + if (article.updated != null && + article.updated != article.created) + Text( + 'Updated: ${timeago.format(article.updated!)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/web_feed/presentation/widgets/feed_card.dart b/app/lib/features/web_feed/presentation/widgets/feed_card.dart new file mode 100644 index 00000000..f25e9765 --- /dev/null +++ b/app/lib/features/web_feed/presentation/widgets/feed_card.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lensai/core/routing/routes.dart'; +import 'package:lensai/extensions/nullable.dart'; +import 'package:lensai/extensions/uri.dart'; +import 'package:lensai/features/web_feed/data/database/database.dart'; +import 'package:lensai/features/web_feed/domain/providers.dart'; +import 'package:lensai/features/web_feed/presentation/widgets/authors_horizontal_list.dart'; +import 'package:lensai/features/web_feed/presentation/widgets/tags_horizontal_list.dart'; +import 'package:lensai/presentation/widgets/rounded_text.dart'; +import 'package:lensai/presentation/widgets/url_icon.dart'; +import 'package:timeago/timeago.dart' as timeago; + +class FeedCard extends HookConsumerWidget { + final FeedData feed; + + const FeedCard({super.key, required this.feed}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () async { + await context.push(FeedArticleListRoute(feedId: feed.url).location); + }, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + UrlIcon(feed.url.base, iconSize: 34.0), + const SizedBox(width: 12.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + feed.title ?? feed.url.host, + style: theme.textTheme.titleMedium, + ), + if (feed.description != null) + Text( + feed.description!, + style: theme.textTheme.bodySmall, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + if (feed.authors.isNotEmpty || feed.tags.isNotEmpty) ...[ + if (feed.authors.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8.0), + + child: AuthorsHorizontalList(authors: feed.authors!), + ), + if (feed.tags.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8.0), + + child: TagsHorizontalList(tags: feed.tags!), + ), + const Divider(), + ], + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Last fetched: ${(feed.lastFetched != null) ? timeago.format(feed.lastFetched!) : 'N/A'}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + Consumer( + builder: (context, ref, child) { + final countAsync = ref.watch( + unreadFeedArticleCountProvider(feed.url), + ); + + return countAsync.when( + data: (count) { + if (count == null) { + return const SizedBox(); + } + + return RoundedBackground( + child: Row( + children: [ + Icon( + MdiIcons.newspaperVariantMultipleOutline, + size: 18, + color: + Theme.of(context).colorScheme.onPrimary, + ), + const SizedBox(width: 4), + Text( + count.toString(), + textAlign: TextAlign.center, + style: TextStyle( + color: + Theme.of(context).colorScheme.onPrimary, + ), + ), + ], + ), + ); + }, + error: (error, stackTrace) => const Text('N/A'), + loading: () => const SizedBox.shrink(), + ); + }, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/web_feed/presentation/widgets/tag_field.dart b/app/lib/features/web_feed/presentation/widgets/tag_field.dart new file mode 100644 index 00000000..e4d03f2a --- /dev/null +++ b/app/lib/features/web_feed/presentation/widgets/tag_field.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:lensai/presentation/hooks/listenable_callback.dart'; + +final _tagSplitPatter = RegExp(r'[,\s]+'); + +class TagField extends HookWidget { + final Set initialTags; + final void Function(Set tags) onTagsUpdate; + + const TagField({ + super.key, + required this.initialTags, + required this.onTagsUpdate, + }); + + @override + Widget build(BuildContext context) { + final textController = useTextEditingController(); + final tags = useState(initialTags); + + useListenableCallback(tags, () { + onTagsUpdate(tags.value); + }); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Tags', style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Wrap( + spacing: 8.0, + children: + tags.value + .map( + (tag) => InputChip( + label: Text(tag), + onDeleted: () { + tags.value = {...tags.value}..remove(tag); + }, + ), + ) + .toList(), + ), + TextField( + controller: textController, + decoration: const InputDecoration( + label: Text('Add'), + floatingLabelBehavior: FloatingLabelBehavior.always, + hintText: 'tag1, tag2, ...', + ), + onChanged: (String value) { + if (value.isNotEmpty) { + final values = value.split(_tagSplitPatter); + + if (values.length > 1) { + final trimmed = values + .map((str) => str.trim()) + .where((str) => str.isNotEmpty); + + if (trimmed.isNotEmpty) { + textController.clear(); + + tags.value = {...tags.value, ...trimmed}; + } + } + } + }, + onSubmitted: (value) { + if (value.isNotEmpty) { + final values = + value + .split(_tagSplitPatter) + .map((str) => str.trim()) + .where((str) => str.isNotEmpty) + .toList(); + + if (values.isNotEmpty) { + textController.clear(); + + tags.value = {...tags.value, ...values}; + } + } + }, + ), + ], + ); + } +} diff --git a/app/lib/features/web_feed/presentation/widgets/tags_horizontal_list.dart b/app/lib/features/web_feed/presentation/widgets/tags_horizontal_list.dart new file mode 100644 index 00000000..fe449795 --- /dev/null +++ b/app/lib/features/web_feed/presentation/widgets/tags_horizontal_list.dart @@ -0,0 +1,54 @@ +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:lensai/extensions/nullable.dart'; +import 'package:lensai/features/web_feed/data/models/feed_category.dart'; + +class TagsHorizontalList extends StatelessWidget { + late final List _tags; + + TagsHorizontalList({ + required List tags, + Set selectedTags = const {}, + void Function(String tagId, bool value)? onTagSelected, + }) { + _tags = + tags + .map( + (tag) => Padding( + padding: const EdgeInsets.only(right: 8.0), + child: FilterChip( + label: Text( + '${tag.id} ${tag.title.mapNotNull((title) => '($title)') ?? ''}' + .trim(), + ), + selected: selectedTags.contains(tag.id), + onSelected: onTagSelected.mapNotNull( + (onTagSelected) => (value) { + onTagSelected(tag.id, value); + }, + ), + ), + ), + ) + .toList(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 48, + child: FadingScroll( + fadingSize: 15, + builder: (context, controller) { + return ListView.builder( + itemCount: _tags.length, + controller: controller, + shrinkWrap: true, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) => _tags[index], + ); + }, + ), + ); + } +} diff --git a/app/lib/features/web_feed/utils/feed_finder.dart b/app/lib/features/web_feed/utils/feed_finder.dart new file mode 100644 index 00000000..353d95ee --- /dev/null +++ b/app/lib/features/web_feed/utils/feed_finder.dart @@ -0,0 +1,95 @@ +import 'package:html/dom.dart'; + +class FeedFinder { + final Uri url; + final Document document; + + late final String _base; + + FeedFinder({required this.url, required this.document}) { + final uri = url.removeFragment(); + _base = '${uri.scheme}://${uri.authority}'; + } + + // Future> _verifyCandidates(Set candidates) async { + // final results = {}; + + // final client = http.Client(); + // try { + // for (final candidate in candidates) { + // try { + // await client.get(Uri.parse(candidate)); + // } catch (e) { + // continue; + // } + + // results.add(candidate); + // } + // } finally { + // client.close(); + // } + + // return results; + // } + + void _parseBody(Set candidates) { + for (final a in document.querySelectorAll('a')) { + var href = a.attributes['href']; + if (href != null) { + if (href.contains('rss') || + href.contains('xml') || + href.contains('feed')) { + // Fix relative URLs + href = href.startsWith('/') ? _base + href : href; + href = href.endsWith('/') ? href.substring(0, href.length - 2) : href; + + // Fix naked URLs + href = !href.startsWith('http') ? '$_base/$href' : href; + + candidates.add(href); + } + } + } + } + + void _parseHead(Set candidates) { + for (final link in document.querySelectorAll("link[rel='alternate']")) { + final type = link.attributes['type']; + if (type != null) { + if (type.contains('rss') || type.contains('xml')) { + var href = link.attributes['href']; + if (href != null) { + // Fix relative URLs + href = href.startsWith('/') ? _base + href : href; + candidates.add(href); + } + } + } + } + } + + Future> parse({ + bool parseHead = true, + bool parseBody = true, + // bool verifyCandidates = true, + }) async { + final candidates = {}; + + // Look for feed candidates in head + if (parseHead) { + _parseHead(candidates); + } + + // Look for feed candidates in body + if (parseBody) { + _parseBody(candidates); + } + + // Verify candidates + // if (verifyCandidates) { + // return _verifyCandidates(candidates); + // } + + return candidates; + } +} diff --git a/app/lib/features/web_feed/utils/feed_parser.dart b/app/lib/features/web_feed/utils/feed_parser.dart new file mode 100644 index 00000000..fc0875e1 --- /dev/null +++ b/app/lib/features/web_feed/utils/feed_parser.dart @@ -0,0 +1,221 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:lensai/extensions/nullable.dart'; +import 'package:lensai/features/web_feed/data/database/database.dart'; +import 'package:lensai/features/web_feed/data/models/feed_article.dart'; +import 'package:lensai/features/web_feed/data/models/feed_author.dart'; +import 'package:lensai/features/web_feed/data/models/feed_link.dart'; +import 'package:lensai/features/web_feed/extensions/atom.dart'; +import 'package:lensai/features/web_feed/extensions/rss.dart'; +import 'package:rss_dart/dart_rss.dart'; +import 'package:rss_dart/domain/rss1_feed.dart'; + +class FeedParser { + final Uri url; + late Object _feed; + + FeedParser._(this.url, this._feed); + + factory FeedParser.parse({required Uri url, required String xmlString}) { + final rssVersion = WebFeed.detectRssVersion(xmlString); + final feed = switch (rssVersion) { + RssVersion.rss1 => Rss1Feed.parse(xmlString), + RssVersion.rss2 => RssFeed.parse(xmlString), + RssVersion.atom => AtomFeed.parse(xmlString), + RssVersion.unknown => + throw Error.safeToString( + 'Invalid XML String? We cannot detect RSS/Atom version.', + ), + }; + + return FeedParser._(url, feed); + } + + FeedData readGeneralData() { + switch (_feed) { + case final Rss1Feed feed: + return FeedData( + url: url, + title: feed.title.whenNotEmpty ?? feed.dc?.title, + description: feed.description.whenNotEmpty ?? feed.dc?.description, + authors: feed.dc?.creator.whenNotEmpty.mapNotNull( + (creator) => [FeedAuthor(name: creator)], + ), + tags: feed.dc?.toFeedCategories(), + ); + case final RssFeed feed: + final categories = feed.categories.toFeedCategories(); + + return FeedData( + url: url, + title: feed.title.whenNotEmpty ?? feed.dc?.title, + description: feed.description.whenNotEmpty ?? feed.dc?.description, + authors: (feed.author.whenNotEmpty ?? feed.dc?.creator.whenNotEmpty) + .mapNotNull((creator) => [FeedAuthor(name: creator)]), + tags: + categories.isNotEmpty ? categories : feed.dc?.toFeedCategories(), + ); + case final AtomFeed feed: + final authors = feed.authors.toFeedAuthors(); + final tags = feed.categories.toFeedCategories(); + + return FeedData( + url: url, + title: feed.title.whenNotEmpty, + description: feed.subtitle.whenNotEmpty, + authors: authors.isNotEmpty ? authors : null, + tags: tags, + ); + default: + throw Exception(); + } + } + + Future> readArticles() async { + final fetchDate = DateTime.now(); + + switch (_feed) { + case final Rss1Feed feed: + final processedContents = await GeckoTurndownService().turndownHtml( + feed.items.map((item) => item.content?.value ?? '').toList(), + ); + + final processedSummaries = await GeckoTurndownService().turndownHtml( + feed.items + .map( + (item) => + item.description.whenNotEmpty ?? item.dc?.description ?? '', + ) + .toList(), + ); + + return feed.items.mapIndexed((i, item) { + final title = item.title.whenNotEmpty ?? item.dc?.title.whenNotEmpty; + + final itemId = item.dc?.identifier.whenNotEmpty ?? title; + final uniqueId = + '${item.link.whenNotEmpty ?? feed.link.whenNotEmpty ?? url}#$itemId'; + final date = SafeParseDateTime.safeParse(item.dc?.date); + final link = item.link.mapNotNull(Uri.tryParse); + + return FeedArticle( + id: uniqueId, + feedId: url, + title: title, + fetched: fetchDate, + created: date, + authors: item.dc?.creator.whenNotEmpty.mapNotNull( + (creator) => [FeedAuthor(name: creator)], + ), + summaryPlain: processedSummaries[i].plain.whenNotEmpty, + summaryMarkdown: processedSummaries[i].markdown.whenNotEmpty, + links: link.mapNotNull((link) => [FeedLink(uri: link)]), + tags: item.dc?.toFeedCategories(), + contentPlain: processedContents[i].plain.trim().whenNotEmpty, + contentMarkdown: processedContents[i].markdown?.trim().whenNotEmpty, + ); + }).toList(); + case final RssFeed feed: + final processedContents = await GeckoTurndownService().turndownHtml( + feed.items.map((item) => item.content?.value ?? '').toList(), + ); + + final processedSummaries = await GeckoTurndownService().turndownHtml( + feed.items + .map( + (item) => + item.description.whenNotEmpty ?? item.dc?.description ?? '', + ) + .toList(), + ); + + return feed.items.mapIndexed((i, item) { + final title = item.title.whenNotEmpty ?? item.dc?.title.whenNotEmpty; + + final itemId = + item.guid.whenNotEmpty ?? + item.dc?.identifier.whenNotEmpty ?? + title; + + final uniqueId = + '${item.link.whenNotEmpty ?? feed.link.whenNotEmpty ?? url}#$itemId'; + final date = SafeParseDateTime.safeParse( + item.pubDate.whenNotEmpty ?? item.dc?.date, + ); + final link = item.link.mapNotNull(Uri.tryParse); + final author = + item.author.whenNotEmpty ?? item.dc?.creator.whenNotEmpty; + + final categories = item.categories.toFeedCategories(); + + return FeedArticle( + id: uniqueId, + feedId: url, + title: title, + fetched: fetchDate, + created: date, + authors: author.mapNotNull( + (creator) => [FeedAuthor(name: creator)], + ), + summaryPlain: processedSummaries[i].plain.whenNotEmpty, + summaryMarkdown: processedSummaries[i].markdown.whenNotEmpty, + links: link.mapNotNull((link) => [FeedLink(uri: link)]), + tags: + categories.isNotEmpty + ? categories + : item.dc?.toFeedCategories(), + contentPlain: processedContents[i].plain.trim().whenNotEmpty, + contentMarkdown: processedContents[i].markdown?.trim().whenNotEmpty, + ); + }).toList(); + case final AtomFeed feed: + final processedContents = await GeckoTurndownService().turndownHtml( + feed.items.map((item) => item.content ?? '').toList(), + ); + + final processedSummaries = await GeckoTurndownService().turndownHtml( + feed.items.map((item) => item.summary ?? '').toList(), + ); + + final feedLink = feed.links.toFeedLinks().firstWhereOrNull( + (link) => link.relation == FeedLinkRelation.self, + ); + + return feed.items.mapIndexed((i, item) { + final authors = item.authors.toFeedAuthors(); + + final tags = item.categories.toFeedCategories(); + + final itemLinks = item.links.toFeedLinks(); + final articleLink = itemLinks.firstWhereOrNull( + (link) => link.relation == FeedLinkRelation.alternate, + ); + + final itemId = item.id.whenNotEmpty ?? item.title; + final uniqueId = + '${articleLink?.uri ?? feedLink?.uri ?? url}#$itemId'; + + final published = SafeParseDateTime.safeParse(item.published); + final updated = SafeParseDateTime.safeParse(item.updated); + + return FeedArticle( + id: uniqueId, + feedId: url, + title: item.title.whenNotEmpty, + summaryPlain: processedSummaries[i].plain.whenNotEmpty, + summaryMarkdown: processedSummaries[i].markdown.whenNotEmpty, + links: itemLinks, + authors: authors.isNotEmpty ? authors : null, + tags: tags, + fetched: fetchDate, + created: published, + updated: updated, + contentPlain: processedContents[i].plain.trim().whenNotEmpty, + contentMarkdown: processedContents[i].markdown?.trim().whenNotEmpty, + ); + }).toList(); + default: + throw Exception(); + } + } +}