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 <nathan@appflowy.io>
This commit is contained in:
Lucas.Xu 2023-05-30 17:55:38 +08:00 committed by GitHub
parent f022f9aa06
commit e8665dc76c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 279 additions and 111 deletions

View File

@ -185,6 +185,7 @@
}, },
"files": { "files": {
"defaultLocation": "Where your data is stored now", "defaultLocation": "Where your data is stored now",
"exportData": "Export your data",
"doubleTapToCopy": "Double tap to copy the path", "doubleTapToCopy": "Double tap to copy the path",
"restoreLocation": "Restore to AppFlowy default path", "restoreLocation": "Restore to AppFlowy default path",
"customizeLocation": "Open another folder", "customizeLocation": "Open another folder",
@ -203,7 +204,9 @@
"create": "Create", "create": "Create",
"folderPath": "Path to store your folder", "folderPath": "Path to store your folder",
"locationCannotBeEmpty": "Path cannot be empty", "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": { "user": {
"name": "Name", "name": "Name",

View File

@ -1,4 +1,5 @@
import 'package:appflowy_backend/protobuf/flowy-folder/app.pb.dart'; 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'; import 'package:flutter_bloc/flutter_bloc.dart';
class SettingsFileExportState { class SettingsFileExportState {
@ -8,6 +9,20 @@ class SettingsFileExportState {
initialize(); 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<AppPB> apps;
List<bool> expanded = []; List<bool> expanded = [];
List<bool> selectedApps = []; List<bool> selectedApps = [];

View File

@ -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(),
),
);
},
);
},
),
),
],
),
);
}
}

View File

