feat: add quotations from marked text, with citations and sharing

This commit is contained in:
randogoth 2026-10-12 10:37:32 +03:00
parent 0e2027c9e2
commit e6e84fdc01
25 changed files with 1558 additions and 4 deletions

View file

@ -148,6 +148,21 @@ class SiteCertificates extends Table {
Set<Column> get primaryKey => {host, port};
}
/// A stretch of text the reader marked on a page and kept, together with the
/// page it came from. The author is carried only when the page's metadata
/// said one, and the site's title when the reader's subscriptions know the
/// site the page belongs to. The column is named [Quotations.quote] because
/// `text` is the drift builder the declaration would otherwise call.
class Quotations extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get pageUrl => text()();
TextColumn get pageTitle => text().nullable()();
TextColumn get author => text().nullable()();
TextColumn get siteTitle => text().nullable()();
TextColumn get quote => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
@DriftDatabase(tables: [
HistoryItems,
Bookmarks,
@ -160,6 +175,7 @@ class SiteCertificates extends Table {
ClientCertificates,
SiteCertificates,
FirstStart,
Quotations,
])
class MewsDatabase extends _$MewsDatabase {
MewsDatabase() : super(_open());
@ -168,7 +184,7 @@ class MewsDatabase extends _$MewsDatabase {
MewsDatabase.test(super.executor);
@override
int get schemaVersion => 6;
int get schemaVersion => 8;
@override
MigrationStrategy get migration => MigrationStrategy(
@ -197,6 +213,12 @@ class MewsDatabase extends _$MewsDatabase {
if (from < 6) {
await m.createTable(firstStart);
}
if (from < 7) {
await m.createTable(quotations);
}
if (from < 8) {
await m.addColumn(quotations, quotations.siteTitle);
}
},
);
@ -717,6 +739,87 @@ class MewsDatabase extends _$MewsDatabase {
return [for (final row in rows) '${row.host}:${row.port}'];
}
// Quotations --------------------------------------------------------------
/// Keeps a marked stretch of text together with the page it came from,
/// and with the site's title when the reader's subscriptions know the site
/// the page belongs to: the site whose posts listed the page, or the page
/// itself when it is the subscribed one.
Future<void> addQuotation({
required String quote,
required String pageUrl,
String? pageTitle,
String? author,
}) async {
into(quotations).insert(QuotationsCompanion.insert(
quote: quote,
pageUrl: pageUrl,
pageTitle: Value(pageTitle),
author: Value(author),
siteTitle: Value(await siteTitleOf(pageUrl)),
));
}
/// The title of the site [pageUrl] belongs to, or null when the reader's
/// subscriptions do not know it. A page the site lists as a post takes the
/// subscribing site's title; a subscribed page is its own site.
Future<String?> siteTitleOf(String pageUrl) async {
final site = alias(subscriptions, 'site');
final throughPosts = await ((select(feedEntries).join([
leftOuterJoin(site, site.url.equalsExp(feedEntries.siteUrl)),
]))
..where(feedEntries.url.equals(pageUrl)))
.get();
if (throughPosts.isNotEmpty) {
return throughPosts.first.readTableOrNull(site)?.title;
}
final own =
await (select(subscriptions)..where((r) => r.url.equals(pageUrl)))
.getSingleOrNull();
return own?.title;
}
Future<void> removeQuotation(int id) =>
(delete(quotations)..where((q) => q.id.equals(id))).go();
/// One quotation, so an action address can act on it.
Future<Quotation?> quotationById(int id) =>
(select(quotations)..where((q) => q.id.equals(id))).getSingleOrNull();
/// Every kept quotation, for the quotations page. [query] narrows by the
/// quoted text, the page's title, its author or its address.
Future<List<Quotation>> quotationsList({
QuotationSort sort = QuotationSort.date,
String? query,
}) async {
final statement = select(quotations)
..orderBy(switch (sort) {
QuotationSort.date => [
// Tiebreak quotations saved within the same clock tick.
(r) => OrderingTerm.desc(r.createdAt),
(r) => OrderingTerm.asc(r.id),
],
QuotationSort.page => [
// A page without a title sorts under its address, so the order
// never depends on a null.
(r) => OrderingTerm.asc(coalesce([r.pageTitle, r.pageUrl])),
(r) => OrderingTerm.desc(r.createdAt),
],
});
final filter = _search(
[quotations.quote, quotations.pageTitle, quotations.author, quotations.pageUrl],
query);
if (filter != null) statement.where((_) => filter);
return statement.get();
}
/// How many quotations are held, for the hub.
Future<int> countQuotations() async {
final total = quotations.id.count();
final query = selectOnly(quotations)..addColumns([total]);
return (await query.getSingle()).read(total) ?? 0;
}
/// Binds a certificate to a host, replacing whatever was bound before.
Future<void> bindCertificate(String host, int port, int certificateId) =>
into(siteCertificates).insert(
@ -743,6 +846,17 @@ enum EntrySort {
};
}
/// How the quotations page is ordered.
enum QuotationSort {
date,
page;
/// Reads the order from an address, falling back to the default rather than
/// refusing a value the reader may have typed.
static QuotationSort parse(String? name) =>
name == 'page' ? QuotationSort.page : QuotationSort.date;
}
/// How widely one tag is used.
class TagUse {
const TagUse({required this.bookmarks, required this.sites});