mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-04-26 07:37:14 -04:00
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:
parent
04e3246976
commit
552dba5abe
14 changed files with 383 additions and 28 deletions
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 '\n';
|
return '\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 ')})\n';
|
||||||
|
}
|
||||||
|
assert(url != null);
|
||||||
|
return '\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('');
|
||||||
|
} else {
|
||||||
|
markdownImages.add('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return markdownImages.join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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';
|
||||||
|
|
|
@ -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 '';
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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,
|
||||||
|
'\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue