fix: support exporting more content to markdown (#7333)

* fix: support exporting to markdown with multiple images

* fix: support exporting to markdown with database

* fix: support exporting to markdown with date or reminder

* fix: support exporting to markdown with subpage and page reference

* chore: add some testing for markdown parser

* chore: add testing for exporting markdown with databse as csv
This commit is contained in:
Morn 2025-02-11 21:46:02 +08:00 committed by GitHub
parent 04e3246976
commit 552dba5abe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 383 additions and 28 deletions

View file

@ -1,12 +1,16 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:appflowy/plugins/shared/share/share_button.dart'; import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../../shared/mock/mock_file_picker.dart'; import '../../shared/mock/mock_file_picker.dart';
import '../../shared/util.dart'; import '../../shared/util.dart';
import '../document/document_with_database_test.dart';
void main() { void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@ -18,7 +22,7 @@ void main() {
// mock the file picker // mock the file picker
final path = await mockSaveFilePath( final path = await mockSaveFilePath(
p.join(context.applicationDataDirectory, 'test.md'), p.join(context.applicationDataDirectory, 'test.zip'),
); );
// click the share button and select markdown // click the share button and select markdown
await tester.tapShareButton(); await tester.tapShareButton();
@ -28,10 +32,14 @@ void main() {
tester.expectToExportSuccess(); tester.expectToExportSuccess();
final file = File(path); final file = File(path);
final isExist = file.existsSync(); expect(file.existsSync(), true);
expect(isExist, true); final archive = ZipDecoder().decodeBytes(file.readAsBytesSync());
final markdown = file.readAsStringSync(); for (final entry in archive) {
expect(markdown, expectedMarkdown); if (entry.isFile && entry.name.endsWith('.md')) {
final markdown = utf8.decode(entry.content);
expect(markdown, expectedMarkdown);
}
}
}); });
testWidgets( testWidgets(
@ -57,7 +65,7 @@ void main() {
final path = await mockSaveFilePath( final path = await mockSaveFilePath(
p.join( p.join(
context.applicationDataDirectory, context.applicationDataDirectory,
'${shareButtonState.view.name}.md', '${shareButtonState.view.name}.zip',
), ),
); );
@ -69,10 +77,44 @@ void main() {
tester.expectToExportSuccess(); tester.expectToExportSuccess();
final file = File(path); final file = File(path);
final isExist = file.existsSync(); expect(file.existsSync(), true);
expect(isExist, true); final archive = ZipDecoder().decodeBytes(file.readAsBytesSync());
for (final entry in archive) {
if (entry.isFile && entry.name.endsWith('.md')) {
final markdown = utf8.decode(entry.content);
expect(markdown, expectedMarkdown);
}
}
}, },
); );
testWidgets('share the markdown with database', (tester) async {
final context = await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await insertLinkedDatabase(tester, ViewLayoutPB.Grid);
// mock the file picker
final path = await mockSaveFilePath(
p.join(context.applicationDataDirectory, 'test.zip'),
);
// click the share button and select markdown
await tester.tapShareButton();
await tester.tapMarkdownButton();
// expect to see the success dialog
tester.expectToExportSuccess();
final file = File(path);
expect(file.existsSync(), true);
final archive = ZipDecoder().decodeBytes(file.readAsBytesSync());
bool hasCsvFile = false;
for (final entry in archive) {
if (entry.isFile && entry.name.endsWith('.csv')) {
hasCsvFile = true;
}
}
expect(hasCsvFile, true);
});
}); });
} }

View file

