refactor(app): the revision merge moves to core, for notes too
`mergeRevisionsOf` merges the revisions of any `MergeableContent` field by field against their newest common ancestor, as the card merge did; cards keep `mergeRevisions`, now a wrapper naming their fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
3b10922888
commit
9639ce8569
4 changed files with 161 additions and 118 deletions
146
halkyon_learn_flutter/lib/src/core/content/revision_merge.dart
Normal file
146
halkyon_learn_flutter/lib/src/core/content/revision_merge.dart
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
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,
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
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';
|
||||
|
||||
/// What kind of card it is: how it is edited and studied. A closed set the
|
||||
|
|
@ -119,12 +120,12 @@ final class const CardContent({
|
|||
|
||||
/// The id of the map a multiple-choice card is drawn on.
|
||||
final String? map,
|
||||
}) with Equatable {
|
||||
}) with Equatable implements MergeableContent<CardField, CardContent> {
|
||||
/// A front/back card with nothing on either side.
|
||||
factory empty() =>
|
||||
CardContent(front: RichContent.empty(), back: RichContent.empty());
|
||||
|
||||
/// The value of [field].
|
||||
@override
|
||||
Object? operator [](CardField field) => switch (field) {
|
||||
CardField.type => type,
|
||||
CardField.front => front,
|
||||
|
|
@ -136,8 +137,7 @@ final class const CardContent({
|
|||
CardField.map => map,
|
||||
};
|
||||
|
||||
/// This content with [field] set to [value], which must be of the field's
|
||||
/// kind.
|
||||
@override
|
||||
CardContent withField(CardField field, Object? value) => CardContent(
|
||||
type: field == CardField.type ? value! as CardType : type,
|
||||
front: field == CardField.front ? value! as RichContent : front,
|
||||
|
|
|
|||
|
|
@ -1,115 +1,11 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_revision.dart';
|
||||
|
||||
/// A card's content, merged from its revisions by [mergeRevisions].
|
||||
final class const MergedCard({
|
||||
/// The current content. For a field in [conflicts], the newest version
|
||||
/// shows until the person chooses.
|
||||
required final CardContent content,
|
||||
typedef MergedCard = Merged<CardField, CardContent>;
|
||||
|
||||
/// The revisions the next edit replaces: all current heads.
|
||||
required final Set<String> heads,
|
||||
|
||||
/// The fields changed differently on different devices, with every
|
||||
/// version, oldest first.
|
||||
final Map<CardField, List<Object?>> conflicts = const {},
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [content, heads, conflicts];
|
||||
}
|
||||
|
||||
/// Merges a card's [revisions] (decided 2026-09-30), 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.
|
||||
MergedCard? mergeRevisions(Iterable<CardRevision> revisions) {
|
||||
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 MergedCard(content: heads.single.content, heads: {heads.single.id});
|
||||
}
|
||||
|
||||
final base = _commonAncestor(heads, byId)?.content;
|
||||
var content = heads.last.content;
|
||||
final conflicts = <CardField, List<Object?>>{};
|
||||
for (final field in CardField.values) {
|
||||
// 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 MergedCard(
|
||||
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.
|
||||
CardRevision? _commonAncestor(
|
||||
List<CardRevision> heads,
|
||||
Map<String, CardRevision> 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,
|
||||
};
|
||||
/// Merges a card's [revisions] (decided 2026-09-30) side by side, or null
|
||||
/// without any: see [mergeRevisionsOf].
|
||||
MergedCard? mergeRevisions(Iterable<CardRevision> revisions) =>
|
||||
mergeRevisionsOf(revisions, fields: CardField.values);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
|
||||
/// One version of a card's content, never changed once written (ADR 0001,
|
||||
|
|
@ -6,15 +7,15 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
|||
/// replaces.
|
||||
final class const CardRevision({
|
||||
/// A UUID v7, made on the device that wrote it; ids sort by creation.
|
||||
required final String id,
|
||||
@override required final String id,
|
||||
|
||||
/// The revisions this one replaces: none for a card's first, several when
|
||||
/// it resolves a conflict.
|
||||
required final Set<String> parents,
|
||||
@override required final Set<String> parents,
|
||||
|
||||
/// The card's content as of this revision.
|
||||
required final CardContent content,
|
||||
}) with Equatable {
|
||||
@override required final CardContent content,
|
||||
}) with Equatable implements Revision<CardContent> {
|
||||
@override
|
||||
List<Object?> get props => [id, parents, content];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue