feat: support skipping in-memory update transaction (#6856)

* feat: support skipping in-memory update transaction

* fix: flutter analyze

* feat: add sentence mode

* test: support skipping in-memory update transaction

* test: add sentence mode

* test: add sentence mode (2)

* chore: set enableDocumentInternalLog to false

* fix: integration test

* fix: integration test
This commit is contained in:
Lucas 2024-11-25 17:55:15 +08:00 committed by GitHub
parent e0226e54a5
commit e86d584ea7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 725 additions and 53 deletions

View File

@ -0,0 +1,47 @@
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../../shared/constants.dart';
import '../../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('AI Writer:', () {
testWidgets('the ai writer transaction should only apply in memory',
(tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
const pageName = 'Document';
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Document,
pageName: pageName,
);
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_aiWriter.tr(),
);
expect(find.byType(AutoCompletionBlockComponent), findsOneWidget);
// switch to another page
await tester.openPage(Constants.gettingStartedPageName);
// switch back to the page
await tester.openPage(pageName);
// expect the ai writer block is not in the document
expect(find.byType(AutoCompletionBlockComponent), findsNothing);
});
});
}

View File

@ -1,5 +1,6 @@
import 'package:integration_test/integration_test.dart';
import 'document_ai_writer_test.dart' as document_ai_writer_test;
import 'document_copy_link_to_block_test.dart'
as document_copy_link_to_block_test;
import 'document_option_actions_test.dart' as document_option_actions_test;
@ -11,4 +12,5 @@ void main() {
document_option_actions_test.main();
document_copy_link_to_block_test.main();
document_publish_test.main();
document_ai_writer_test.main();
}

View File

