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:
Tizian 「ティツィアーン」 2026-10-02 20:57:19 +02:00
commit 9639ce8569
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 161 additions and 118 deletions

View 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,
};

View file

@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart'; 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'; 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 /// 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. /// The id of the map a multiple-choice card is drawn on.
final String? map, final String? map,
}) with Equatable { }) with Equatable implements MergeableContent<CardField, CardContent> {
/// A front/back card with nothing on either side. /// A front/back card with nothing on either side.
factory empty() => factory empty() =>
CardContent(front: RichContent.empty(), back: RichContent.empty()); CardContent(front: RichContent.empty(), back: RichContent.empty());
/// The value of [field]. @override
Object? operator [](CardField field) => switch (field) { Object? operator [](CardField field) => switch (field) {
CardField.type => type, CardField.type => type,
CardField.front => front, CardField.front => front,
@ -136,8 +137,7 @@ final class const CardContent({
CardField.map => map, CardField.map => map,
}; };
/// This content with [field] set to [value], which must be of the field's @override
/// kind.
CardContent withField(CardField field, Object? value) => CardContent( CardContent withField(CardField field, Object? value) => CardContent(
type: field == CardField.type ? value! as CardType : type, type: field == CardField.type ? value! as CardType : type,
front: field == CardField.front ? value! as RichContent : front, front: field == CardField.front ? value! as RichContent : front,

View file

@ -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_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_revision.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_revision.dart';
/// A card's content, merged from its revisions by [mergeRevisions]. /// A card's content, merged from its revisions by [mergeRevisions].
final class const MergedCard({ typedef MergedCard = Merged<CardField, CardContent>;
/// The current content. For a field in [conflicts], the newest version
/// shows until the person chooses.
required final CardContent content,
/// The revisions the next edit replaces: all current heads. /// Merges a card's [revisions] (decided 2026-09-30) side by side, or null
required final Set<String> heads, /// without any: see [mergeRevisionsOf].
MergedCard? mergeRevisions(Iterable<CardRevision> revisions) =>
/// The fields changed differently on different devices, with every mergeRevisionsOf(revisions, fields: CardField.values);
/// 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,
};

View file

@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart'; 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_content.dart';
/// One version of a card's content, never changed once written (ADR 0001, /// 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. /// replaces.
final class const CardRevision({ final class const CardRevision({
/// A UUID v7, made on the device that wrote it; ids sort by creation. /// 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 /// The revisions this one replaces: none for a card's first, several when
/// it resolves a conflict. /// it resolves a conflict.
required final Set<String> parents, @override required final Set<String> parents,
/// The card's content as of this revision. /// The card's content as of this revision.
required final CardContent content, @override required final CardContent content,
}) with Equatable { }) with Equatable implements Revision<CardContent> {
@override @override
List<Object?> get props => [id, parents, content]; List<Object?> get props => [id, parents, content];
} }