prepare for multiple apps

This commit is contained in:
Fabian Freund
2026-04-06 12:23:11 +02:00
parent bd1600e8dc
commit 5afc323f04
904 changed files with 29 additions and 29 deletions
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:weblibre/features/web_feed/data/models/feed_author.dart';
class FeedAuthorsConverter extends TypeConverter<List<FeedAuthor>, String> {
const FeedAuthorsConverter();
@override
List<FeedAuthor> fromSql(String fromDb) {
final authors = jsonDecode(fromDb) as List<dynamic>;
return authors
.map((author) => FeedAuthor.fromJson(author as Map<String, dynamic>))
.toList();
}
@override
String toSql(List<FeedAuthor> value) {
return jsonEncode(value.map((author) => author.toJson()).toList());
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:weblibre/features/web_feed/data/models/feed_category.dart';
class FeedCategoriesConverter
extends TypeConverter<List<FeedCategory>, String> {
const FeedCategoriesConverter();
@override
List<FeedCategory> fromSql(String fromDb) {
final categories = jsonDecode(fromDb) as List<dynamic>;
return categories
.map(
(category) => FeedCategory.fromJson(category as Map<String, dynamic>),
)
.toList();
}
@override
String toSql(List<FeedCategory> value) {
return jsonEncode(value.map((category) => category.toJson()).toList());
}
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:json_annotation/json_annotation.dart';
import 'package:weblibre/features/web_feed/data/database/definitions.drift.dart';
class FeedDataConverter extends JsonConverter<FeedData, Map<String, dynamic>> {
const FeedDataConverter();
@override
FeedData fromJson(Map<String, dynamic> json) {
return FeedData.fromJson(json);
}
@override
Map<String, dynamic> toJson(FeedData object) {
return object.toJson();
}
}
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:weblibre/features/web_feed/data/models/feed_link.dart';
class FeedLinksConverter extends TypeConverter<List<FeedLink>, String> {
const FeedLinksConverter();
@override
List<FeedLink> fromSql(String fromDb) {
final links = jsonDecode(fromDb) as List<dynamic>;
return links
.map((link) => FeedLink.fromJson(link as Map<String, dynamic>))
.toList();
}
@override
String toSql(List<FeedLink> value) {
return jsonEncode(value.map((link) => link.toJson()).toList());
}
}
@@ -0,0 +1,173 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:drift/drift.dart';
import 'package:weblibre/features/web_feed/data/database/daos/article.drift.dart';
import 'package:weblibre/features/web_feed/data/database/database.dart';
import 'package:weblibre/features/web_feed/data/database/definitions.drift.dart';
import 'package:weblibre/features/web_feed/data/models/feed_article.dart';
import 'package:weblibre/features/web_feed/data/models/feed_article_query_result.dart';
@DriftAccessor()
class ArticleDao extends DatabaseAccessor<FeedDatabase> with $ArticleDaoMixin {
ArticleDao(super.attachedDatabase);
Selectable<FeedArticle> getFeedArticles(Uri? url) {
final select = db.articleView.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,
),
]);
}
Selectable<FeedArticle> getUnprocessedArticles() {
return db.articleView.select()..where(
(article) =>
(article.contentHtml.isNotNull() &
(article.contentMarkdown.isNull() |
article.contentPlain.isNull())) |
(article.summaryHtml.isNotNull() &
(article.summaryMarkdown.isNull() |
article.summaryPlain.isNull())),
);
}
SingleOrNullSelectable<FeedArticle> getArticleById(String articleId) {
return db.articleView.select()..where((row) => row.id.equals(articleId));
}
Future<void> updateArticleContent(List<FeedArticle> articles) {
return db.transaction(() async {
await Future.wait(
articles.map((newArticle) {
final statement = db.article.update()
..where((article) => article.id.equals(newArticle.id));
return statement.write(
ArticleCompanion(
summaryHtml: Value(newArticle.summaryHtml),
summaryMarkdown: Value(newArticle.summaryMarkdown),
summaryPlain: Value(newArticle.summaryPlain),
contentHtml: Value(newArticle.contentHtml),
contentMarkdown: Value(newArticle.contentMarkdown),
contentPlain: Value(newArticle.contentPlain),
),
);
}),
);
});
}
Future<void> upsertArticles(List<FeedArticle> articles) {
return db.transaction(() async {
await Future.wait(
articles
.map(
(article) => db.article.insertOne(
article,
onConflict: DoUpdate(
(old) {
return ArticleCompanion(
authors: Value(article.authors),
contentHtml: Value(article.contentHtml),
contentMarkdown: Value(article.contentMarkdown),
contentPlain: Value(article.contentPlain),
links: Value(article.links),
summaryHtml: Value(article.summaryHtml),
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<int> 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<FeedArticleQueryResult> queryArticles({
required String matchPrefix,
required String matchSuffix,
required String ellipsis,
required int snippetLength,
required String searchString,
required Uri? feedId,
int limit = 25,
}) {
final ftsQuery = db.buildFtsQuery(searchString);
if (ftsQuery.isNotEmpty) {
return db.definitionsDrift.queryArticlesFullContent(
feedId: feedId?.toString(),
query: ftsQuery,
snippetLength: snippetLength,
beforeMatch: matchPrefix,
afterMatch: matchSuffix,
ellipsis: ellipsis,
limit: limit,
);
} else {
return db.definitionsDrift.queryArticlesBasic(
feedId: feedId?.toString(),
query: db.buildLikeQuery(searchString),
limit: limit,
);
}
}
}
@@ -0,0 +1,13 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:weblibre/features/web_feed/data/database/database.dart' as i1;
mixin $ArticleDaoMixin on i0.DatabaseAccessor<i1.FeedDatabase> {
ArticleDaoManager get managers => ArticleDaoManager(this);
}
class ArticleDaoManager {
final $ArticleDaoMixin _db;
ArticleDaoManager(this._db);
}
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:drift/drift.dart';
import 'package:weblibre/features/web_feed/data/database/daos/feed.drift.dart';
import 'package:weblibre/features/web_feed/data/database/database.dart';
import 'package:weblibre/features/web_feed/data/database/definitions.drift.dart';
@DriftAccessor()
class FeedDao extends DatabaseAccessor<FeedDatabase> with $FeedDaoMixin {
FeedDao(super.attachedDatabase);
Selectable<FeedData> getFeeds() {
return db.feed.select();
}
SingleOrNullSelectable<FeedData> getFeed(Uri feedId) {
return db.feed.select()..where((feed) => feed.url.equalsValue(feedId));
}
Future<int> updateFeedFetched(Uri feedId, DateTime fetched) {
final statement = db.feed.update()
..where((feed) => feed.url.equalsValue(feedId));
return statement.write(FeedCompanion(lastFetched: Value(fetched)));
}
Future<int> deleteFeed(Uri feedId) {
return db.feed.deleteWhere((feed) => feed.url.equals(feedId.toString()));
}
Future<int> 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),
);
}),
);
}
}
@@ -0,0 +1,13 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:weblibre/features/web_feed/data/database/database.dart' as i1;
mixin $FeedDaoMixin on i0.DatabaseAccessor<i1.FeedDatabase> {
FeedDaoManager get managers => FeedDaoManager(this);
}
class FeedDaoManager {
final $FeedDaoMixin _db;
FeedDaoManager(this._db);
}
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:drift/drift.dart';
import 'package:drift_dev/api/migrations_native.dart';
import 'package:flutter/foundation.dart';
import 'package:weblibre/features/search/domain/fts_tokenizer.dart';
import 'package:weblibre/features/web_feed/data/database/daos/article.dart';
import 'package:weblibre/features/web_feed/data/database/daos/feed.dart';
import 'package:weblibre/features/web_feed/data/database/database.drift.dart';
@DriftDatabase(include: {'definitions.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 {
if (kDebugMode) {
// This check pulls in a fair amount of code that's not needed
// anywhere else, so we recommend only doing it in debug builds.
await validateDatabaseSchema();
}
await customStatement('PRAGMA foreign_keys = ON;');
await definitionsDrift.optimizeFtsIndex();
},
);
FeedDatabase(super.e);
}
@@ -0,0 +1,127 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:weblibre/features/web_feed/data/database/definitions.drift.dart'
as i1;
import 'package:weblibre/features/web_feed/data/database/daos/article.dart'
as i2;
import 'package:weblibre/features/web_feed/data/database/database.dart' as i3;
import 'package:weblibre/features/web_feed/data/database/daos/feed.dart' as i4;
import 'package:drift/internal/modular.dart' as i5;
import 'package:sqlite3/common.dart' as i6;
abstract class $FeedDatabase extends i0.GeneratedDatabase {
$FeedDatabase(i0.QueryExecutor e) : super(e);
$FeedDatabaseManager get managers => $FeedDatabaseManager(this);
late final i1.Feed feed = i1.Feed(this);
late final i1.Article article = i1.Article(this);
late final i1.ArticleView articleView = i1.ArticleView(this);
late final i1.ArticleFts articleFts = i1.ArticleFts(this);
late final i2.ArticleDao articleDao = i2.ArticleDao(this as i3.FeedDatabase);
late final i4.FeedDao feedDao = i4.FeedDao(this as i3.FeedDatabase);
i1.DefinitionsDrift get definitionsDrift => i5.ReadDatabaseContainer(
this,
).accessor<i1.DefinitionsDrift>(i1.DefinitionsDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@override
List<i0.DatabaseSchemaEntity> get allSchemaEntities => [
feed,
article,
articleView,
i1.articleFeedId,
articleFts,
i1.articleAfterInsert,
i1.articleAfterDelete,
i1.articleAfterUpdate,
];
@override
i0.StreamQueryUpdateRules get streamUpdateRules =>
const i0.StreamQueryUpdateRules([
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'feed',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('article', kind: i0.UpdateKind.delete)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'article',
limitUpdateKind: i0.UpdateKind.insert,
),
result: [i0.TableUpdate('article_fts', kind: i0.UpdateKind.insert)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'article',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('article_fts', kind: i0.UpdateKind.insert)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'article',
limitUpdateKind: i0.UpdateKind.update,
),
result: [i0.TableUpdate('article_fts', kind: i0.UpdateKind.insert)],
),
]);
}
class $FeedDatabaseManager {
final $FeedDatabase _db;
$FeedDatabaseManager(this._db);
i1.$FeedTableManager get feed => i1.$FeedTableManager(_db, _db.feed);
i1.$ArticleTableManager get article =>
i1.$ArticleTableManager(_db, _db.article);
i1.$ArticleFtsTableManager get articleFts =>
i1.$ArticleFtsTableManager(_db, _db.articleFts);
}
extension DefineFunctions on i6.CommonDatabase {
void defineFunctions({
required String Function(int, String?) lexoRankNext,
required String Function(int, String?) lexoRankPrevious,
required String Function(String?, String?) lexoRankReorderAfter,
required String Function(String?, String?) lexoRankReorderBefore,
}) {
createFunction(
functionName: 'lexo_rank_next',
argumentCount: const i6.AllowedArgumentCount(2),
function: (args) {
final arg0 = args[0] as int;
final arg1 = args[1] as String?;
return lexoRankNext(arg0, arg1);
},
);
createFunction(
functionName: 'lexo_rank_previous',
argumentCount: const i6.AllowedArgumentCount(2),
function: (args) {
final arg0 = args[0] as int;
final arg1 = args[1] as String?;
return lexoRankPrevious(arg0, arg1);
},
);
createFunction(
functionName: 'lexo_rank_reorder_after',
argumentCount: const i6.AllowedArgumentCount(2),
function: (args) {
final arg0 = args[0] as String?;
final arg1 = args[1] as String?;
return lexoRankReorderAfter(arg0, arg1);
},
);
createFunction(
functionName: 'lexo_rank_reorder_before',
argumentCount: const i6.AllowedArgumentCount(2),
function: (args) {
final arg0 = args[0] as String?;
final arg1 = args[1] as String?;
return lexoRankReorderBefore(arg0, arg1);
},
);
}
}
@@ -0,0 +1,137 @@
import 'package:weblibre/data/database/converters/uri.dart';
import 'package:weblibre/features/web_feed/data/models/feed_article_query_result.dart';
import 'package:weblibre/features/web_feed/data/database/converters/feed_authors.dart';
import 'package:weblibre/features/web_feed/data/database/converters/feed_categories.dart';
import 'package:weblibre/features/web_feed/data/database/converters/feed_links.dart';
import 'package:weblibre/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,
icon TEXT MAPPED BY `const UriConverterNullable()`,
site_link TEXT MAPPED BY `const UriConverterNullable()`,
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()`,
summaryHtml TEXT,
summaryMarkdown TEXT,
summaryPlain TEXT,
contentHtml TEXT,
contentMarkdown TEXT,
contentPlain TEXT
) WITH FeedArticle;
CREATE VIEW article_view WITH FeedArticle AS
SELECT
a.*,
f.icon,
f.site_link
FROM
article a
INNER JOIN
feed f on f.url = a.feed_id;
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.*,
f.icon,
(
bm25(article_fts, weights.title_weight)
) AS weighted_rank
FROM article_fts fts
INNER JOIN
article a ON a.rowid = fts.rowid
INNER JOIN
feed f ON f.url = a.feed_id
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
LIMIT :limit;
queryArticlesFullContent(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult:
WITH weights AS (
SELECT
-- Customize these weights (higher = more important)
10.0 as title_weight, -- Title matches are most important
3.0 as summary_weight, -- Summary matches are quite important
1.0 as content_weight -- Content matches are basic
)
SELECT
a.*,
f.icon,
highlight(article_fts, 0, :beforeMatch, :afterMatch) AS title_highlight,
snippet(article_fts, 1, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS summary_snippet,
snippet(article_fts, 2, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS content_snippet,
(
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
INNER JOIN
feed f ON f.url = a.feed_id
CROSS JOIN weights
WHERE
:feed_id IS NULL OR a.feed_id = :feed_id
ORDER BY
weighted_rank ASC,
a.created DESC NULLS LAST
LIMIT :limit;
File diff suppressed because it is too large Load Diff