mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: support uploading image to cloud storage (#4413)
* feat: add object ext * feat: integrate upload image api * feat: support uploading local file to cloud * feat: abstact the CachedNetworkImage as FlowyNetworkImage * ci: fix tauri ci * fix: integration test --------- Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io> Co-authored-by: nathan <nathan@appflowy.io>
This commit is contained in:
parent
4852e5c8d4
commit
0a0f2adf76
@ -43,8 +43,6 @@ void main() {
|
||||
await tester.editor.hoverOnCover();
|
||||
await tester.editor.tapOnChangeCover();
|
||||
await tester.editor.addNetworkImageCover(imageUrl);
|
||||
await tester.editor.switchNetworkImageCover(imageUrl);
|
||||
await tester.editor.dismissCoverPicker();
|
||||
tester.expectToSeeDocumentCover(CoverType.file);
|
||||
|
||||
// Remove the cover
|
||||
|
@ -1,13 +1,18 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/core/config/kv.dart';
|
||||
import 'package:appflowy/core/config/kv_keys.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu;
|
||||
import 'package:appflowy_editor/appflowy_editor.dart'
|
||||
hide UploadImageMenu, ResizableImage;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -61,6 +66,7 @@ void main() {
|
||||
paths: [imagePath],
|
||||
);
|
||||
|
||||
getIt<KeyValueStorage>().set(KVKeys.kCloudType, '0');
|
||||
await tester.tapButtonWithName(
|
||||
LocaleKeys.document_imageBlock_upload_placeholder.tr(),
|
||||
);
|
||||
|
@ -6,9 +6,9 @@ import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart';
|
||||
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@ -121,19 +121,25 @@ class EditorOperations {
|
||||
}
|
||||
|
||||
Future<void> addNetworkImageCover(String imageUrl) async {
|
||||
final findNewImageButton = find.byType(NewCustomCoverButton);
|
||||
await tester.tapButton(findNewImageButton);
|
||||
final embedLinkButton = find.findTextInFlowyText(
|
||||
LocaleKeys.document_imageBlock_embedLink_label.tr(),
|
||||
);
|
||||
await tester.tapButton(embedLinkButton);
|
||||
|
||||
final imageUrlTextField = find.descendant(
|
||||
of: find.byType(NetworkImageUrlInput),
|
||||
of: find.byType(EmbedImageUrlWidget),
|
||||
matching: find.byType(TextField),
|
||||
);
|
||||
await tester.enterText(imageUrlTextField, imageUrl);
|
||||
await tester.tapButtonWithName(
|
||||
LocaleKeys.document_plugins_cover_add.tr(),
|
||||
);
|
||||
await tester.tapButtonWithName(
|
||||
LocaleKeys.document_plugins_cover_saveToGallery.tr(),
|
||||
final textField = tester.widget<TextField>(imageUrlTextField);
|
||||
textField.controller?.text = imageUrl;
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapButton(
|
||||
find.descendant(
|
||||
of: find.byType(EmbedImageUrlWidget),
|
||||
matching: find.findTextInFlowyText(
|
||||
LocaleKeys.document_imageBlock_embedLink_label.tr(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -14,6 +14,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class MobileHomeScreen extends StatelessWidget {
|
||||
const MobileHomeScreen({super.key});
|
||||
@ -51,9 +52,12 @@ class MobileHomeScreen extends StatelessWidget {
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: MobileHomePage(
|
||||
userProfile: userProfile,
|
||||
workspaceSetting: workspaceSetting,
|
||||
child: Provider.value(
|
||||
value: userProfile,
|
||||
child: MobileHomePage(
|
||||
userProfile: userProfile,
|
||||
workspaceSetting: workspaceSetting,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -4,16 +4,18 @@ import 'package:appflowy/mobile/application/mobile_router.dart';
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_text.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/shared/appflowy_network_image.dart';
|
||||
import 'package:appflowy/workspace/application/doc/doc_listener.dart';
|
||||
import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
|
||||
class MobileRecentView extends StatefulWidget {
|
||||
@ -161,9 +163,10 @@ class _MobileRecentViewState extends State<MobileRecentView> {
|
||||
switch (type) {
|
||||
case CoverType.file:
|
||||
if (isURL(cover)) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: cover,
|
||||
fit: BoxFit.cover,
|
||||
final userProfilePB = Provider.of<UserProfilePB?>(context);
|
||||
return FlowyNetworkImage(
|
||||
url: cover,
|
||||
userProfilePB: userProfilePB,
|
||||
);
|
||||
}
|
||||
final imageFile = File(cover);
|
||||
|
@ -4,6 +4,8 @@ import 'package:appflowy/plugins/document/application/doc_service.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
|
||||
import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart';
|
||||
import 'package:appflowy/plugins/trash/application/trash_service.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/workspace/application/doc/doc_listener.dart';
|
||||
import 'package:appflowy/workspace/application/doc/sync_state_listener.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_listener.dart';
|
||||
@ -73,21 +75,26 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
final editorState = await _fetchDocumentState();
|
||||
_onViewChanged();
|
||||
_onDocumentChanged();
|
||||
editorState.fold(
|
||||
(l) => emit(
|
||||
await editorState.fold(
|
||||
(l) async => emit(
|
||||
state.copyWith(
|
||||
error: l,
|
||||
editorState: null,
|
||||
isLoading: false,
|
||||
),
|
||||
),
|
||||
(r) => emit(
|
||||
state.copyWith(
|
||||
error: null,
|
||||
editorState: r,
|
||||
isLoading: false,
|
||||
),
|
||||
),
|
||||
(r) async {
|
||||
final result = await getIt<AuthService>().getUser();
|
||||
final userProfilePB = result.fold((l) => null, (r) => r);
|
||||
emit(
|
||||
state.copyWith(
|
||||
error: null,
|
||||
editorState: r,
|
||||
isLoading: false,
|
||||
userProfilePB: userProfilePB,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
moveToTrash: () async {
|
||||
|
@ -100,4 +100,37 @@ class DocumentService {
|
||||
final result = await DocumentEventApplyTextDeltaEvent(payload).send();
|
||||
return result.swap();
|
||||
}
|
||||
|
||||
/// Upload a file to the cloud storage.
|
||||
Future<Either<FlowyError, UploadedFilePB>> uploadFile({
|
||||
required String localFilePath,
|
||||
}) async {
|
||||
final workspace = await FolderEventReadCurrentWorkspace().send();
|
||||
return workspace.fold((l) async {
|
||||
final payload = UploadFileParamsPB(
|
||||
workspaceId: l.id,
|
||||
localFilePath: localFilePath,
|
||||
);
|
||||
final result = await DocumentEventUploadFile(payload).send();
|
||||
return result.swap();
|
||||
}, (r) async {
|
||||
return left(FlowyError(msg: 'Workspace not found'));
|
||||
});
|
||||
}
|
||||
|
||||
/// Download a file from the cloud storage.
|
||||
Future<Either<FlowyError, Unit>> downloadFile({
|
||||
required String url,
|
||||
}) async {
|
||||
final workspace = await FolderEventReadCurrentWorkspace().send();
|
||||
return workspace.fold((l) async {
|
||||
final payload = UploadedFilePB(
|
||||
url: url,
|
||||
);
|
||||
final result = await DocumentEventDownloadFile(payload).send();
|
||||
return result.swap();
|
||||
}, (r) async {
|
||||
return left(FlowyError(msg: 'Workspace not found'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,22 +1,26 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart';
|
||||
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
|
||||
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/shared/appflowy_network_image.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_listener.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu;
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/rounded_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
|
||||
@ -165,13 +169,14 @@ class _DocumentHeaderNodeWidgetState extends State<DocumentHeaderNodeWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveCover({(CoverType, String?)? cover, String? icon}) {
|
||||
Future<void> _saveCover({(CoverType, String?)? cover, String? icon}) async {
|
||||
final transaction = widget.editorState.transaction;
|
||||
final coverType = widget.node.attributes[DocumentHeaderBlockKeys.coverType];
|
||||
final coverDetails =
|
||||
widget.node.attributes[DocumentHeaderBlockKeys.coverDetails];
|
||||
final Map<String, dynamic> attributes = {
|
||||
DocumentHeaderBlockKeys.coverType:
|
||||
widget.node.attributes[DocumentHeaderBlockKeys.coverType],
|
||||
DocumentHeaderBlockKeys.coverDetails:
|
||||
widget.node.attributes[DocumentHeaderBlockKeys.coverDetails],
|
||||
DocumentHeaderBlockKeys.coverType: coverType,
|
||||
DocumentHeaderBlockKeys.coverDetails: coverDetails,
|
||||
DocumentHeaderBlockKeys.icon:
|
||||
widget.node.attributes[DocumentHeaderBlockKeys.icon],
|
||||
};
|
||||
@ -501,9 +506,13 @@ class DocumentCoverState extends State<DocumentCover> {
|
||||
switch (widget.coverType) {
|
||||
case CoverType.file:
|
||||
if (isURL(detail)) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: detail,
|
||||
fit: BoxFit.cover,
|
||||
final userProfilePB =
|
||||
context.read<DocumentBloc>().state.userProfilePB;
|
||||
return FlowyNetworkImage(
|
||||
url: detail,
|
||||
userProfilePB: userProfilePB,
|
||||
errorWidgetBuilder: (context, url, error) =>
|
||||
const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
final imageFile = File(detail);
|
||||
@ -542,7 +551,11 @@ class DocumentCoverState extends State<DocumentCover> {
|
||||
triggerActions: PopoverTriggerFlags.none,
|
||||
offset: const Offset(0, 8),
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
constraints: BoxConstraints.loose(const Size(380, 450)),
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 540,
|
||||
maxHeight: 360,
|
||||
minHeight: 80,
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
onClose: () => isPopoverOpen = false,
|
||||
child: IntrinsicWidth(
|
||||
@ -558,23 +571,55 @@ class DocumentCoverState extends State<DocumentCover> {
|
||||
),
|
||||
popupBuilder: (BuildContext popoverContext) {
|
||||
isPopoverOpen = true;
|
||||
return ChangeCoverPopover(
|
||||
node: widget.node,
|
||||
editorState: widget.editorState,
|
||||
onCoverChanged: (cover, selection) =>
|
||||
widget.onCoverChanged(cover, selection),
|
||||
|
||||
return UploadImageMenu(
|
||||
supportTypes: const [
|
||||
UploadImageType.color,
|
||||
UploadImageType.local,
|
||||
UploadImageType.url,
|
||||
UploadImageType.unsplash,
|
||||
],
|
||||
onSelectedLocalImage: (path) async {
|
||||
popoverController.close();
|
||||
onCoverChanged(CoverType.file, path);
|
||||
},
|
||||
onSelectedAIImage: (_) {
|
||||
throw UnimplementedError();
|
||||
},
|
||||
onSelectedNetworkImage: (url) async {
|
||||
popoverController.close();
|
||||
onCoverChanged(CoverType.file, url);
|
||||
},
|
||||
onSelectedColor: (color) {
|
||||
popoverController.close();
|
||||
onCoverChanged(CoverType.color, color);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const HSpace(10),
|
||||
DeleteCoverButton(
|
||||
onTap: () => widget.onCoverChanged(CoverType.none, null),
|
||||
onTap: () => onCoverChanged(CoverType.none, null),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onCoverChanged(CoverType type, String? details) async {
|
||||
if (type == CoverType.file && details != null && !isURL(details)) {
|
||||
final type = await getAuthenticatorType();
|
||||
// if the user is using local authenticator, we need to save the image to local storage
|
||||
if (type == AuthenticatorType.local) {
|
||||
details = await saveImageToLocalStorage(details);
|
||||
} else {
|
||||
// else we should save the image to cloud storage
|
||||
details = await saveImageToCloudStorage(details);
|
||||
}
|
||||
}
|
||||
widget.onCoverChanged(type, details);
|
||||
}
|
||||
|
||||
void setOverlayButtonsHidden(bool value) {
|
||||
if (isOverlayButtonsHidden == value) return;
|
||||
setState(() {
|
||||
|
@ -6,10 +6,11 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsupport_image_widget.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@ -22,6 +23,69 @@ import 'package:string_validator/string_validator.dart';
|
||||
|
||||
const kImagePlaceholderKey = 'imagePlaceholderKey';
|
||||
|
||||
enum CustomImageType {
|
||||
local,
|
||||
internal, // the images saved in self-host cloud
|
||||
external; // the images linked from network, like unsplash, https://xxx/yyy/zzz.jpg
|
||||
|
||||
static CustomImageType fromIntValue(int value) {
|
||||
switch (value) {
|
||||
case 0:
|
||||
return CustomImageType.local;
|
||||
case 1:
|
||||
return CustomImageType.internal;
|
||||
case 2:
|
||||
return CustomImageType.external;
|
||||
default:
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
int toIntValue() {
|
||||
switch (this) {
|
||||
case CustomImageType.local:
|
||||
return 0;
|
||||
case CustomImageType.internal:
|
||||
return 1;
|
||||
case CustomImageType.external:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CustomImageBlockKeys {
|
||||
const CustomImageBlockKeys._();
|
||||
|
||||
static const String type = 'image';
|
||||
|
||||
/// The align data of a image block.
|
||||
///
|
||||
/// The value is a String.
|
||||
/// left, center, right
|
||||
static const String align = 'align';
|
||||
|
||||
/// The image src of a image block.
|
||||
///
|
||||
/// The value is a String.
|
||||
/// It can be a url or a base64 string(web).
|
||||
static const String url = 'url';
|
||||
|
||||
/// The height of a image block.
|
||||
///
|
||||
/// The value is a double.
|
||||
static const String width = 'width';
|
||||
|
||||
/// The width of a image block.
|
||||
///
|
||||
/// The value is a double.
|
||||
static const String height = 'height';
|
||||
|
||||
/// The image type of a image block.
|
||||
///
|
||||
/// The value is a CustomImageType enum.
|
||||
static const String imageType = 'image_type';
|
||||
}
|
||||
|
||||
typedef CustomImageBlockComponentMenuBuilder = Widget Function(
|
||||
Node node,
|
||||
CustomImageBlockComponentState state,
|
||||
@ -103,14 +167,16 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
||||
Widget build(BuildContext context) {
|
||||
final node = widget.node;
|
||||
final attributes = node.attributes;
|
||||
final src = attributes[ImageBlockKeys.url];
|
||||
final src = attributes[CustomImageBlockKeys.url];
|
||||
|
||||
final alignment = AlignmentExtension.fromString(
|
||||
attributes[ImageBlockKeys.align] ?? 'center',
|
||||
attributes[CustomImageBlockKeys.align] ?? 'center',
|
||||
);
|
||||
final width = attributes[ImageBlockKeys.width]?.toDouble() ??
|
||||
final width = attributes[CustomImageBlockKeys.width]?.toDouble() ??
|
||||
MediaQuery.of(context).size.width;
|
||||
final height = attributes[ImageBlockKeys.height]?.toDouble();
|
||||
final height = attributes[CustomImageBlockKeys.height]?.toDouble();
|
||||
final rawImageType = attributes[CustomImageBlockKeys.imageType] ?? 0;
|
||||
final imageType = CustomImageType.fromIntValue(rawImageType);
|
||||
|
||||
final imagePlaceholderKey = node.extraInfos?[kImagePlaceholderKey];
|
||||
Widget child;
|
||||
@ -119,7 +185,8 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
||||
key: imagePlaceholderKey is GlobalKey ? imagePlaceholderKey : null,
|
||||
node: node,
|
||||
);
|
||||
} else if (!_checkIfURLIsValid(src)) {
|
||||
} else if (imageType != CustomImageType.internal &&
|
||||
!_checkIfURLIsValid(src)) {
|
||||
child = const UnSupportImageWidget();
|
||||
} else {
|
||||
child = ResizableImage(
|
||||
@ -128,30 +195,39 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
||||
height: height,
|
||||
editable: editorState.editable,
|
||||
alignment: alignment,
|
||||
type: imageType,
|
||||
onResize: (width) {
|
||||
final transaction = editorState.transaction
|
||||
..updateNode(node, {
|
||||
ImageBlockKeys.width: width,
|
||||
CustomImageBlockKeys.width: width,
|
||||
});
|
||||
editorState.apply(transaction);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
child = BlockSelectionContainer(
|
||||
node: node,
|
||||
delegate: this,
|
||||
listenable: editorState.selectionNotifier,
|
||||
blockColor: editorState.editorStyle.selectionColor,
|
||||
supportTypes: const [
|
||||
BlockSelectionType.block,
|
||||
],
|
||||
child: Padding(
|
||||
if (PlatformExtension.isDesktopOrWeb) {
|
||||
child = BlockSelectionContainer(
|
||||
node: node,
|
||||
delegate: this,
|
||||
listenable: editorState.selectionNotifier,
|
||||
blockColor: editorState.editorStyle.selectionColor,
|
||||
supportTypes: const [
|
||||
BlockSelectionType.block,
|
||||
],
|
||||
child: Padding(
|
||||
key: imageKey,
|
||||
padding: padding,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
child = Padding(
|
||||
key: imageKey,
|
||||
padding: padding,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.showActions && widget.actionBuilder != null) {
|
||||
child = BlockComponentActionWrapper(
|
||||
@ -176,7 +252,7 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: showActionsNotifier,
|
||||
builder: (context, value, child) {
|
||||
final url = node.attributes[ImageBlockKeys.url];
|
||||
final url = node.attributes[CustomImageBlockKeys.url];
|
||||
return Stack(
|
||||
children: [
|
||||
BlockSelectionContainer(
|
||||
@ -202,6 +278,7 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
||||
} else {
|
||||
// show a fixed menu on mobile
|
||||
child = MobileBlockActionButtons(
|
||||
showThreeDots: false,
|
||||
node: node,
|
||||
editorState: editorState,
|
||||
extendActionWidgets: _buildExtendActionWidgets(context),
|
||||
@ -282,7 +359,7 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
||||
|
||||
// only used on mobile platform
|
||||
List<Widget> _buildExtendActionWidgets(BuildContext context) {
|
||||
final url = widget.node.attributes[ImageBlockKeys.url];
|
||||
final url = widget.node.attributes[CustomImageBlockKeys.url];
|
||||
if (!_checkIfURLIsValid(url)) {
|
||||
return [];
|
||||
}
|
||||
|
@ -1,8 +1,12 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
|
||||
@ -35,6 +39,7 @@ class ImagePlaceholder extends StatefulWidget {
|
||||
|
||||
class ImagePlaceholderState extends State<ImagePlaceholder> {
|
||||
final controller = PopoverController();
|
||||
final documentService = DocumentService();
|
||||
late final editorState = context.read<EditorState>();
|
||||
|
||||
@override
|
||||
@ -158,33 +163,35 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
final path = await getIt<ApplicationDataStorage>().getPath();
|
||||
final imagePath = p.join(
|
||||
path,
|
||||
'images',
|
||||
);
|
||||
try {
|
||||
// create the directory if not exists
|
||||
final directory = Directory(imagePath);
|
||||
if (!directory.existsSync()) {
|
||||
await directory.create(recursive: true);
|
||||
}
|
||||
final copyToPath = p.join(
|
||||
imagePath,
|
||||
'${uuid()}${p.extension(url)}',
|
||||
);
|
||||
await File(url).copy(
|
||||
copyToPath,
|
||||
);
|
||||
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(widget.node, {
|
||||
ImageBlockKeys.url: copyToPath,
|
||||
});
|
||||
await editorState.apply(transaction);
|
||||
} catch (e) {
|
||||
Log.error('cannot copy image file', e);
|
||||
final transaction = editorState.transaction;
|
||||
final type = await getAuthenticatorType();
|
||||
String? path;
|
||||
CustomImageType imageType = CustomImageType.local;
|
||||
|
||||
// if the user is using local authenticator, we need to save the image to local storage
|
||||
if (type == AuthenticatorType.local) {
|
||||
path = await saveImageToLocalStorage(url);
|
||||
} else {
|
||||
// else we should save the image to cloud storage
|
||||
path = await saveImageToCloudStorage(url);
|
||||
imageType = CustomImageType.internal;
|
||||
}
|
||||
|
||||
if (path == null && context.mounted) {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.document_imageBlock_error_invalidImage.tr(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
transaction.updateNode(widget.node, {
|
||||
CustomImageBlockKeys.url: path,
|
||||
CustomImageBlockKeys.imageType: imageType.toIntValue(),
|
||||
});
|
||||
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
Future<void> insertAIImage(String url) async {
|
||||
|
@ -0,0 +1,52 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/shared/custom_image_cache_manager.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
Future<String?> saveImageToLocalStorage(String localImagePath) async {
|
||||
final path = await getIt<ApplicationDataStorage>().getPath();
|
||||
final imagePath = p.join(
|
||||
path,
|
||||
'images',
|
||||
);
|
||||
try {
|
||||
// create the directory if not exists
|
||||
final directory = Directory(imagePath);
|
||||
if (!directory.existsSync()) {
|
||||
await directory.create(recursive: true);
|
||||
}
|
||||
final copyToPath = p.join(
|
||||
imagePath,
|
||||
'${uuid()}${p.extension(localImagePath)}',
|
||||
);
|
||||
await File(localImagePath).copy(
|
||||
copyToPath,
|
||||
);
|
||||
return copyToPath;
|
||||
} catch (e) {
|
||||
Log.error('cannot save image file', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> saveImageToCloudStorage(String localImagePath) async {
|
||||
final documentService = DocumentService();
|
||||
final result = await documentService.uploadFile(
|
||||
localFilePath: localImagePath,
|
||||
);
|
||||
return result.fold(
|
||||
(l) => null,
|
||||
(r) async {
|
||||
await CustomImageCacheManager().putFile(
|
||||
r.url,
|
||||
File(localImagePath).readAsBytesSync(),
|
||||
);
|
||||
return r.url;
|
||||
},
|
||||
);
|
||||
}
|
@ -0,0 +1,244 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
|
||||
import 'package:appflowy/shared/appflowy_network_image.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
|
||||
class ResizableImage extends StatefulWidget {
|
||||
const ResizableImage({
|
||||
super.key,
|
||||
required this.type,
|
||||
required this.alignment,
|
||||
required this.editable,
|
||||
required this.onResize,
|
||||
required this.width,
|
||||
required this.src,
|
||||
this.height,
|
||||
});
|
||||
|
||||
final String src;
|
||||
final CustomImageType type;
|
||||
final double width;
|
||||
final double? height;
|
||||
final Alignment alignment;
|
||||
final bool editable;
|
||||
|
||||
final void Function(double width) onResize;
|
||||
|
||||
@override
|
||||
State<ResizableImage> createState() => _ResizableImageState();
|
||||
}
|
||||
|
||||
const _kImageBlockComponentMinWidth = 30.0;
|
||||
|
||||
class _ResizableImageState extends State<ResizableImage> {
|
||||
late double imageWidth;
|
||||
|
||||
double initialOffset = 0;
|
||||
double moveDistance = 0;
|
||||
|
||||
Widget? _cacheImage;
|
||||
|
||||
@visibleForTesting
|
||||
bool onFocus = false;
|
||||
|
||||
final documentService = DocumentService();
|
||||
|
||||
UserProfilePB? _userProfilePB;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
imageWidth = widget.width;
|
||||
|
||||
if (widget.type == CustomImageType.internal) {
|
||||
_userProfilePB = context.read<DocumentBloc>().state.userProfilePB;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: widget.alignment,
|
||||
child: SizedBox(
|
||||
width: max(_kImageBlockComponentMinWidth, imageWidth - moveDistance),
|
||||
height: widget.height,
|
||||
child: MouseRegion(
|
||||
onEnter: (event) => setState(() {
|
||||
onFocus = true;
|
||||
}),
|
||||
onExit: (event) => setState(() {
|
||||
onFocus = false;
|
||||
}),
|
||||
child: _buildResizableImage(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResizableImage(BuildContext context) {
|
||||
Widget child;
|
||||
final src = widget.src;
|
||||
if (isURL(src)) {
|
||||
// load network image
|
||||
if (widget.type == CustomImageType.internal && _userProfilePB == null) {
|
||||
return _buildLoading(context);
|
||||
}
|
||||
|
||||
_cacheImage ??= FlowyNetworkImage(
|
||||
url: widget.src,
|
||||
width: widget.width,
|
||||
userProfilePB: _userProfilePB,
|
||||
errorWidgetBuilder: (context, url, error) =>
|
||||
_buildError(context, error),
|
||||
progressIndicatorBuilder: (context, url, progress) =>
|
||||
_buildLoading(context),
|
||||
);
|
||||
child = _cacheImage!;
|
||||
} else {
|
||||
// load local file
|
||||
_cacheImage ??= Image.file(File(src));
|
||||
child = _cacheImage!;
|
||||
}
|
||||
return Stack(
|
||||
children: [
|
||||
child,
|
||||
if (widget.editable) ...[
|
||||
_buildEdgeGesture(
|
||||
context,
|
||||
top: 0,
|
||||
left: 5,
|
||||
bottom: 0,
|
||||
width: 5,
|
||||
onUpdate: (distance) {
|
||||
setState(() {
|
||||
moveDistance = distance;
|
||||
});
|
||||
},
|
||||
),
|
||||
_buildEdgeGesture(
|
||||
context,
|
||||
top: 0,
|
||||
right: 5,
|
||||
bottom: 0,
|
||||
width: 5,
|
||||
onUpdate: (distance) {
|
||||
setState(() {
|
||||
moveDistance = -distance;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoading(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 150,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox.fromSize(
|
||||
size: const Size(18, 18),
|
||||
child: const CircularProgressIndicator(),
|
||||
),
|
||||
SizedBox.fromSize(
|
||||
size: const Size(10, 10),
|
||||
),
|
||||
Text(AppFlowyEditorL10n.current.loading),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(BuildContext context, Object error) {
|
||||
return Container(
|
||||
height: 100,
|
||||
width: imageWidth,
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
|
||||
border: Border.all(width: 1, color: Colors.black),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowyText(AppFlowyEditorL10n.current.imageLoadFailed),
|
||||
const VSpace(4),
|
||||
FlowyText.small(
|
||||
error.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEdgeGesture(
|
||||
BuildContext context, {
|
||||
double? top,
|
||||
double? left,
|
||||
double? right,
|
||||
double? bottom,
|
||||
double? width,
|
||||
void Function(double distance)? onUpdate,
|
||||
}) {
|
||||
return Positioned(
|
||||
top: top,
|
||||
left: left,
|
||||
right: right,
|
||||
bottom: bottom,
|
||||
width: width,
|
||||
child: GestureDetector(
|
||||
onHorizontalDragStart: (details) {
|
||||
initialOffset = details.globalPosition.dx;
|
||||
},
|
||||
onHorizontalDragUpdate: (details) {
|
||||
if (onUpdate != null) {
|
||||
var offset = (details.globalPosition.dx - initialOffset);
|
||||
if (widget.alignment == Alignment.center) {
|
||||
offset *= 2.0;
|
||||
}
|
||||
onUpdate(offset);
|
||||
}
|
||||
},
|
||||
onHorizontalDragEnd: (details) {
|
||||
imageWidth =
|
||||
max(_kImageBlockComponentMinWidth, imageWidth - moveDistance);
|
||||
initialOffset = 0;
|
||||
moveDistance = 0;
|
||||
|
||||
widget.onResize(imageWidth);
|
||||
},
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeLeftRight,
|
||||
child: onFocus
|
||||
? Center(
|
||||
child: Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(5.0),
|
||||
),
|
||||
border: Border.all(width: 1, color: Colors.white),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:appflowy/shared/appflowy_network_image.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@ -40,11 +40,10 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
||||
topLeft: Radius.circular(6.0),
|
||||
bottomLeft: Radius.circular(6.0),
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: imageUrl!,
|
||||
child: FlowyNetworkImage(
|
||||
url: imageUrl!,
|
||||
width: 180,
|
||||
height: 120,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
|
@ -0,0 +1,62 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/shared/custom_image_cache_manager.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
|
||||
/// This widget handles the downloading and caching of either internal or network images.
|
||||
///
|
||||
/// It will append the access token to the URL if the URL is internal.
|
||||
class FlowyNetworkImage extends StatelessWidget {
|
||||
const FlowyNetworkImage({
|
||||
super.key,
|
||||
this.userProfilePB,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.progressIndicatorBuilder,
|
||||
this.errorWidgetBuilder,
|
||||
required this.url,
|
||||
});
|
||||
|
||||
final UserProfilePB? userProfilePB;
|
||||
final String url;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
final ProgressIndicatorBuilder? progressIndicatorBuilder;
|
||||
final LoadingErrorWidgetBuilder? errorWidgetBuilder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
assert(isURL(url));
|
||||
|
||||
if (url.contains('beta.appflowy')) {
|
||||
assert(userProfilePB != null && userProfilePB!.token.isNotEmpty);
|
||||
}
|
||||
|
||||
return CachedNetworkImage(
|
||||
cacheManager: CustomImageCacheManager(),
|
||||
httpHeaders: _header(),
|
||||
imageUrl: url,
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
progressIndicatorBuilder: progressIndicatorBuilder,
|
||||
errorWidget: (context, url, error) =>
|
||||
errorWidgetBuilder?.call(context, url, error) ??
|
||||
const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String> _header() {
|
||||
final header = <String, String>{};
|
||||
final token = userProfilePB?.token;
|
||||
if (token != null) {
|
||||
header['Authorization'] = 'Bearer ${jsonDecode(token)['access_token']}';
|
||||
}
|
||||
return header;
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
|
||||
class CustomImageCacheManager extends CacheManager with ImageCacheManager {
|
||||
static const key = 'appflowy_image_cache';
|
||||
|
||||
static final CustomImageCacheManager _instance = CustomImageCacheManager._();
|
||||
|
||||
factory CustomImageCacheManager() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
CustomImageCacheManager._() : super(Config(key));
|
||||
}
|
@ -564,7 +564,7 @@ packages:
|
||||
source: hosted
|
||||
version: "8.1.3"
|
||||
flutter_cache_manager:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_cache_manager
|
||||
sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba"
|
||||
|
@ -128,6 +128,7 @@ dependencies:
|
||||
leak_tracker: ^9.0.6
|
||||
keyboard_height_plugin: ^0.0.5
|
||||
scrollable_positioned_list: ^0.3.8
|
||||
flutter_cache_manager: ^3.3.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^3.0.1
|
||||
|
2
frontend/appflowy_tauri/src-tauri/Cargo.lock
generated
2
frontend/appflowy_tauri/src-tauri/Cargo.lock
generated
@ -1784,7 +1784,6 @@ dependencies = [
|
||||
"flowy-notification",
|
||||
"flowy-storage",
|
||||
"futures",
|
||||
"fxhash",
|
||||
"getrandom 0.2.10",
|
||||
"indexmap 2.1.0",
|
||||
"lib-dispatch",
|
||||
@ -1991,6 +1990,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"flowy-error",
|
||||
"fxhash",
|
||||
"lib-infra",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
|
2
frontend/rust-lib/Cargo.lock
generated
2
frontend/rust-lib/Cargo.lock
generated
@ -1777,7 +1777,6 @@ dependencies = [
|
||||
"flowy-notification",
|
||||
"flowy-storage",
|
||||
"futures",
|
||||
"fxhash",
|
||||
"getrandom 0.2.10",
|
||||
"indexmap 2.1.0",
|
||||
"lib-dispatch",
|
||||
@ -1993,6 +1992,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"flowy-error",
|
||||
"fxhash",
|
||||
"lib-infra",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
|
@ -36,7 +36,6 @@ futures.workspace = true
|
||||
tokio-stream = { workspace = true, features = ["sync"] }
|
||||
scraper = "0.18.0"
|
||||
lru.workspace = true
|
||||
fxhash = "0.2.1"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
getrandom = { version = "0.2", features = ["js"]}
|
||||
|
@ -10,8 +10,7 @@ use collab_document::blocks::DocumentData;
|
||||
use collab_document::document::Document;
|
||||
use collab_document::document_data::default_document_data;
|
||||
use collab_entity::CollabType;
|
||||
use flowy_storage::ObjectIdentity;
|
||||
use flowy_storage::ObjectValue;
|
||||
use flowy_storage::object_from_disk;
|
||||
use lru::LruCache;
|
||||
use parking_lot::Mutex;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
@ -257,18 +256,9 @@ impl DocumentManager {
|
||||
workspace_id: String,
|
||||
local_file_path: &str,
|
||||
) -> FlowyResult<String> {
|
||||
let object_value = ObjectValue::from_file(local_file_path).await?;
|
||||
|
||||
let (object_identity, object_value) = object_from_disk(&workspace_id, local_file_path).await?;
|
||||
let storage_service = self.storage_service_upgrade()?;
|
||||
let url = {
|
||||
let hash = fxhash::hash(object_value.raw.as_ref());
|
||||
storage_service
|
||||
.get_object_url(ObjectIdentity {
|
||||
workspace_id: workspace_id.to_owned(),
|
||||
file_id: hash.to_string(),
|
||||
})
|
||||
.await?
|
||||
};
|
||||
let url = storage_service.get_object_url(object_identity).await?;
|
||||
|
||||
// let the upload happen in the background
|
||||
let clone_url = url.clone();
|
||||
|
@ -19,8 +19,9 @@ where
|
||||
fn get_object_url(&self, object_id: ObjectIdentity) -> FutureResult<String, FlowyError> {
|
||||
let try_get_client = self.0.try_get_client();
|
||||
FutureResult::new(async move {
|
||||
let file_name = format!("{}.{}", object_id.file_id, object_id.ext);
|
||||
let client = try_get_client?;
|
||||
let url = client.get_blob_url(&object_id.workspace_id, &object_id.file_id);
|
||||
let url = client.get_blob_url(&object_id.workspace_id, &file_name);
|
||||
Ok(url)
|
||||
})
|
||||
}
|
||||
|
@ -18,3 +18,4 @@ flowy-error = { workspace = true, features = ["impl_from_reqwest"] }
|
||||
mime = "0.3.17"
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
fxhash = "0.2.1"
|
||||
|
@ -1,3 +1,5 @@
|
||||
use std::path::Path;
|
||||
|
||||
use bytes::Bytes;
|
||||
|
||||
use flowy_error::FlowyError;
|
||||
@ -9,6 +11,7 @@ use tracing::info;
|
||||
pub struct ObjectIdentity {
|
||||
pub workspace_id: String,
|
||||
pub file_id: String,
|
||||
pub ext: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@ -17,19 +20,33 @@ pub struct ObjectValue {
|
||||
pub mime: Mime,
|
||||
}
|
||||
|
||||
impl ObjectValue {
|
||||
pub async fn from_file(local_file_path: &str) -> Result<Self, FlowyError> {
|
||||
let mut file = tokio::fs::File::open(local_file_path).await?;
|
||||
let mut content = Vec::new();
|
||||
let n = file.read_to_end(&mut content).await?;
|
||||
info!("read {} bytes from file: {}", n, local_file_path);
|
||||
let mime = mime_guess::from_path(local_file_path).first_or_octet_stream();
|
||||
pub async fn object_from_disk(
|
||||
workspace_id: &str,
|
||||
local_file_path: &str,
|
||||
) -> Result<(ObjectIdentity, ObjectValue), FlowyError> {
|
||||
let ext = Path::new(local_file_path)
|
||||
.extension()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
let mut file = tokio::fs::File::open(local_file_path).await?;
|
||||
let mut content = Vec::new();
|
||||
let n = file.read_to_end(&mut content).await?;
|
||||
info!("read {} bytes from file: {}", n, local_file_path);
|
||||
let mime = mime_guess::from_path(local_file_path).first_or_octet_stream();
|
||||
let hash = fxhash::hash(&content);
|
||||
|
||||
Ok(ObjectValue {
|
||||
Ok((
|
||||
ObjectIdentity {
|
||||
workspace_id: workspace_id.to_owned(),
|
||||
file_id: hash.to_string(),
|
||||
ext,
|
||||
},
|
||||
ObjectValue {
|
||||
raw: content.into(),
|
||||
mime,
|
||||
})
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Provides a service for object storage.
|
||||
|
Loading…
Reference in New Issue
Block a user