@ -11,8 +11,8 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'widgets/ask_ai_action.dart';
import 'ask_ai_block_component.dart'; import 'ask_ai_block_component.dart';
import 'widgets/ask_ai_action.dart';
const _kAskAIToolbarItemId = 'appflowy.editor.ask_ai'; const _kAskAIToolbarItemId = 'appflowy.editor.ask_ai';
@ -118,7 +118,7 @@ class _AskAIActionListState extends State<AskAIActionList> {
return; return;
} }
final markdown = editorState.getMarkdownInSelection(selection); final markdown = await editorState.getMarkdownInSelection(selection);
final transaction = editorState.transaction; final transaction = editorState.transaction;
transaction.insertNode( transaction.insertNode(

View file

@ -2,7 +2,7 @@ import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
extension AskAINodeExtension on EditorState { extension AskAINodeExtension on EditorState {
String getMarkdownInSelection(Selection? selection) { Future<String> getMarkdownInSelection(Selection? selection) async {
selection ??= this.selection?.normalized; selection ??= this.selection?.normalized;
if (selection == null || selection.isCollapsed) { if (selection == null || selection.isCollapsed) {
return ''; return '';
@ -33,7 +33,7 @@ extension AskAINodeExtension on EditorState {
slicedNodes.add(copiedNode); slicedNodes.add(copiedNode);
} }
final markdown = customDocumentToMarkdown( final markdown = await customDocumentToMarkdown(
Document.blank()..insert([0], slicedNodes), Document.blank()..insert([0], slicedNodes),
); );

View file

@ -13,10 +13,11 @@ import 'package:universal_platform/universal_platform.dart';
const kMultiImagePlaceholderKey = 'multiImagePlaceholderKey'; const kMultiImagePlaceholderKey = 'multiImagePlaceholderKey';
Node multiImageNode() => Node( Node multiImageNode({List<ImageBlockData>? images}) => Node(
type: MultiImageBlockKeys.type, type: MultiImageBlockKeys.type,
attributes: { attributes: {
MultiImageBlockKeys.images: MultiImageData(images: []).toJson(), MultiImageBlockKeys.images:
MultiImageData(images: images ?? []).toJson(),
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(), MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
}, },
); );

View file

@ -1,4 +1,9 @@
import 'dart:io';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:archive/archive.dart';
import 'package:path/path.dart' as p;
import '../image/custom_image_block_component/custom_image_block_component.dart'; import '../image/custom_image_block_component/custom_image_block_component.dart';
@ -16,3 +21,64 @@ class CustomImageNodeParser extends NodeParser {
return '![]($url)\n'; return '![]($url)\n';
} }
} }
class CustomImageNodeFileParser extends NodeParser {
const CustomImageNodeFileParser(this.files, this.dirPath);
final List<Future<ArchiveFile>> files;
final String dirPath;
@override
String get id => ImageBlockKeys.type;
@override
String transform(Node node, DocumentMarkdownEncoder? encoder) {
assert(node.children.isEmpty);
final url = node.attributes[CustomImageBlockKeys.url];
final hasFile = File(url).existsSync();
if (hasFile) {
final bytes = File(url).readAsBytesSync();
files.add(
Future.value(
ArchiveFile(p.join(dirPath, p.basename(url)), bytes.length, bytes),
),
);
return '![](${p.join(dirPath, p.basename(url))})\n';
}
assert(url != null);
return '![]($url)\n';
}
}
class CustomMultiImageNodeFileParser extends NodeParser {
const CustomMultiImageNodeFileParser(this.files, this.dirPath);
final List<Future<ArchiveFile>> files;
final String dirPath;
@override
String get id => MultiImageBlockKeys.type;
@override
String transform(Node node, DocumentMarkdownEncoder? encoder) {
assert(node.children.isEmpty);
final images = node.attributes[MultiImageBlockKeys.images] as List;
final List<String> markdownImages = [];
for (final image in images) {
final String url = image['url'] ?? '';
if (url.isEmpty) continue;
final hasFile = File(url).existsSync();
if (hasFile) {
final bytes = File(url).readAsBytesSync();
final filePath = p.join(dirPath, p.basename(url));
files.add(
Future.value(ArchiveFile(filePath, bytes.length, bytes)),
);
markdownImages.add('![]($filePath)');
} else {
markdownImages.add('![]($url)');
}
}
return markdownImages.join('\n');
}
}

View file

@ -0,0 +1,37 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
class CustomParagraphNodeParser extends NodeParser {
const CustomParagraphNodeParser();
@override
String get id => ParagraphBlockKeys.type;
@override
String transform(Node node, DocumentMarkdownEncoder? encoder) {
final delta = node.delta;
if (delta != null) {
for (final o in delta) {
final attribute = o.attributes ?? {};
final Map? mention = attribute[MentionBlockKeys.mention] ?? {};
if (mention == null) continue;
/// filter date reminder node, and return it
final String date = mention[MentionBlockKeys.date] ?? '';
if (date.isNotEmpty) {
final dateTime = DateTime.tryParse(date);
if (dateTime == null) continue;
return '${DateFormat.yMMMd().format(dateTime)}\n';
}
/// filter reference page
final String pageId = mention[MentionBlockKeys.pageId] ?? '';
if (pageId.isNotEmpty) {
return '[]($pageId)\n';
}
}
}
return const TextNodeParser().transform(node, encoder);
}
}

View file

@ -0,0 +1,53 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/database/database_view_block_component.dart';
import 'package:appflowy/workspace/application/settings/share/export_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:archive/archive.dart';
import 'package:path/path.dart' as p;
abstract class DatabaseNodeParser extends NodeParser {
DatabaseNodeParser(this.files, this.dirPath);
final List<Future<ArchiveFile>> files;
final String dirPath;
@override
String transform(Node node, DocumentMarkdownEncoder? encoder) {
final String viewId = node.attributes[DatabaseBlockKeys.viewID] ?? '';
if (viewId.isEmpty) return '';
files.add(_convertDatabaseToCSV(viewId));
return '[](${p.join(dirPath, '$viewId.csv')})\n';
}
Future<ArchiveFile> _convertDatabaseToCSV(String viewId) async {
final result = await BackendExportService.exportDatabaseAsCSV(viewId);
final filePath = p.join(dirPath, '$viewId.csv');
ArchiveFile file = ArchiveFile.string(filePath, '');
result.fold(
(s) => file = ArchiveFile.string(filePath, s.data),
(f) => Log.error('convertDatabaseToCSV error with $viewId, error: $f'),
);
return file;
}
}
class GridNodeParser extends DatabaseNodeParser {
GridNodeParser(super.files, super.dirPath);
@override
String get id => DatabaseBlockKeys.gridType;
}
class BoardNodeParser extends DatabaseNodeParser {
BoardNodeParser(super.files, super.dirPath);
@override
String get id => DatabaseBlockKeys.boardType;
}
class CalendarNodeParser extends DatabaseNodeParser {
CalendarNodeParser(super.files, super.dirPath);
@override
String get id => DatabaseBlockKeys.calendarType;
}

View file

@ -1,5 +1,7 @@
export 'callout_node_parser.dart'; export 'callout_node_parser.dart';
export 'custom_image_node_parser.dart'; export 'custom_image_node_parser.dart';
export 'custom_paragraph_node_parser.dart';
export 'database_node_parser.dart';
export 'file_block_node_parser.dart'; export 'file_block_node_parser.dart';
export 'link_preview_node_parser.dart'; export 'link_preview_node_parser.dart';
export 'math_equation_node_parser.dart'; export 'math_equation_node_parser.dart';

View file

@ -0,0 +1,20 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
class SubPageNodeParser extends NodeParser {
const SubPageNodeParser();
@override
String get id => SubPageBlockKeys.type;
@override
String transform(Node node, DocumentMarkdownEncoder? encoder) {
final String viewId = node.attributes[SubPageBlockKeys.viewId] ?? '';
if (viewId.isNotEmpty) {
final view = pageMemorizer[viewId];
return '[$viewId](${view?.name ?? ''})\n';
}
return '';
}
}

View file

@ -105,7 +105,7 @@ class ExportTab extends StatelessWidget {
final viewName = context.read<ShareBloc>().state.viewName; final viewName = context.read<ShareBloc>().state.viewName;
final exportPath = await getIt<FilePickerService>().saveFile( final exportPath = await getIt<FilePickerService>().saveFile(
dialogTitle: '', dialogTitle: '',
fileName: '${viewName.toFileName()}.md', fileName: '${viewName.toFileName()}.zip',
); );
if (context.mounted && exportPath != null) { if (context.mounted && exportPath != null) {
context.read<ShareBloc>().add( context.read<ShareBloc>().add(

View file

@ -324,19 +324,21 @@ class ShareBloc extends Bloc<ShareEvent, ShareState> {
(f) => FlowyResult.failure(f), (f) => FlowyResult.failure(f),
); );
} else { } else {
result = await documentExporter.export(type.documentExportType); result =
await documentExporter.export(type.documentExportType, path: path);
} }
return result.fold( return result.fold(
(s) { (s) {
if (path != null) { if (path != null) {
switch (type) { switch (type) {
case ShareType.markdown:
case ShareType.html: case ShareType.html:
case ShareType.csv: case ShareType.csv:
case ShareType.json: case ShareType.json:
case ShareType.rawDatabaseData: case ShareType.rawDatabaseData:
File(path).writeAsStringSync(s); File(path).writeAsStringSync(s);
return FlowyResult.success(type); return FlowyResult.success(type);
case ShareType.markdown:
return FlowyResult.success(type);
default: default:
break; break;
} }
@ -387,22 +389,30 @@ enum ShareType {
@freezed @freezed
class ShareEvent with _$ShareEvent { class ShareEvent with _$ShareEvent {
const factory ShareEvent.initial() = _Initial; const factory ShareEvent.initial() = _Initial;
const factory ShareEvent.share( const factory ShareEvent.share(
ShareType type, ShareType type,
String? path, String? path,
) = _Share; ) = _Share;
const factory ShareEvent.publish( const factory ShareEvent.publish(
String nameSpace, String nameSpace,
String pageId, String pageId,
List<String> selectedViewIds, List<String> selectedViewIds,
) = _Publish; ) = _Publish;
const factory ShareEvent.unPublish() = _UnPublish; const factory ShareEvent.unPublish() = _UnPublish;
const factory ShareEvent.updateViewName(String name, String viewId) = const factory ShareEvent.updateViewName(String name, String viewId) =
_UpdateViewName; _UpdateViewName;
const factory ShareEvent.updatePublishStatus() = _UpdatePublishStatus; const factory ShareEvent.updatePublishStatus() = _UpdatePublishStatus;
const factory ShareEvent.setPublishStatus(bool isPublished) = const factory ShareEvent.setPublishStatus(bool isPublished) =
_SetPublishStatus; _SetPublishStatus;
const factory ShareEvent.updatePathName(String pathName) = _UpdatePathName; const factory ShareEvent.updatePathName(String pathName) = _UpdatePathName;
const factory ShareEvent.clearPathNameResult() = _ClearPathNameResult; const factory ShareEvent.clearPathNameResult() = _ClearPathNameResult;
} }

View file

@ -1,5 +1,13 @@
import 'dart:io';
import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/sub_page_node_parser.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:archive/archive.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:share_plus/share_plus.dart';
Document customMarkdownToDocument( Document customMarkdownToDocument(
String markdown, { String markdown, {
@ -14,17 +22,66 @@ Document customMarkdownToDocument(
); );
} }
String customDocumentToMarkdown(Document document) { Future<String> customDocumentToMarkdown(
return documentToMarkdown( Document document, {
String path = '',
AsyncValueSetter<Archive>? onArchive,
}) async {
final List<Future<ArchiveFile>> fileFutures = [];
/// create root Archive and directory
final id = document.root.id,
archive = Archive(),
resourceDir = ArchiveFile('$id/', 0, null)..isFile = false,
fileName = p.basenameWithoutExtension(path),
dirName = resourceDir.name;
final markdown = documentToMarkdown(
document, document,
customParsers: [ customParsers: [
const MathEquationNodeParser(), const MathEquationNodeParser(),
const CalloutNodeParser(), const CalloutNodeParser(),
const ToggleListNodeParser(), const ToggleListNodeParser(),
const CustomImageNodeParser(), CustomImageNodeFileParser(fileFutures, dirName),
CustomMultiImageNodeFileParser(fileFutures, dirName),
GridNodeParser(fileFutures, dirName),
BoardNodeParser(fileFutures, dirName),
CalendarNodeParser(fileFutures, dirName),
const CustomParagraphNodeParser(),
const SubPageNodeParser(),
const SimpleTableNodeParser(), const SimpleTableNodeParser(),
const LinkPreviewNodeParser(), const LinkPreviewNodeParser(),
const FileBlockNodeParser(), const FileBlockNodeParser(),
], ],
); );
/// create resource directory
if (fileFutures.isNotEmpty) archive.addFile(resourceDir);
/// add markdown file to Archive
archive.addFile(ArchiveFile.string('$fileName-$id.md', markdown));
for (final fileFuture in fileFutures) {
archive.addFile(await fileFuture);
}
if (archive.isNotEmpty && path.isNotEmpty) {
if (onArchive == null) {
final zipEncoder = ZipEncoder();
final zip = zipEncoder.encode(archive);
if (zip != null) {
final zipFile = await File(path).writeAsBytes(zip);
if (Platform.isIOS) {
await Share.shareUri(zipFile.uri);
await zipFile.delete();
} else if (Platform.isAndroid) {
await Share.shareXFiles([XFile(zipFile.path)]);
await zipFile.delete();
}
Log.info('documentToMarkdownFiles to $path');
}
} else {
await onArchive.call(archive);
}
}
return markdown;
} }

View file

@ -25,12 +25,13 @@ class DocumentExporter {
final ViewPB view; final ViewPB view;
Future<FlowyResult<String, FlowyError>> export( Future<FlowyResult<String, FlowyError>> export(
DocumentExportType type, DocumentExportType type, {
) async { String? path,
}) async {
final documentService = DocumentService(); final documentService = DocumentService();
final result = await documentService.openDocument(documentId: view.id); final result = await documentService.openDocument(documentId: view.id);
return result.fold( return result.fold(
(r) { (r) async {
final document = r.toDocument(); final document = r.toDocument();
if (document == null) { if (document == null) {
return FlowyResult.failure( return FlowyResult.failure(
@ -43,8 +44,14 @@ class DocumentExporter {
case DocumentExportType.json: case DocumentExportType.json:
return FlowyResult.success(jsonEncode(document)); return FlowyResult.success(jsonEncode(document));
case DocumentExportType.markdown: case DocumentExportType.markdown:
final markdown = customDocumentToMarkdown(document); if (path != null) {
return FlowyResult.success(markdown); await customDocumentToMarkdown(document, path: path);
return FlowyResult.success('');
} else {
return FlowyResult.success(
await customDocumentToMarkdown(document),
);
}
case DocumentExportType.text: case DocumentExportType.text:
throw UnimplementedError(); throw UnimplementedError();
case DocumentExportType.html: case DocumentExportType.html:

View file

@ -1,7 +1,10 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
@ -17,21 +20,78 @@ void main() {
), ),
], ],
); );
final markdown = customDocumentToMarkdown(document); final markdown = await customDocumentToMarkdown(document);
expect(markdown, '[file.txt](https://file.com)\n'); expect(markdown, '[file.txt](https://file.com)\n');
}); });
test('link preview', () { test('link preview', () async {
final document = Document.blank() final document = Document.blank()
..insert( ..insert(
[0], [0],
[linkPreviewNode(url: 'https://www.link_preview.com')], [linkPreviewNode(url: 'https://www.link_preview.com')],
); );
final markdown = customDocumentToMarkdown(document); final markdown = await customDocumentToMarkdown(document);
expect( expect(
markdown, markdown,
'[https://www.link_preview.com](https://www.link_preview.com)\n', '[https://www.link_preview.com](https://www.link_preview.com)\n',
); );
}); });
test('multiple images', () async {
const png1 = 'https://www.appflowy.png',
png2 = 'https://www.appflowy2.png';
final document = Document.blank()
..insert(
[0],
[
multiImageNode(
images: [
ImageBlockData(
url: png1,
type: CustomImageType.external,
),
ImageBlockData(
url: png2,
type: CustomImageType.external,
),
],
),
],
);
final markdown = await customDocumentToMarkdown(document);
expect(
markdown,
'![]($png1)\n![]($png2)',
);
});
test('subpage block', () async {
const testSubpageId = 'testSubpageId';
final subpageNode = pageMentionNode(testSubpageId);
final document = Document.blank()
..insert(
[0],
[subpageNode],
);
final markdown = await customDocumentToMarkdown(document);
expect(
markdown,
'[]($testSubpageId)\n',
);
});
test('date or reminder', () async {
final dateTime = DateTime.now();
final document = Document.blank()
..insert(
[0],
[dateMentionNode()],
);
final markdown = await customDocumentToMarkdown(document);
expect(
markdown,
'${DateFormat.yMMMd().format(dateTime)}\n',
);
});
}); });
} }