@ -26,7 +26,6 @@ import '../../shared/util.dart';
const _testImageUrls = [
'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640',
'https://mathiasbynens.be/demo/animated-webp-supported.webp',
'https://www.easygifanimator.net/images/samples/eglite.gif',
'https://people.math.sc.edu/Burkardt/data/bmp/snail.bmp',
'https://file-examples.com/storage/fe9566cb7d67345489a5a97/2017/10/file_example_JPG_100kB.jpg',

View File

@ -48,7 +48,7 @@ void main() {
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
offset: 100,
offset: 80,
);
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
@ -146,7 +146,7 @@ void main() {
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
offset: 100,
offset: 80,
);
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
expect(find.byType(MultiImagePlaceholder), findsOneWidget);

View File

@ -246,10 +246,16 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
(event) async {
final time = event.$1;
final transaction = event.$2;
final options = event.$3;
if (time != TransactionTime.before) {
return;
}
if (options.inMemoryUpdate) {
Log.info('skip transaction for in-memory update');
return;
}
if (enableDocumentInternalLog) {
Log.debug(
'[TransactionAdapter] 1. transaction before apply: ${transaction.hashCode}',

View File

@ -404,8 +404,9 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
}
List<SelectionMenuItem> _customSlashMenuItems() {
final isLocalMode = context.read<DocumentBloc>().isLocalMode;
return [
aiWriterSlashMenuItem,
if (!isLocalMode) aiWriterSlashMenuItem,
textSlashMenuItem,
heading1SlashMenuItem,
heading2SlashMenuItem,

View File

@ -3,6 +3,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
enum TextRobotInputType {
character,
word,
sentence,
}
class TextRobot {
@ -16,44 +17,78 @@ class TextRobot {
String text, {
TextRobotInputType inputType = TextRobotInputType.word,
Duration delay = const Duration(milliseconds: 10),
String separator = '\n',
}) async {
if (text == '\n') {
return editorState.insertNewLine();
if (text == separator) {
await editorState.insertNewLine();
await Future.delayed(delay);
return;
}
final lines = text.split('\n');
final lines = _splitText(text, separator);
for (final line in lines) {
if (line.isEmpty) {
await editorState.insertNewLine();
await Future.delayed(delay);
continue;
}
switch (inputType) {
case TextRobotInputType.character:
final iterator = line.runes.iterator;
while (iterator.moveNext()) {
await editorState.insertTextAtCurrentSelection(
iterator.currentAsString,
);
await Future.delayed(delay);
}
await insertCharacter(line, delay);
break;
case TextRobotInputType.word:
final words = line.split(' ');
if (words.length == 1 ||
(words.length == 2 &&
(words.first.isEmpty || words.last.isEmpty))) {
await editorState.insertTextAtCurrentSelection(
line,
);
} else {
for (final word in words.map((e) => '$e ')) {
await editorState.insertTextAtCurrentSelection(
word,
);
}
}
await Future.delayed(delay);
await insertWord(line, delay);
break;
case TextRobotInputType.sentence:
await insertSentence(line, delay);
break;
}
}
}
Future<void> insertCharacter(String line, Duration delay) async {
final iterator = line.runes.iterator;
while (iterator.moveNext()) {
await editorState.insertTextAtCurrentSelection(
iterator.currentAsString,
);
await Future.delayed(delay);
}
}
Future<void> insertWord(String line, Duration delay) async {
final words = line.split(' ');
if (words.length == 1 ||
(words.length == 2 && (words.first.isEmpty || words.last.isEmpty))) {
await editorState.insertTextAtCurrentSelection(
line,
);
} else {
for (final word in words.map((e) => '$e ')) {
await editorState.insertTextAtCurrentSelection(
word,
);
}
}
await Future.delayed(delay);
}
Future<void> insertSentence(String line, Duration delay) async {
await editorState.insertTextAtCurrentSelection(line);
await Future.delayed(delay);
}
}
List<String> _splitText(String text, String separator) {
final parts = text.split(RegExp(separator));
final result = <String>[];
for (int i = 0; i < parts.length; i++) {
result.add(parts[i]);
// Only add empty string if it's not the last part and the next part is not empty
if (i < parts.length - 1 && parts[i + 1].isNotEmpty) {
result.add('');
}
}
return result;
}

View File

@ -194,7 +194,7 @@ class _AutoCompletionBlockComponentState
final transaction = editorState.transaction..deleteNode(widget.node);
await editorState.apply(
transaction,
options: const ApplyOptions(recordUndo: false),
options: const ApplyOptions(recordUndo: false, inMemoryUpdate: true),
withUpdateSelection: false,
);
}
@ -231,6 +231,8 @@ class _AutoCompletionBlockComponentState
onProcess: (text) async {
await textRobot.autoInsertText(
text,
separator: r'\n\n',
inputType: TextRobotInputType.sentence,
delay: Duration.zero,
);
},
@ -267,7 +269,10 @@ class _AutoCompletionBlockComponentState
start,
end.last - start.last + 1,
);
await editorState.apply(transaction);
await editorState.apply(
transaction,
options: const ApplyOptions(inMemoryUpdate: true),
);
await _makeSurePreviousNodeIsEmptyParagraphNode();
}
}
@ -318,6 +323,8 @@ class _AutoCompletionBlockComponentState
onProcess: (text) async {
await textRobot.autoInsertText(
text,
inputType: TextRobotInputType.sentence,
separator: r'\n\n',
delay: Duration.zero,
);
},
@ -394,12 +401,13 @@ class _AutoCompletionBlockComponentState
Future<void> _makeSurePreviousNodeIsEmptyParagraphNode() async {
// make sure the previous node is a empty paragraph node without any styles.
final transaction = editorState.transaction;
final previous = widget.node.previous;
final Selection selection;
if (previous == null ||
previous.type != ParagraphBlockKeys.type ||
(previous.delta?.toPlainText().isNotEmpty ?? false)) {
final transaction = editorState.transaction;
selection = Selection.single(
path: widget.node.path,
startOffset: 0,
@ -408,17 +416,22 @@ class _AutoCompletionBlockComponentState
widget.node.path,
paragraphNode(),
);
await editorState.apply(transaction);
} else {
selection = Selection.single(
path: previous.path,
startOffset: 0,
);
}
final transaction = editorState.transaction;
transaction.updateNode(widget.node, {
AutoCompletionBlockKeys.startSelection: selection.toJson(),
});
transaction.afterSelection = selection;
await editorState.apply(transaction);
await editorState.apply(
transaction,
options: const ApplyOptions(inMemoryUpdate: true),
);
}
void _subscribeSelectionGesture() {

View File

@ -88,8 +88,7 @@ class _OutlineBlockWidgetState extends State<OutlineBlockWidget>
@override
late EditorState editorState = context.read<EditorState>();
late Stream<(TransactionTime, Transaction)> stream =
editorState.transactionStream;
late Stream<EditorTransactionValue> stream = editorState.transactionStream;
@override
Widget build(BuildContext context) {

View File

@ -465,6 +465,7 @@ SelectionMenuItem outlineSlashMenuItem = SelectionMenuItem(
editorState.apply(transaction);
},
);
// math equation
SelectionMenuItem mathEquationSlashMenuItem = SelectionMenuItem.node(
getName: () => LocaleKeys.document_slashMenu_name_mathEquation.tr(),
@ -541,20 +542,37 @@ SelectionMenuItem emojiSlashMenuItem = SelectionMenuItem(
);
// auto generate menu item
SelectionMenuItem aiWriterSlashMenuItem = SelectionMenuItem.node(
getName: () => LocaleKeys.document_slashMenu_name_aiWriter.tr(),
nameBuilder: _slashMenuItemNameBuilder,
iconBuilder: (editorState, isSelected, style) => SelectableSvgWidget(
SelectionMenuItem aiWriterSlashMenuItem = SelectionMenuItem(
getName: LocaleKeys.document_slashMenu_name_aiWriter.tr,
icon: (editorState, isSelected, style) => SelectableSvgWidget(
data: FlowySvgs.slash_menu_icon_ai_writer_s,
isSelected: isSelected,
style: style,
),
keywords: ['ai', 'openai', 'writer', 'ai writer', 'autogenerator'],
nodeBuilder: (editorState, _) {
final node = autoCompletionNode(start: editorState.selection!);
return node;
handler: (editorState, menuService, context) {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return;
}
final node = editorState.getNodeAtPath(selection.end.path);
final delta = node?.delta;
if (node == null || delta == null) {
return;
}
final newNode = autoCompletionNode(start: selection);
final transaction = editorState.transaction;
//default insert after
final path = node.path.next;
transaction
..insertNode(path, newNode)
..afterSelection = null;
editorState.apply(
transaction,
options: const ApplyOptions(inMemoryUpdate: true),
);
},
replace: (_, node) => false,
);
// table menu item

View File

@ -40,7 +40,7 @@ class EditorTransactionService extends StatefulWidget {
}
class _EditorTransactionServiceState extends State<EditorTransactionService> {
StreamSubscription<(TransactionTime, Transaction)>? transactionSubscription;
StreamSubscription<EditorTransactionValue>? transactionSubscription;
bool isUndoRedo = false;
bool isPaste = false;
@ -131,8 +131,11 @@ class _EditorTransactionServiceState extends State<EditorTransactionService> {
return matchingNodes;
}
void onEditorTransaction((TransactionTime, Transaction) event) {
if (event.$1 == TransactionTime.before) {
void onEditorTransaction(EditorTransactionValue event) {
final time = event.$1;
final transaction = event.$2;
if (time == TransactionTime.before) {
return;
}
@ -145,7 +148,7 @@ class _EditorTransactionServiceState extends State<EditorTransactionService> {
handler.type: handler.livesInDelta ? <MentionBlockData>[] : <Node>[],
};
for (final op in event.$2.operations) {
for (final op in transaction.operations) {
if (op is InsertOperation) {
for (final n in op.nodes) {
for (final handler in _transactionHandlers) {

View File

@ -61,8 +61,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: ea81e3c
resolved-ref: ea81e3c1647344aff45970c39556902ffad4373d
ref: "5b3878d"
resolved-ref: "5b3878dcc5876ae7a329b308ff82763f02cf8c5f"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "4.0.0"
@ -70,8 +70,8 @@ packages:
dependency: "direct main"
description:
path: "packages/appflowy_editor_plugins"
ref: "27c898d"
resolved-ref: "27c898d1343f52d80444a0f469b8ee403606cf36"
ref: "3f82111"
resolved-ref: "3f82111f958b0ac9f06aa80fd19a629f3a649ec0"
url: "https://github.com/AppFlowy-IO/AppFlowy-plugins.git"
source: git
version: "0.0.6"

View File

@ -172,13 +172,13 @@ dependency_overrides:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "ea81e3c"
ref: "5b3878d"
appflowy_editor_plugins:
git:
url: https://github.com/AppFlowy-IO/AppFlowy-plugins.git
path: "packages/appflowy_editor_plugins"
ref: "27c898d"
ref: "3f82111"
sheet:
git:

View File

@ -0,0 +1,549 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/text_robot.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('text robot:', () {
setUpAll(() {
Log.shared.disableLog = true;
});
tearDownAll(() {
Log.shared.disableLog = false;
});
test('auto insert text with sentence mode (1)', () async {
final editorState = EditorState.blank();
editorState.selection = Selection.collapsed(Position(path: [0]));
final textRobot = TextRobot(
editorState: editorState,
);
for (final text in _sample1) {
await textRobot.autoInsertText(
text,
separator: r'\n\n',
inputType: TextRobotInputType.sentence,
delay: Duration.zero,
);
}
final p1 = editorState.document.nodeAtPath([0])!.delta!.toPlainText();
final p2 = editorState.document.nodeAtPath([1])!.delta!.toPlainText();
final p3 = editorState.document.nodeAtPath([2])!.delta!.toPlainText();
expect(
p1,
'In a quaint village nestled between rolling hills, a young girl named Elara discovered a hidden garden. She stumbled upon it while chasing a mischievous rabbit through a narrow, winding path. ',
);
expect(
p2,
'The garden was a vibrant oasis, brimming with colorful flowers and whispering trees. Elara felt an inexplicable connection to the place, as if it held secrets from a forgotten time. ',
);
expect(
p3,
'Determined to uncover its mysteries, she visited daily, unraveling tales of ancient magic and wisdom. The garden transformed her spirit, teaching her the importance of harmony and the beauty of nature\'s wonders.',
);
});
test('auto insert text with sentence mode (2)', () async {
final editorState = EditorState.blank();
editorState.selection = Selection.collapsed(Position(path: [0]));
final textRobot = TextRobot(
editorState: editorState,
);
var breakCount = 0;
for (final text in _sample2) {
if (text.contains('\n\n')) {
breakCount++;
}
await textRobot.autoInsertText(
text,
separator: r'\n\n',
inputType: TextRobotInputType.sentence,
delay: Duration.zero,
);
}
final len = editorState.document.root.children.length;
expect(len, breakCount + 1);
expect(len, 7);
final p1 = editorState.document.nodeAtPath([0])!.delta!.toPlainText();
final p2 = editorState.document.nodeAtPath([1])!.delta!.toPlainText();
final p3 = editorState.document.nodeAtPath([2])!.delta!.toPlainText();
final p4 = editorState.document.nodeAtPath([3])!.delta!.toPlainText();
final p5 = editorState.document.nodeAtPath([4])!.delta!.toPlainText();
final p6 = editorState.document.nodeAtPath([5])!.delta!.toPlainText();
final p7 = editorState.document.nodeAtPath([6])!.delta!.toPlainText();
expect(
p1,
'Once upon a time in the small, whimsical village of Greenhollow, nestled between rolling hills and lush forests, there lived a young girl named Elara. Unlike the other villagers, Elara had a unique gift: she could communicate with animals. This extraordinary ability made her both a beloved and mysterious figure in Greenhollow.',
);
expect(
p2,
'One crisp autumn morning, as golden leaves danced in the breeze, Elara heard a distressed call from the forest. Following the sound, she discovered a young fox trapped in a hunter\'s snare. With gentle hands and a calming voice, she freed the frightened creature, who introduced himself as Rufus. Grateful for her help, Rufus promised to assist Elara whenever she needed.',
);
expect(
p3,
'Word of Elara\'s kindness spread among the forest animals, and soon she found herself surrounded by a diverse group of animal friends, from wise old owls to playful otters. Together, they shared stories, solved problems, and looked out for one another.',
);
expect(
p4,
'One day, the village faced an unexpected threat: a severe drought that threatened their crops and water supply. The villagers grew anxious, unsure of how to cope with the impending scarcity. Elara, determined to help, turned to her animal friends for guidance.',
);
expect(
p5,
'The animals led Elara to a hidden spring deep within the forest, a source of fresh water unknown to the villagers. With Rufus\'s clever planning and the otters\' help in directing the flow, they managed to channel the spring water to the village, saving the crops and quenching the villagers\' thirst.',
);
expect(
p6,
'Grateful and amazed, the villagers hailed Elara as a hero. They came to understand the importance of living harmoniously with nature and the wonders that could be achieved through kindness and cooperation.',
);
expect(
p7,
'From that day on, Greenhollow thrived as a community where humans and animals lived together in harmony, cherishing the bonds that Elara had helped forge. And whenever challenges arose, the villagers knew they could rely on Elara and her extraordinary friends to guide them through, ensuring that the spirit of unity and compassion always prevailed.',
);
});
});
}
final _sample1 = [
"In",
" a quaint",
" village",
" nestled",
" between",
" rolling",
" hills",
",",
" a",
" young",
" girl",
" named",
" El",
"ara discovered",
" a hidden",
" garden",
".",
" She stumbled",
" upon",
" it",
" while",
" chasing",
" a",
" misch",
"iev",
"ous rabbit",
" through",
" a",
" narrow,",
" winding path",
".",
" \n\n",
"The",
" garden",
" was",
" a",
" vibrant",
" oasis",
",",
" br",
"imming with",
" colorful",
" flowers",
" and whisper",
"ing",
" trees",
".",
" El",
"ara",
" felt",
" an inexp",
"licable",
" connection",
" to",
" the",
" place,",
" as",
" if",
" it held",
" secrets",
" from",
" a",
" forgotten",
" time",
".",
" \n\n",
"Determ",
"ined to",
" uncover",
" its",
" mysteries",
",",
" she",
" visited",
" daily,",
" unravel",
"ing",
" tales",
" of",
" ancient",
" magic",
" and",
" wisdom",
".",
" The",
" garden transformed",
" her",
" spirit",
", teaching",
" her the",
" importance of harmony and",
" the",
" beauty",
" of",
" nature",
"'s wonders.",
];
final _sample2 = [
"Once",
" upon",
" a",
" time",
" in",
" the small",
",",
" whimsical",
" village",
" of",
" Green",
"h",
"ollow",
",",
" nestled",
" between",
" rolling hills",
" and",
" lush",
" forests",
",",
" there",
" lived",
" a young",
" girl",
" named",
" Elara.",
" Unlike the",
" other",
" villagers",
",",
" El",
"ara",
" had",
" a unique",
" gift",
":",
" she could",
" communicate",
" with",
" animals",
".",
" This",
" extraordinary",
" ability",
" made",
" her both a",
" beloved",
" and",
" mysterious",
" figure",
" in",
" Green",
"h",
"ollow",
".\n\n",
"One",
" crisp",
" autumn",
" morning,",
" as",
" golden",
" leaves",
" danced",
" in",
" the",
" breeze",
", El",
"ara heard",
" a distressed",
" call",
" from",
" the",
" forest",
".",
" Following",
" the",
" sound",
",",
" she",
" discovered",
" a",
" young",
" fox",
" trapped",
" in",
" a",
" hunter's",
" snare",
".",
" With",
" gentle",
" hands",
" and",
" a",
" calming",
" voice",
",",
" she",
" freed",
" the",
" frightened",
" creature",
", who",
" introduced",
" himself",
" as Ruf",
"us.",
" Gr",
"ateful",
" for",
" her",
" help",
",",
" Rufus promised",
" to assist",
" Elara",
" whenever",
" she",
" needed.\n\n",
"Word",
" of",
" Elara",
"'s kindness",
" spread among",
" the forest",
" animals",
",",
" and soon",
" she",
" found",
" herself",
" surrounded",
" by",
" a",
" diverse",
" group",
" of",
" animal",
" friends",
",",
" from",
" wise",
" old ow",
"ls to playful",
" ot",
"ters.",
" Together,",
" they",
" shared stories",
",",
" solved problems",
",",
" and",
" looked",
" out",
" for",
" one",
" another",
".\n\n",
"One",
" day",
", the village faced",
" an unexpected",
" threat",
":",
" a",
" severe",
" drought",
" that",
" threatened",
" their",
" crops",
" and",
" water supply",
".",
" The",
" villagers",
" grew",
" anxious",
",",
" unsure",
" of",
" how to",
" cope",
" with",
" the",
" impending",
" scarcity",
".",
" El",
"ara",
",",
" determined",
" to",
" help",
",",
" turned",
" to her",
" animal friends",
" for",
" guidance",
".\n\nThe",
" animals",
" led",
" El",
"ara",
" to",
" a",
" hidden",
" spring",
" deep",
" within",
" the forest,",
" a source",
" of",
" fresh",
" water unknown",
" to the",
" villagers",
".",
" With",
" Ruf",
"us's",
" clever planning",
" and the",
" ot",
"ters",
"'",
" help",
" in directing",
" the",
" flow",
",",
" they",
" managed",
" to",
" channel the",
" spring",
" water",
" to",
" the",
" village,",
" saving the",
" crops",
" and",
" quenching",
" the",
" villagers",
"'",
" thirst",
".\n\n",
"Gr",
"ateful and",
" amazed,",
" the",
" villagers",
" hailed El",
"ara as",
" a",
" hero",
".",
" They",
" came",
" to",
" understand the",
" importance",
" of living",
" harmon",
"iously",
" with",
" nature",
" and",
" the",
" wonders",
" that",
" could",
" be",
" achieved",
" through kindness",
" and cooperation",
".\n\nFrom",
" that day",
" on",
",",
" Greenh",
"ollow",
" thr",
"ived",
" as",
" a",
" community",
" where",
" humans",
" and",
" animals",
" lived together",
" in",
" harmony",
",",
" cher",
"ishing",
" the",
" bonds that",
" El",
"ara",
" had",
" helped",
" forge",
".",
" And whenever",
" challenges arose",
", the",
" villagers",
" knew",
" they",
" could",
" rely on",
" El",
"ara and",
" her",
" extraordinary",
" friends",
" to",
" guide them",
" through",
",",
" ensuring",
" that",
" the",
" spirit",
" of",
" unity",
" and",
" compassion",
" always prevailed.",
];