From e8665dc76c0bcd7cdd89c618ac7fc9e3d63d14d2 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Tue, 30 May 2023 17:55:38 +0800 Subject: [PATCH] feat: support exporting document data to afdoc format (#2610) * feat: support exporting document data to afdoc format * feat: export database * fix: resolve comment issues * fix: add error tips when exporting files failed --------- Co-authored-by: nathan --- .../assets/translations/en.json | 5 +- .../settings_file_exporter_cubit.dart | 15 ++ .../widgets/settings_export_file_widget.dart | 59 +++++++ ...settings_file_customize_location_view.dart | 12 +- .../settings_file_exporter_widget.dart | 164 ++++++++++++++---- .../widgets/settings_file_system_view.dart | 21 ++- .../flowy-database/src/services/export.rs | 114 ++++++------ 7 files changed, 279 insertions(+), 111 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_export_file_widget.dart diff --git a/frontend/appflowy_flutter/assets/translations/en.json b/frontend/appflowy_flutter/assets/translations/en.json index ff4afe9be2..50fbf5e968 100644 --- a/frontend/appflowy_flutter/assets/translations/en.json +++ b/frontend/appflowy_flutter/assets/translations/en.json @@ -185,6 +185,7 @@ }, "files": { "defaultLocation": "Where your data is stored now", + "exportData": "Export your data", "doubleTapToCopy": "Double tap to copy the path", "restoreLocation": "Restore to AppFlowy default path", "customizeLocation": "Open another folder", @@ -203,7 +204,9 @@ "create": "Create", "folderPath": "Path to store your folder", "locationCannotBeEmpty": "Path cannot be empty", - "pathCopiedSnackbar": "File storage path copied to clipboard!" + "pathCopiedSnackbar": "File storage path copied to clipboard!", + "exportFileSuccess": "Export file successfully!", + "exportFileFail": "Export file failed!" }, "user": { "name": "Name", diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_file_exporter_cubit.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_file_exporter_cubit.dart index 04be1f61bf..fdb87004f1 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_file_exporter_cubit.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_file_exporter_cubit.dart @@ -1,4 +1,5 @@ import 'package:appflowy_backend/protobuf/flowy-folder/app.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SettingsFileExportState { @@ -8,6 +9,20 @@ class SettingsFileExportState { initialize(); } + List get selectedViews { + final selectedViews = []; + for (var i = 0; i < apps.length; i++) { + if (selectedApps[i]) { + for (var j = 0; j < apps[i].belongings.items.length; j++) { + if (selectedItems[i][j]) { + selectedViews.add(apps[i].belongings.items[j]); + } + } + } + } + return selectedViews; + } + List apps; List expanded = []; List selectedApps = []; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_export_file_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_export_file_widget.dart new file mode 100644 index 0000000000..c32ca77db4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_export_file_widget.dart @@ -0,0 +1,59 @@ +import 'package:appflowy/workspace/presentation/settings/widgets/settings_file_exporter_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +import '../../../../generated/locale_keys.g.dart'; + +class SettingsExportFileWidget extends StatefulWidget { + const SettingsExportFileWidget({ + super.key, + }); + + @override + State createState() => + SettingsExportFileWidgetState(); +} + +@visibleForTesting +class SettingsExportFileWidgetState extends State { + @override + Widget build(BuildContext context) { + return ListTile( + title: FlowyText.medium( + LocaleKeys.settings_files_exportData.tr(), + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Tooltip( + message: LocaleKeys.settings_files_open.tr(), + child: FlowyIconButton( + height: 40, + width: 40, + icon: const Icon(Icons.folder_open_outlined), + hoverColor: Theme.of(context).colorScheme.secondaryContainer, + onPressed: () async { + await showDialog( + context: context, + builder: (context) { + return const FlowyDialog( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 20, + ), + child: FileExporterWidget(), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart index 347e83cb71..5855e9f0d9 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart @@ -12,8 +12,8 @@ import '../../../../startup/launch_configuration.dart'; import '../../../../startup/startup.dart'; import '../../../../startup/tasks/prelude.dart'; -class SettingsFileLocationCustomzier extends StatefulWidget { - const SettingsFileLocationCustomzier({ +class SettingsFileLocationCustomizer extends StatefulWidget { + const SettingsFileLocationCustomizer({ super.key, required this.cubit, }); @@ -21,13 +21,13 @@ class SettingsFileLocationCustomzier extends StatefulWidget { final SettingsLocationCubit cubit; @override - State createState() => - SettingsFileLocationCustomzierState(); + State createState() => + SettingsFileLocationCustomizerState(); } @visibleForTesting -class SettingsFileLocationCustomzierState - extends State { +class SettingsFileLocationCustomizerState + extends State { @override Widget build(BuildContext context) { return BlocProvider.value( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_exporter_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_exporter_widget.dart index 2b075d16fc..cf71ca4a46 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_exporter_widget.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_exporter_widget.dart @@ -1,15 +1,22 @@ +import 'dart:io'; + +import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/file_picker/file_picker_service.dart'; import 'package:appflowy/workspace/application/settings/settings_file_exporter_cubit.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database/database_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:dartz/dartz.dart' as dartz; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart' hide WidgetBuilder; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; - +import 'package:path/path.dart' as p; +import 'package:tuple/tuple.dart'; import '../../../../generated/locale_keys.g.dart'; class FileExporterWidget extends StatefulWidget { @@ -22,24 +29,44 @@ class FileExporterWidget extends StatefulWidget { class _FileExporterWidgetState extends State { // Map> _selectedPages = {}; + SettingsFileExporterCubit? cubit; + @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowyText.medium( - LocaleKeys.settings_files_selectFiles.tr(), - fontSize: 16.0, - ), - const VSpace(8), - Expanded(child: _buildFileSelector(context)), - const VSpace(8), - _buildButtons(context) - ], + return FutureBuilder>( + future: FolderEventReadCurrentWorkspace().send(), + builder: (context, snapshot) { + if (snapshot.hasData && + snapshot.connectionState == ConnectionState.done) { + final workspaces = snapshot.data?.getLeftOrNull(); + if (workspaces != null) { + final apps = workspaces.workspace.apps.items; + cubit ??= SettingsFileExporterCubit(apps: apps); + return BlocProvider.value( + value: cubit!, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText.medium( + LocaleKeys.settings_files_selectFiles.tr(), + fontSize: 16.0, + ), + const VSpace(8), + const Expanded(child: _ExpandedList()), + const VSpace(8), + _buildButtons() + ], + ), + ); + } + } + return const CircularProgressIndicator(); + }, ); } - Row _buildButtons(BuildContext context) { + Widget _buildButtons() { return Row( children: [ const Spacer(), @@ -55,8 +82,28 @@ class _FileExporterWidgetState extends State { onPressed: () async { await getIt() .getDirectoryPath() - .then((exportPath) { - Navigator.of(context).pop(); + .then((exportPath) async { + if (exportPath != null && cubit != null) { + final views = cubit!.state.selectedViews; + final result = + await _AppFlowyFileExporter.exportToPath(exportPath, views); + if (result.item1) { + // success + _showToast(LocaleKeys.settings_files_exportFileSuccess.tr()); + } else { + _showToast( + LocaleKeys.settings_files_exportFileFail.tr() + + result.item2.join('\n'), + ); + } + } else { + _showToast(LocaleKeys.settings_files_exportFileFail.tr()); + } + if (mounted) { + Navigator.of(context).popUntil( + (router) => router.settings.name == '/', + ); + } }); }, ), @@ -64,24 +111,14 @@ class _FileExporterWidgetState extends State { ); } - FutureBuilder> - _buildFileSelector(BuildContext context) { - return FutureBuilder>( - future: FolderEventReadCurrentWorkspace().send(), - builder: (context, snapshot) { - if (snapshot.hasData && - snapshot.connectionState == ConnectionState.done) { - final workspaces = snapshot.data?.getLeftOrNull(); - if (workspaces != null) { - final apps = workspaces.workspace.apps.items; - return BlocProvider( - create: (_) => SettingsFileExporterCubit(apps: apps), - child: const _ExpandedList(), - ); - } - } - return const CircularProgressIndicator(); - }, + void _showToast(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: FlowyText( + message, + color: Theme.of(context).colorScheme.onSurface, + ), + ), ); } } @@ -131,9 +168,9 @@ class _ExpandedListState extends State<_ExpandedList> { final apps = state.apps; final expanded = state.expanded; final selectedItems = state.selectedItems; - final isExpaned = expanded[index] == true; + final isExpanded = expanded[index] == true; List expandedChildren = []; - if (isExpaned) { + if (isExpanded) { for (var i = 0; i < selectedItems[index].length; i++) { final name = apps[index].belongings.items[i].name; final checkbox = CheckboxListTile( @@ -160,7 +197,7 @@ class _ExpandedListState extends State<_ExpandedList> { child: ListTile( title: FlowyText.medium(apps[index].name), trailing: Icon( - isExpaned + isExpanded ? Icons.arrow_drop_down_rounded : Icons.arrow_drop_up_rounded, ), @@ -182,3 +219,54 @@ extension AppFlowy on dartz.Either { return null; } } + +class _AppFlowyFileExporter { + static Future>> exportToPath( + String path, + List views, + ) async { + final failedFileNames = []; + final Map names = {}; + final documentService = DocumentService(); + for (final view in views) { + String? content; + String? fileExtension; + switch (view.layout) { + case ViewLayoutTypePB.Document: + final document = await documentService.openDocument(view: view); + document.fold( + (l) => content = l.content, + (r) => Log.error(r), + ); + fileExtension = 'afdoc'; + break; + default: + final result = await exportDatabase(view.id); + result.fold( + (pb) => content = pb.data, + (r) => Log.error(r), + ); + fileExtension = 'afdb'; + break; + } + if (content != null) { + final count = names.putIfAbsent(view.name, () => 0); + final name = count == 0 ? view.name : '${view.name}($count)'; + final file = File(p.join(path, '$name.$fileExtension')); + await file.writeAsString(content!); + names[view.name] = count + 1; + } else { + failedFileNames.add(view.name); + } + } + + return Tuple2(failedFileNames.isEmpty, failedFileNames); + } +} + +Future> exportDatabase( + String viewId, +) async { + final payload = DatabaseViewIdPB.create()..value = viewId; + return DatabaseEventExportCSV(payload).send(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_system_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_system_view.dart index c90793fbfd..22e2a43a6a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_system_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_system_view.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/workspace/presentation/settings/widgets/settings_export_file_widget.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart'; import 'package:flutter/material.dart'; @@ -14,22 +15,20 @@ class SettingsFileSystemView extends StatefulWidget { class _SettingsFileSystemViewState extends State { final _locationCubit = SettingsLocationCubit()..fetchLocation(); + late final _items = [ + SettingsFileLocationCustomizer( + cubit: _locationCubit, + ), + const SettingsExportFileWidget() + ]; @override Widget build(BuildContext context) { return ListView.separated( - itemBuilder: (context, index) { - if (index == 0) { - return SettingsFileLocationCustomzier( - cubit: _locationCubit, - ); - } else if (index == 1) { - // return _buildExportDatabaseButton(); - } - return Container(); - }, + shrinkWrap: true, + itemBuilder: (context, index) => _items[index], separatorBuilder: (context, index) => const Divider(), - itemCount: 2, // make the divider taking effect. + itemCount: _items.length, ); } } diff --git a/frontend/rust-lib/flowy-database/src/services/export.rs b/frontend/rust-lib/flowy-database/src/services/export.rs index da5c0728b3..1884a7298a 100644 --- a/frontend/rust-lib/flowy-database/src/services/export.rs +++ b/frontend/rust-lib/flowy-database/src/services/export.rs @@ -38,61 +38,65 @@ impl From<&Arc> for ExportField { let field_type = FieldType::from(field_rev.ty); let mut type_options: HashMap = HashMap::new(); - field_rev.type_options.iter().for_each(|(k, s)| { - let value = match field_type { - FieldType::RichText => { - let pb = RichTextTypeOptionPB::from_json_str(s); - serde_json::to_value(pb).unwrap() - }, - FieldType::Number => { - let pb = NumberTypeOptionPB::from_json_str(s); - let mut map = Map::new(); - map.insert("format".to_string(), json!(pb.format as u8)); - map.insert("scale".to_string(), json!(pb.scale)); - map.insert("symbol".to_string(), json!(pb.symbol)); - map.insert("name".to_string(), json!(pb.name)); - Value::Object(map) - }, - FieldType::DateTime => { - let pb = DateTypeOptionPB::from_json_str(s); - let mut map = Map::new(); - map.insert("date_format".to_string(), json!(pb.date_format as u8)); - map.insert("time_format".to_string(), json!(pb.time_format as u8)); - map.insert("field_type".to_string(), json!(FieldType::DateTime as u8)); - Value::Object(map) - }, - FieldType::SingleSelect => { - let pb = SingleSelectTypeOptionPB::from_json_str(s); - let value = serde_json::to_string(&pb).unwrap(); - let mut map = Map::new(); - map.insert("content".to_string(), Value::String(value)); - Value::Object(map) - }, - FieldType::MultiSelect => { - let pb = MultiSelectTypeOptionPB::from_json_str(s); - let value = serde_json::to_string(&pb).unwrap(); - let mut map = Map::new(); - map.insert("content".to_string(), Value::String(value)); - Value::Object(map) - }, - FieldType::Checkbox => { - let pb = CheckboxTypeOptionPB::from_json_str(s); - serde_json::to_value(pb).unwrap() - }, - FieldType::URL => { - let pb = RichTextTypeOptionPB::from_json_str(s); - serde_json::to_value(pb).unwrap() - }, - FieldType::Checklist => { - let pb = ChecklistTypeOptionPB::from_json_str(s); - let value = serde_json::to_string(&pb).unwrap(); - let mut map = Map::new(); - map.insert("content".to_string(), Value::String(value)); - Value::Object(map) - }, - }; - type_options.insert(k.clone(), value); - }); + field_rev + .type_options + .iter() + .filter(|(k, _)| k == &&field_rev.ty.to_string()) + .for_each(|(k, s)| { + let value = match field_type { + FieldType::RichText => { + let pb = RichTextTypeOptionPB::from_json_str(s); + serde_json::to_value(pb).unwrap() + }, + FieldType::Number => { + let pb = NumberTypeOptionPB::from_json_str(s); + let mut map = Map::new(); + map.insert("format".to_string(), json!(pb.format as u8)); + map.insert("scale".to_string(), json!(pb.scale)); + map.insert("symbol".to_string(), json!(pb.symbol)); + map.insert("name".to_string(), json!(pb.name)); + Value::Object(map) + }, + FieldType::DateTime => { + let pb = DateTypeOptionPB::from_json_str(s); + let mut map = Map::new(); + map.insert("date_format".to_string(), json!(pb.date_format as u8)); + map.insert("time_format".to_string(), json!(pb.time_format as u8)); + map.insert("field_type".to_string(), json!(FieldType::DateTime as u8)); + Value::Object(map) + }, + FieldType::SingleSelect => { + let pb = SingleSelectTypeOptionPB::from_json_str(s); + let value = serde_json::to_string(&pb).unwrap(); + let mut map = Map::new(); + map.insert("content".to_string(), Value::String(value)); + Value::Object(map) + }, + FieldType::MultiSelect => { + let pb = MultiSelectTypeOptionPB::from_json_str(s); + let value = serde_json::to_string(&pb).unwrap(); + let mut map = Map::new(); + map.insert("content".to_string(), Value::String(value)); + Value::Object(map) + }, + FieldType::Checkbox => { + let pb = CheckboxTypeOptionPB::from_json_str(s); + serde_json::to_value(pb).unwrap() + }, + FieldType::URL => { + let pb = RichTextTypeOptionPB::from_json_str(s); + serde_json::to_value(pb).unwrap() + }, + FieldType::Checklist => { + let pb = ChecklistTypeOptionPB::from_json_str(s); + let value = serde_json::to_string(&pb).unwrap(); + let mut map = Map::new(); + map.insert("content".to_string(), Value::String(value)); + Value::Object(map) + }, + }; + type_options.insert(k.clone(), value); + }); Self { id: field_rev.id.clone(), name: field_rev.name.clone(),