feat: notes in studysets, synced and merged as cards are, with tags
Server: three synced tables, only additions (migration
20261002191122122-notes): `note` (its studyset, a fractional position),
`note_revision` (append-only, as a card's: parents, author, report,
the payload {schemaVersion, title, body}) and `note_tag` (one row per
assignment, never updated, as `card_tag`). Each indexes `spaceId`, and
everything goes with its space, studyset, note or tag. A migration of
synced tables locks app builds from before it out of syncing (ADR 0002,
rule 3): cheap now, before the first release.
App, `feature/note`:
- `NoteContent` is a title and rich text, merged apart (ADR 0003): a
title changed on one device and the body on another both survive, a
body changed on two is a conflict.
- `NoteRepository` creates a note at the end of its studyset, saves an
edit on the revisions it started from (`saveEdit`, as the card
composer now does), says when a note was last edited (its newest
head's UUID v7), counts notes per studyset, and deletes a note's
revisions blanked first (ADR 0002, rule 2).
- Notes share the person's tags: `setTagsOfNote`, and folding tags of
one name or undoing a tag's deletion covers notes too.
- The delayed deletion with its Undo moves to `core/async`
(`DelayedDeletions`): `StudysetDeletions` builds on it and now takes
a studyset's notes too; `NoteDeletions` deletes a single note.
Tests: content, merge, payload, repository, deletions, tags, the device
migration guard, and sync tests against the server (four).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
9639ce8569
commit
f3b56f2c0c
31 changed files with 25469 additions and 131 deletions
|
|
@ -0,0 +1,91 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
/// Deletions that can be undone for a moment (decided 2026-10-01): what is
|
||||
/// deleted leaves every list at once ([pending]), and only once the time to
|
||||
/// undo is over does [_deleteNow] run, since blanking can't be undone (ADR
|
||||
/// 0002, rule 2).
|
||||
///
|
||||
/// It belongs to the person's data, so a deletion outlives the screen it
|
||||
/// started on, and it closes with the person's data: a deletion still
|
||||
/// waiting then is dropped, and what it would have deleted stays.
|
||||
class DelayedDeletions(
|
||||
/// Deletes an item, given its id, for good.
|
||||
final Future<void> Function(String id) _deleteNow, {
|
||||
|
||||
/// Names the log a failed deletion goes to.
|
||||
final String _logName = 'Deletions',
|
||||
|
||||
/// How long a deletion can be undone: the three seconds the message
|
||||
/// offering it shows, and a moment for a late tap.
|
||||
final Duration _undoWindow = const Duration(seconds: 4),
|
||||
}) {
|
||||
/// The deletions that can still be undone, by the item's id.
|
||||
final _waiting = <String, Timer>{};
|
||||
|
||||
/// The deletions under way, too late to undo.
|
||||
final _deleting = <String, Future<void>>{};
|
||||
|
||||
final _changes = StreamController<Set<String>>.broadcast();
|
||||
|
||||
/// The items whose deletion waits or is under way: lists leave them out.
|
||||
Set<String> get pending => {..._waiting.keys, ..._deleting.keys};
|
||||
|
||||
/// [pending], now and after every change.
|
||||
Stream<Set<String>> watchPending() => Stream.multi((listener) {
|
||||
listener.add(pending);
|
||||
final subscription = _changes.stream.listen(listener.add);
|
||||
listener.onCancel = subscription.cancel;
|
||||
});
|
||||
|
||||
/// Deletes the item [id] once the time to undo that is over.
|
||||
void delete(String id) {
|
||||
if (_deleting.containsKey(id)) {
|
||||
return;
|
||||
}
|
||||
_waiting[id]?.cancel();
|
||||
_waiting[id] = Timer(_undoWindow, () => _start(id));
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// Keeps the item [id] after all, while its deletion still waits.
|
||||
void undo(String id) {
|
||||
if (_waiting.remove(id) case final timer?) {
|
||||
timer.cancel();
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the deletions still waiting, and lets those under way finish.
|
||||
Future<void> dispose() async {
|
||||
for (final timer in _waiting.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
_waiting.clear();
|
||||
await Future.wait(_deleting.values);
|
||||
await _changes.close();
|
||||
}
|
||||
|
||||
void _start(String id) {
|
||||
_waiting.remove(id);
|
||||
_deleting[id] = _guarded(id).whenComplete(() {
|
||||
_deleting.remove(id);
|
||||
_changed();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _guarded(String id) async {
|
||||
try {
|
||||
await _deleteNow(id);
|
||||
} on Object catch (error) {
|
||||
// What is left shows again; deleting it once more retries.
|
||||
log('Deleting $id failed', name: _logName, error: error);
|
||||
}
|
||||
}
|
||||
|
||||
void _changed() {
|
||||
if (!_changes.isClosed) {
|
||||
_changes.add(pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,9 @@ import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/sync_note_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
|
|
@ -153,13 +156,18 @@ Future<PersonDependencies> _openPerson(
|
|||
final cards = CardRepository(
|
||||
SyncCardGateway(db, sync, person.authUserId),
|
||||
);
|
||||
final notes = NoteRepository(
|
||||
SyncNoteGateway(db, sync, person.authUserId),
|
||||
);
|
||||
final tags = TagRepository(
|
||||
SyncTagGateway(db, sync),
|
||||
cardChanges: cards.changes,
|
||||
noteChanges: notes.changes,
|
||||
);
|
||||
final settings = SettingsRepository(SyncSettingsGateway(db, sync));
|
||||
final reviews = ReviewRepository(SyncReviewGateway(db, sync));
|
||||
final deletions = StudysetDeletions(studysets, cards);
|
||||
final deletions = StudysetDeletions(studysets, cards, notes);
|
||||
final noteDeletions = NoteDeletions(notes);
|
||||
final assets = AssetRepository(
|
||||
SyncAssetGateway(db, sync),
|
||||
ServerpodAssetServerGateway(client),
|
||||
|
|
@ -170,21 +178,25 @@ Future<PersonDependencies> _openPerson(
|
|||
return PersonDependencies(
|
||||
studysets: studysets,
|
||||
cards: cards,
|
||||
notes: notes,
|
||||
reviews: reviews,
|
||||
tags: tags,
|
||||
settings: settings,
|
||||
assets: assets,
|
||||
deletions: deletions,
|
||||
noteDeletions: noteDeletions,
|
||||
onDispose: () async {
|
||||
try {
|
||||
// Deletions first: those still waiting are dropped, those under way
|
||||
// finish while their repositories are open.
|
||||
await deletions.dispose();
|
||||
await noteDeletions.dispose();
|
||||
await studysets.dispose();
|
||||
await tags.dispose();
|
||||
await settings.dispose();
|
||||
await reviews.dispose();
|
||||
await cards.dispose();
|
||||
await notes.dispose();
|
||||
await assets.dispose();
|
||||
await sync.dispose();
|
||||
await db.close();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
|
|
@ -18,10 +20,13 @@ class PersonDependencies({
|
|||
/// The person's flashcards.
|
||||
required final CardRepository cards,
|
||||
|
||||
/// The person's notes.
|
||||
required final NoteRepository notes,
|
||||
|
||||
/// The person's reviews.
|
||||
required final ReviewRepository reviews,
|
||||
|
||||
/// The person's tags and which cards have them.
|
||||
/// The person's tags and which cards and notes have them.
|
||||
required final TagRepository tags,
|
||||
|
||||
/// The person's settings, which follow them to every device.
|
||||
|
|
@ -32,6 +37,9 @@ class PersonDependencies({
|
|||
|
||||
/// The studyset deletions that can still be undone.
|
||||
required final StudysetDeletions deletions,
|
||||
|
||||
/// The note deletions that can still be undone.
|
||||
required final NoteDeletions noteDeletions,
|
||||
final Future<void> Function()? _onDispose,
|
||||
}) {
|
||||
/// Closes what was opened for the person.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
import 'package:halkyon_learn_flutter/src/core/async/delayed_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
|
||||
/// The person's note deletions, which can be undone for a moment (see
|
||||
/// [DelayedDeletions]). Once the time to undo is over, the note is blanked
|
||||
/// and deleted.
|
||||
class NoteDeletions(NoteRepository notes, {super.undoWindow})
|
||||
extends DelayedDeletions {
|
||||
this : super(notes.delete, logName: 'Notes');
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
/// A note's row as the device's database stores it.
|
||||
typedef NoteRow = ({String id, String position});
|
||||
|
||||
/// A note wherever it is: its id and its studyset.
|
||||
typedef NoteStampRow = ({String id, String studysetId});
|
||||
|
||||
/// A note revision's row as the device's database stores it, with when it
|
||||
/// was written (from its UUID v7 id; null for an id that doesn't say).
|
||||
typedef NoteRevisionRow = ({
|
||||
String id,
|
||||
String noteId,
|
||||
Set<String> parents,
|
||||
ByteData payload,
|
||||
DateTime? writtenAt,
|
||||
});
|
||||
|
||||
/// The boundary to the notes in this person's local, synced database. Tests
|
||||
/// replace it with a fake.
|
||||
abstract interface class NoteGateway() {
|
||||
/// The notes of the studyset [studysetId] on this device, unordered.
|
||||
Future<List<NoteRow>> notesOf(String studysetId);
|
||||
|
||||
/// Every note of the person on this device, unordered.
|
||||
Future<List<NoteStampRow>> allNotes();
|
||||
|
||||
/// The revisions of the notes [noteIds], unordered.
|
||||
Future<List<NoteRevisionRow>> revisionsOf(Set<String> noteIds);
|
||||
|
||||
/// Stores a new note with its first revision; returns both ids.
|
||||
Future<({String noteId, String revisionId})> insertNote({
|
||||
required String studysetId,
|
||||
required String position,
|
||||
required ByteData payload,
|
||||
});
|
||||
|
||||
/// Appends a revision to the note [noteId] that replaces [parents];
|
||||
/// returns its id.
|
||||
Future<String> insertRevision({
|
||||
required String noteId,
|
||||
required Set<String> parents,
|
||||
required ByteData payload,
|
||||
});
|
||||
|
||||
/// Blanks the payload of every revision of the note [noteId].
|
||||
Future<void> blankRevisions(String noteId);
|
||||
|
||||
/// Deletes the note [noteId], its revisions and its tags.
|
||||
Future<void> deleteNote(String noteId);
|
||||
|
||||
/// Fires when a sync brought changes from the person's other devices.
|
||||
Stream<void> get remoteChanges;
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
|
||||
|
||||
// A note revision's content as its synced row stores it (see
|
||||
// core/content/payload). Schema 1: the title and the body.
|
||||
|
||||
/// The schema this app writes.
|
||||
const noteSchemaVersion = 1;
|
||||
|
||||
/// Encodes a note's content into a revision's payload.
|
||||
ByteData encodeNotePayload(NoteContent content) =>
|
||||
encodePayload(noteSchemaVersion, {
|
||||
'title': content.title,
|
||||
'body': content.body.toJson(),
|
||||
});
|
||||
|
||||
/// Decodes a note revision's payload.
|
||||
///
|
||||
/// Throws a [FormatException] for anything else, including content written
|
||||
/// by a newer app version in a schema this one doesn't know.
|
||||
NoteContent decodeNotePayload(ByteData payload) {
|
||||
final json = decodePayload(payload, newestSchema: noteSchemaVersion);
|
||||
if (json case {
|
||||
'title': final String title,
|
||||
'body': final Map<String, Object?> body,
|
||||
}) {
|
||||
return NoteContent(title: title, body: RichContent.fromJson(body));
|
||||
}
|
||||
throw const FormatException('Not a note payload.');
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:fractional_indexing_dart/fractional_indexing_dart.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_merge.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_revision.dart';
|
||||
|
||||
/// The person's notes: content kept as append-only revisions (ADR 0001,
|
||||
/// rule 2), merged title and body apart when devices edited offline (ADR
|
||||
/// 0003), ordered by a fractional index.
|
||||
class NoteRepository(final NoteGateway _gateway) {
|
||||
this {
|
||||
_remoteChanges = _gateway.remoteChanges.listen((_) => _changes.add(null));
|
||||
}
|
||||
|
||||
final _changes = StreamController<void>.broadcast();
|
||||
late final StreamSubscription<void> _remoteChanges;
|
||||
|
||||
/// Fires after every change of any note, made on this device or synced
|
||||
/// from another.
|
||||
Stream<void> get changes => _changes.stream;
|
||||
|
||||
/// The notes of [studysetId] in order, now and after every change, local
|
||||
/// or synced. A read that fails arrives as an error; the next change reads
|
||||
/// again.
|
||||
Stream<List<Note>> watch(String studysetId) => Stream.multi((listener) {
|
||||
var cancelled = false;
|
||||
var reading = Future<void>.value();
|
||||
// One read after another, so an older list never overtakes a newer one.
|
||||
void read() => reading = reading.then((_) async {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final notes = await notesOf(studysetId);
|
||||
if (!cancelled) {
|
||||
listener.add(notes);
|
||||
}
|
||||
} on Object catch (error, st) {
|
||||
log('Reading notes failed', name: 'Notes', error: error);
|
||||
if (!cancelled) {
|
||||
listener.addError(error, st);
|
||||
}
|
||||
}
|
||||
});
|
||||
final subscription = _changes.stream.listen((_) => read());
|
||||
listener.onCancel = () {
|
||||
cancelled = true;
|
||||
return subscription.cancel();
|
||||
};
|
||||
read();
|
||||
});
|
||||
|
||||
/// The notes of [studysetId] on this device right now, in order, read
|
||||
/// once; [watch] follows every change.
|
||||
Future<List<Note>> notesOf(String studysetId) async {
|
||||
final rows = await _gateway.notesOf(studysetId);
|
||||
final revisionsByNote = <String, List<NoteRevision>>{};
|
||||
for (final row in await _gateway.revisionsOf({
|
||||
for (final note in rows) note.id,
|
||||
})) {
|
||||
if (_decode(row) case final revision?) {
|
||||
(revisionsByNote[row.noteId] ??= []).add(revision);
|
||||
}
|
||||
}
|
||||
return [
|
||||
for (final row in rows)
|
||||
if (revisionsByNote[row.id] case final revisions?)
|
||||
if (mergeNoteRevisions(revisions) case final merged?)
|
||||
Note(
|
||||
id: row.id,
|
||||
position: row.position,
|
||||
content: merged.content,
|
||||
heads: merged.heads,
|
||||
conflicts: merged.conflicts,
|
||||
editedAt: _newestOf(revisions, merged.heads)?.writtenAt,
|
||||
),
|
||||
]..sort(
|
||||
(a, b) => switch (a.position.compareTo(b.position)) {
|
||||
0 => a.id.compareTo(b.id),
|
||||
final order => order,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// How many notes each studyset holds, by the studyset's id; studysets
|
||||
/// without notes are left out.
|
||||
Future<Map<String, int>> countsByStudyset() async {
|
||||
final counts = <String, int>{};
|
||||
for (final note in await _gateway.allNotes()) {
|
||||
counts.update(note.studysetId, (count) => count + 1, ifAbsent: () => 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// Creates a note with [content] at the end of [studysetId]; returns its
|
||||
/// id and its first revision's, for the next edit to build on.
|
||||
Future<({String noteId, String revisionId})> create(
|
||||
String studysetId,
|
||||
NoteContent content,
|
||||
) async {
|
||||
String? last;
|
||||
for (final note in await _gateway.notesOf(studysetId)) {
|
||||
if (last == null || note.position.compareTo(last) > 0) {
|
||||
last = note.position;
|
||||
}
|
||||
}
|
||||
final ids = await _gateway.insertNote(
|
||||
studysetId: studysetId,
|
||||
position: FractionalIndexing.generateKeyBetween(last, null),
|
||||
payload: encodeNotePayload(content.normalized),
|
||||
);
|
||||
_changes.add(null);
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// Stores [content] as the next revision of the note [noteId], replacing
|
||||
/// [basedOn]: the revisions the edit started from (all current heads to
|
||||
/// settle a conflict). What another device wrote meanwhile stays a head of
|
||||
/// its own and merges with this one (ADR 0003), so it is never replaced
|
||||
/// unseen. Returns the new revision's id, for the next edit to build on.
|
||||
Future<String> saveEdit(
|
||||
String noteId,
|
||||
NoteContent content, {
|
||||
required Set<String> basedOn,
|
||||
}) async {
|
||||
final id = await _gateway.insertRevision(
|
||||
noteId: noteId,
|
||||
parents: basedOn,
|
||||
payload: encodeNotePayload(content.normalized),
|
||||
);
|
||||
_changes.add(null);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Deletes the note [noteId]. Its revisions are blanked first: the sync
|
||||
/// engine keeps deleted rows until the whole space is deleted (ADR 0002,
|
||||
/// rule 2).
|
||||
Future<void> delete(String noteId) async {
|
||||
await _gateway.blankRevisions(noteId);
|
||||
await _gateway.deleteNote(noteId);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Deletes every note of [studysetId] as [delete] does, before the
|
||||
/// studyset itself goes.
|
||||
Future<void> deleteAllOf(String studysetId) async {
|
||||
for (final note in await _gateway.notesOf(studysetId)) {
|
||||
await _gateway.blankRevisions(note.id);
|
||||
await _gateway.deleteNote(note.id);
|
||||
}
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Stops following remote changes.
|
||||
Future<void> dispose() async {
|
||||
await _remoteChanges.cancel();
|
||||
await _changes.close();
|
||||
}
|
||||
|
||||
/// The newest of [revisions] among [heads] (UUID v7 ids sort by creation),
|
||||
/// or null.
|
||||
NoteRevision? _newestOf(List<NoteRevision> revisions, Set<String> heads) {
|
||||
NoteRevision? newest;
|
||||
for (final revision in revisions) {
|
||||
if (heads.contains(revision.id) &&
|
||||
(newest == null || revision.id.compareTo(newest.id) > 0)) {
|
||||
newest = revision;
|
||||
}
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
/// The revision in [row], or null for one this app can't read: written by
|
||||
/// a newer app version, or blanked.
|
||||
NoteRevision? _decode(NoteRevisionRow row) {
|
||||
try {
|
||||
return NoteRevision(
|
||||
id: row.id,
|
||||
parents: row.parents,
|
||||
content: decodeNotePayload(row.payload),
|
||||
writtenAt: row.writtenAt,
|
||||
);
|
||||
} on FormatException catch (error) {
|
||||
log('Skipped note revision ${row.id}: ${error.message}', name: 'Notes');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [NoteGateway] on the person's local database, which the sync engine keeps
|
||||
/// in step with their other devices. Revisions record [_authorId] as their
|
||||
/// author.
|
||||
class SyncNoteGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
final String _authorId,
|
||||
) implements NoteGateway {
|
||||
@override
|
||||
Future<List<NoteRow>> notesOf(String studysetId) async => [
|
||||
for (final note in await protocol.Note.db.find(
|
||||
_db,
|
||||
where: (t) =>
|
||||
t.studysetId.equals(protocol.UuidValue.withValidation(studysetId)),
|
||||
))
|
||||
(id: note.id!.uuid, position: note.position),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<NoteStampRow>> allNotes() async => [
|
||||
for (final note in await protocol.Note.db.find(_db))
|
||||
(id: note.id!.uuid, studysetId: note.studysetId.uuid),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<NoteRevisionRow>> revisionsOf(Set<String> noteIds) async {
|
||||
if (noteIds.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
return [
|
||||
for (final revision in await protocol.NoteRevision.db.find(
|
||||
_db,
|
||||
where: (t) => t.noteId.inSet({
|
||||
for (final id in noteIds) protocol.UuidValue.withValidation(id),
|
||||
}),
|
||||
))
|
||||
(
|
||||
id: revision.id!.uuid,
|
||||
noteId: revision.noteId.uuid,
|
||||
parents: {for (final parent in revision.parents) parent.uuid},
|
||||
payload: revision.payload,
|
||||
writtenAt: uuidV7Time(revision.id!.uuid),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<({String noteId, String revisionId})> insertNote({
|
||||
required String studysetId,
|
||||
required String position,
|
||||
required ByteData payload,
|
||||
}) async {
|
||||
final note = await protocol.Note.db.insertRow(
|
||||
_db,
|
||||
protocol.Note(
|
||||
studysetId: protocol.UuidValue.withValidation(studysetId),
|
||||
position: position,
|
||||
),
|
||||
);
|
||||
final revisionId = await insertRevision(
|
||||
noteId: note.id!.uuid,
|
||||
parents: const {},
|
||||
payload: payload,
|
||||
);
|
||||
return (noteId: note.id!.uuid, revisionId: revisionId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> insertRevision({
|
||||
required String noteId,
|
||||
required Set<String> parents,
|
||||
required ByteData payload,
|
||||
}) async {
|
||||
final revision = await protocol.NoteRevision.db.insertRow(
|
||||
_db,
|
||||
protocol.NoteRevision(
|
||||
noteId: protocol.UuidValue.withValidation(noteId),
|
||||
parents: [
|
||||
for (final parent in parents)
|
||||
protocol.UuidValue.withValidation(parent),
|
||||
],
|
||||
authorId: protocol.UuidValue.withValidation(_authorId),
|
||||
payload: payload,
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
return revision.id!.uuid;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> blankRevisions(String noteId) async {
|
||||
final revisions = await protocol.NoteRevision.db.find(
|
||||
_db,
|
||||
where: (t) => t.noteId.equals(protocol.UuidValue.withValidation(noteId)),
|
||||
);
|
||||
// A column-scoped update, which syncs like any other edit (ADR 0002,
|
||||
// rule 2).
|
||||
await protocol.NoteRevision.db.update(
|
||||
_db,
|
||||
[
|
||||
for (final revision in revisions)
|
||||
revision.copyWith(payload: redactedPayload),
|
||||
],
|
||||
columns: (t) => [t.payload],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteNote(String noteId) async {
|
||||
final id = protocol.UuidValue.withValidation(noteId);
|
||||
await protocol.NoteTag.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.noteId.equals(id),
|
||||
);
|
||||
await protocol.NoteRevision.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.noteId.equals(id),
|
||||
);
|
||||
await protocol.Note.db.deleteWhere(_db, where: (t) => t.id.equals(id));
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _sync.merges;
|
||||
}
|
||||
40
halkyon_learn_flutter/lib/src/feature/note/model/note.dart
Normal file
40
halkyon_learn_flutter/lib/src/feature/note/model/note.dart
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
|
||||
|
||||
/// A note as the app shows it: its revisions merged (`mergeNoteRevisions`).
|
||||
final class const Note({
|
||||
/// A UUID v7, made on the device that created it.
|
||||
required final String id,
|
||||
|
||||
/// Where the note stands among its studyset's notes: a fractional index.
|
||||
required final String position,
|
||||
|
||||
/// The current content. For a field in [conflicts], the newest version
|
||||
/// shows until the person chooses.
|
||||
required final NoteContent content,
|
||||
|
||||
/// The revisions its content is merged from: all current heads.
|
||||
required final Set<String> heads,
|
||||
|
||||
/// The fields changed differently on different devices, with every
|
||||
/// version, oldest first.
|
||||
final Map<NoteField, List<Object?>> conflicts = const {},
|
||||
|
||||
/// When it was last edited, here or on another device: its newest
|
||||
/// revision; null when the revision doesn't say.
|
||||
final DateTime? editedAt,
|
||||
}) with Equatable {
|
||||
/// Whether devices changed a field differently and the person has to
|
||||
/// choose.
|
||||
bool get hasConflict => conflicts.isNotEmpty;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
position,
|
||||
content,
|
||||
heads,
|
||||
conflicts,
|
||||
editedAt,
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
|
||||
/// The parts of a note that merge separately when devices edited it while
|
||||
/// offline (ADR 0003): a title changed on one device and the body on another
|
||||
/// both survive.
|
||||
enum NoteField() {
|
||||
/// The title.
|
||||
title,
|
||||
|
||||
/// The text.
|
||||
body,
|
||||
}
|
||||
|
||||
/// What a note holds: a title and rich text.
|
||||
final class const NoteContent({
|
||||
/// The title; empty for an untitled note.
|
||||
final String title = '',
|
||||
|
||||
/// The text.
|
||||
required final RichContent body,
|
||||
}) with Equatable implements MergeableContent<NoteField, NoteContent> {
|
||||
/// A note with no title and no text.
|
||||
factory empty() => NoteContent(body: RichContent.empty());
|
||||
|
||||
@override
|
||||
Object? operator [](NoteField field) => switch (field) {
|
||||
NoteField.title => title,
|
||||
NoteField.body => body,
|
||||
};
|
||||
|
||||
@override
|
||||
NoteContent withField(NoteField field, Object? value) => NoteContent(
|
||||
title: field == NoteField.title ? value! as String : title,
|
||||
body: field == NoteField.body ? value! as RichContent : body,
|
||||
);
|
||||
|
||||
/// Whether it holds nothing: no title and no text.
|
||||
bool get isBlank => title.trim().isEmpty && body.isBlank;
|
||||
|
||||
/// This content as it is stored: the title without surrounding spaces.
|
||||
NoteContent get normalized => NoteContent(title: title.trim(), body: body);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [title, body];
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_revision.dart';
|
||||
|
||||
/// A note's content, merged from its revisions by [mergeNoteRevisions].
|
||||
typedef MergedNote = Merged<NoteField, NoteContent>;
|
||||
|
||||
/// Merges a note's [revisions] title and body apart, or null without any:
|
||||
/// see [mergeRevisionsOf].
|
||||
MergedNote? mergeNoteRevisions(Iterable<NoteRevision> revisions) =>
|
||||
mergeRevisionsOf(revisions, fields: NoteField.values);
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
|
||||
|
||||
/// One version of a note's content, never changed once written (ADR 0001,
|
||||
/// rule 2): editing appends a revision whose [parents] are the versions the
|
||||
/// edit started from.
|
||||
final class const NoteRevision({
|
||||
/// A UUID v7, made on the device that wrote it; ids sort by creation.
|
||||
@override required final String id,
|
||||
|
||||
/// The revisions this one replaces: none for a note's first, several when
|
||||
/// it resolves a conflict.
|
||||
@override required final Set<String> parents,
|
||||
|
||||
/// The note's content as of this revision.
|
||||
@override required final NoteContent content,
|
||||
|
||||
/// When it was written; null for an id that doesn't say.
|
||||
final DateTime? writtenAt,
|
||||
}) with Equatable implements Revision<NoteContent> {
|
||||
@override
|
||||
List<Object?> get props => [id, parents, content, writtenAt];
|
||||
}
|
||||
|
|
@ -1,102 +1,37 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/core/async/delayed_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
|
||||
/// The person's studyset deletions, which can be undone for a moment
|
||||
/// (decided 2026-10-01). A deleted studyset leaves every list at once; only
|
||||
/// once the time to undo is over are the studysets of its subtree and their
|
||||
/// cards blanked and deleted, since blanking can't be undone (ADR 0002, rule
|
||||
/// 2).
|
||||
///
|
||||
/// It belongs to the person's data, so a deletion outlives the screen it
|
||||
/// started on (deleting a studyset from its own screen closes that screen),
|
||||
/// and it closes with the person's data: a deletion still waiting then is
|
||||
/// dropped, and the studyset stays.
|
||||
/// The person's studyset deletions, which can be undone for a moment (see
|
||||
/// [DelayedDeletions]). Once the time to undo is over, the studysets of the
|
||||
/// deleted one's subtree are blanked and deleted with their cards and notes.
|
||||
class StudysetDeletions(
|
||||
final StudysetRepository _studysets,
|
||||
final CardRepository _cards, {
|
||||
StudysetRepository studysets,
|
||||
CardRepository cards,
|
||||
NoteRepository notes, {
|
||||
super.undoWindow,
|
||||
}) extends DelayedDeletions {
|
||||
this
|
||||
: super(
|
||||
(id) => _deleteSubtree(id, studysets, cards, notes),
|
||||
logName: 'Studysets',
|
||||
);
|
||||
}
|
||||
|
||||
/// How long a deletion can be undone: the three seconds the message
|
||||
/// offering it shows, and a moment for a late tap.
|
||||
final Duration _undoWindow = const Duration(seconds: 4),
|
||||
}) {
|
||||
/// The deletions that can still be undone, by the studyset's id.
|
||||
final _waiting = <String, Timer>{};
|
||||
|
||||
/// The deletions under way, too late to undo.
|
||||
final _deleting = <String, Future<void>>{};
|
||||
|
||||
final _changes = StreamController<Set<String>>.broadcast();
|
||||
|
||||
/// The studysets whose deletion waits or is under way: lists leave them
|
||||
/// out, with everything inside them.
|
||||
Set<String> get pending => {..._waiting.keys, ..._deleting.keys};
|
||||
|
||||
/// [pending], now and after every change.
|
||||
Stream<Set<String>> watchPending() => Stream.multi((listener) {
|
||||
listener.add(pending);
|
||||
final subscription = _changes.stream.listen(listener.add);
|
||||
listener.onCancel = subscription.cancel;
|
||||
});
|
||||
|
||||
/// Deletes the studyset [id] with everything inside it, once the time to
|
||||
/// undo that is over.
|
||||
void delete(String id) {
|
||||
if (_deleting.containsKey(id)) {
|
||||
return;
|
||||
}
|
||||
_waiting[id]?.cancel();
|
||||
_waiting[id] = Timer(_undoWindow, () => _deleteNow(id));
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// Keeps the studyset [id] after all, while its deletion still waits.
|
||||
void undo(String id) {
|
||||
if (_waiting.remove(id) case final timer?) {
|
||||
timer.cancel();
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the deletions still waiting, and lets those under way finish.
|
||||
Future<void> dispose() async {
|
||||
for (final timer in _waiting.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
_waiting.clear();
|
||||
await Future.wait(_deleting.values);
|
||||
await _changes.close();
|
||||
}
|
||||
|
||||
void _deleteNow(String id) {
|
||||
_waiting.remove(id);
|
||||
_deleting[id] = _deleteSubtree(id).whenComplete(() {
|
||||
_deleting.remove(id);
|
||||
_changed();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _deleteSubtree(String id) async {
|
||||
try {
|
||||
// What is inside first, so no studyset is ever left without its
|
||||
// parent; each studyset's cards before it, so no card content
|
||||
// outlives its studyset.
|
||||
for (final studysetId in subtreeOf(id, await _studysets.all()).reversed) {
|
||||
await _cards.deleteAllOf(studysetId);
|
||||
await _studysets.delete(studysetId);
|
||||
}
|
||||
} on Object catch (error) {
|
||||
// What is left shows again; deleting it once more retries.
|
||||
log('Deleting studyset $id failed', name: 'Studysets', error: error);
|
||||
}
|
||||
}
|
||||
|
||||
void _changed() {
|
||||
if (!_changes.isClosed) {
|
||||
_changes.add(pending);
|
||||
}
|
||||
Future<void> _deleteSubtree(
|
||||
String id,
|
||||
StudysetRepository studysets,
|
||||
CardRepository cards,
|
||||
NoteRepository notes,
|
||||
) async {
|
||||
// What is inside first, so no studyset is ever left without its parent;
|
||||
// each studyset's cards and notes before it, so no content outlives its
|
||||
// studyset.
|
||||
for (final studysetId in subtreeOf(id, await studysets.all()).reversed) {
|
||||
await cards.deleteAllOf(studysetId);
|
||||
await notes.deleteAllOf(studysetId);
|
||||
await studysets.delete(studysetId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,16 @@ class SyncTagGateway(
|
|||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<NoteTagRow>> noteTags() async => [
|
||||
for (final noteTag in await protocol.NoteTag.db.find(_db))
|
||||
(
|
||||
id: noteTag.id!.uuid,
|
||||
noteId: noteTag.noteId.uuid,
|
||||
tagId: noteTag.tagId.uuid,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<String> insertTag(ByteData payload) async {
|
||||
final tag = await protocol.Tag.db.insertRow(
|
||||
|
|
@ -58,6 +68,10 @@ class SyncTagGateway(
|
|||
_db,
|
||||
where: (t) => t.tagId.equals(_uuid(id)),
|
||||
);
|
||||
await protocol.NoteTag.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.tagId.equals(_uuid(id)),
|
||||
);
|
||||
await protocol.Tag.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.id.equals(_uuid(id)),
|
||||
|
|
@ -95,6 +109,36 @@ class SyncTagGateway(
|
|||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insertNoteTags(
|
||||
Iterable<({String noteId, String tagId})> assignments,
|
||||
) async {
|
||||
final rows = [
|
||||
for (final assignment in assignments)
|
||||
protocol.NoteTag(
|
||||
noteId: _uuid(assignment.noteId),
|
||||
tagId: _uuid(assignment.tagId),
|
||||
),
|
||||
];
|
||||
if (rows.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await protocol.NoteTag.db.insert(_db, rows);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteNoteTags(Set<String> ids) async {
|
||||
if (ids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await protocol.NoteTag.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.id.inSet({for (final id in ids) _uuid(id)}),
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _sync.merges;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ typedef TagRow = ({String id, ByteData payload});
|
|||
/// assignment, never updated (ADR 0004, rule 2).
|
||||
typedef CardTagRow = ({String id, String cardId, String tagId});
|
||||
|
||||
/// A note having a tag, as the device's database stores it: one row per
|
||||
/// assignment, never updated, as for cards.
|
||||
typedef NoteTagRow = ({String id, String noteId, String tagId});
|
||||
|
||||
/// The boundary to the tags in this person's local, synced database. Tests
|
||||
/// replace it with a fake.
|
||||
abstract interface class TagGateway() {
|
||||
|
|
@ -16,13 +20,16 @@ abstract interface class TagGateway() {
|
|||
/// Every card's tags on this device, unordered.
|
||||
Future<List<CardTagRow>> cardTags();
|
||||
|
||||
/// Every note's tags on this device, unordered.
|
||||
Future<List<NoteTagRow>> noteTags();
|
||||
|
||||
/// Stores a new tag and returns its id, a UUID v7 made on the device.
|
||||
Future<String> insertTag(ByteData payload);
|
||||
|
||||
/// Replaces a tag's payload.
|
||||
Future<void> updateTagPayload(String id, ByteData payload);
|
||||
|
||||
/// Deletes the tag [id] and every card's assignment of it.
|
||||
/// Deletes the tag [id] and every card's and note's assignment of it.
|
||||
Future<void> deleteTag(String id);
|
||||
|
||||
/// Gives each card in [assignments] its tag.
|
||||
|
|
@ -33,6 +40,14 @@ abstract interface class TagGateway() {
|
|||
/// Deletes the assignments [ids].
|
||||
Future<void> deleteCardTags(Set<String> ids);
|
||||
|
||||
/// Gives each note in [assignments] its tag.
|
||||
Future<void> insertNoteTags(
|
||||
Iterable<({String noteId, String tagId})> assignments,
|
||||
);
|
||||
|
||||
/// Deletes the note assignments [ids].
|
||||
Future<void> deleteNoteTags(Set<String> ids);
|
||||
|
||||
/// Fires when a sync brought changes from the person's other devices.
|
||||
Stream<void> get remoteChanges;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,25 +9,35 @@ import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
||||
|
||||
/// A deleted tag, as much of it as undoing the deletion brings back.
|
||||
typedef DeletedTag = ({String name, String colorName, Set<String> cardIds});
|
||||
typedef DeletedTag = ({
|
||||
String name,
|
||||
String colorName,
|
||||
Set<String> cardIds,
|
||||
Set<String> noteIds,
|
||||
});
|
||||
|
||||
/// The person's tags and which cards have them: read from this device,
|
||||
/// changed offline, and synced with their other devices in the background.
|
||||
/// The person's tags and which cards and notes have them: read from this
|
||||
/// device, changed offline, and synced with their other devices in the
|
||||
/// background. Cards and notes share the tags.
|
||||
///
|
||||
/// A tag is its name (ADR 0004, rule 2). Two tags of one name, made on two
|
||||
/// devices offline or by renaming one, fold into the one with the smallest
|
||||
/// id: it gets the other's cards, and the other is deleted. Every device picks
|
||||
/// the same one, so they all end up alike.
|
||||
/// id: it gets the other's cards and notes, and the other is deleted. Every
|
||||
/// device picks the same one, so they all end up alike.
|
||||
class TagRepository(
|
||||
final TagGateway _gateway, {
|
||||
|
||||
/// Fires when cards change; a deleted card takes its tags along.
|
||||
Stream<void> cardChanges = const Stream.empty(),
|
||||
|
||||
/// Fires when notes change; a deleted note takes its tags along.
|
||||
Stream<void> noteChanges = const Stream.empty(),
|
||||
}) {
|
||||
this {
|
||||
_subscriptions = [
|
||||
_gateway.remoteChanges.listen((_) => unawaited(_refresh())),
|
||||
cardChanges.listen((_) => unawaited(_refresh())),
|
||||
noteChanges.listen((_) => unawaited(_refresh())),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -110,6 +120,30 @@ class TagRepository(
|
|||
await _refresh();
|
||||
}
|
||||
|
||||
/// Gives the note [noteId] exactly the tags [tagIds]: the missing ones
|
||||
/// are added, the others taken away. Tags gone in the meantime are left
|
||||
/// out.
|
||||
Future<void> setTagsOfNote(String noteId, Set<String> tagIds) async {
|
||||
await _serially(() async {
|
||||
final tagging = await _read();
|
||||
final rows = [
|
||||
for (final row in await _gateway.noteTags())
|
||||
if (row.noteId == noteId) row,
|
||||
];
|
||||
final has = {for (final row in rows) row.tagId};
|
||||
await _gateway.deleteNoteTags({
|
||||
for (final row in rows)
|
||||
if (!tagIds.contains(row.tagId)) row.id,
|
||||
});
|
||||
await _gateway.insertNoteTags([
|
||||
for (final tagId in tagIds)
|
||||
if (!has.contains(tagId) && tagging.byId(tagId) != null)
|
||||
(noteId: noteId, tagId: tagId),
|
||||
]);
|
||||
});
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
/// Gives cards more tags at once: each card in [tagIdsByCard] the tags
|
||||
/// listed for it that it hasn't got yet. Tags gone in the meantime are left
|
||||
/// out.
|
||||
|
|
@ -129,7 +163,8 @@ class TagRepository(
|
|||
await _refresh();
|
||||
}
|
||||
|
||||
/// Deletes the tag [id] from every card and then itself; returns what
|
||||
/// Deletes the tag [id] from every card and note and then itself; returns
|
||||
/// what
|
||||
/// [restore] needs to undo that. Its name is blanked first: the sync
|
||||
/// engine keeps deleted rows until the whole space is deleted (ADR 0002,
|
||||
/// rule 2).
|
||||
|
|
@ -138,30 +173,43 @@ class TagRepository(
|
|||
final tagging = await _read();
|
||||
final tag = tagging.byId(id);
|
||||
if (tag == null) {
|
||||
return (name: '', colorName: defaultTagColorName, cardIds: <String>{});
|
||||
return (
|
||||
name: '',
|
||||
colorName: defaultTagColorName,
|
||||
cardIds: <String>{},
|
||||
noteIds: <String>{},
|
||||
);
|
||||
}
|
||||
await _deleteTag(id);
|
||||
return (
|
||||
name: tag.name,
|
||||
colorName: tag.colorName,
|
||||
cardIds: tagging.cardsWith(id),
|
||||
noteIds: tagging.notesWith(id),
|
||||
);
|
||||
});
|
||||
await _refresh();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// Undoes deleting a tag: a tag of its name and colour, on its cards.
|
||||
/// Undoes deleting a tag: a tag of its name and colour, on its cards and
|
||||
/// notes.
|
||||
Future<void> restore(DeletedTag deleted) async {
|
||||
if (deleted.name.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await _serially(() async {
|
||||
final id = await _idOf(deleted.name, deleted.colorName);
|
||||
final has = (await _read()).cardsWith(id);
|
||||
final tagging = await _read();
|
||||
final hasCards = tagging.cardsWith(id);
|
||||
await _gateway.insertCardTags([
|
||||
for (final cardId in deleted.cardIds)
|
||||
if (!has.contains(cardId)) (cardId: cardId, tagId: id),
|
||||
if (!hasCards.contains(cardId)) (cardId: cardId, tagId: id),
|
||||
]);
|
||||
final hasNotes = tagging.notesWith(id);
|
||||
await _gateway.insertNoteTags([
|
||||
for (final noteId in deleted.noteIds)
|
||||
if (!hasNotes.contains(noteId)) (noteId: noteId, tagId: id),
|
||||
]);
|
||||
});
|
||||
await _refresh();
|
||||
|
|
@ -202,7 +250,8 @@ class TagRepository(
|
|||
Future<Tagging> _read() async {
|
||||
final tags = [for (final row in await _gateway.tags()) ?_decode(row)];
|
||||
final rows = await _gateway.cardTags();
|
||||
if (await _fold(tags, rows)) {
|
||||
final noteRows = await _gateway.noteTags();
|
||||
if (await _fold(tags, rows, noteRows)) {
|
||||
return await _read();
|
||||
}
|
||||
final known = {for (final tag in tags) tag.id};
|
||||
|
|
@ -212,15 +261,26 @@ class TagRepository(
|
|||
(tagIdsByCard[row.cardId] ??= {}).add(row.tagId);
|
||||
}
|
||||
}
|
||||
final tagIdsByNote = <String, Set<String>>{};
|
||||
for (final row in noteRows) {
|
||||
if (known.contains(row.tagId)) {
|
||||
(tagIdsByNote[row.noteId] ??= {}).add(row.tagId);
|
||||
}
|
||||
}
|
||||
return Tagging(
|
||||
tags..sort((a, b) => a.name.compareTo(b.name)),
|
||||
tagIdsByCard,
|
||||
tagIdsByNote,
|
||||
);
|
||||
}
|
||||
|
||||
/// Folds every name's tags into the one with the smallest id; returns
|
||||
/// whether there were any to fold.
|
||||
Future<bool> _fold(List<Tag> tags, List<CardTagRow> rows) async {
|
||||
Future<bool> _fold(
|
||||
List<Tag> tags,
|
||||
List<CardTagRow> rows,
|
||||
List<NoteTagRow> noteRows,
|
||||
) async {
|
||||
final byName = <String, List<Tag>>{};
|
||||
for (final tag in tags) {
|
||||
(byName[tag.name] ??= []).add(tag);
|
||||
|
|
@ -245,6 +305,17 @@ class TagRepository(
|
|||
})
|
||||
if (!kept.contains(cardId)) (cardId: cardId, tagId: keeper.id),
|
||||
]);
|
||||
final keptNotes = {
|
||||
for (final row in noteRows)
|
||||
if (row.tagId == keeper.id) row.noteId,
|
||||
};
|
||||
await _gateway.insertNoteTags([
|
||||
for (final noteId in {
|
||||
for (final row in noteRows)
|
||||
if (others.contains(row.tagId)) row.noteId,
|
||||
})
|
||||
if (!keptNotes.contains(noteId)) (noteId: noteId, tagId: keeper.id),
|
||||
]);
|
||||
for (final other in others) {
|
||||
await _deleteTag(other);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
|
||||
/// The person's tagging: their tags and the cards that have them, as read at
|
||||
/// one moment.
|
||||
/// The person's tagging: their tags and the cards and notes that have them,
|
||||
/// as read at one moment.
|
||||
final class const Tagging(
|
||||
/// Every tag, by name.
|
||||
final List<Tag> all,
|
||||
|
||||
/// The ids of the tags each card has, by the card's id.
|
||||
final Map<String, Set<String>> _tagIdsByCard,
|
||||
) with Equatable {
|
||||
final Map<String, Set<String>> _tagIdsByCard, [
|
||||
|
||||
/// The ids of the tags each note has, by the note's id.
|
||||
final Map<String, Set<String>> _tagIdsByNote = const {},
|
||||
]) with Equatable {
|
||||
/// No tags at all.
|
||||
static const empty = Tagging([], {});
|
||||
|
||||
|
|
@ -24,13 +27,10 @@ final class const Tagging(
|
|||
}
|
||||
|
||||
/// The tags the card [cardId] has, by name.
|
||||
List<Tag> of(String cardId) {
|
||||
final ids = _tagIdsByCard[cardId] ?? const {};
|
||||
return [
|
||||
for (final tag in all)
|
||||
if (ids.contains(tag.id)) tag,
|
||||
];
|
||||
}
|
||||
List<Tag> of(String cardId) => _tagsIn(_tagIdsByCard[cardId]);
|
||||
|
||||
/// The tags the note [noteId] has, by name.
|
||||
List<Tag> ofNote(String noteId) => _tagsIn(_tagIdsByNote[noteId]);
|
||||
|
||||
/// The ids of the tags [refs] refer to: by id, or by name once the id is
|
||||
/// gone. So a renamed tag is still found, and so is one folded into another
|
||||
|
|
@ -45,6 +45,18 @@ final class const Tagging(
|
|||
if (ids.contains(tagId)) card,
|
||||
};
|
||||
|
||||
/// The notes that have the tag [tagId].
|
||||
Set<String> notesWith(String tagId) => {
|
||||
for (final MapEntry(key: note, value: ids) in _tagIdsByNote.entries)
|
||||
if (ids.contains(tagId)) note,
|
||||
};
|
||||
|
||||
/// The tags with the ids [ids], by name.
|
||||
List<Tag> _tagsIn(Set<String>? ids) => [
|
||||
for (final tag in all)
|
||||
if (ids?.contains(tag.id) ?? false) tag,
|
||||
];
|
||||
|
||||
@override
|
||||
List<Object?> get props => [all, _tagIdsByCard];
|
||||
List<Object?> get props => [all, _tagIdsByCard, _tagIdsByNote];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.
|
|||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
|
|
@ -15,6 +17,7 @@ import 'package:material_ui/material_ui.dart';
|
|||
import '../../support/fake_asset_gateway.dart';
|
||||
import '../../support/fake_asset_server_gateway.dart';
|
||||
import '../../support/fake_card_gateway.dart';
|
||||
import '../../support/fake_note_gateway.dart';
|
||||
import '../../support/fake_review_gateway.dart';
|
||||
import '../../support/fake_settings_gateway.dart';
|
||||
import '../../support/fake_studyset_gateway.dart';
|
||||
|
|
@ -26,9 +29,11 @@ import '../../support/test_dependencies.dart';
|
|||
PersonDependencies personDependencies({Future<void> Function()? onDispose}) {
|
||||
final studysets = StudysetRepository(FakeStudysetGateway());
|
||||
final cards = CardRepository(FakeCardGateway());
|
||||
final notes = NoteRepository(FakeNoteGateway());
|
||||
return PersonDependencies(
|
||||
studysets: studysets,
|
||||
cards: cards,
|
||||
notes: notes,
|
||||
reviews: ReviewRepository(FakeReviewGateway()),
|
||||
tags: TagRepository(FakeTagGateway()),
|
||||
settings: SettingsRepository(FakeSettingsGateway()),
|
||||
|
|
@ -37,7 +42,8 @@ PersonDependencies personDependencies({Future<void> Function()? onDispose}) {
|
|||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
),
|
||||
deletions: StudysetDeletions(studysets, cards),
|
||||
deletions: StudysetDeletions(studysets, cards, notes),
|
||||
noteDeletions: NoteDeletions(notes),
|
||||
onDispose: onDispose,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ Map<String, int> _fillSyncedTables(Database db) {
|
|||
final studyset = _id();
|
||||
final card = _id();
|
||||
final tag = _id();
|
||||
final note = _id();
|
||||
final rows = <String, (String, List<Object?>)>{
|
||||
'studyset': (
|
||||
"INSERT INTO studyset (id, spaceId, payload) VALUES (?, 1, x'00')",
|
||||
|
|
@ -91,6 +92,20 @@ Map<String, int> _fillSyncedTables(Database db) {
|
|||
"VALUES (?, 1, 'ab', 'image/png', 1, x'00')",
|
||||
[_id()],
|
||||
),
|
||||
'note': (
|
||||
'INSERT INTO note (id, spaceId, studysetId, position) '
|
||||
"VALUES (?, 1, ?, 'a0')",
|
||||
[note, studyset],
|
||||
),
|
||||
'note_revision': (
|
||||
'INSERT INTO note_revision (id, spaceId, noteId, parents, authorId, '
|
||||
"payload) VALUES (?, 1, ?, '[]', ?, x'00')",
|
||||
[_id(), note, _id()],
|
||||
),
|
||||
'note_tag': (
|
||||
'INSERT INTO note_tag (id, spaceId, noteId, tagId) VALUES (?, 1, ?, ?)',
|
||||
[_id(), note, tag],
|
||||
),
|
||||
};
|
||||
|
||||
final synced = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
|
||||
|
||||
import '../../../support/eventually.dart';
|
||||
import '../../../support/fake_note_gateway.dart';
|
||||
|
||||
void main() {
|
||||
late FakeNoteGateway gateway;
|
||||
late NoteRepository notes;
|
||||
late NoteDeletions deletions;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeNoteGateway();
|
||||
notes = NoteRepository(gateway);
|
||||
deletions = NoteDeletions(
|
||||
notes,
|
||||
undoWindow: const Duration(milliseconds: 50),
|
||||
);
|
||||
});
|
||||
tearDown(() async {
|
||||
await deletions.dispose();
|
||||
await notes.dispose();
|
||||
});
|
||||
|
||||
Future<String> note() async => (await notes.create(
|
||||
'japanese',
|
||||
NoteContent(title: 'Particles', body: RichContent.plain('は')),
|
||||
)).noteId;
|
||||
|
||||
test('a deletion waits for the time to undo, then blanks the note and '
|
||||
'deletes it', () async {
|
||||
final id = await note();
|
||||
|
||||
deletions.delete(id);
|
||||
expect(deletions.pending, {id});
|
||||
expect(gateway.deletions, isEmpty);
|
||||
|
||||
await eventually(() => deletions.pending.isEmpty);
|
||||
expect(gateway.deletions, ['blank $id', 'delete $id']);
|
||||
expect(await notes.notesOf('japanese'), isEmpty);
|
||||
});
|
||||
|
||||
test('undoing in time keeps the note', () async {
|
||||
final id = await note();
|
||||
|
||||
deletions
|
||||
..delete(id)
|
||||
..undo(id);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(deletions.pending, isEmpty);
|
||||
expect(gateway.deletions, isEmpty);
|
||||
expect(await notes.notesOf('japanese'), hasLength(1));
|
||||
});
|
||||
|
||||
test('closing drops what still waits', () async {
|
||||
final id = await note();
|
||||
|
||||
deletions.delete(id);
|
||||
await deletions.dispose();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(gateway.deletions, isEmpty);
|
||||
deletions = NoteDeletions(notes);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue