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: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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue