mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
f022f9aa06
commit
e8665dc76c
@ -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",
|
||||||
|
@ -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 = [];
|
||||||
|
@ -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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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(
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
Loading…
Reference in New Issue
Block a user