@ -12,8 +12,8 @@ import '../../../../startup/launch_configuration.dart';
import '../../../../startup/startup.dart'; import '../../../../startup/startup.dart';
import '../../../../startup/tasks/prelude.dart'; import '../../../../startup/tasks/prelude.dart';
class SettingsFileLocationCustomzier extends StatefulWidget { class SettingsFileLocationCustomizer extends StatefulWidget {
const SettingsFileLocationCustomzier({ const SettingsFileLocationCustomizer({
super.key, super.key,
required this.cubit, required this.cubit,
}); });
@ -21,13 +21,13 @@ class SettingsFileLocationCustomzier extends StatefulWidget {
final SettingsLocationCubit cubit; final SettingsLocationCubit cubit;
@override @override
State<SettingsFileLocationCustomzier> createState() => State<SettingsFileLocationCustomizer> createState() =>
SettingsFileLocationCustomzierState(); SettingsFileLocationCustomizerState();
} }
@visibleForTesting @visibleForTesting
class SettingsFileLocationCustomzierState class SettingsFileLocationCustomizerState
extends State<SettingsFileLocationCustomzier> { extends State<SettingsFileLocationCustomizer> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<SettingsLocationCubit>.value( return BlocProvider<SettingsLocationCubit>.value(

View File

@ -1,15 +1,22 @@
import 'dart:io';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/file_picker/file_picker_service.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/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:dartz/dartz.dart' as dartz;
import 'package:easy_localization/easy_localization.dart'; 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/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.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 'package:path/path.dart' as p;
import 'package:tuple/tuple.dart';
import '../../../../generated/locale_keys.g.dart'; import '../../../../generated/locale_keys.g.dart';
class FileExporterWidget extends StatefulWidget { class FileExporterWidget extends StatefulWidget {
@ -22,24 +29,44 @@ class FileExporterWidget extends StatefulWidget {
class _FileExporterWidgetState extends State<FileExporterWidget> { class _FileExporterWidgetState extends State<FileExporterWidget> {
// Map<String, List<String>> _selectedPages = {}; // Map<String, List<String>> _selectedPages = {};
SettingsFileExporterCubit? cubit;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( 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, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
FlowyText.medium( FlowyText.medium(
LocaleKeys.settings_files_selectFiles.tr(), LocaleKeys.settings_files_selectFiles.tr(),
fontSize: 16.0, fontSize: 16.0,
), ),
const VSpace(8), const VSpace(8),
Expanded(child: _buildFileSelector(context)), const Expanded(child: _ExpandedList()),
const VSpace(8), const VSpace(8),
_buildButtons(context) _buildButtons()
], ],
),
);
}
}
return const CircularProgressIndicator();
},
); );
} }
Row _buildButtons(BuildContext context) { Widget _buildButtons() {
return Row( return Row(
children: [ children: [
const Spacer(), const Spacer(),
@ -55,8 +82,28 @@ class _FileExporterWidgetState extends State<FileExporterWidget> {
onPressed: () async { onPressed: () async {
await getIt<FilePickerService>() await getIt<FilePickerService>()
.getDirectoryPath() .getDirectoryPath()
.then((exportPath) { .then((exportPath) async {
Navigator.of(context).pop(); 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>> void _showToast(String message) {
_buildFileSelector(BuildContext context) { ScaffoldMessenger.of(context).showSnackBar(
return FutureBuilder<dartz.Either<WorkspaceSettingPB, FlowyError>>( SnackBar(
future: FolderEventReadCurrentWorkspace().send(), content: FlowyText(
builder: (context, snapshot) { message,
if (snapshot.hasData && color: Theme.of(context).colorScheme.onSurface,
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();
},
); );
} }
} }
@ -131,9 +168,9 @@ class _ExpandedListState extends State<_ExpandedList> {
final apps = state.apps; final apps = state.apps;
final expanded = state.expanded; final expanded = state.expanded;
final selectedItems = state.selectedItems; final selectedItems = state.selectedItems;
final isExpaned = expanded[index] == true; final isExpanded = expanded[index] == true;
List<Widget> expandedChildren = []; List<Widget> expandedChildren = [];
if (isExpaned) { if (isExpanded) {
for (var i = 0; i < selectedItems[index].length; i++) { for (var i = 0; i < selectedItems[index].length; i++) {
final name = apps[index].belongings.items[i].name; final name = apps[index].belongings.items[i].name;
final checkbox = CheckboxListTile( final checkbox = CheckboxListTile(
@ -160,7 +197,7 @@ class _ExpandedListState extends State<_ExpandedList> {
child: ListTile( child: ListTile(
title: FlowyText.medium(apps[index].name), title: FlowyText.medium(apps[index].name),
trailing: Icon( trailing: Icon(
isExpaned isExpanded
? Icons.arrow_drop_down_rounded ? Icons.arrow_drop_down_rounded
: Icons.arrow_drop_up_rounded, : Icons.arrow_drop_up_rounded,
), ),
@ -182,3 +219,54 @@ extension AppFlowy on dartz.Either {
return null; 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();
}

View File

@ -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:appflowy/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -14,22 +15,20 @@ class SettingsFileSystemView extends StatefulWidget {
class _SettingsFileSystemViewState extends State<SettingsFileSystemView> { class _SettingsFileSystemViewState extends State<SettingsFileSystemView> {
final _locationCubit = SettingsLocationCubit()..fetchLocation(); final _locationCubit = SettingsLocationCubit()..fetchLocation();
late final _items = [
SettingsFileLocationCustomizer(
cubit: _locationCubit,
),
const SettingsExportFileWidget()
];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView.separated( return ListView.separated(
itemBuilder: (context, index) { shrinkWrap: true,
if (index == 0) { itemBuilder: (context, index) => _items[index],
return SettingsFileLocationCustomzier(
cubit: _locationCubit,
);
} else if (index == 1) {
// return _buildExportDatabaseButton();
}
return Container();
},
separatorBuilder: (context, index) => const Divider(), separatorBuilder: (context, index) => const Divider(),
itemCount: 2, // make the divider taking effect. itemCount: _items.length,
); );
} }
} }

View File

@ -38,7 +38,11 @@ impl From<&Arc<FieldRevision>> for ExportField {
let field_type = FieldType::from(field_rev.ty); let field_type = FieldType::from(field_rev.ty);
let mut type_options: HashMap<String, Value> = HashMap::new(); let mut type_options: HashMap<String, Value> = HashMap::new();
field_rev.type_options.iter().for_each(|(k, s)| { field_rev
.type_options
.iter()
.filter(|(k, _)| k == &&field_rev.ty.to_string())
.for_each(|(k, s)| {
let value = match field_type { let value = match field_type {
FieldType::RichText => { FieldType::RichText => {
let pb = RichTextTypeOptionPB::from_json_str(s); let pb = RichTextTypeOptionPB::from_json_str(s);