瀏覽代碼

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 <[email protected]>
Lucas.Xu 2 年之前
父節點
當前提交
e8665dc76c

+ 4 - 1
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",

+ 15 - 0
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<ViewPB> get selectedViews {
+    final selectedViews = <ViewPB>[];
+    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<AppPB> apps;
   List<bool> expanded = [];
   List<bool> selectedApps = [];

+ 59 - 0
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<SettingsExportFileWidget> createState() =>
+      SettingsExportFileWidgetState();
+}
+
+@visibleForTesting
+class SettingsExportFileWidgetState extends State<SettingsExportFileWidget> {
+  @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(),
+                      ),
+                    );
+                  },
+                );
+              },
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 6 - 6
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<SettingsFileLocationCustomzier> createState() =>
-      SettingsFileLocationCustomzierState();
+  State<SettingsFileLocationCustomizer> createState() =>
+      SettingsFileLocationCustomizerState();
 }
 
 @visibleForTesting
-class SettingsFileLocationCustomzierState
-    extends State<SettingsFileLocationCustomzier> {
+class SettingsFileLocationCustomizerState
+    extends State<SettingsFileLocationCustomizer> {
   @override
   Widget build(BuildContext context) {
     return BlocProvider<SettingsLocationCubit>.value(

+ 126 - 38
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<FileExporterWidget> {
   // Map<String, List<String>> _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<dartz.Either<WorkspaceSettingPB, FlowyError>>(
+      future: FolderEventReadCurrentWorkspace().send(),
+      builder: (context, snapshot) {
+        if (snapshot.hasData &&
+            snapshot.connectionState == ConnectionState.done) {
+          final workspaces = snapshot.data?.getLeftOrNull<WorkspaceSettingPB>();
+          if (workspaces != null) {
+            final apps = workspaces.workspace.apps.items;
+            cubit ??= SettingsFileExporterCubit(apps: apps);
+            return BlocProvider<SettingsFileExporterCubit>.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<FileExporterWidget> {
           onPressed: () async {
             await getIt<FilePickerService>()
                 .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<FileExporterWidget> {
     );
   }
 
-  FutureBuilder<dartz.Either<WorkspaceSettingPB, FlowyError>>
-      _buildFileSelector(BuildContext context) {
-    return FutureBuilder<dartz.Either<WorkspaceSettingPB, FlowyError>>(
-      future: FolderEventReadCurrentWorkspace().send(),
-      builder: (context, snapshot) {
-        if (snapshot.hasData &&
-            snapshot.connectionState == ConnectionState.done) {
-          final workspaces = snapshot.data?.getLeftOrNull<WorkspaceSettingPB>();
-          if (workspaces != null) {
-            final apps = workspaces.workspace.apps.items;
-            return BlocProvider<SettingsFileExporterCubit>(
-              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<Widget> 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<Tuple2<bool, List<String>>> exportToPath(
+    String path,
+    List<ViewPB> views,
+  ) async {
+    final failedFileNames = <String>[];
+    final Map<String, int> 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<dartz.Either<ExportCSVPB, FlowyError>> exportDatabase(
+  String viewId,
+) async {
+  final payload = DatabaseViewIdPB.create()..value = viewId;
+  return DatabaseEventExportCSV(payload).send();
+}

+ 10 - 11
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<SettingsFileSystemView> {
   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,
     );
   }
 }

+ 59 - 55
frontend/rust-lib/flowy-database/src/services/export.rs

@@ -38,61 +38,65 @@ impl From<&Arc<FieldRevision>> for ExportField {
     let field_type = FieldType::from(field_rev.ty);
     let mut type_options: HashMap<String, Value> = 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(),