373 lines
14 KiB
Dart
373 lines
14 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:drift/drift.dart';
|
|
import 'package:drift/native.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import '../mews/document.dart' show MewsEntry;
|
|
|
|
part 'database.g.dart';
|
|
|
|
/// Visited pages, newest first; the most recent row is the app's start page.
|
|
/// The same page can appear many times, so the index carries the lookups that
|
|
/// group or remove by address.
|
|
@TableIndex(name: 'history_items_url', columns: {#url})
|
|
class HistoryItems extends Table {
|
|
IntColumn get id => integer().autoIncrement()();
|
|
TextColumn get url => text()();
|
|
TextColumn get title => text().nullable()();
|
|
DateTimeColumn get visitedAt => dateTime().withDefault(currentDateAndTime)();
|
|
}
|
|
|
|
/// A bookmarked page, keyed by its address so it can only be held once.
|
|
class Bookmarks extends Table {
|
|
TextColumn get url => text()();
|
|
TextColumn get title => text().nullable()();
|
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {url};
|
|
}
|
|
|
|
/// A subscribed page (spec 6). Its posts live in [FeedEntries].
|
|
class Subscriptions extends Table {
|
|
TextColumn get url => text()();
|
|
TextColumn get title => text().nullable()();
|
|
|
|
/// The page's Atom feed, resolved absolute, when it declares one (spec 6.2).
|
|
TextColumn get atomHref => text().nullable()();
|
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
|
DateTimeColumn get lastCheckedAt => dateTime().nullable()();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {url};
|
|
}
|
|
|
|
/// One post found on a subscribed page, through its dated links (spec 6.1) or
|
|
/// its Atom feed (spec 6.2). An entry's URL identifies it for its lifetime, so
|
|
/// (siteUrl, url) is its identity and finding it again never clears [readAt].
|
|
class FeedEntries extends Table {
|
|
TextColumn get siteUrl => text()();
|
|
TextColumn get url => text()();
|
|
TextColumn get title => text()();
|
|
DateTimeColumn get date => dateTime()();
|
|
DateTimeColumn get foundAt => dateTime().withDefault(currentDateAndTime)();
|
|
DateTimeColumn get readAt => dateTime().nullable()();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {siteUrl, url};
|
|
}
|
|
|
|
/// What a tag is attached to.
|
|
enum TagKind { bookmark, subscription }
|
|
|
|
/// Tags the reader puts on a bookmark or a subscribed site. A tag exists only
|
|
/// while something carries it, so there is no separate table of tags to keep
|
|
/// in step. Comparison is case-sensitive.
|
|
class ItemTags extends Table {
|
|
TextColumn get kind => textEnum<TagKind>()();
|
|
TextColumn get url => text()();
|
|
TextColumn get tag => text()();
|
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {kind, url, tag};
|
|
}
|
|
|
|
/// Single-row table (id 0) holding the reader's presentation choices.
|
|
class ReaderSettings extends Table {
|
|
IntColumn get id => integer().withDefault(const Constant(0))();
|
|
TextColumn get font => text().withDefault(const Constant('Atkinson Hyperlegible'))();
|
|
RealColumn get fontScale => real().withDefault(const Constant(1.0))();
|
|
TextColumn get themeMode => text().withDefault(const Constant('system'))();
|
|
TextColumn get variant => text().withDefault(const Constant('site'))();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {id};
|
|
}
|
|
|
|
/// The fetched source of a page, kept so the reader can reopen it offline.
|
|
class CachedPages extends Table {
|
|
TextColumn get url => text()();
|
|
TextColumn get title => text().nullable()();
|
|
TextColumn get source => text()();
|
|
TextColumn get etag => text().nullable()();
|
|
TextColumn get lastModified => text().nullable()();
|
|
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {url};
|
|
}
|
|
|
|
@DriftDatabase(tables: [
|
|
HistoryItems,
|
|
Bookmarks,
|
|
Subscriptions,
|
|
FeedEntries,
|
|
ItemTags,
|
|
ReaderSettings,
|
|
CachedPages,
|
|
])
|
|
class MewsDatabase extends _$MewsDatabase {
|
|
MewsDatabase() : super(_open());
|
|
|
|
/// For tests.
|
|
MewsDatabase.test(super.executor);
|
|
|
|
@override
|
|
int get schemaVersion => 2;
|
|
|
|
/// Pre-release: the entry store replaces the old list of seen entry URLs and
|
|
/// the library tables gain real primary keys, so an existing database is
|
|
/// recreated rather than migrated.
|
|
@override
|
|
MigrationStrategy get migration => destructiveFallback;
|
|
|
|
static QueryExecutor _open() => LazyDatabase(() async {
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
return NativeDatabase.createInBackground(File(p.join(dir.path, 'mews.sqlite')));
|
|
});
|
|
|
|
// History -------------------------------------------------------------
|
|
|
|
Future<void> recordVisit(String url, String? title) =>
|
|
(into(historyItems).insert(HistoryItemsCompanion.insert(url: url, title: Value(title))));
|
|
|
|
Future<String?> lastVisitedUrl() async {
|
|
final query = (select(historyItems)
|
|
..orderBy([
|
|
(r) => OrderingTerm.desc(r.visitedAt),
|
|
// Tiebreak pages recorded within the same clock tick.
|
|
(r) => OrderingTerm.desc(r.id),
|
|
])
|
|
..limit(1));
|
|
final row = await query.getSingleOrNull();
|
|
return row?.url;
|
|
}
|
|
|
|
/// Row counts for the hub, so it can say what each page holds without
|
|
/// reading every row. History counts addresses, not visits.
|
|
Future<int> countHistory() =>
|
|
_countOf(historyItems, historyItems.url, distinct: true);
|
|
Future<int> countBookmarks() => _countOf(bookmarks, bookmarks.url);
|
|
Future<int> countSubscriptions() => _countOf(subscriptions, subscriptions.url);
|
|
|
|
Future<int> _countOf<T extends Object>(
|
|
ResultSetImplementation<HasResultSet, dynamic> table,
|
|
GeneratedColumn<T> column,
|
|
{bool distinct = false}) async {
|
|
final total = column.count(distinct: distinct);
|
|
final query = selectOnly(table)..addColumns([total]);
|
|
return (await query.getSingle()).read(total) ?? 0;
|
|
}
|
|
|
|
/// The most recent external visits, newest first.
|
|
Future<List<HistoryItem>> recentVisits({int limit = 50}) async {
|
|
final query = (select(historyItems)
|
|
..orderBy([
|
|
(r) => OrderingTerm.desc(r.visitedAt),
|
|
(r) => OrderingTerm.desc(r.id),
|
|
])
|
|
..limit(limit));
|
|
return query.get();
|
|
}
|
|
|
|
/// Bookmarks in the order they were added, newest first.
|
|
Future<List<Bookmark>> bookmarksByDate() async {
|
|
final query = (select(bookmarks)
|
|
..orderBy([
|
|
(r) => OrderingTerm.desc(r.createdAt),
|
|
// Tiebreak bookmarks added within the same clock tick.
|
|
(r) => OrderingTerm.asc(r.url),
|
|
]));
|
|
return query.get();
|
|
}
|
|
|
|
// Bookmarks -----------------------------------------------------------
|
|
|
|
Future<List<Bookmark>> allBookmarks() => select(bookmarks).get();
|
|
|
|
Future<void> addBookmark(String url, String? title) => into(bookmarks).insert(
|
|
BookmarksCompanion.insert(url: url, title: Value(title)),
|
|
mode: InsertMode.insertOrIgnore);
|
|
|
|
/// Removes a bookmark and whatever the reader tagged it with.
|
|
Future<void> removeBookmark(String url) => transaction(() async {
|
|
await (delete(bookmarks)..where((r) => r.url.equals(url))).go();
|
|
await _removeTagsOf(TagKind.bookmark, url);
|
|
});
|
|
|
|
// Subscriptions ---------------------------------------------------------
|
|
|
|
Future<void> addSubscription(String url, String? title, String? atomHref) =>
|
|
into(subscriptions).insert(
|
|
SubscriptionsCompanion.insert(
|
|
url: url, title: Value(title), atomHref: Value(atomHref)),
|
|
mode: InsertMode.insertOrIgnore);
|
|
|
|
/// Removes a subscription along with its posts and tags, so nothing is left
|
|
/// pointing at a site the reader has left.
|
|
Future<void> removeSubscription(String url) => transaction(() async {
|
|
await (delete(subscriptions)..where((r) => r.url.equals(url))).go();
|
|
await (delete(feedEntries)..where((r) => r.siteUrl.equals(url))).go();
|
|
await _removeTagsOf(TagKind.subscription, url);
|
|
});
|
|
|
|
Future<List<Subscription>> allSubscriptions() async {
|
|
final query = (select(subscriptions)
|
|
..orderBy([
|
|
(r) => OrderingTerm.desc(r.createdAt),
|
|
(r) => OrderingTerm.asc(r.url),
|
|
]));
|
|
return query.get();
|
|
}
|
|
|
|
/// Records that a site was checked, whether or not anything was found.
|
|
Future<void> touchSubscription(String url, DateTime checkedAt) =>
|
|
(update(subscriptions)..where((r) => r.url.equals(url)))
|
|
.write(SubscriptionsCompanion(lastCheckedAt: Value(checkedAt)));
|
|
|
|
/// Updates what is known about a site. Each field is left alone when it is
|
|
/// not given, so a refresh cannot blank a stored title.
|
|
Future<void> describeSubscription(String url,
|
|
{String? title, String? atomHref}) =>
|
|
(update(subscriptions)..where((r) => r.url.equals(url))).write(
|
|
SubscriptionsCompanion(
|
|
title: title == null ? const Value.absent() : Value(title),
|
|
atomHref: atomHref == null ? const Value.absent() : Value(atomHref),
|
|
),
|
|
);
|
|
|
|
// Posts -------------------------------------------------------------------
|
|
|
|
/// Stores the posts of [siteUrl] that are not held yet and returns them, so
|
|
/// the caller can say what is new. An entry already held keeps its read
|
|
/// mark. Pass [read] to store them already read, which is how a new
|
|
/// subscription takes on a site's back catalogue without flagging it.
|
|
Future<List<MewsEntry>> addEntries(String siteUrl, List<MewsEntry> entries,
|
|
{bool read = false}) async {
|
|
if (entries.isEmpty) return const [];
|
|
final urls = entries.map((e) => e.url).toList();
|
|
final held = await (select(feedEntries)
|
|
..where((r) => r.siteUrl.equals(siteUrl) & r.url.isIn(urls)))
|
|
.get();
|
|
final known = held.map((r) => r.url).toSet();
|
|
final fresh = entries.where((e) => !known.contains(e.url)).toList();
|
|
if (fresh.isEmpty) return const [];
|
|
await batch((b) => b.insertAll(
|
|
feedEntries,
|
|
[
|
|
for (final entry in fresh)
|
|
FeedEntriesCompanion.insert(
|
|
siteUrl: siteUrl,
|
|
url: entry.url,
|
|
title: entry.title,
|
|
date: entry.date,
|
|
readAt: Value(read ? DateTime.now() : null),
|
|
),
|
|
],
|
|
mode: InsertMode.insertOrIgnore,
|
|
));
|
|
return fresh;
|
|
}
|
|
|
|
/// Marks a post read. The address identifies a post for its lifetime, so
|
|
/// the site is only needed when two sites happen to list the same post.
|
|
Future<void> markEntryRead(String url, {String? siteUrl}) {
|
|
final statement = update(feedEntries)..where((r) => r.url.equals(url));
|
|
if (siteUrl != null) statement.where((r) => r.siteUrl.equals(siteUrl));
|
|
return statement.write(FeedEntriesCompanion(readAt: Value(DateTime.now())));
|
|
}
|
|
|
|
Future<void> markEntryUnread(String url, {String? siteUrl}) {
|
|
final statement = update(feedEntries)..where((r) => r.url.equals(url));
|
|
if (siteUrl != null) statement.where((r) => r.siteUrl.equals(siteUrl));
|
|
return statement.write(const FeedEntriesCompanion(readAt: Value(null)));
|
|
}
|
|
|
|
/// How many posts are unread, for one site or across all of them.
|
|
Future<int> unreadCount({String? siteUrl}) async {
|
|
final total = feedEntries.url.count();
|
|
final query = selectOnly(feedEntries)
|
|
..addColumns([total])
|
|
..where(feedEntries.readAt.isNull());
|
|
if (siteUrl != null) query.where(feedEntries.siteUrl.equals(siteUrl));
|
|
return (await query.getSingle()).read(total) ?? 0;
|
|
}
|
|
|
|
/// The unread count per site, so a list of sites costs one query.
|
|
Future<Map<String, int>> unreadCounts() async {
|
|
final total = feedEntries.url.count();
|
|
final query = selectOnly(feedEntries)
|
|
..addColumns([feedEntries.siteUrl, total])
|
|
..where(feedEntries.readAt.isNull())
|
|
..groupBy([feedEntries.siteUrl]);
|
|
return {
|
|
for (final row in await query.get())
|
|
row.read(feedEntries.siteUrl)!: row.read(total) ?? 0,
|
|
};
|
|
}
|
|
|
|
// Tags ---------------------------------------------------------------------
|
|
|
|
/// The tags on one item, in the order they were added.
|
|
Future<List<String>> tagsOf(TagKind kind, String url) async {
|
|
final query = (select(itemTags)
|
|
..where((r) => r.kind.equalsValue(kind) & r.url.equals(url))
|
|
..orderBy([
|
|
(r) => OrderingTerm.asc(r.createdAt),
|
|
(r) => OrderingTerm.asc(r.tag),
|
|
]));
|
|
return (await query.get()).map((row) => row.tag).toList();
|
|
}
|
|
|
|
Future<void> addTag(TagKind kind, String url, String tag) =>
|
|
into(itemTags).insert(
|
|
ItemTagsCompanion.insert(kind: kind, url: url, tag: tag),
|
|
mode: InsertMode.insertOrIgnore);
|
|
|
|
Future<void> removeTag(TagKind kind, String url, String tag) =>
|
|
(delete(itemTags)
|
|
..where((r) =>
|
|
r.kind.equalsValue(kind) &
|
|
r.url.equals(url) &
|
|
r.tag.equals(tag)))
|
|
.go();
|
|
|
|
Future<void> _removeTagsOf(TagKind kind, String url) =>
|
|
(delete(itemTags)
|
|
..where((r) => r.kind.equalsValue(kind) & r.url.equals(url)))
|
|
.go();
|
|
|
|
// Settings ---------------------------------------------------------------
|
|
|
|
Future<ReaderSetting?> readSettings() async {
|
|
final query = select(readerSettings)..where((r) => r.id.equals(0));
|
|
return query.getSingleOrNull();
|
|
}
|
|
|
|
Future<void> saveSettings({
|
|
required String font,
|
|
required double fontScale,
|
|
required String themeMode,
|
|
required String variant,
|
|
}) =>
|
|
into(readerSettings).insert(
|
|
ReaderSettingsCompanion.insert(
|
|
id: const Value(0),
|
|
font: Value(font),
|
|
fontScale: Value(fontScale),
|
|
themeMode: Value(themeMode),
|
|
variant: Value(variant),
|
|
),
|
|
mode: InsertMode.insertOrReplace);
|
|
|
|
// Cache --------------------------------------------------------------------
|
|
|
|
Future<CachedPage?> cachedPage(String url) async =>
|
|
(select(cachedPages)..where((r) => r.url.equals(url))).getSingleOrNull();
|
|
|
|
Future<void> saveCachedPage(CachedPagesCompanion entry) =>
|
|
into(cachedPages).insert(entry, mode: InsertMode.insertOrReplace);
|
|
}
|