feat: bookmark the site directory on a first start
This commit is contained in:
parent
95602ac623
commit
dcb70a93d1
14 changed files with 506 additions and 1 deletions
1
SPEC.md
1
SPEC.md
|
|
@ -85,6 +85,7 @@ The v1 client presents one reader screen:
|
|||
- `mews:posts` is every post from every subscribed site in one list. The reader can order it by date, by site, or unread first, and limit it to unread posts; those choices ride in the address as a query string, so going back undoes one and a swipe preview shows the page the reader will land on. Unread posts are shown bold, and opening a post marks it read.
|
||||
- A site that publishes through an Atom feed instead of dated links is read through the feed (spec 6.2). Dated links are the normative mechanism, so the feed is only asked for when the page itself listed nothing, and a feed on another site is refused. Posts are keyed by address, so a post listed both ways is stored once, under the title the page gave it.
|
||||
- Subscribed sites are checked once in the background at launch, and whenever the reader asks. Sites are checked one after another rather than at once, and a site that cannot be reached keeps the posts already held and is still recorded as checked, so one site being down never stops the rest.
|
||||
- A fresh install comes with the project's directory of sites already bookmarked, so a first session has somewhere to go. It is a bookmark like any other: removing it is the reader's own, and no later start gives it back.
|
||||
- The reader can tag bookmarks and sites. A tag exists only while something carries it, so adding a tag and making one are the same act; `mews:tags` lists every tag in use and what carries it, and following a tag narrows the list it belongs to. There is no renaming and no empty tag.
|
||||
- Each library page carries a search box that narrows it by title or address as the reader types, a quarter second after they stop. The query rides in the address as any other view state does, but it replaces the step of the walk they are standing on rather than adding one, so a search of eight letters is one step back and not eight; the box keeps what they typed as the page rebuilds under them, and follows the page when the page sets it, as clearing the search does. Submitting the box still works, for a reader who would rather say when to search. Each row carries its own actions: remove a bookmark, leave a site or check it now, forget a page. History lists one row per address, headed by the most recent visit, rather than repeating a page once per visit. Clearing history takes a second step on a page the reader can simply leave, since it cannot be undone.
|
||||
- A change is a link too: following `mews:do/<verb>` applies it and rerenders the page in place without adding a history entry. Only a link followed from one of the client's own pages is honored, so a fetched page cannot clear history or change a setting on the reader's behalf (spec 8.1). A form on one of these pages is routed the same way rather than sent to the network.
|
||||
|
|
|
|||
|
|
@ -127,6 +127,16 @@ class ClientCertificates extends Table {
|
|||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
|
||||
/// What a fresh install has already been given, so it is given once: what
|
||||
/// the reader removes afterwards stays removed.
|
||||
class FirstStart extends Table {
|
||||
IntColumn get id => integer().withDefault(const Constant(0))();
|
||||
BoolColumn get directorySeeded => boolean().withDefault(const Constant(false))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// Which certificate the reader presents to one host: a binding the reader
|
||||
/// made on a certificate request, worn only there.
|
||||
class SiteCertificates extends Table {
|
||||
|
|
@ -149,6 +159,7 @@ class SiteCertificates extends Table {
|
|||
KnownHosts,
|
||||
ClientCertificates,
|
||||
SiteCertificates,
|
||||
FirstStart,
|
||||
])
|
||||
class MewsDatabase extends _$MewsDatabase {
|
||||
MewsDatabase() : super(_open());
|
||||
|
|
@ -157,7 +168,7 @@ class MewsDatabase extends _$MewsDatabase {
|
|||
MewsDatabase.test(super.executor);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 5;
|
||||
int get schemaVersion => 6;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
|
|
@ -183,6 +194,9 @@ class MewsDatabase extends _$MewsDatabase {
|
|||
await m.createTable(clientCertificates);
|
||||
await m.createTable(siteCertificates);
|
||||
}
|
||||
if (from < 6) {
|
||||
await m.createTable(firstStart);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -309,6 +323,28 @@ class MewsDatabase extends _$MewsDatabase {
|
|||
await _removeTagsOf(TagKind.bookmark, url);
|
||||
});
|
||||
|
||||
/// Gives a fresh install the project's directory of sites as a bookmark,
|
||||
/// so the reader starts somewhere. It happens once: removing it is the
|
||||
/// reader's, and later starts never bring it back.
|
||||
Future<void> seedDirectoryBookmark(String url, String title) async {
|
||||
await transaction(() async {
|
||||
if (await directorySeeded()) return;
|
||||
await addBookmark(url, title);
|
||||
await into(firstStart).insert(
|
||||
FirstStartCompanion.insert(
|
||||
id: const Value(0),
|
||||
directorySeeded: const Value(true),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace);
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the directory bookmark has been given to this install.
|
||||
Future<bool> directorySeeded() async {
|
||||
final row = await (select(firstStart)..where((r) => r.id.equals(0))).getSingleOrNull();
|
||||
return row?.directorySeeded ?? false;
|
||||
}
|
||||
|
||||
// Subscriptions ---------------------------------------------------------
|
||||
|
||||
Future<void> addSubscription(String url, String? title, String? atomHref) =>
|
||||
|
|
|
|||
|
|
@ -3376,6 +3376,209 @@ class SiteCertificatesCompanion extends UpdateCompanion<SiteCertificate> {
|
|||
}
|
||||
}
|
||||
|
||||
class $FirstStartTable extends FirstStart
|
||||
with TableInfo<$FirstStartTable, FirstStartData> {
|
||||
@override
|
||||
final GeneratedDatabase attachedDatabase;
|
||||
final String? _alias;
|
||||
$FirstStartTable(this.attachedDatabase, [this._alias]);
|
||||
static const VerificationMeta _idMeta = const VerificationMeta('id');
|
||||
@override
|
||||
late final GeneratedColumn<int> id = GeneratedColumn<int>(
|
||||
'id',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: const Constant(0),
|
||||
);
|
||||
static const VerificationMeta _directorySeededMeta = const VerificationMeta(
|
||||
'directorySeeded',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<bool> directorySeeded = GeneratedColumn<bool>(
|
||||
'directory_seeded',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.bool,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("directory_seeded" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const Constant(false),
|
||||
);
|
||||
@override
|
||||
List<GeneratedColumn> get $columns => [id, directorySeeded];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
String get actualTableName => $name;
|
||||
static const String $name = 'first_start';
|
||||
@override
|
||||
VerificationContext validateIntegrity(
|
||||
Insertable<FirstStartData> instance, {
|
||||
bool isInserting = false,
|
||||
}) {
|
||||
final context = VerificationContext();
|
||||
final data = instance.toColumns(true);
|
||||
if (data.containsKey('id')) {
|
||||
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
|
||||
}
|
||||
if (data.containsKey('directory_seeded')) {
|
||||
context.handle(
|
||||
_directorySeededMeta,
|
||||
directorySeeded.isAcceptableOrUnknown(
|
||||
data['directory_seeded']!,
|
||||
_directorySeededMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<GeneratedColumn> get $primaryKey => {id};
|
||||
@override
|
||||
FirstStartData map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||
return FirstStartData(
|
||||
id: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}id'],
|
||||
)!,
|
||||
directorySeeded: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}directory_seeded'],
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
$FirstStartTable createAlias(String alias) {
|
||||
return $FirstStartTable(attachedDatabase, alias);
|
||||
}
|
||||
}
|
||||
|
||||
class FirstStartData extends DataClass implements Insertable<FirstStartData> {
|
||||
final int id;
|
||||
final bool directorySeeded;
|
||||
const FirstStartData({required this.id, required this.directorySeeded});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
map['id'] = Variable<int>(id);
|
||||
map['directory_seeded'] = Variable<bool>(directorySeeded);
|
||||
return map;
|
||||
}
|
||||
|
||||
FirstStartCompanion toCompanion(bool nullToAbsent) {
|
||||
return FirstStartCompanion(
|
||||
id: Value(id),
|
||||
directorySeeded: Value(directorySeeded),
|
||||
);
|
||||
}
|
||||
|
||||
factory FirstStartData.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
ValueSerializer? serializer,
|
||||
}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return FirstStartData(
|
||||
id: serializer.fromJson<int>(json['id']),
|
||||
directorySeeded: serializer.fromJson<bool>(json['directorySeeded']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'id': serializer.toJson<int>(id),
|
||||
'directorySeeded': serializer.toJson<bool>(directorySeeded),
|
||||
};
|
||||
}
|
||||
|
||||
FirstStartData copyWith({int? id, bool? directorySeeded}) => FirstStartData(
|
||||
id: id ?? this.id,
|
||||
directorySeeded: directorySeeded ?? this.directorySeeded,
|
||||
);
|
||||
FirstStartData copyWithCompanion(FirstStartCompanion data) {
|
||||
return FirstStartData(
|
||||
id: data.id.present ? data.id.value : this.id,
|
||||
directorySeeded: data.directorySeeded.present
|
||||
? data.directorySeeded.value
|
||||
: this.directorySeeded,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('FirstStartData(')
|
||||
..write('id: $id, ')
|
||||
..write('directorySeeded: $directorySeeded')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, directorySeeded);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is FirstStartData &&
|
||||
other.id == this.id &&
|
||||
other.directorySeeded == this.directorySeeded);
|
||||
}
|
||||
|
||||
class FirstStartCompanion extends UpdateCompanion<FirstStartData> {
|
||||
final Value<int> id;
|
||||
final Value<bool> directorySeeded;
|
||||
const FirstStartCompanion({
|
||||
this.id = const Value.absent(),
|
||||
this.directorySeeded = const Value.absent(),
|
||||
});
|
||||
FirstStartCompanion.insert({
|
||||
this.id = const Value.absent(),
|
||||
this.directorySeeded = const Value.absent(),
|
||||
});
|
||||
static Insertable<FirstStartData> custom({
|
||||
Expression<int>? id,
|
||||
Expression<bool>? directorySeeded,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
if (id != null) 'id': id,
|
||||
if (directorySeeded != null) 'directory_seeded': directorySeeded,
|
||||
});
|
||||
}
|
||||
|
||||
FirstStartCompanion copyWith({Value<int>? id, Value<bool>? directorySeeded}) {
|
||||
return FirstStartCompanion(
|
||||
id: id ?? this.id,
|
||||
directorySeeded: directorySeeded ?? this.directorySeeded,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
if (id.present) {
|
||||
map['id'] = Variable<int>(id.value);
|
||||
}
|
||||
if (directorySeeded.present) {
|
||||
map['directory_seeded'] = Variable<bool>(directorySeeded.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('FirstStartCompanion(')
|
||||
..write('id: $id, ')
|
||||
..write('directorySeeded: $directorySeeded')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _$MewsDatabase extends GeneratedDatabase {
|
||||
_$MewsDatabase(QueryExecutor e) : super(e);
|
||||
$MewsDatabaseManager get managers => $MewsDatabaseManager(this);
|
||||
|
|
@ -3392,6 +3595,7 @@ abstract class _$MewsDatabase extends GeneratedDatabase {
|
|||
late final $SiteCertificatesTable siteCertificates = $SiteCertificatesTable(
|
||||
this,
|
||||
);
|
||||
late final $FirstStartTable firstStart = $FirstStartTable(this);
|
||||
late final Index historyItemsUrl = Index(
|
||||
'history_items_url',
|
||||
'CREATE INDEX history_items_url ON history_items (url)',
|
||||
|
|
@ -3411,6 +3615,7 @@ abstract class _$MewsDatabase extends GeneratedDatabase {
|
|||
knownHosts,
|
||||
clientCertificates,
|
||||
siteCertificates,
|
||||
firstStart,
|
||||
historyItemsUrl,
|
||||
];
|
||||
}
|
||||
|
|
@ -5333,6 +5538,137 @@ typedef $$SiteCertificatesTableProcessedTableManager =
|
|||
SiteCertificate,
|
||||
PrefetchHooks Function()
|
||||
>;
|
||||
typedef $$FirstStartTableCreateCompanionBuilder =
|
||||
FirstStartCompanion Function({Value<int> id, Value<bool> directorySeeded});
|
||||
typedef $$FirstStartTableUpdateCompanionBuilder =
|
||||
FirstStartCompanion Function({Value<int> id, Value<bool> directorySeeded});
|
||||
|
||||
class $$FirstStartTableFilterComposer
|
||||
extends Composer<_$MewsDatabase, $FirstStartTable> {
|
||||
$$FirstStartTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnFilters<int> get id => $composableBuilder(
|
||||
column: $table.id,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<bool> get directorySeeded => $composableBuilder(
|
||||
column: $table.directorySeeded,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$FirstStartTableOrderingComposer
|
||||
extends Composer<_$MewsDatabase, $FirstStartTable> {
|
||||
$$FirstStartTableOrderingComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnOrderings<int> get id => $composableBuilder(
|
||||
column: $table.id,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<bool> get directorySeeded => $composableBuilder(
|
||||
column: $table.directorySeeded,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$FirstStartTableAnnotationComposer
|
||||
extends Composer<_$MewsDatabase, $FirstStartTable> {
|
||||
$$FirstStartTableAnnotationComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
GeneratedColumn<int> get id =>
|
||||
$composableBuilder(column: $table.id, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<bool> get directorySeeded => $composableBuilder(
|
||||
column: $table.directorySeeded,
|
||||
builder: (column) => column,
|
||||
);
|
||||
}
|
||||
|
||||
class $$FirstStartTableTableManager
|
||||
extends
|
||||
RootTableManager<
|
||||
_$MewsDatabase,
|
||||
$FirstStartTable,
|
||||
FirstStartData,
|
||||
$$FirstStartTableFilterComposer,
|
||||
$$FirstStartTableOrderingComposer,
|
||||
$$FirstStartTableAnnotationComposer,
|
||||
$$FirstStartTableCreateCompanionBuilder,
|
||||
$$FirstStartTableUpdateCompanionBuilder,
|
||||
(
|
||||
FirstStartData,
|
||||
BaseReferences<_$MewsDatabase, $FirstStartTable, FirstStartData>,
|
||||
),
|
||||
FirstStartData,
|
||||
PrefetchHooks Function()
|
||||
> {
|
||||
$$FirstStartTableTableManager(_$MewsDatabase db, $FirstStartTable table)
|
||||
: super(
|
||||
TableManagerState(
|
||||
db: db,
|
||||
table: table,
|
||||
createFilteringComposer: () =>
|
||||
$$FirstStartTableFilterComposer($db: db, $table: table),
|
||||
createOrderingComposer: () =>
|
||||
$$FirstStartTableOrderingComposer($db: db, $table: table),
|
||||
createComputedFieldComposer: () =>
|
||||
$$FirstStartTableAnnotationComposer($db: db, $table: table),
|
||||
updateCompanionCallback:
|
||||
({
|
||||
Value<int> id = const Value.absent(),
|
||||
Value<bool> directorySeeded = const Value.absent(),
|
||||
}) =>
|
||||
FirstStartCompanion(id: id, directorySeeded: directorySeeded),
|
||||
createCompanionCallback:
|
||||
({
|
||||
Value<int> id = const Value.absent(),
|
||||
Value<bool> directorySeeded = const Value.absent(),
|
||||
}) => FirstStartCompanion.insert(
|
||||
id: id,
|
||||
directorySeeded: directorySeeded,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
typedef $$FirstStartTableProcessedTableManager =
|
||||
ProcessedTableManager<
|
||||
_$MewsDatabase,
|
||||
$FirstStartTable,
|
||||
FirstStartData,
|
||||
$$FirstStartTableFilterComposer,
|
||||
$$FirstStartTableOrderingComposer,
|
||||
$$FirstStartTableAnnotationComposer,
|
||||
$$FirstStartTableCreateCompanionBuilder,
|
||||
$$FirstStartTableUpdateCompanionBuilder,
|
||||
(
|
||||
FirstStartData,
|
||||
BaseReferences<_$MewsDatabase, $FirstStartTable, FirstStartData>,
|
||||
),
|
||||
FirstStartData,
|
||||
PrefetchHooks Function()
|
||||
>;
|
||||
|
||||
class $MewsDatabaseManager {
|
||||
final _$MewsDatabase _db;
|
||||
|
|
@ -5357,4 +5693,6 @@ class $MewsDatabaseManager {
|
|||
$$ClientCertificatesTableTableManager(_db, _db.clientCertificates);
|
||||
$$SiteCertificatesTableTableManager get siteCertificates =>
|
||||
$$SiteCertificatesTableTableManager(_db, _db.siteCertificates);
|
||||
$$FirstStartTableTableManager get firstStart =>
|
||||
$$FirstStartTableTableManager(_db, _db.firstStart);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,11 @@ class AppDependencies {
|
|||
Future<void> start() async {
|
||||
await settings.load();
|
||||
await applyLanguage();
|
||||
// A fresh install starts with the project's directory of sites
|
||||
// bookmarked, so the reader has somewhere to go; it is given once, so
|
||||
// removing it is the reader's own and it never comes back.
|
||||
await db.seedDirectoryBookmark(
|
||||
'https://mews.page/directory', strings.t('bookmarks-directory-title'));
|
||||
await bookmarks.load();
|
||||
await subscriptions.load();
|
||||
await browser.restoreLastSession();
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ history-clear = Verlauf löschen
|
|||
history-clear-confirm = Beim Löschen des Verlaufs werden alle besuchten Seiten entfernt. Das lässt sich nicht rückgängig machen.
|
||||
history-clear-yes = Verlauf jetzt löschen
|
||||
history-clear-no = Verlauf behalten
|
||||
# The pre-set bookmark that names the directory of sites, given on first start.
|
||||
bookmarks-directory-title = Mews-Verzeichnis
|
||||
bookmarks-title = Lesezeichen
|
||||
bookmarks-empty = Hier erscheinen die Seiten, die du als Lesezeichen speicherst.
|
||||
bookmarks-no-matches = Keine Lesezeichen passen zu deiner Suche.
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ history-clear = Clear history
|
|||
history-clear-confirm = Clearing history removes every page you visited. You can't undo this.
|
||||
history-clear-yes = Clear history now
|
||||
history-clear-no = Keep my history
|
||||
# The pre-set bookmark that names the directory of sites, given on first start.
|
||||
bookmarks-directory-title = Mews directory
|
||||
bookmarks-title = Bookmarks
|
||||
bookmarks-empty = Pages you bookmark appear here.
|
||||
bookmarks-no-matches = No bookmarks match your search.
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ history-clear = Borrar el historial
|
|||
history-clear-confirm = Al borrar el historial se eliminan todas las páginas que has visitado. Esto no se puede deshacer.
|
||||
history-clear-yes = Borrar el historial ahora
|
||||
history-clear-no = Conservar mi historial
|
||||
# The pre-set bookmark that names the directory of sites, given on first start.
|
||||
bookmarks-directory-title = Directorio de Mews
|
||||
bookmarks-title = Marcadores
|
||||
bookmarks-empty = Aquí aparecen las páginas que guardas como marcador.
|
||||
bookmarks-no-matches = Ningún marcador coincide con tu búsqueda.
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ history-clear = Effacer l'historique
|
|||
history-clear-confirm = Effacer l'historique supprime toutes les pages visitées. Cette action est définitive.
|
||||
history-clear-yes = Effacer l'historique maintenant
|
||||
history-clear-no = Garder mon historique
|
||||
# The pre-set bookmark that names the directory of sites, given on first start.
|
||||
bookmarks-directory-title = Annuaire Mews
|
||||
bookmarks-title = Signets
|
||||
bookmarks-empty = Les pages que tu mets en signet apparaissent ici.
|
||||
bookmarks-no-matches = Aucun signet ne correspond à ta recherche.
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ history-clear = ניקוי ההיסטוריה
|
|||
history-clear-confirm = ניקוי ההיסטוריה מסיר כל דף שביקרתם בו. אי אפשר לבטל את הפעולה.
|
||||
history-clear-yes = לנקות את ההיסטוריה
|
||||
history-clear-no = להשאיר את ההיסטוריה
|
||||
# The pre-set bookmark that names the directory of sites, given on first start.
|
||||
bookmarks-directory-title = מדריך Mews
|
||||
bookmarks-title = סימניות
|
||||
bookmarks-empty = הדפים שתסמנו יופיעו כאן.
|
||||
bookmarks-no-matches = אין סימניות התואמות לחיפוש.
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ history-clear = 履歴を消去
|
|||
history-clear-confirm = 履歴を消去すると、訪れたページがすべて削除されます。元に戻せません。
|
||||
history-clear-yes = 履歴を消去する
|
||||
history-clear-no = 履歴を残す
|
||||
# The pre-set bookmark that names the directory of sites, given on first start.
|
||||
bookmarks-directory-title = Mewsディレクトリ
|
||||
bookmarks-title = ブックマーク
|
||||
bookmarks-empty = ブックマークしたページがここに表示されます。
|
||||
bookmarks-no-matches = 検索に一致するブックマークはありません。
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ history-clear = Limpar o histórico
|
|||
history-clear-confirm = Limpar o histórico remove todas as páginas que visitaste. Não é possível anular.
|
||||
history-clear-yes = Limpar o histórico agora
|
||||
history-clear-no = Manter o meu histórico
|
||||
# The pre-set bookmark that names the directory of sites, given on first start.
|
||||
bookmarks-directory-title = Diretório Mews
|
||||
bookmarks-title = Marcadores
|
||||
bookmarks-empty = Aqui aparecem as páginas que guardas como marcador.
|
||||
bookmarks-no-matches = Nenhum marcador corresponde à tua procura.
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ history-clear = Очистить историю
|
|||
history-clear-confirm = При очистке истории будут удалены все посещённые страницы. Это действие необратимо.
|
||||
history-clear-yes = Очистить историю
|
||||
history-clear-no = Оставить историю
|
||||
# The pre-set bookmark that names the directory of sites, given on first start.
|
||||
bookmarks-directory-title = Каталог Mews
|
||||
bookmarks-title = Закладки
|
||||
bookmarks-empty = Здесь появятся страницы, которые вы добавите в закладки.
|
||||
bookmarks-no-matches = Ни одна закладка не найдена по запросу.
|
||||
|
|
|
|||
|
|
@ -137,6 +137,97 @@ void main() {
|
|||
expect(find.textContaining('Try again'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a first start gives the directory bookmark once', (tester) async {
|
||||
final db = MewsDatabase.test(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
|
||||
// The first start of the app wires in the directory of sites, named in
|
||||
// the reader's language.
|
||||
var deps = AppDependencies(
|
||||
database: db, fetch: FakeFetch(const {}), strings: strings);
|
||||
await deps.start();
|
||||
expect(await db.allBookmarks(), hasLength(1));
|
||||
final bookmark = (await db.allBookmarks()).single;
|
||||
expect(bookmark.url, 'https://mews.page/directory');
|
||||
expect(bookmark.title, 'Mews directory');
|
||||
expect(deps.bookmarks.isBookmarked(bookmark.url), isTrue);
|
||||
|
||||
// The reader removes it, as any bookmark can be removed.
|
||||
await deps.bookmarks.toggle(bookmark.url, bookmark.title);
|
||||
expect(deps.bookmarks.isBookmarked(bookmark.url), isFalse);
|
||||
|
||||
// A later start does not give it again: what the reader removed stays
|
||||
// removed.
|
||||
deps = AppDependencies(
|
||||
database: db, fetch: FakeFetch(const {}), strings: strings);
|
||||
await deps.start();
|
||||
expect(await db.allBookmarks(), isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('a post a refresh finds arrives unread on the posts page',
|
||||
(tester) async {
|
||||
// The site's index, as gemtext with dated links (spec 6.1).
|
||||
var index = '''
|
||||
# A capsule
|
||||
|
||||
=> a.gmi 2026-10-08 Post a
|
||||
=> b.gmi 2026-09-21 Post b
|
||||
''';
|
||||
final sources = {'gemini://capsule.example': index};
|
||||
final stubDeps = AppDependencies(
|
||||
database: MewsDatabase.test(NativeDatabase.memory()),
|
||||
fetch: FakeFetch(sources),
|
||||
strings: strings,
|
||||
);
|
||||
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Subscribing takes on what the site already published as read.
|
||||
await stubDeps.browser.open('gemini://capsule.example');
|
||||
await tester.pumpAndSettle();
|
||||
await stubDeps.subscriptions.toggle(stubDeps.browser.currentPage.value!);
|
||||
|
||||
// The site publishes a new post, and a refresh finds it.
|
||||
sources['gemini://capsule.example'] = '''
|
||||
# A capsule
|
||||
|
||||
=> a.gmi 2026-10-08 Post a
|
||||
=> b.gmi 2026-09-21 Post b
|
||||
=> c.gmi 2026-10-11 New post
|
||||
''';
|
||||
await stubDeps.subscriptions.refreshAll();
|
||||
|
||||
// On the posts page the new post carries weight and the back catalogue
|
||||
// does not, which is how the page says unread.
|
||||
await stubDeps.browser.open('mews:posts');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Whether a row's glyphs carry bold weight somewhere in their spans.
|
||||
// TextSpan.visitChildren walks whole subtrees, so this walks the children
|
||||
// itself, one level per step.
|
||||
bool bold(String label) {
|
||||
final text = tester.widget<Text>(find.textContaining(label).first);
|
||||
InlineSpan? span = text.textSpan;
|
||||
bool weight(InlineSpan span) {
|
||||
if (span is TextSpan && span.style?.fontWeight == FontWeight.bold) {
|
||||
return true;
|
||||
}
|
||||
final children = span is TextSpan ? span.children : null;
|
||||
if (children == null) return false;
|
||||
for (final child in children) {
|
||||
if (weight(child)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return span != null && weight(span);
|
||||
}
|
||||
|
||||
expect(bold('New post'), isTrue, reason: 'a found post is unread');
|
||||
expect(bold('Post a'), isFalse, reason: 'the back catalogue was read');
|
||||
expect(find.textContaining('1 unread post'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a web page that will not load can open in a web browser',
|
||||
(tester) async {
|
||||
final stubDeps = AppDependencies(
|
||||
|
|
|
|||
|
|
@ -193,6 +193,24 @@ void main() {
|
|||
expect((await db.allPins())['capsule.example:1965'], renewed);
|
||||
});
|
||||
|
||||
test('the directory bookmark is given once and removal keeps it removed',
|
||||
() async {
|
||||
// A fresh install has nothing.
|
||||
expect(await db.directorySeeded(), isFalse);
|
||||
|
||||
// The first start gives the directory of sites.
|
||||
await db.seedDirectoryBookmark('https://mews.page/directory', 'Mews directory');
|
||||
expect(await db.countBookmarks(), 1);
|
||||
expect((await db.allBookmarks()).single.title, 'Mews directory');
|
||||
expect(await db.directorySeeded(), isTrue);
|
||||
|
||||
// Starting again adds nothing, and the reader's removal of it is not
|
||||
// undone by any later start.
|
||||
await db.removeBookmark('https://mews.page/directory');
|
||||
await db.seedDirectoryBookmark('https://mews.page/directory', 'Mews directory');
|
||||
expect(await db.countBookmarks(), 0);
|
||||
});
|
||||
|
||||
test('bindings tie identities to hosts and follow the identity out', () async {
|
||||
final id = await db.addClientCertificate('Reader', 'CERT PEM');
|
||||
await db.bindCertificate('capsule.example', 1965, id);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue