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 '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:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import '../../shared/mock/mock_file_picker.dart';
import '../../shared/util.dart';
import '../document/document_with_database_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@ -18,7 +22,7 @@ void main() {
// mock the file picker
final path = await mockSaveFilePath(
p.join(context.applicationDataDirectory, 'test.md'),
p.join(context.applicationDataDirectory, 'test.zip'),
);
// click the share button and select markdown
await tester.tapShareButton();
@ -28,10 +32,14 @@ void main() {
tester.expectToExportSuccess();
final file = File(path);
final isExist = file.existsSync();
expect(isExist, true);
final markdown = file.readAsStringSync();
expect(file.existsSync(), 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(
@ -57,7 +65,7 @@ void main() {
final path = await mockSaveFilePath(
p.join(
context.applicationDataDirectory,
'${shareButtonState.view.name}.md',
'${shareButtonState.view.name}.zip',
),
);
@ -69,10 +77,44 @@ void main() {
tester.expectToExportSuccess();
final file = File(path);
final isExist = file.existsSync();
expect(isExist, true);
expect(file.existsSync(), 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_bloc/flutter_bloc.dart';
import 'widgets/ask_ai_action.dart';
import 'ask_ai_block_component.dart';
import 'widgets/ask_ai_action.dart';
const _kAskAIToolbarItemId = 'appflowy.editor.ask_ai';
@ -118,7 +118,7 @@ class _AskAIActionListState extends State<AskAIActionList> {
return;
}
final markdown = editorState.getMarkdownInSelection(selection);
final markdown = await editorState.getMarkdownInSelection(selection);
final transaction = editorState.transaction;
transaction.insertNode(

View file

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

View file

@ -13,10 +13,11 @@ import 'package:universal_platform/universal_platform.dart';
const kMultiImagePlaceholderKey = 'multiImagePlaceholderKey';
Node multiImageNode() => Node(
Node multiImageNode({List<ImageBlockData>? images}) => Node(
type: MultiImageBlockKeys.type,
attributes: {
MultiImageBlockKeys.images: MultiImageData(images: []).toJson(),
MultiImageBlockKeys.images:
MultiImageData(images: images ?? []).toJson(),
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:archive/archive.dart';
import 'package:path/path.dart' as p;
import '../image/custom_image_block_component/custom_image_block_component.dart';
@ -16,3 +21,64 @@ class CustomImageNodeParser extends NodeParser {
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 'custom_image_node_parser.dart';
export 'custom_paragraph_node_parser.dart';
export 'database_node_parser.dart';
export 'file_block_node_parser.dart';
export 'link_preview_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 exportPath = await getIt<FilePickerService>().saveFile(
dialogTitle: '',
fileName: '${viewName.toFileName()}.md',
fileName: '${viewName.toFileName()}.zip',
);
if (context.mounted && exportPath != null) {
context.read<ShareBloc>().add(

View file

@ -324,19 +324,21 @@ class ShareBloc extends Bloc<ShareEvent, ShareState> {
(f) => FlowyResult.failure(f),
);
} else {
result = await documentExporter.export(type.documentExportType);
result =
await documentExporter.export(type.documentExportType, path: path);
}
return result.fold(
(s) {
if (path != null) {
switch (type) {
case ShareType.markdown:
case ShareType.html:
case ShareType.csv:
case ShareType.json:
case ShareType.rawDatabaseData:
File(path).writeAsStringSync(s);
return FlowyResult.success(type);
case ShareType.markdown:
return FlowyResult.success(type);
default:
break;
}
@ -387,22 +389,30 @@ enum ShareType {
@freezed
class ShareEvent with _$ShareEvent {
const factory ShareEvent.initial() = _Initial;
const factory ShareEvent.share(
ShareType type,
String? path,
) = _Share;
const factory ShareEvent.publish(
String nameSpace,
String pageId,
List<String> selectedViewIds,
) = _Publish;
const factory ShareEvent.unPublish() = _UnPublish;
const factory ShareEvent.updateViewName(String name, String viewId) =
_UpdateViewName;
const factory ShareEvent.updatePublishStatus() = _UpdatePublishStatus;
const factory ShareEvent.setPublishStatus(bool isPublished) =
_SetPublishStatus;
const factory ShareEvent.updatePathName(String pathName) = _UpdatePathName;
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_backend/log.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(
String markdown, {
@ -14,17 +22,66 @@ Document customMarkdownToDocument(
);
}
String customDocumentToMarkdown(Document document) {
return documentToMarkdown(
Future<String> customDocumentToMarkdown(
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,
customParsers: [
const MathEquationNodeParser(),
const CalloutNodeParser(),
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 LinkPreviewNodeParser(),
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;
Future<FlowyResult<String, FlowyError>> export(
DocumentExportType type,
) async {
DocumentExportType type, {
String? path,
}) async {
final documentService = DocumentService();
final result = await documentService.openDocument(documentId: view.id);
return result.fold(
(r) {
(r) async {
final document = r.toDocument();
if (document == null) {
return FlowyResult.failure(
@ -43,8 +44,14 @@ class DocumentExporter {
case DocumentExportType.json:
return FlowyResult.success(jsonEncode(document));
case DocumentExportType.markdown:
final markdown = customDocumentToMarkdown(document);
return FlowyResult.success(markdown);
if (path != null) {
await customDocumentToMarkdown(document, path: path);
return FlowyResult.success('');
} else {
return FlowyResult.success(
await customDocumentToMarkdown(document),
);
}
case DocumentExportType.text:
throw UnimplementedError();
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/shared/markdown_to_document.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
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');
});
test('link preview', () {
test('link preview', () async {
final document = Document.blank()
..insert(
[0],
[linkPreviewNode(url: 'https://www.link_preview.com')],
);
final markdown = customDocumentToMarkdown(document);
final markdown = await customDocumentToMarkdown(document);
expect(
markdown,
'[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',
);
});
});
}