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
7 changed files with 3517 additions and 2007 deletions
|
|
@ -2,9 +2,11 @@
|
|||
/// they keep.
|
||||
library;
|
||||
|
||||
// The document they keep, pure Dart in halkyon_content (Halkyon's ADR 0044,
|
||||
// D49), so models and Learn's agent read it without Flutter.
|
||||
export 'package:halkyon_content/rich_text.dart';
|
||||
|
||||
export 'l10n/halkyon_editor_localizations.dart';
|
||||
export 'src/content/content_blocks.dart';
|
||||
export 'src/content/rich_content.dart';
|
||||
export 'src/editor/atomic_block.dart';
|
||||
export 'src/editor/basic_blocks.dart';
|
||||
export 'src/editor/block_menu.dart';
|
||||
|
|
|
|||
|
|
@ -1,91 +0,0 @@
|
|||
// The blocks of ours in rich text, beyond the editor's own: their types and
|
||||
// the attributes they keep, in the editor's document JSON. Pure Dart, so
|
||||
// `RichContent` and the server read them as the editor writes them.
|
||||
//
|
||||
// Every block keeps its type for good: content with a type an app doesn't
|
||||
// know stays as it is there, shown as needing a newer app.
|
||||
|
||||
/// A sound: the person's file at `url` (`assetUrl`), played on a tap.
|
||||
const audioBlockType = 'audio';
|
||||
|
||||
/// On text within a line: its furigana (ruby), the reading of the text it
|
||||
/// is on (`かんじ` on `漢字`), shown above it.
|
||||
const rubyKey = 'ruby';
|
||||
|
||||
/// On the editor's own `image` block: the id of the PDF a screenshot was
|
||||
/// taken from; missing for any other image.
|
||||
const imagePdfKey = 'pdf';
|
||||
|
||||
/// On the editor's own `image` block: the page of that PDF, from 1.
|
||||
const imagePageKey = 'page';
|
||||
|
||||
/// A boxed aside of a kind (a note, a tip, a warning…), holding blocks of
|
||||
/// its own as children.
|
||||
const calloutBlockType = 'callout';
|
||||
|
||||
/// The callout's kind, one of [calloutKinds]; any other reads as `note`.
|
||||
const calloutKindKey = 'kind';
|
||||
|
||||
/// The callout's title; empty or missing for its kind's name.
|
||||
const calloutTitleKey = 'title';
|
||||
|
||||
/// Whether the callout starts folded, its blocks hidden until opened.
|
||||
const calloutFoldedKey = 'folded';
|
||||
|
||||
/// The kinds of callouts, in the order they are offered.
|
||||
const calloutKinds = [
|
||||
'note',
|
||||
'info',
|
||||
'tip',
|
||||
'success',
|
||||
'question',
|
||||
'warning',
|
||||
'danger',
|
||||
'quote',
|
||||
];
|
||||
|
||||
/// Text hidden until it is revealed, in the block's own `delta`.
|
||||
const spoilerBlockType = 'spoiler';
|
||||
|
||||
/// A formula set on its own line.
|
||||
const mathBlockType = 'math';
|
||||
|
||||
/// The formula in LaTeX, of a [mathBlockType] block.
|
||||
const mathFormulaKey = 'formula';
|
||||
|
||||
/// The text mark of a formula within a line: its text is the LaTeX.
|
||||
const inlineMathKey = 'math';
|
||||
|
||||
/// A YouTube video, played only once someone asks for it.
|
||||
const youtubeBlockType = 'youtube';
|
||||
|
||||
/// The video's id: the 11 characters YouTube names it by.
|
||||
const youtubeVideoKey = 'video';
|
||||
|
||||
/// Where the video starts, in whole seconds; missing for its beginning.
|
||||
const youtubeStartKey = 'start';
|
||||
|
||||
/// The caption shown in place of the video; optional.
|
||||
const youtubeCaptionKey = 'caption';
|
||||
|
||||
/// A button starting a study session of a studyset's cards.
|
||||
const studyButtonBlockType = 'study_button';
|
||||
|
||||
/// The id of the studyset studied.
|
||||
const studyButtonStudysetKey = 'studyset';
|
||||
|
||||
/// The id of the tag whose cards alone are studied; missing for all.
|
||||
const studyButtonTagKey = 'tag';
|
||||
|
||||
/// The name of that tag, to find it by once its id is gone (a tag folded
|
||||
/// into another of its name).
|
||||
const studyButtonTagNameKey = 'tagName';
|
||||
|
||||
/// The button's label; empty or missing for a plain "Study".
|
||||
const studyButtonLabelKey = 'label';
|
||||
|
||||
/// Whether the studyset's topics are studied too; true when missing.
|
||||
const studyButtonTopicsKey = 'topics';
|
||||
|
||||
/// Whether front/back and vocabulary cards ask their back first.
|
||||
const studyButtonBackFirstKey = 'backFirst';
|
||||
|
|
@ -1,275 +0,0 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_editor/src/content/content_blocks.dart';
|
||||
|
||||
/// Rich text as the editor stores it: the editor's document JSON plus the
|
||||
/// version of that format, so stored content can be migrated when it changes.
|
||||
///
|
||||
/// Pure Dart on purpose: models hold it, the data layer encodes it into the
|
||||
/// content payload, and the server can read it without Flutter. The editor
|
||||
/// converts it to and from its own document type in `rich_content_document.dart`.
|
||||
///
|
||||
/// Made from the JSON the editor writes for a document, which it keeps as a
|
||||
/// deeply unmodifiable copy.
|
||||
final class RichContent(
|
||||
Map<String, Object?> editorJson, {
|
||||
|
||||
/// The version of the format [editorJson] is in.
|
||||
final int formatVersion = currentFormatVersion,
|
||||
}) with Equatable {
|
||||
/// A document without any blocks.
|
||||
factory empty() => RichContent(const {
|
||||
'document': {'type': 'page'},
|
||||
});
|
||||
|
||||
/// [text] as a document, a paragraph per line.
|
||||
factory plain(String text) => RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
for (final line in text.split('\n'))
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
if (line.isNotEmpty) {'insert': line},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
/// Reads the JSON written by [toJson].
|
||||
///
|
||||
/// Throws a [FormatException] for anything else, including content in a
|
||||
/// newer format than this app knows.
|
||||
factory fromJson(Map<String, Object?> json) {
|
||||
final formatVersion = json['formatVersion'];
|
||||
final editorJson = json['editor'];
|
||||
if (formatVersion is! int || editorJson is! Map<String, Object?>) {
|
||||
throw FormatException('Not rich content.', json);
|
||||
}
|
||||
if (formatVersion > currentFormatVersion) {
|
||||
throw FormatException(
|
||||
'Rich content in format $formatVersion, but this app only knows '
|
||||
'formats up to $currentFormatVersion.',
|
||||
json,
|
||||
);
|
||||
}
|
||||
return RichContent(editorJson, formatVersion: formatVersion);
|
||||
}
|
||||
|
||||
/// The format this app writes.
|
||||
static const currentFormatVersion = 1;
|
||||
|
||||
/// The editor's document JSON, deeply unmodifiable.
|
||||
final Map<String, Object?> editorJson = _unmodifiableMap(editorJson);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [formatVersion, editorJson];
|
||||
|
||||
/// The text without its formatting, one line per block; for previews.
|
||||
/// A block's text kept beside its blocks counts too: a formula, a
|
||||
/// callout's title, a video's caption, a study button's label.
|
||||
String get plainText {
|
||||
final blocks = <String>[];
|
||||
void visit(Object? node) {
|
||||
if (node case {'data': final Map<String, Object?> data}) {
|
||||
if (data[_textKeys[node['type']]] case final String text) {
|
||||
blocks.add(text);
|
||||
}
|
||||
if (data case {'delta': final List<Object?> delta}) {
|
||||
blocks.add(
|
||||
[
|
||||
for (final operation in delta)
|
||||
if (operation case {'insert': final String text}) text,
|
||||
].join(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (node case {'children': final List<Object?> children}) {
|
||||
children.forEach(visit);
|
||||
}
|
||||
}
|
||||
|
||||
visit(editorJson['document']);
|
||||
return blocks.where((block) => block.isNotEmpty).join('\n');
|
||||
}
|
||||
|
||||
/// Whether it holds nothing: no text, and no block that shows something
|
||||
/// without text (an image, a sound, a video, a study button).
|
||||
bool get isBlank =>
|
||||
plainText.trim().isEmpty &&
|
||||
!_nodes.any((node) => _somethingWithoutText.contains(node['type']));
|
||||
|
||||
/// Whether it shows an image, the person's or another.
|
||||
bool get hasImages => _hasBlock('image');
|
||||
|
||||
/// Whether it plays a sound.
|
||||
bool get hasSounds => _hasBlock(audioBlockType);
|
||||
|
||||
/// Whether it plays a video.
|
||||
bool get hasVideos => _hasBlock(youtubeBlockType);
|
||||
|
||||
/// The person's files it shows or plays, by their asset rows' ids.
|
||||
Set<String> get assetIds => {
|
||||
for (final node in _nodes)
|
||||
if (node case {'data': {'url': final url}}) ?assetIdOf(url),
|
||||
};
|
||||
|
||||
/// The person's images it shows, by their asset rows' ids, in order and
|
||||
/// each once: what an assistant that reads images may be sent of it (ADR
|
||||
/// 0021, D12).
|
||||
List<String> get imageIds => {
|
||||
for (final node in _nodes)
|
||||
if (node case {'type': 'image', 'data': {'url': final url}})
|
||||
?assetIdOf(url),
|
||||
}.toList();
|
||||
|
||||
bool _hasBlock(String type) => _nodes.any((node) => node['type'] == type);
|
||||
|
||||
/// This content without its first block when that is a heading reading
|
||||
/// [text], whatever its case and spaces (decided 2026-10-16): a heading
|
||||
/// that repeats the title shown above it, as imported lessons often
|
||||
/// open. Unchanged otherwise.
|
||||
RichContent withoutHeading(String text) {
|
||||
String tidy(String text) =>
|
||||
text.trim().replaceAll(RegExp(r'\s+'), ' ').toLowerCase();
|
||||
if (editorJson['document']
|
||||
case {
|
||||
'children': [
|
||||
{
|
||||
'type': 'heading',
|
||||
'data': {'delta': final List<Object?> delta},
|
||||
},
|
||||
...final rest,
|
||||
],
|
||||
} &&
|
||||
final Map<String, Object?> document
|
||||
when tidy(text).isNotEmpty) {
|
||||
final heading = [
|
||||
for (final operation in delta)
|
||||
if (operation case {'insert': final String text}) text,
|
||||
].join();
|
||||
if (tidy(heading) == tidy(text)) {
|
||||
return RichContent({
|
||||
...editorJson,
|
||||
'document': {...document, 'children': rest},
|
||||
}, formatVersion: formatVersion);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// Every block, depth first.
|
||||
Iterable<Map<String, Object?>> get _nodes sync* {
|
||||
Iterable<Map<String, Object?>> visit(Object? node) sync* {
|
||||
if (node is Map<String, Object?>) {
|
||||
yield node;
|
||||
if (node case {'children': final List<Object?> children}) {
|
||||
for (final child in children) {
|
||||
yield* visit(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield* visit(editorJson['document']);
|
||||
}
|
||||
|
||||
/// This content with [text] after it, a paragraph per line: as text
|
||||
/// taken from a PDF joins what is written. An empty last line takes the
|
||||
/// text rather than staying above it.
|
||||
RichContent withParagraphs(String text) {
|
||||
final document =
|
||||
editorJson['document'] as Map<String, Object?>? ??
|
||||
const {'type': 'page'};
|
||||
final children = [...?document['children'] as List<Object?>?];
|
||||
if (children.lastOrNull
|
||||
case {
|
||||
'type': 'paragraph',
|
||||
'data': {'delta': final List<Object?> delta},
|
||||
}
|
||||
when delta.isEmpty) {
|
||||
children.removeLast();
|
||||
}
|
||||
return RichContent({
|
||||
'document': {
|
||||
...document,
|
||||
'children': [
|
||||
...children,
|
||||
for (final line in text.split('\n'))
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
if (line.isNotEmpty) {'insert': line},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}, formatVersion: formatVersion);
|
||||
}
|
||||
|
||||
/// The JSON stored in the content payload.
|
||||
Map<String, Object?> toJson() => {
|
||||
'formatVersion': formatVersion,
|
||||
'editor': editorJson,
|
||||
};
|
||||
}
|
||||
|
||||
/// The URL naming the person's file [assetId] in content: an image's or a
|
||||
/// sound's (ADR 0004, rule 5).
|
||||
String assetUrl(String assetId) => '$_assetScheme$assetId';
|
||||
|
||||
/// The asset row [url] names, if it names one.
|
||||
String? assetIdOf(Object? url) =>
|
||||
url is String &&
|
||||
url.startsWith(_assetScheme) &&
|
||||
url.length > _assetScheme.length
|
||||
? url.substring(_assetScheme.length)
|
||||
: null;
|
||||
|
||||
const _assetScheme = 'asset:';
|
||||
|
||||
/// The person's files [json] (a payload's JSON, or a part of it) names: the
|
||||
/// `url` of every block that shows or plays one, as [RichContent.assetIds]
|
||||
/// finds them, at any depth.
|
||||
Set<String> assetIdsIn(Object? json) => switch (json) {
|
||||
final Map<String, Object?> map => {
|
||||
?assetIdOf(map['url']),
|
||||
for (final value in map.values) ...assetIdsIn(value),
|
||||
},
|
||||
final List<Object?> list => {for (final value in list) ...assetIdsIn(value)},
|
||||
_ => const {},
|
||||
};
|
||||
|
||||
/// The attribute holding the text of a block that keeps it beside its
|
||||
/// blocks, by the block's type.
|
||||
const _textKeys = {
|
||||
mathBlockType: mathFormulaKey,
|
||||
calloutBlockType: calloutTitleKey,
|
||||
youtubeBlockType: youtubeCaptionKey,
|
||||
studyButtonBlockType: studyButtonLabelKey,
|
||||
};
|
||||
|
||||
/// The blocks that show something without any text.
|
||||
const _somethingWithoutText = {
|
||||
'image',
|
||||
audioBlockType,
|
||||
youtubeBlockType,
|
||||
studyButtonBlockType,
|
||||
};
|
||||
|
||||
Map<String, Object?> _unmodifiableMap(Map<String, Object?> map) =>
|
||||
Map.unmodifiable({
|
||||
for (final MapEntry(:key, :value) in map.entries)
|
||||
key: _unmodifiableValue(value),
|
||||
});
|
||||
|
||||
Object? _unmodifiableValue(Object? value) => switch (value) {
|
||||
Map<String, Object?>() => _unmodifiableMap(value),
|
||||
List<Object?>() => List<Object?>.unmodifiable(value.map(_unmodifiableValue)),
|
||||
_ => value,
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:halkyon_content/rich_text.dart';
|
||||
import 'package:halkyon_editor/l10n/halkyon_editor_localizations.dart';
|
||||
import 'package:halkyon_editor/src/content/content_blocks.dart';
|
||||
import 'package:halkyon_editor/src/editor/atomic_block.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:halkyon_editor/src/content/rich_content.dart';
|
||||
import 'package:halkyon_content/rich_text.dart';
|
||||
|
||||
/// Opens [RichContent] in the editor.
|
||||
extension RichContentToDocument on RichContent {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import 'dart:math' as math;
|
|||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:halkyon_content/rich_text.dart';
|
||||
import 'package:halkyon_editor/l10n/halkyon_editor_localizations.dart';
|
||||
import 'package:halkyon_editor/src/content/rich_content.dart';
|
||||
import 'package:halkyon_editor/src/editor/basic_blocks.dart';
|
||||
import 'package:halkyon_editor/src/editor/block_menu.dart';
|
||||
import 'package:halkyon_editor/src/editor/editor_blocks.dart';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:halkyon_content/rich_text.dart';
|
||||
import 'package:halkyon_editor/l10n/halkyon_editor_localizations.dart';
|
||||
import 'package:halkyon_editor/src/content/rich_content.dart';
|
||||
import 'package:halkyon_editor/src/editor/basic_blocks.dart';
|
||||
import 'package:halkyon_editor/src/editor/editor_blocks.dart';
|
||||
import 'package:halkyon_editor/src/editor/editor_style.dart';
|
||||
|
|
|
|||
Loading…
Reference in a new issue