refactor(content,core,app,editor,learn): Learn's content in pure Dart, for its agent
ADR 0044's D49 (amended in building it): what reads and seals Learn's content moves out of the Flutter packages, so Learn's agent, which runs without Flutter, shares it rather than repeating the privacy rules. packages/halkyon_content (new, pure Dart) takes from halkyon_app the envelope and its cipher, SealedFile, UnlockedAccountKeys, shared spaces' gateway with SharedKeys and KeyPins, FileCache and FileServerGateway, with their fakes and tests; halkyon_app exports it, so no app changes. Its rich_text.dart takes RichContent from halkyon_editor, which exports it. newAccountKeys makes an agent's key pair. halkyon_core takes DelayedDeletions and gains SyncRequests, what a synced gateway asks of the sync, which SyncService implements. apps/learn/halkyon_learn_core (new, pure Dart) takes Learn's privacy, sync, payloads and the models, codecs, gateways and repositories of studysets, cards, notes, tags and feedback, with what they reach of files, learning paths, citations and study options; the app imports them from there. A subject's icon, colour and name stay in the app (SubjectLook); what only the app shows (progress, exam coverage, the card browser, deletions' waits) stays too. Nothing changes for people using the apps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
82c777b1a5
commit
d09625d43e
436 changed files with 3517 additions and 2007 deletions
|
|
@ -1,6 +1,8 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_core/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_core/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/widget/studyset_files.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
|
||||
|
|
@ -38,8 +40,6 @@ import 'package:halkyon_learn_flutter/src/feature/sharing/widget/marks_share_see
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import 'dart:developer';
|
|||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_core/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/sound_player.dart';
|
||||
|
||||
/// One sound in content: plays it when asked, and stops it again; it also
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
/// An asset row as stored: the file's id, SHA-256 (lowercase hex), media type
|
||||
/// and size, and its payload, opened. A file of a private studyset (ADR
|
||||
/// 0019) is sealed with its tree's key, `keyId`; the row's hash and size are
|
||||
/// then the sealed file's, and `locked` where that key doesn't open here
|
||||
/// (its payload then blank).
|
||||
typedef AssetRow = ({
|
||||
String id,
|
||||
String sha256,
|
||||
String mimeType,
|
||||
int byteSize,
|
||||
ByteData payload,
|
||||
String? keyId,
|
||||
bool locked,
|
||||
});
|
||||
|
||||
/// The person's asset rows, one per file of their content (ADR 0004,
|
||||
/// rule 5); the files themselves are on the server and in the
|
||||
/// device's cache.
|
||||
abstract interface class AssetGateway() {
|
||||
/// The row [id]; null if there is none.
|
||||
Future<AssetRow?> byId(String id);
|
||||
|
||||
/// A row of the file with [sha256]; null if there is none.
|
||||
Future<AssetRow?> bySha256(String sha256);
|
||||
|
||||
/// Every row, sealed or readable.
|
||||
Future<List<AssetRow>> all();
|
||||
|
||||
/// An id for a new row, made before it is stored, as a sealed file is
|
||||
/// sealed for its row.
|
||||
String newId();
|
||||
|
||||
/// Stores a new row [id], its payload sealed with [keyId] (readable
|
||||
/// without), and returns its id.
|
||||
Future<String> insert({
|
||||
required String id,
|
||||
required String sha256,
|
||||
required String mimeType,
|
||||
required int byteSize,
|
||||
required ByteData payload,
|
||||
String? keyId,
|
||||
});
|
||||
|
||||
/// Blanks the row [id]'s payload, then deletes the row: its file goes from
|
||||
/// the server once nothing refers to it any more.
|
||||
Future<void> delete(String id);
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
|
||||
// A file's content as its synced asset row stores it (see core/content/payload);
|
||||
// the hash, media type and size are columns of their own.
|
||||
//
|
||||
// Schema 1 holds the name the file came with.
|
||||
// Schema 2 adds, for a file of a private studyset (ADR 0019), `plain`: the
|
||||
// SHA-256 of the file before it was sealed, which the row's own hash, of
|
||||
// the sealed file, doesn't tell; it lives only in this payload, sealed too.
|
||||
|
||||
/// The schema this app writes for a readable file.
|
||||
const assetSchemaVersion = 1;
|
||||
|
||||
/// The newest schema this app reads.
|
||||
const _newestAssetSchema = 2;
|
||||
|
||||
/// What a file's payload holds.
|
||||
typedef AssetFileInfo = ({String fileName, String? plainSha256});
|
||||
|
||||
/// Encodes a file's [fileName] into its payload; for a sealed file also the
|
||||
/// SHA-256 of what was sealed ([plainSha256]).
|
||||
ByteData encodeAssetPayload({
|
||||
required String fileName,
|
||||
String? plainSha256,
|
||||
}) => plainSha256 == null
|
||||
? encodePayload(assetSchemaVersion, {'fileName': fileName})
|
||||
: encodePayload(2, {'fileName': fileName, 'plain': plainSha256});
|
||||
|
||||
/// Decodes a file's payload into the name it came with.
|
||||
///
|
||||
/// Throws a [FormatException] for anything else, including content written
|
||||
/// by a newer app version in a schema this one doesn't know.
|
||||
String decodeAssetPayload(ByteData payload) =>
|
||||
decodeAssetInfo(payload).fileName;
|
||||
|
||||
/// Decodes a file's payload: the name it came with, and for a sealed file
|
||||
/// the SHA-256 of what was sealed.
|
||||
///
|
||||
/// Throws a [FormatException] as [decodeAssetPayload] does.
|
||||
AssetFileInfo decodeAssetInfo(ByteData payload) {
|
||||
final json = decodePayload(payload, newestSchema: _newestAssetSchema);
|
||||
if (json case {'fileName': final String fileName}) {
|
||||
return (
|
||||
fileName: fileName,
|
||||
plainSha256: switch (json['plain']) {
|
||||
final String plain => plain,
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
}
|
||||
throw const FormatException('Not a file payload.');
|
||||
}
|
||||
|
|
@ -1,437 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart'
|
||||
show FileCache, FileLimitReached, FileRoom, FileServerGateway, isSha256;
|
||||
import 'package:halkyon_core/halkyon_core.dart'
|
||||
show CoalescingRunner, SerialQueue;
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_sealing.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/model/stored_file.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/tree_files.dart';
|
||||
|
||||
/// The files of the person's content (ADR 0004, rule 5): an asset row each,
|
||||
/// synced like all content, and the file itself on this device and the
|
||||
/// server, by its SHA-256.
|
||||
///
|
||||
/// A file added here is kept on this device and waits to be uploaded; the
|
||||
/// uploads run whenever `uploadTriggers` fires (after each sync, so the row
|
||||
/// is usually on the server before its file), one at a time, and what fails
|
||||
/// waits for the next time. A file this device lacks is downloaded when asked
|
||||
/// for, checked against its hash and kept.
|
||||
///
|
||||
/// A file of a private studyset (ADR 0019, D5) is sealed with its tree's key
|
||||
/// for its row before it is hashed ([AssetSealing]): the row's hash and the
|
||||
/// file uploaded (`encrypted`) are the sealed file's, and the instance never
|
||||
/// sees it readable. This device keeps the sealed file under its hash, to
|
||||
/// upload and check, and the opened one under the hash of what was sealed,
|
||||
/// which only the row's sealed payload tells, for what reads files by path.
|
||||
/// The same file in a private and a readable studyset, or in two private
|
||||
/// trees, is a file per key.
|
||||
class AssetRepository(
|
||||
final AssetGateway _rows,
|
||||
final FileServerGateway _server,
|
||||
final FileCache _files,
|
||||
final AssetSealing _sealing, {
|
||||
Stream<void> uploadTriggers = const Stream.empty(),
|
||||
|
||||
/// Whether files may go up and come down now: not while sync holds them
|
||||
/// back on mobile data (Halkyon's ADR 0042); always where not said.
|
||||
final bool Function() _filesAllowed = _always,
|
||||
}) implements TreeFiles {
|
||||
this {
|
||||
_triggers = uploadTriggers.listen((_) {
|
||||
// A sync may have brought rows: the index is read anew.
|
||||
_sealedIndex = null;
|
||||
unawaited(uploadPending());
|
||||
});
|
||||
}
|
||||
|
||||
/// The sealed rows by their key and the hash of what they hold
|
||||
/// (`<keyId>|<sha256>`), read once until rows change, so adding many files
|
||||
/// doesn't open every row for each (the reading of 22 Oct).
|
||||
Map<String, AssetRow>? _sealedIndex;
|
||||
|
||||
/// The largest file the server takes, of any kind but a PDF.
|
||||
static const maxBytes = 20 * 1024 * 1024;
|
||||
|
||||
/// The largest PDF the server takes (decided 2026-10-03; 100 MB since
|
||||
/// 2026-11-09).
|
||||
static const maxPdfBytes = 100 * 1024 * 1024;
|
||||
|
||||
/// A PDF's media type.
|
||||
static const pdfMediaType = 'application/pdf';
|
||||
|
||||
/// The largest file of [mimeType] the server takes.
|
||||
static int maxBytesOf(String mimeType) =>
|
||||
mimeType == pdfMediaType ? maxPdfBytes : maxBytes;
|
||||
|
||||
late final StreamSubscription<void> _triggers;
|
||||
|
||||
/// Adding one file at a time, so a file added twice at once is one row.
|
||||
final _adding = SerialQueue();
|
||||
|
||||
/// The downloads under way, by hash, so a file asked for twice at once is
|
||||
/// downloaded once.
|
||||
final _downloads = <String, Future<Uint8List?>>{};
|
||||
|
||||
/// Uploads what waits, one round at a time: a call during a round runs
|
||||
/// one more after it.
|
||||
late final _uploads = CoalescingRunner(
|
||||
_uploadAll,
|
||||
onError: (error, _) =>
|
||||
log('Uploading files failed', name: 'Assets', error: error),
|
||||
);
|
||||
|
||||
/// Whether it stopped: no round starts any more.
|
||||
var _stopped = false;
|
||||
|
||||
/// Adds [bytes], a file named [fileName] of [mimeType] in the studyset
|
||||
/// [studysetId] (sealed with its tree's key where it is private; readable
|
||||
/// without a studyset), and returns the id of its asset row: the row of
|
||||
/// the same file under the same key if there is one, else a new one.
|
||||
/// Throws an [ArgumentError] for an empty file or one too large for its
|
||||
/// kind ([maxBytesOf]).
|
||||
Future<String> add(
|
||||
Uint8List bytes, {
|
||||
required String fileName,
|
||||
required String mimeType,
|
||||
required String? studysetId,
|
||||
}) async => await addWithKey(
|
||||
bytes,
|
||||
fileName: fileName,
|
||||
mimeType: mimeType,
|
||||
keyId: studysetId == null ? null : await _sealing.keyToWriteIn(studysetId),
|
||||
);
|
||||
|
||||
/// Adds [bytes] as [add] does, for the item [itemId] of [kind]: sealed
|
||||
/// with the key of the tree it is in, where that is private.
|
||||
Future<String> addToItem(
|
||||
Uint8List bytes, {
|
||||
required String fileName,
|
||||
required String mimeType,
|
||||
required ItemKind kind,
|
||||
required String itemId,
|
||||
}) async => await addWithKey(
|
||||
bytes,
|
||||
fileName: fileName,
|
||||
mimeType: mimeType,
|
||||
keyId: await _sealing.keyOfItem(kind, itemId),
|
||||
);
|
||||
|
||||
/// The id of the asset row of the file with [sha256] where it would be
|
||||
/// added for the item [itemId] of [kind]; null while there is none.
|
||||
Future<String?> idInItem(
|
||||
String sha256, {
|
||||
required ItemKind kind,
|
||||
required String itemId,
|
||||
}) async => await _idUnder(sha256, await _sealing.keyOfItem(kind, itemId));
|
||||
|
||||
@override
|
||||
Future<String> addWithKey(
|
||||
Uint8List bytes, {
|
||||
required String fileName,
|
||||
required String mimeType,
|
||||
required String? keyId,
|
||||
}) async {
|
||||
if (bytes.isEmpty || bytes.length > maxBytesOf(mimeType)) {
|
||||
throw ArgumentError.value(bytes.length, 'bytes', 'is out of range');
|
||||
}
|
||||
return await _adding.run(() async {
|
||||
final plain = sha256.convert(bytes).toString();
|
||||
await _files.write(plain, bytes);
|
||||
if (keyId == null) {
|
||||
if (await _rows.bySha256(plain) case final row?
|
||||
when row.keyId == null) {
|
||||
return row.id;
|
||||
}
|
||||
final id = await _rows.insert(
|
||||
id: _rows.newId(),
|
||||
sha256: plain,
|
||||
mimeType: mimeType,
|
||||
byteSize: bytes.length,
|
||||
payload: encodeAssetPayload(fileName: fileName),
|
||||
);
|
||||
// Every new row asks the server, even for a file uploaded before:
|
||||
// the server may have pruned it once no row referred to it.
|
||||
await _files.markPending(plain);
|
||||
return id;
|
||||
}
|
||||
if (await _sealedRowOf(plain, keyId) case final row?) {
|
||||
return row.id;
|
||||
}
|
||||
final id = _rows.newId();
|
||||
final sealed = _sealing.sealFile(bytes, keyId: keyId, assetId: id);
|
||||
final hash = sha256.convert(sealed).toString();
|
||||
await _files.write(hash, sealed);
|
||||
await _rows.insert(
|
||||
id: id,
|
||||
sha256: hash,
|
||||
mimeType: mimeType,
|
||||
byteSize: sealed.length,
|
||||
payload: encodeAssetPayload(fileName: fileName, plainSha256: plain),
|
||||
keyId: keyId,
|
||||
);
|
||||
// Read anew next time, with this row.
|
||||
_sealedIndex = null;
|
||||
await _files.markPending(hash);
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
/// The row of the file whose content before sealing has [plain] as its
|
||||
/// hash, sealed with [keyId]; null while there is none.
|
||||
Future<AssetRow?> _sealedRowOf(String plain, String keyId) async {
|
||||
final index = _sealedIndex ??= {
|
||||
for (final row in await _rows.all())
|
||||
if (row.keyId case final rowKey? when !row.locked)
|
||||
if (_plainOf(row) case final rowPlain?) '$rowKey|$rowPlain': row,
|
||||
};
|
||||
return index['$keyId|$plain'];
|
||||
}
|
||||
|
||||
/// The hash of what [row]'s sealed file holds; null for a readable row,
|
||||
/// or one whose payload doesn't say.
|
||||
static String? _plainOf(AssetRow row) {
|
||||
try {
|
||||
return switch (decodeAssetInfo(row.payload).plainSha256) {
|
||||
final plain? when isSha256(plain) => plain,
|
||||
_ => null,
|
||||
};
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The id of the asset row of the file with [sha256] (as it is before any
|
||||
/// sealing) where it would be added for the studyset [studysetId]: sealed
|
||||
/// with its tree's key, or readable; null while the person has none.
|
||||
Future<String?> idOf(String sha256, {required String? studysetId}) async {
|
||||
final keyId = studysetId == null
|
||||
? null
|
||||
: await _sealing.keyOfStudyset(studysetId);
|
||||
return await _idUnder(sha256, keyId);
|
||||
}
|
||||
|
||||
/// The id of the row of the file with [sha256] sealed with [keyId], or
|
||||
/// readable without one.
|
||||
Future<String?> _idUnder(String sha256, String? keyId) async {
|
||||
if (keyId != null) {
|
||||
return (await _sealedRowOf(sha256, keyId))?.id;
|
||||
}
|
||||
return switch (await _rows.bySha256(sha256)) {
|
||||
final row? when row.keyId == null => row.id,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> keyIdOf(String id) async => (await _rows.byId(id))?.keyId;
|
||||
|
||||
@override
|
||||
Future<bool> exists(String id) async => await _rows.byId(id) != null;
|
||||
|
||||
/// The key of the tree the item [id] of [kind] is in; null while
|
||||
/// readable.
|
||||
Future<String?> keyOfItem(ItemKind kind, String id) =>
|
||||
_sealing.keyOfItem(kind, id);
|
||||
|
||||
@override
|
||||
Future<({Uint8List bytes, String fileName, String mimeType})?> plainOf(
|
||||
String id,
|
||||
) async => switch (await fileOf(id)) {
|
||||
final file? => (
|
||||
bytes: file.bytes,
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
),
|
||||
null => null,
|
||||
};
|
||||
|
||||
/// The file of the asset row [id]: from this device, else downloaded.
|
||||
/// Null when it can't be had now: no such row, no connection, or not
|
||||
/// uploaded yet.
|
||||
Future<StoredFile?> fileOf(String id) async {
|
||||
final row = await _rows.byId(id);
|
||||
if (row == null || row.locked || !isSha256(row.sha256)) {
|
||||
return null;
|
||||
}
|
||||
if (row.keyId != null) {
|
||||
return await _sealedFileOf(row);
|
||||
}
|
||||
final bytes =
|
||||
await _files.read(row.sha256) ??
|
||||
await (_downloads[row.sha256] ??=
|
||||
_download(
|
||||
id,
|
||||
row.sha256,
|
||||
).whenComplete(() {
|
||||
_downloads.remove(row.sha256);
|
||||
}));
|
||||
if (bytes == null) {
|
||||
return null;
|
||||
}
|
||||
String fileName;
|
||||
try {
|
||||
fileName = decodeAssetPayload(row.payload);
|
||||
} on FormatException {
|
||||
fileName = '';
|
||||
}
|
||||
return StoredFile(
|
||||
bytes: bytes,
|
||||
mimeType: row.mimeType,
|
||||
fileName: fileName,
|
||||
path: _files.pathOf(row.sha256),
|
||||
);
|
||||
}
|
||||
|
||||
/// The file of the sealed [row], opened: as this device keeps it opened,
|
||||
/// else its sealed file (kept, or downloaded), checked against the row's
|
||||
/// hash, opened for the row, and checked against the hash of what was
|
||||
/// sealed.
|
||||
Future<StoredFile?> _sealedFileOf(AssetRow row) async {
|
||||
final plain = _plainOf(row);
|
||||
if (plain == null) {
|
||||
return null;
|
||||
}
|
||||
var bytes = await _files.read(plain);
|
||||
if (bytes == null) {
|
||||
final sealed =
|
||||
await _files.read(row.sha256) ??
|
||||
await (_downloads[row.sha256] ??= _download(row.id, row.sha256)
|
||||
.whenComplete(() {
|
||||
// A block, not an arrow: the future removed is this one,
|
||||
// and returned it would be waited for by itself.
|
||||
_downloads.remove(row.sha256);
|
||||
}));
|
||||
final opened = sealed == null
|
||||
? null
|
||||
: _sealing.openFile(sealed, assetId: row.id);
|
||||
if (opened == null || sha256.convert(opened).toString() != plain) {
|
||||
return null;
|
||||
}
|
||||
await _files.write(plain, opened);
|
||||
bytes = opened;
|
||||
}
|
||||
return StoredFile(
|
||||
bytes: bytes,
|
||||
mimeType: row.mimeType,
|
||||
fileName: decodeAssetInfo(row.payload).fileName,
|
||||
path: _files.pathOf(plain),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List?> _download(String id, String hash) async {
|
||||
if (!_filesAllowed()) {
|
||||
// As without a connection: what this device lacks waits.
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final bytes = await _server.download(id);
|
||||
if (bytes == null || sha256.convert(bytes).toString() != hash) {
|
||||
return null;
|
||||
}
|
||||
await _files.write(hash, bytes);
|
||||
return bytes;
|
||||
} on Object catch (error) {
|
||||
log('Downloading a file failed', name: 'Assets', error: error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads the files that wait; what fails waits for the next time. One
|
||||
/// run at a time: a call during a run runs once more after it.
|
||||
Future<void> uploadPending() => _uploads.run();
|
||||
|
||||
Future<void> _uploadAll() async {
|
||||
if (_stopped || !_filesAllowed()) {
|
||||
return;
|
||||
}
|
||||
for (final hash in await _files.pendingUploads()) {
|
||||
try {
|
||||
if (!await _upload(hash)) {
|
||||
continue;
|
||||
}
|
||||
await _files.clearPending(hash);
|
||||
} on FileLimitReached catch (error) {
|
||||
// The others wouldn't fit either: they stay on this device, and
|
||||
// the next round asks again, once room may have been made.
|
||||
log('No room for more files', name: 'Assets', error: error);
|
||||
return;
|
||||
} on Object catch (error) {
|
||||
// Without a connection the others fail too: next time.
|
||||
log('Uploading a file failed', name: 'Assets', error: error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads the file with [hash]; whether it no longer waits.
|
||||
Future<bool> _upload(String hash) async {
|
||||
final bytes = await _files.read(hash);
|
||||
if (bytes == null) {
|
||||
// Gone from this device: nothing to upload, ever.
|
||||
return true;
|
||||
}
|
||||
final row = await _rows.bySha256(hash);
|
||||
if (row == null) {
|
||||
// No live row names it any more, as one a private studyset's rewrite
|
||||
// replaced: never uploaded, least of all readable (the reading of 22
|
||||
// Oct).
|
||||
return true;
|
||||
}
|
||||
// A sealed file says so, as only a readable one is looked into.
|
||||
final encrypted = row.keyId != null;
|
||||
final description = await _server.startUpload(
|
||||
hash,
|
||||
bytes.length,
|
||||
encrypted: encrypted,
|
||||
);
|
||||
if (description == null) {
|
||||
return true;
|
||||
}
|
||||
return await _server.upload(description, bytes) &&
|
||||
await _server.finishUpload(hash, encrypted: encrypted);
|
||||
}
|
||||
|
||||
/// How much room the person's files take on the server, and may take; null
|
||||
/// when that can't be asked now (no connection).
|
||||
Future<FileRoom?> room() async {
|
||||
try {
|
||||
return await _server.room();
|
||||
} on Exception catch (error) {
|
||||
log('Asking for the room of files failed', name: 'Assets', error: error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The size of the file [id] in bytes, from its row; null without one.
|
||||
Future<int?> byteSizeOf(String id) async => (await _rows.byId(id))?.byteSize;
|
||||
|
||||
/// Deletes the row of the file [id], blanked first: the file goes from the
|
||||
/// server once nothing refers to it, and stops taking the person's room.
|
||||
/// Only for files no content refers to any more.
|
||||
@override
|
||||
Future<void> delete(String id) async {
|
||||
final row = await _rows.byId(id);
|
||||
await _rows.delete(id);
|
||||
_sealedIndex = null;
|
||||
// Its upload, if still waiting, is no longer owed (the reading of 22
|
||||
// Oct), unless another row holds the same file.
|
||||
if (row != null && await _rows.bySha256(row.sha256) == null) {
|
||||
await _files.clearPending(row.sha256);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops uploading; an upload under way finishes first.
|
||||
Future<void> dispose() async {
|
||||
await _triggers.cancel();
|
||||
_stopped = true;
|
||||
await _uploads.idle;
|
||||
}
|
||||
}
|
||||
|
||||
bool _always() => true;
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
/// The kinds of item a file can be added to without naming its studyset.
|
||||
enum ItemKind() {
|
||||
card,
|
||||
note,
|
||||
step,
|
||||
}
|
||||
|
||||
/// How the files of a private studyset are sealed (ADR 0019, D5): with its
|
||||
/// tree's key, for the asset row they belong to, so a file moved to another
|
||||
/// row never opens. `Privacy` does it; tests fake it.
|
||||
abstract interface class AssetSealing() {
|
||||
/// The key [studysetId]'s tree is encrypted with; null while readable.
|
||||
Future<String?> keyOfStudyset(String studysetId);
|
||||
|
||||
/// [keyOfStudyset] for a file about to be added there; throws where this
|
||||
/// device may write nothing (it lacks the keys of an account that has
|
||||
/// them, the final reading of 22 Oct).
|
||||
Future<String?> keyToWriteIn(String studysetId);
|
||||
|
||||
/// The key of the tree the item [id] of [kind] is in; null while
|
||||
/// readable, or for an item that isn't there.
|
||||
Future<String?> keyOfItem(ItemKind kind, String id);
|
||||
|
||||
/// [plain] sealed with the key [keyId] for the asset row [assetId], as it
|
||||
/// is stored and uploaded. Throws a [StateError] for a key not open here.
|
||||
Uint8List sealFile(
|
||||
Uint8List plain, {
|
||||
required String keyId,
|
||||
required String assetId,
|
||||
});
|
||||
|
||||
/// What [sealed] holds, for the asset row [assetId]; null where it
|
||||
/// doesn't open here (its key isn't open, it was changed, or it belongs
|
||||
/// to another row).
|
||||
Uint8List? openFile(Uint8List sealed, {required String assetId});
|
||||
}
|
||||
|
|
@ -2,9 +2,9 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
||||
import 'package:halkyon_learn_core/core/asset/data/asset_gateway.dart';
|
||||
import 'package:halkyon_learn_core/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_core/core/privacy/data/privacy.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [AssetGateway] on the person's local database, which the sync engine keeps
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A file of the person's content (an image or a sound), on this device and
|
||||
/// ready to show or play.
|
||||
class const StoredFile({
|
||||
/// Its content.
|
||||
required final Uint8List bytes,
|
||||
|
||||
/// Its media type, e.g. 'image/png' or 'audio/mpeg'.
|
||||
required final String mimeType,
|
||||
|
||||
/// The name it came with.
|
||||
required final String fileName,
|
||||
|
||||
/// Where it is on this device, for players that read files.
|
||||
required final String path,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [bytes, mimeType, fileName, path];
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import 'dart:ui';
|
|||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_core/core/asset/data/asset_repository.dart';
|
||||
|
||||
/// The image in the person's file [assetId], as [assets] has it: from this
|
||||
/// device, else downloaded.
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
/// A value a repository reads again after every change, handed to everyone
|
||||
/// following it (decided 2026-10-17): one starting to follow gets the value
|
||||
/// last read at once, then every value read after it with the others. Only
|
||||
/// while there is none, before the first read, after one failed or once
|
||||
/// nobody followed it, does one starting to follow have it read; everyone
|
||||
/// following gets what that read brings. Before, every screen opening had
|
||||
/// everything read again and handed to every screen open.
|
||||
///
|
||||
/// While nobody follows it, nothing is kept, so a repository need not read
|
||||
/// after a change then ([hasListener]).
|
||||
class LatestValue<T>(
|
||||
/// Has the value read and handed on ([add], [addError]).
|
||||
final void Function() _read,
|
||||
) {
|
||||
late final _controller = StreamController<T>.broadcast(
|
||||
onCancel: () => _last = null,
|
||||
);
|
||||
|
||||
/// The value last read; null before the first read, after one failed and
|
||||
/// while nobody follows it.
|
||||
({T value})? _last;
|
||||
|
||||
/// The value last read, then every value read after it; a read that fails
|
||||
/// arrives as an error.
|
||||
Stream<T> get stream => Stream.multi((listener) {
|
||||
final subscription = _controller.stream.listen(
|
||||
listener.add,
|
||||
onError: listener.addError,
|
||||
);
|
||||
listener.onCancel = subscription.cancel;
|
||||
if (_last case (:final value)?) {
|
||||
listener.add(value);
|
||||
} else {
|
||||
_read();
|
||||
}
|
||||
});
|
||||
|
||||
/// The value last read; null before the first read, after one failed and
|
||||
/// while nobody follows it.
|
||||
T? get value => _last?.value;
|
||||
|
||||
/// Whether anyone follows it: while nobody does, there is no need to read.
|
||||
bool get hasListener => _controller.hasListener;
|
||||
|
||||
bool get isClosed => _controller.isClosed;
|
||||
|
||||
/// Hands [value], just read, to everyone following, and keeps it for
|
||||
/// those starting to; with nobody following, it isn't kept, as changes
|
||||
/// from now on may not be read.
|
||||
void add(T value) {
|
||||
_last = hasListener ? (value: value) : null;
|
||||
_controller.add(value);
|
||||
}
|
||||
|
||||
/// Hands a read that failed to everyone following; the next one starting
|
||||
/// to follow has it read again.
|
||||
void addError(Object error, StackTrace stackTrace) {
|
||||
_last = null;
|
||||
_controller.addError(error, stackTrace);
|
||||
}
|
||||
|
||||
Future<void> close() => _controller.close();
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:halkyon_core/halkyon_core.dart' show CoalescingRunner;
|
||||
|
||||
/// [read]'s value now and after every event of [changes], for each listener
|
||||
/// on its own: one read at a time, changes during a read making one more
|
||||
/// after it rather than one each, so an older value never overtakes a newer
|
||||
/// one. A failed read is logged under [logName] as reading [what] and
|
||||
/// arrives as an error; the next change reads again.
|
||||
Stream<T> watchReads<T>(
|
||||
Stream<void> changes,
|
||||
Future<T> Function() read, {
|
||||
required String what,
|
||||
required String logName,
|
||||
}) => Stream.multi((listener) {
|
||||
var cancelled = false;
|
||||
final reads = CoalescingRunner(
|
||||
() async {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
final value = await read();
|
||||
if (!cancelled) {
|
||||
listener.add(value);
|
||||
}
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
log('Reading $what failed', name: logName, error: error);
|
||||
if (!cancelled) {
|
||||
listener.addError(error, stackTrace);
|
||||
}
|
||||
},
|
||||
);
|
||||
final subscription = changes.listen((_) => unawaited(reads.run()));
|
||||
listener.onCancel = () {
|
||||
cancelled = true;
|
||||
return subscription.cancel();
|
||||
};
|
||||
unawaited(reads.run());
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||
import 'package:halkyon_learn_core/core/citation/model/source.dart';
|
||||
|
||||
/// How sources are cited, as the device chose (decided 2026-10-07), with
|
||||
/// the address of the style's own guide to it.
|
||||
|
|
|
|||
|
|
@ -1,141 +0,0 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// What kind of work a source is, which its form asks for and its citation
|
||||
/// says (decided 2026-10-12).
|
||||
enum SourceKind() {
|
||||
/// A book, an article, a chapter: the default.
|
||||
work,
|
||||
|
||||
/// A page on the web, [Source.container] the website's name.
|
||||
website,
|
||||
|
||||
/// A YouTube video, its [Source.authors] the channel; played in the app
|
||||
/// where it can be.
|
||||
video,
|
||||
}
|
||||
|
||||
/// A work cited: by a step of a learning path, or in a studyset's
|
||||
/// bibliography. Every part is optional and plain text, as the person wrote
|
||||
/// it; `formatCitation` puts them together in the style the device uses.
|
||||
final class const Source({
|
||||
/// What kind of work it is.
|
||||
final SourceKind kind = SourceKind.work,
|
||||
|
||||
/// Who wrote it, one full name per line (or separated by `;`), as
|
||||
/// "Jonathan Gregory Pevsner"; each style abbreviates and orders them
|
||||
/// itself.
|
||||
final String authors = '',
|
||||
|
||||
/// When it appeared, as "2020"; empty when unknown.
|
||||
final String year = '',
|
||||
|
||||
/// Its title.
|
||||
final String title = '',
|
||||
|
||||
/// What it is a part of: the journal, book or website it appeared in, with
|
||||
/// pages where they matter, as "Nature 591, 12–15"; empty for a work of
|
||||
/// its own, such as a book. The styles set a part's title apart from the
|
||||
/// whole's by this.
|
||||
final String container = '',
|
||||
|
||||
/// Who published it, as "Wiley-Blackwell".
|
||||
final String publisher = '',
|
||||
|
||||
/// The book's ISBN, as written.
|
||||
final String isbn = '',
|
||||
|
||||
/// Where to find it on the web.
|
||||
final String url = '',
|
||||
|
||||
/// The citation as a file brought it, for what couldn't be split into the
|
||||
/// parts above; shown as it is while those are empty.
|
||||
final String raw = '',
|
||||
}) with Equatable {
|
||||
/// The source in [json], as [toJson] writes it.
|
||||
///
|
||||
/// Each part is text; a number reads as its text (a file written by hand
|
||||
/// may give the year as one). A [FormatException] for anything else.
|
||||
factory fromJson(Object? json) {
|
||||
if (json is! Map<String, Object?>) {
|
||||
throw const FormatException('A source is an object.');
|
||||
}
|
||||
String part(String key) => switch (json[key]) {
|
||||
null => '',
|
||||
final String text => text,
|
||||
final num number => '$number',
|
||||
_ => throw FormatException('A source with a malformed $key.'),
|
||||
};
|
||||
return Source(
|
||||
// A kind this app doesn't know yet reads as a work: its parts still
|
||||
// cite it.
|
||||
kind:
|
||||
SourceKind.values
|
||||
.where((kind) => kind.name == json['kind'])
|
||||
.firstOrNull ??
|
||||
SourceKind.work,
|
||||
authors: part('authors'),
|
||||
year: part('year'),
|
||||
title: part('title'),
|
||||
container: part('container'),
|
||||
publisher: part('publisher'),
|
||||
isbn: part('isbn'),
|
||||
url: part('url'),
|
||||
raw: part('raw'),
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether nothing of it is written.
|
||||
bool get isEmpty =>
|
||||
authors.isEmpty &&
|
||||
year.isEmpty &&
|
||||
title.isEmpty &&
|
||||
container.isEmpty &&
|
||||
publisher.isEmpty &&
|
||||
isbn.isEmpty &&
|
||||
url.isEmpty &&
|
||||
raw.isEmpty;
|
||||
|
||||
/// This source without spaces around its parts, nor around each author.
|
||||
Source trimmed() => Source(
|
||||
kind: kind,
|
||||
authors: authors
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.where((line) => line.isNotEmpty)
|
||||
.join('\n'),
|
||||
year: year.trim(),
|
||||
title: title.trim(),
|
||||
container: container.trim(),
|
||||
publisher: publisher.trim(),
|
||||
isbn: isbn.trim(),
|
||||
url: url.trim(),
|
||||
raw: raw.trim(),
|
||||
);
|
||||
|
||||
/// This source as JSON, each part left out when empty, and its kind
|
||||
/// when it isn't a work.
|
||||
Map<String, Object?> toJson() => {
|
||||
if (kind != SourceKind.work) 'kind': kind.name,
|
||||
if (authors.isNotEmpty) 'authors': authors,
|
||||
if (year.isNotEmpty) 'year': year,
|
||||
if (title.isNotEmpty) 'title': title,
|
||||
if (container.isNotEmpty) 'container': container,
|
||||
if (publisher.isNotEmpty) 'publisher': publisher,
|
||||
if (isbn.isNotEmpty) 'isbn': isbn,
|
||||
if (url.isNotEmpty) 'url': url,
|
||||
if (raw.isNotEmpty) 'raw': raw,
|
||||
};
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
kind,
|
||||
authors,
|
||||
year,
|
||||
title,
|
||||
container,
|
||||
publisher,
|
||||
isbn,
|
||||
url,
|
||||
raw,
|
||||
];
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:halkyon_learn_core/core/citation/model/source.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/widget/source_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/widget/source_view.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:halkyon_learn_core/core/citation/model/source.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_core/core/citation/model/source.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
// The content payload of synced rows: UTF-8 JSON with a `schemaVersion`. A new
|
||||
// content field raises the version instead of migrating a synced table, which
|
||||
// would lock older app versions out of syncing (ADR 0002, rule 3).
|
||||
|
||||
/// The payload deleted content keeps: the sync engine keeps deleted rows until
|
||||
/// their space is deleted, so the content is blanked first (ADR 0002, rule 2).
|
||||
final redactedPayload = ByteData(0);
|
||||
|
||||
/// [json] as a payload in [schemaVersion].
|
||||
ByteData encodePayload(int schemaVersion, Map<String, Object?> json) =>
|
||||
ByteData.sublistView(
|
||||
utf8.encode(jsonEncode({'schemaVersion': schemaVersion, ...json})),
|
||||
);
|
||||
|
||||
/// The JSON in [payload], its `schemaVersion` included.
|
||||
///
|
||||
/// Throws a [FormatException] for anything else, including a schema newer
|
||||
/// than [newestSchema]: content written by a newer app version.
|
||||
Map<String, Object?> decodePayload(
|
||||
ByteData payload, {
|
||||
required int newestSchema,
|
||||
}) {
|
||||
final Object? json;
|
||||
try {
|
||||
json = jsonDecode(utf8.decode(Uint8List.sublistView(payload)));
|
||||
} on FormatException {
|
||||
throw const FormatException('Not a content payload.');
|
||||
}
|
||||
if (json case {'schemaVersion': final int version}) {
|
||||
if (version > newestSchema) {
|
||||
throw FormatException(
|
||||
'Content in schema $version, but this app only knows schemas up to '
|
||||
'$newestSchema.',
|
||||
);
|
||||
}
|
||||
return json as Map<String, Object?>;
|
||||
}
|
||||
throw const FormatException('Not a content payload.');
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
/// A page of a PDF that content is tied to: a card made from it, a note
|
||||
/// about it. The PDF is named by its row's id; pages count from 1.
|
||||
typedef PdfPageRef = ({String pdfId, int page});
|
||||
|
||||
/// [ref] as content payloads store it: `{"pdf", "page"}`.
|
||||
Map<String, Object?> pdfPageRefToJson(PdfPageRef ref) => {
|
||||
'pdf': ref.pdfId,
|
||||
'page': ref.page,
|
||||
};
|
||||
|
||||
/// The page [json] names, as [pdfPageRefToJson] wrote it; null for null.
|
||||
/// Throws a [FormatException] for anything else.
|
||||
PdfPageRef? pdfPageRefFromJson(Object? json) => switch (json) {
|
||||
null => null,
|
||||
{'pdf': final String pdfId, 'page': final int page} when page > 0 => (
|
||||
pdfId: pdfId,
|
||||
page: page,
|
||||
),
|
||||
_ => throw const FormatException('A malformed page of a PDF.'),
|
||||
};
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Where an item of a copy came from (ADR 0005, rule 4): the published
|
||||
/// studyset ([publication], its public id), the item there (its card's,
|
||||
/// note's, step's, path's, PDF's or studyset's id in the release), and the
|
||||
/// release whose content the item last took ([version]). A later release
|
||||
/// is taken from that one on, so what the learner changed since stays. An
|
||||
/// item the author withdrew in [version] stays the learner's, [withdrawn].
|
||||
final class const PublishedRef({
|
||||
required final String publication,
|
||||
required final String item,
|
||||
required final int version,
|
||||
final bool withdrawn = false,
|
||||
}) with Equatable {
|
||||
/// This origin as of release [version], whose content the item took.
|
||||
PublishedRef taking(int version) =>
|
||||
PublishedRef(publication: publication, item: item, version: version);
|
||||
|
||||
/// This origin with the item withdrawn in release [version].
|
||||
PublishedRef withdrawnIn(int version) => PublishedRef(
|
||||
publication: publication,
|
||||
item: item,
|
||||
version: version,
|
||||
withdrawn: true,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [publication, item, version, withdrawn];
|
||||
}
|
||||
|
||||
/// [ref] as content payloads store it: `{"publication", "item", "version"}`,
|
||||
/// and `"withdrawn": true` for an item the author withdrew.
|
||||
Map<String, Object?> publishedRefToJson(PublishedRef ref) => {
|
||||
'publication': ref.publication,
|
||||
'item': ref.item,
|
||||
'version': ref.version,
|
||||
if (ref.withdrawn) 'withdrawn': true,
|
||||
};
|
||||
|
||||
/// The origin [json] names, as [publishedRefToJson] wrote it; null for null.
|
||||
/// Throws a [FormatException] for anything else.
|
||||
PublishedRef? publishedRefFromJson(Object? json) => switch (json) {
|
||||
null => null,
|
||||
{
|
||||
'publication': final String publication,
|
||||
'item': final String item,
|
||||
'version': final int version,
|
||||
}
|
||||
when version > 0 =>
|
||||
PublishedRef(
|
||||
publication: publication,
|
||||
item: item,
|
||||
version: version,
|
||||
withdrawn: json['withdrawn'] == true,
|
||||
),
|
||||
_ => throw const FormatException('A malformed published origin.'),
|
||||
};
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Content whose fields merge separately when devices edited it while
|
||||
/// offline (ADR 0003): a card's sides, a note's title and body.
|
||||
abstract interface class MergeableContent<
|
||||
F,
|
||||
C extends MergeableContent<F, C>
|
||||
>() {
|
||||
/// The value of [field].
|
||||
Object? operator [](F field);
|
||||
|
||||
/// This content with [field] set to [value], which must be of the field's
|
||||
/// kind.
|
||||
C withField(F field, Object? value);
|
||||
}
|
||||
|
||||
/// One version of some content, never changed once written (ADR 0001,
|
||||
/// rule 2): editing appends a revision whose [parents] are the versions it
|
||||
/// replaces.
|
||||
abstract interface class Revision<C>() {
|
||||
/// A UUID v7, made on the device that wrote it; ids sort by creation.
|
||||
String get id;
|
||||
|
||||
/// The revisions this one replaces: none for the first, several when it
|
||||
/// resolves a conflict.
|
||||
Set<String> get parents;
|
||||
|
||||
/// The content as of this revision.
|
||||
C get content;
|
||||
}
|
||||
|
||||
/// Content merged from its revisions by [mergeRevisionsOf].
|
||||
final class const Merged<F, C>({
|
||||
/// The current content. For a field in [conflicts], the newest version
|
||||
/// shows until the person chooses.
|
||||
required final C content,
|
||||
|
||||
/// The current heads: what an edit of the merged content replaces.
|
||||
required final Set<String> heads,
|
||||
|
||||
/// The fields changed differently on different devices, with every
|
||||
/// version, oldest first.
|
||||
final Map<F, List<Object?>> conflicts = const {},
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [content, heads, conflicts];
|
||||
}
|
||||
|
||||
/// Merges [revisions] of one piece of content field by field (decided
|
||||
/// 2026-09-30, ADR 0003), or null without any.
|
||||
///
|
||||
/// The heads are the revisions no other revision replaces. One head is the
|
||||
/// current version. Several heads, from edits on different devices while
|
||||
/// offline, merge field by field against their newest common ancestor: a
|
||||
/// field only one of them changed takes that change, a field they changed
|
||||
/// differently is a conflict. Nothing is merged silently inside a field.
|
||||
/// Revisions whose parents haven't arrived yet count as they are.
|
||||
Merged<F, C>? mergeRevisionsOf<F, C extends MergeableContent<F, C>>(
|
||||
Iterable<Revision<C>> revisions, {
|
||||
required List<F> fields,
|
||||
}) {
|
||||
final byId = {for (final revision in revisions) revision.id: revision};
|
||||
if (byId.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final replaced = {for (final revision in byId.values) ...revision.parents};
|
||||
// UUID v7 ids sort by creation (ADR 0001, rule 7): the newest head is last.
|
||||
final heads = [
|
||||
for (final revision in byId.values)
|
||||
if (!replaced.contains(revision.id)) revision,
|
||||
]..sort((a, b) => a.id.compareTo(b.id));
|
||||
if (heads.length == 1) {
|
||||
return Merged(content: heads.single.content, heads: {heads.single.id});
|
||||
}
|
||||
|
||||
final base = _commonAncestor(heads, byId)?.content;
|
||||
var content = heads.last.content;
|
||||
final conflicts = <F, List<Object?>>{};
|
||||
for (final field in fields) {
|
||||
// What the heads changed this field to, oldest first, without repeats.
|
||||
final changed = <Object?>[];
|
||||
for (final head in heads) {
|
||||
final version = head.content[field];
|
||||
if (!_same(version, base?[field]) &&
|
||||
!changed.any((each) => _same(each, version))) {
|
||||
changed.add(version);
|
||||
}
|
||||
}
|
||||
switch (changed) {
|
||||
case []:
|
||||
// Unchanged everywhere; the newest head holds it already.
|
||||
break;
|
||||
case [final only]:
|
||||
content = content.withField(field, only);
|
||||
default:
|
||||
conflicts[field] = changed;
|
||||
content = content.withField(field, changed.last);
|
||||
}
|
||||
}
|
||||
return Merged(
|
||||
content: content,
|
||||
heads: {for (final head in heads) head.id},
|
||||
conflicts: conflicts,
|
||||
);
|
||||
}
|
||||
|
||||
/// The newest revision all [heads] descend from, or null if they share none.
|
||||
///
|
||||
/// Of the common ancestors, only those no other common ancestor descends from
|
||||
/// qualify; of those (possible after criss-cross merges), the newest.
|
||||
Revision<C>? _commonAncestor<C>(
|
||||
List<Revision<C>> heads,
|
||||
Map<String, Revision<C>> byId,
|
||||
) {
|
||||
Set<String> lineage(String id) {
|
||||
final seen = <String>{};
|
||||
final pending = [id];
|
||||
while (pending.isNotEmpty) {
|
||||
final next = pending.removeLast();
|
||||
if (byId.containsKey(next) && seen.add(next)) {
|
||||
pending.addAll(byId[next]!.parents);
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
final common = heads
|
||||
.map((head) => lineage(head.id))
|
||||
.reduce((a, b) => a.intersection(b));
|
||||
final nearest = [
|
||||
for (final candidate in common)
|
||||
if (!common.any(
|
||||
(other) => other != candidate && lineage(other).contains(candidate),
|
||||
))
|
||||
candidate,
|
||||
]..sort();
|
||||
return nearest.isEmpty ? null : byId[nearest.last];
|
||||
}
|
||||
|
||||
/// Whether two values of a field are the same: lists by their elements.
|
||||
bool _same(Object? a, Object? b) => switch ((a, b)) {
|
||||
(final List<Object?> a, final List<Object?> b) =>
|
||||
a.length == b.length &&
|
||||
[for (var i = 0; i < a.length; i++) a[i] == b[i]].every((same) => same),
|
||||
_ => a == b,
|
||||
};
|
||||
|
||||
/// What taking an update into content kept as revisions came to: a
|
||||
/// dictionary's correction into a card, a release into a copy's card, note
|
||||
/// or step.
|
||||
enum UpdateTaken() {
|
||||
/// Nothing: the content can't take it.
|
||||
none,
|
||||
|
||||
/// Taken whole.
|
||||
taken,
|
||||
|
||||
/// Taken, but the person changed a side the update changed too: a
|
||||
/// conflict to settle.
|
||||
toSettle,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue