From 4b4e719470184e49cd62084b1aa53c9a777a9fec Mon Sep 17 00:00:00 2001 From: boojack Date: Mon, 6 Apr 2026 10:47:01 +0800 Subject: [PATCH] feat(attachments): add Live Photo and Motion Photo support (#5810) --- internal/motionphoto/motionphoto.go | 110 +++++ internal/motionphoto/motionphoto_test.go | 25 ++ internal/testutil/motionphoto.go | 18 + proto/api/v1/attachment_service.proto | 35 ++ .../attachment_service.connect.go | 41 +- proto/gen/api/v1/attachment_service.pb.go | 380 +++++++++++++++--- proto/gen/api/v1/attachment_service.pb.gw.go | 86 +++- .../gen/api/v1/attachment_service_grpc.pb.go | 50 ++- proto/gen/openapi.yaml | 59 +++ proto/gen/store/attachment.pb.go | 260 ++++++++++-- proto/store/attachment.proto | 23 ++ server/router/api/v1/attachment_motion.go | 69 ++++ server/router/api/v1/attachment_service.go | 139 ++++++- server/router/api/v1/connect_services.go | 8 + .../router/api/v1/memo_attachment_service.go | 138 +++++-- .../api/v1/test/attachment_service_test.go | 116 ++++++ .../v1/test/memo_attachment_service_test.go | 60 +++ server/router/fileserver/fileserver.go | 74 +++- server/router/fileserver/fileserver_test.go | 46 +++ store/attachment.go | 73 +++- store/db/mysql/attachment.go | 33 ++ store/db/postgres/attachment.go | 33 ++ store/db/sqlite/attachment.go | 33 ++ store/driver.go | 1 + .../MemoEditor/components/EditorMetadata.tsx | 1 + .../MemoEditor/hooks/useFileUpload.ts | 62 ++- .../MemoEditor/services/uploadService.ts | 6 +- .../components/MemoEditor/state/actions.ts | 5 + .../components/MemoEditor/state/reducer.ts | 6 + web/src/components/MemoEditor/state/types.ts | 1 + .../components/MemoEditor/types/attachment.ts | 111 ++++- .../Attachment/AttachmentListEditor.tsx | 99 +++-- .../Attachment/AttachmentListView.tsx | 146 +++---- .../components/MemoPreview/MemoPreview.tsx | 46 ++- web/src/components/MemoView/MemoView.tsx | 2 +- .../components/MemoView/MemoViewContext.tsx | 3 +- .../MemoView/hooks/useImagePreview.ts | 40 +- .../MemoView/hooks/useMemoHandlers.ts | 3 +- web/src/components/PreviewImageDialog.tsx | 48 ++- .../proto/api/v1/attachment_service_pb.ts | 148 ++++++- web/src/utils/attachment.ts | 24 +- web/src/utils/media-item.ts | 179 +++++++++ 42 files changed, 2503 insertions(+), 337 deletions(-) create mode 100644 internal/motionphoto/motionphoto.go create mode 100644 internal/motionphoto/motionphoto_test.go create mode 100644 internal/testutil/motionphoto.go create mode 100644 server/router/api/v1/attachment_motion.go create mode 100644 web/src/utils/media-item.ts diff --git a/internal/motionphoto/motionphoto.go b/internal/motionphoto/motionphoto.go new file mode 100644 index 000000000..2bc0b1ab0 --- /dev/null +++ b/internal/motionphoto/motionphoto.go @@ -0,0 +1,110 @@ +package motionphoto + +import ( + "bytes" + "encoding/binary" + "regexp" + "strconv" +) + +type Detection struct { + VideoStart int + PresentationTimestampUs int64 +} + +var ( + motionPhotoMarkerRegex = regexp.MustCompile(`(?i)(?:Camera:MotionPhoto|GCamera:MotionPhoto|MicroVideo)["'=:\s>]+1`) + presentationRegex = regexp.MustCompile(`(?i)(?:Camera:MotionPhotoPresentationTimestampUs|GCamera:MotionPhotoPresentationTimestampUs)["'=:\s>]+(-?\d+)`) + microVideoOffsetRegex = regexp.MustCompile(`(?i)(?:Camera:MicroVideoOffset|GCamera:MicroVideoOffset)["'=:\s>]+(\d+)`) +) + +const maxMetadataScanBytes = 256 * 1024 + +func DetectJPEG(blob []byte) *Detection { + if len(blob) < 16 || !bytes.HasPrefix(blob, []byte{0xFF, 0xD8}) { + return nil + } + + text := string(blob[:min(len(blob), maxMetadataScanBytes)]) + if !motionPhotoMarkerRegex.MatchString(text) { + return nil + } + + videoStart := detectVideoStart(blob, text) + if videoStart < 0 || videoStart >= len(blob) { + return nil + } + + return &Detection{ + VideoStart: videoStart, + PresentationTimestampUs: parsePresentationTimestampUs(text), + } +} + +func ExtractVideo(blob []byte) ([]byte, *Detection) { + detection := DetectJPEG(blob) + if detection == nil { + return nil, nil + } + + videoBlob := blob[detection.VideoStart:] + if !looksLikeMP4(videoBlob) { + return nil, nil + } + + return videoBlob, detection +} + +func detectVideoStart(blob []byte, text string) int { + if matches := microVideoOffsetRegex.FindStringSubmatch(text); len(matches) == 2 { + if offset, err := strconv.Atoi(matches[1]); err == nil && offset > 0 && offset < len(blob) { + start := len(blob) - offset + if looksLikeMP4(blob[start:]) { + return start + } + } + } + + return findEmbeddedMP4Start(blob) +} + +func parsePresentationTimestampUs(text string) int64 { + matches := presentationRegex.FindStringSubmatch(text) + if len(matches) != 2 { + return 0 + } + + value, err := strconv.ParseInt(matches[1], 10, 64) + if err != nil { + return 0 + } + return value +} + +func findEmbeddedMP4Start(blob []byte) int { + searchFrom := len(blob) + for searchFrom > 8 { + index := bytes.LastIndex(blob[:searchFrom], []byte("ftyp")) + if index < 4 { + return -1 + } + + start := index - 4 + if looksLikeMP4(blob[start:]) { + return start + } + + searchFrom = index - 1 + } + + return -1 +} + +func looksLikeMP4(blob []byte) bool { + if len(blob) < 12 || !bytes.Equal(blob[4:8], []byte("ftyp")) { + return false + } + + size := binary.BigEndian.Uint32(blob[:4]) + return size == 1 || size >= 8 +} diff --git a/internal/motionphoto/motionphoto_test.go b/internal/motionphoto/motionphoto_test.go new file mode 100644 index 000000000..ae58e0e23 --- /dev/null +++ b/internal/motionphoto/motionphoto_test.go @@ -0,0 +1,25 @@ +package motionphoto + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/usememos/memos/internal/testutil" +) + +func TestDetectJPEG(t *testing.T) { + t.Parallel() + + blob := testutil.BuildMotionPhotoJPEG() + detection := DetectJPEG(blob) + require.NotNil(t, detection) + require.Positive(t, detection.VideoStart) + require.EqualValues(t, 123456, detection.PresentationTimestampUs) + + videoBlob, extracted := ExtractVideo(blob) + require.NotNil(t, extracted) + require.True(t, bytes.Equal(videoBlob[:4], []byte{0x00, 0x00, 0x00, 0x10})) + require.Equal(t, []byte("ftyp"), videoBlob[4:8]) +} diff --git a/internal/testutil/motionphoto.go b/internal/testutil/motionphoto.go new file mode 100644 index 000000000..3da5dc86f --- /dev/null +++ b/internal/testutil/motionphoto.go @@ -0,0 +1,18 @@ +package testutil + +// BuildMotionPhotoJPEG returns a minimal JPEG blob with Motion Photo metadata +// and an embedded MP4 header for tests. +func BuildMotionPhotoJPEG() []byte { + return append( + []byte{ + 0xFF, 0xD8, 0xFF, 0xE1, + }, + append( + []byte(``), + []byte{ + 0xFF, 0xD9, + 0x00, 0x00, 0x00, 0x10, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm', 0x00, 0x00, 0x00, 0x00, + }..., + )..., + ) +} diff --git a/proto/api/v1/attachment_service.proto b/proto/api/v1/attachment_service.proto index da6db3116..04cd4a929 100644 --- a/proto/api/v1/attachment_service.proto +++ b/proto/api/v1/attachment_service.proto @@ -43,6 +43,34 @@ service AttachmentService { option (google.api.http) = {delete: "/api/v1/{name=attachments/*}"}; option (google.api.method_signature) = "name"; } + // BatchDeleteAttachments deletes multiple attachments in one request. + rpc BatchDeleteAttachments(BatchDeleteAttachmentsRequest) returns (google.protobuf.Empty) { + option (google.api.http) = { + post: "/api/v1/attachments:batchDelete" + body: "*" + }; + } +} + +enum MotionMediaFamily { + MOTION_MEDIA_FAMILY_UNSPECIFIED = 0; + APPLE_LIVE_PHOTO = 1; + ANDROID_MOTION_PHOTO = 2; +} + +enum MotionMediaRole { + MOTION_MEDIA_ROLE_UNSPECIFIED = 0; + STILL = 1; + VIDEO = 2; + CONTAINER = 3; +} + +message MotionMedia { + MotionMediaFamily family = 1; + MotionMediaRole role = 2; + string group_id = 3; + int64 presentation_timestamp_us = 4; + bool has_embedded_video = 5; } message Attachment { @@ -78,6 +106,9 @@ message Attachment { // Optional. The related memo. Refer to `Memo.name`. // Format: memos/{memo} optional string memo = 8 [(google.api.field_behavior) = OPTIONAL]; + + // Optional. Motion media metadata. + MotionMedia motion_media = 9 [(google.api.field_behavior) = OPTIONAL]; } message CreateAttachmentRequest { @@ -148,3 +179,7 @@ message DeleteAttachmentRequest { (google.api.resource_reference) = {type: "memos.api.v1/Attachment"} ]; } + +message BatchDeleteAttachmentsRequest { + repeated string names = 1 [(google.api.field_behavior) = REQUIRED]; +} diff --git a/proto/gen/api/v1/apiv1connect/attachment_service.connect.go b/proto/gen/api/v1/apiv1connect/attachment_service.connect.go index f10145aaa..a5dda3afd 100644 --- a/proto/gen/api/v1/apiv1connect/attachment_service.connect.go +++ b/proto/gen/api/v1/apiv1connect/attachment_service.connect.go @@ -49,6 +49,9 @@ const ( // AttachmentServiceDeleteAttachmentProcedure is the fully-qualified name of the AttachmentService's // DeleteAttachment RPC. AttachmentServiceDeleteAttachmentProcedure = "/memos.api.v1.AttachmentService/DeleteAttachment" + // AttachmentServiceBatchDeleteAttachmentsProcedure is the fully-qualified name of the + // AttachmentService's BatchDeleteAttachments RPC. + AttachmentServiceBatchDeleteAttachmentsProcedure = "/memos.api.v1.AttachmentService/BatchDeleteAttachments" ) // AttachmentServiceClient is a client for the memos.api.v1.AttachmentService service. @@ -63,6 +66,8 @@ type AttachmentServiceClient interface { UpdateAttachment(context.Context, *connect.Request[v1.UpdateAttachmentRequest]) (*connect.Response[v1.Attachment], error) // DeleteAttachment deletes an attachment by name. DeleteAttachment(context.Context, *connect.Request[v1.DeleteAttachmentRequest]) (*connect.Response[emptypb.Empty], error) + // BatchDeleteAttachments deletes multiple attachments in one request. + BatchDeleteAttachments(context.Context, *connect.Request[v1.BatchDeleteAttachmentsRequest]) (*connect.Response[emptypb.Empty], error) } // NewAttachmentServiceClient constructs a client for the memos.api.v1.AttachmentService service. By @@ -106,16 +111,23 @@ func NewAttachmentServiceClient(httpClient connect.HTTPClient, baseURL string, o connect.WithSchema(attachmentServiceMethods.ByName("DeleteAttachment")), connect.WithClientOptions(opts...), ), + batchDeleteAttachments: connect.NewClient[v1.BatchDeleteAttachmentsRequest, emptypb.Empty]( + httpClient, + baseURL+AttachmentServiceBatchDeleteAttachmentsProcedure, + connect.WithSchema(attachmentServiceMethods.ByName("BatchDeleteAttachments")), + connect.WithClientOptions(opts...), + ), } } // attachmentServiceClient implements AttachmentServiceClient. type attachmentServiceClient struct { - createAttachment *connect.Client[v1.CreateAttachmentRequest, v1.Attachment] - listAttachments *connect.Client[v1.ListAttachmentsRequest, v1.ListAttachmentsResponse] - getAttachment *connect.Client[v1.GetAttachmentRequest, v1.Attachment] - updateAttachment *connect.Client[v1.UpdateAttachmentRequest, v1.Attachment] - deleteAttachment *connect.Client[v1.DeleteAttachmentRequest, emptypb.Empty] + createAttachment *connect.Client[v1.CreateAttachmentRequest, v1.Attachment] + listAttachments *connect.Client[v1.ListAttachmentsRequest, v1.ListAttachmentsResponse] + getAttachment *connect.Client[v1.GetAttachmentRequest, v1.Attachment] + updateAttachment *connect.Client[v1.UpdateAttachmentRequest, v1.Attachment] + deleteAttachment *connect.Client[v1.DeleteAttachmentRequest, emptypb.Empty] + batchDeleteAttachments *connect.Client[v1.BatchDeleteAttachmentsRequest, emptypb.Empty] } // CreateAttachment calls memos.api.v1.AttachmentService.CreateAttachment. @@ -143,6 +155,11 @@ func (c *attachmentServiceClient) DeleteAttachment(ctx context.Context, req *con return c.deleteAttachment.CallUnary(ctx, req) } +// BatchDeleteAttachments calls memos.api.v1.AttachmentService.BatchDeleteAttachments. +func (c *attachmentServiceClient) BatchDeleteAttachments(ctx context.Context, req *connect.Request[v1.BatchDeleteAttachmentsRequest]) (*connect.Response[emptypb.Empty], error) { + return c.batchDeleteAttachments.CallUnary(ctx, req) +} + // AttachmentServiceHandler is an implementation of the memos.api.v1.AttachmentService service. type AttachmentServiceHandler interface { // CreateAttachment creates a new attachment. @@ -155,6 +172,8 @@ type AttachmentServiceHandler interface { UpdateAttachment(context.Context, *connect.Request[v1.UpdateAttachmentRequest]) (*connect.Response[v1.Attachment], error) // DeleteAttachment deletes an attachment by name. DeleteAttachment(context.Context, *connect.Request[v1.DeleteAttachmentRequest]) (*connect.Response[emptypb.Empty], error) + // BatchDeleteAttachments deletes multiple attachments in one request. + BatchDeleteAttachments(context.Context, *connect.Request[v1.BatchDeleteAttachmentsRequest]) (*connect.Response[emptypb.Empty], error) } // NewAttachmentServiceHandler builds an HTTP handler from the service implementation. It returns @@ -194,6 +213,12 @@ func NewAttachmentServiceHandler(svc AttachmentServiceHandler, opts ...connect.H connect.WithSchema(attachmentServiceMethods.ByName("DeleteAttachment")), connect.WithHandlerOptions(opts...), ) + attachmentServiceBatchDeleteAttachmentsHandler := connect.NewUnaryHandler( + AttachmentServiceBatchDeleteAttachmentsProcedure, + svc.BatchDeleteAttachments, + connect.WithSchema(attachmentServiceMethods.ByName("BatchDeleteAttachments")), + connect.WithHandlerOptions(opts...), + ) return "/memos.api.v1.AttachmentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case AttachmentServiceCreateAttachmentProcedure: @@ -206,6 +231,8 @@ func NewAttachmentServiceHandler(svc AttachmentServiceHandler, opts ...connect.H attachmentServiceUpdateAttachmentHandler.ServeHTTP(w, r) case AttachmentServiceDeleteAttachmentProcedure: attachmentServiceDeleteAttachmentHandler.ServeHTTP(w, r) + case AttachmentServiceBatchDeleteAttachmentsProcedure: + attachmentServiceBatchDeleteAttachmentsHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -234,3 +261,7 @@ func (UnimplementedAttachmentServiceHandler) UpdateAttachment(context.Context, * func (UnimplementedAttachmentServiceHandler) DeleteAttachment(context.Context, *connect.Request[v1.DeleteAttachmentRequest]) (*connect.Response[emptypb.Empty], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("memos.api.v1.AttachmentService.DeleteAttachment is not implemented")) } + +func (UnimplementedAttachmentServiceHandler) BatchDeleteAttachments(context.Context, *connect.Request[v1.BatchDeleteAttachmentsRequest]) (*connect.Response[emptypb.Empty], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("memos.api.v1.AttachmentService.BatchDeleteAttachments is not implemented")) +} diff --git a/proto/gen/api/v1/attachment_service.pb.go b/proto/gen/api/v1/attachment_service.pb.go index ea6525e5c..cd68d346d 100644 --- a/proto/gen/api/v1/attachment_service.pb.go +++ b/proto/gen/api/v1/attachment_service.pb.go @@ -25,6 +25,183 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type MotionMediaFamily int32 + +const ( + MotionMediaFamily_MOTION_MEDIA_FAMILY_UNSPECIFIED MotionMediaFamily = 0 + MotionMediaFamily_APPLE_LIVE_PHOTO MotionMediaFamily = 1 + MotionMediaFamily_ANDROID_MOTION_PHOTO MotionMediaFamily = 2 +) + +// Enum value maps for MotionMediaFamily. +var ( + MotionMediaFamily_name = map[int32]string{ + 0: "MOTION_MEDIA_FAMILY_UNSPECIFIED", + 1: "APPLE_LIVE_PHOTO", + 2: "ANDROID_MOTION_PHOTO", + } + MotionMediaFamily_value = map[string]int32{ + "MOTION_MEDIA_FAMILY_UNSPECIFIED": 0, + "APPLE_LIVE_PHOTO": 1, + "ANDROID_MOTION_PHOTO": 2, + } +) + +func (x MotionMediaFamily) Enum() *MotionMediaFamily { + p := new(MotionMediaFamily) + *p = x + return p +} + +func (x MotionMediaFamily) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (MotionMediaFamily) Descriptor() protoreflect.EnumDescriptor { + return file_api_v1_attachment_service_proto_enumTypes[0].Descriptor() +} + +func (MotionMediaFamily) Type() protoreflect.EnumType { + return &file_api_v1_attachment_service_proto_enumTypes[0] +} + +func (x MotionMediaFamily) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use MotionMediaFamily.Descriptor instead. +func (MotionMediaFamily) EnumDescriptor() ([]byte, []int) { + return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{0} +} + +type MotionMediaRole int32 + +const ( + MotionMediaRole_MOTION_MEDIA_ROLE_UNSPECIFIED MotionMediaRole = 0 + MotionMediaRole_STILL MotionMediaRole = 1 + MotionMediaRole_VIDEO MotionMediaRole = 2 + MotionMediaRole_CONTAINER MotionMediaRole = 3 +) + +// Enum value maps for MotionMediaRole. +var ( + MotionMediaRole_name = map[int32]string{ + 0: "MOTION_MEDIA_ROLE_UNSPECIFIED", + 1: "STILL", + 2: "VIDEO", + 3: "CONTAINER", + } + MotionMediaRole_value = map[string]int32{ + "MOTION_MEDIA_ROLE_UNSPECIFIED": 0, + "STILL": 1, + "VIDEO": 2, + "CONTAINER": 3, + } +) + +func (x MotionMediaRole) Enum() *MotionMediaRole { + p := new(MotionMediaRole) + *p = x + return p +} + +func (x MotionMediaRole) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (MotionMediaRole) Descriptor() protoreflect.EnumDescriptor { + return file_api_v1_attachment_service_proto_enumTypes[1].Descriptor() +} + +func (MotionMediaRole) Type() protoreflect.EnumType { + return &file_api_v1_attachment_service_proto_enumTypes[1] +} + +func (x MotionMediaRole) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use MotionMediaRole.Descriptor instead. +func (MotionMediaRole) EnumDescriptor() ([]byte, []int) { + return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{1} +} + +type MotionMedia struct { + state protoimpl.MessageState `protogen:"open.v1"` + Family MotionMediaFamily `protobuf:"varint,1,opt,name=family,proto3,enum=memos.api.v1.MotionMediaFamily" json:"family,omitempty"` + Role MotionMediaRole `protobuf:"varint,2,opt,name=role,proto3,enum=memos.api.v1.MotionMediaRole" json:"role,omitempty"` + GroupId string `protobuf:"bytes,3,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + PresentationTimestampUs int64 `protobuf:"varint,4,opt,name=presentation_timestamp_us,json=presentationTimestampUs,proto3" json:"presentation_timestamp_us,omitempty"` + HasEmbeddedVideo bool `protobuf:"varint,5,opt,name=has_embedded_video,json=hasEmbeddedVideo,proto3" json:"has_embedded_video,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MotionMedia) Reset() { + *x = MotionMedia{} + mi := &file_api_v1_attachment_service_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MotionMedia) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MotionMedia) ProtoMessage() {} + +func (x *MotionMedia) ProtoReflect() protoreflect.Message { + mi := &file_api_v1_attachment_service_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MotionMedia.ProtoReflect.Descriptor instead. +func (*MotionMedia) Descriptor() ([]byte, []int) { + return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{0} +} + +func (x *MotionMedia) GetFamily() MotionMediaFamily { + if x != nil { + return x.Family + } + return MotionMediaFamily_MOTION_MEDIA_FAMILY_UNSPECIFIED +} + +func (x *MotionMedia) GetRole() MotionMediaRole { + if x != nil { + return x.Role + } + return MotionMediaRole_MOTION_MEDIA_ROLE_UNSPECIFIED +} + +func (x *MotionMedia) GetGroupId() string { + if x != nil { + return x.GroupId + } + return "" +} + +func (x *MotionMedia) GetPresentationTimestampUs() int64 { + if x != nil { + return x.PresentationTimestampUs + } + return 0 +} + +func (x *MotionMedia) GetHasEmbeddedVideo() bool { + if x != nil { + return x.HasEmbeddedVideo + } + return false +} + type Attachment struct { state protoimpl.MessageState `protogen:"open.v1"` // The name of the attachment. @@ -44,14 +221,16 @@ type Attachment struct { Size int64 `protobuf:"varint,7,opt,name=size,proto3" json:"size,omitempty"` // Optional. The related memo. Refer to `Memo.name`. // Format: memos/{memo} - Memo *string `protobuf:"bytes,8,opt,name=memo,proto3,oneof" json:"memo,omitempty"` + Memo *string `protobuf:"bytes,8,opt,name=memo,proto3,oneof" json:"memo,omitempty"` + // Optional. Motion media metadata. + MotionMedia *MotionMedia `protobuf:"bytes,9,opt,name=motion_media,json=motionMedia,proto3" json:"motion_media,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Attachment) Reset() { *x = Attachment{} - mi := &file_api_v1_attachment_service_proto_msgTypes[0] + mi := &file_api_v1_attachment_service_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -63,7 +242,7 @@ func (x *Attachment) String() string { func (*Attachment) ProtoMessage() {} func (x *Attachment) ProtoReflect() protoreflect.Message { - mi := &file_api_v1_attachment_service_proto_msgTypes[0] + mi := &file_api_v1_attachment_service_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -76,7 +255,7 @@ func (x *Attachment) ProtoReflect() protoreflect.Message { // Deprecated: Use Attachment.ProtoReflect.Descriptor instead. func (*Attachment) Descriptor() ([]byte, []int) { - return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{0} + return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{1} } func (x *Attachment) GetName() string { @@ -135,6 +314,13 @@ func (x *Attachment) GetMemo() string { return "" } +func (x *Attachment) GetMotionMedia() *MotionMedia { + if x != nil { + return x.MotionMedia + } + return nil +} + type CreateAttachmentRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Required. The attachment to create. @@ -148,7 +334,7 @@ type CreateAttachmentRequest struct { func (x *CreateAttachmentRequest) Reset() { *x = CreateAttachmentRequest{} - mi := &file_api_v1_attachment_service_proto_msgTypes[1] + mi := &file_api_v1_attachment_service_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -160,7 +346,7 @@ func (x *CreateAttachmentRequest) String() string { func (*CreateAttachmentRequest) ProtoMessage() {} func (x *CreateAttachmentRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_v1_attachment_service_proto_msgTypes[1] + mi := &file_api_v1_attachment_service_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -173,7 +359,7 @@ func (x *CreateAttachmentRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateAttachmentRequest.ProtoReflect.Descriptor instead. func (*CreateAttachmentRequest) Descriptor() ([]byte, []int) { - return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{1} + return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{2} } func (x *CreateAttachmentRequest) GetAttachment() *Attachment { @@ -214,7 +400,7 @@ type ListAttachmentsRequest struct { func (x *ListAttachmentsRequest) Reset() { *x = ListAttachmentsRequest{} - mi := &file_api_v1_attachment_service_proto_msgTypes[2] + mi := &file_api_v1_attachment_service_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -226,7 +412,7 @@ func (x *ListAttachmentsRequest) String() string { func (*ListAttachmentsRequest) ProtoMessage() {} func (x *ListAttachmentsRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_v1_attachment_service_proto_msgTypes[2] + mi := &file_api_v1_attachment_service_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -239,7 +425,7 @@ func (x *ListAttachmentsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListAttachmentsRequest.ProtoReflect.Descriptor instead. func (*ListAttachmentsRequest) Descriptor() ([]byte, []int) { - return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{2} + return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{3} } func (x *ListAttachmentsRequest) GetPageSize() int32 { @@ -285,7 +471,7 @@ type ListAttachmentsResponse struct { func (x *ListAttachmentsResponse) Reset() { *x = ListAttachmentsResponse{} - mi := &file_api_v1_attachment_service_proto_msgTypes[3] + mi := &file_api_v1_attachment_service_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -297,7 +483,7 @@ func (x *ListAttachmentsResponse) String() string { func (*ListAttachmentsResponse) ProtoMessage() {} func (x *ListAttachmentsResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_v1_attachment_service_proto_msgTypes[3] + mi := &file_api_v1_attachment_service_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -310,7 +496,7 @@ func (x *ListAttachmentsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListAttachmentsResponse.ProtoReflect.Descriptor instead. func (*ListAttachmentsResponse) Descriptor() ([]byte, []int) { - return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{3} + return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{4} } func (x *ListAttachmentsResponse) GetAttachments() []*Attachment { @@ -345,7 +531,7 @@ type GetAttachmentRequest struct { func (x *GetAttachmentRequest) Reset() { *x = GetAttachmentRequest{} - mi := &file_api_v1_attachment_service_proto_msgTypes[4] + mi := &file_api_v1_attachment_service_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -357,7 +543,7 @@ func (x *GetAttachmentRequest) String() string { func (*GetAttachmentRequest) ProtoMessage() {} func (x *GetAttachmentRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_v1_attachment_service_proto_msgTypes[4] + mi := &file_api_v1_attachment_service_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -370,7 +556,7 @@ func (x *GetAttachmentRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetAttachmentRequest.ProtoReflect.Descriptor instead. func (*GetAttachmentRequest) Descriptor() ([]byte, []int) { - return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{4} + return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{5} } func (x *GetAttachmentRequest) GetName() string { @@ -392,7 +578,7 @@ type UpdateAttachmentRequest struct { func (x *UpdateAttachmentRequest) Reset() { *x = UpdateAttachmentRequest{} - mi := &file_api_v1_attachment_service_proto_msgTypes[5] + mi := &file_api_v1_attachment_service_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -404,7 +590,7 @@ func (x *UpdateAttachmentRequest) String() string { func (*UpdateAttachmentRequest) ProtoMessage() {} func (x *UpdateAttachmentRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_v1_attachment_service_proto_msgTypes[5] + mi := &file_api_v1_attachment_service_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -417,7 +603,7 @@ func (x *UpdateAttachmentRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateAttachmentRequest.ProtoReflect.Descriptor instead. func (*UpdateAttachmentRequest) Descriptor() ([]byte, []int) { - return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{5} + return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{6} } func (x *UpdateAttachmentRequest) GetAttachment() *Attachment { @@ -445,7 +631,7 @@ type DeleteAttachmentRequest struct { func (x *DeleteAttachmentRequest) Reset() { *x = DeleteAttachmentRequest{} - mi := &file_api_v1_attachment_service_proto_msgTypes[6] + mi := &file_api_v1_attachment_service_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -457,7 +643,7 @@ func (x *DeleteAttachmentRequest) String() string { func (*DeleteAttachmentRequest) ProtoMessage() {} func (x *DeleteAttachmentRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_v1_attachment_service_proto_msgTypes[6] + mi := &file_api_v1_attachment_service_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -470,7 +656,7 @@ func (x *DeleteAttachmentRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteAttachmentRequest.ProtoReflect.Descriptor instead. func (*DeleteAttachmentRequest) Descriptor() ([]byte, []int) { - return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{6} + return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{7} } func (x *DeleteAttachmentRequest) GetName() string { @@ -480,11 +666,61 @@ func (x *DeleteAttachmentRequest) GetName() string { return "" } +type BatchDeleteAttachmentsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Names []string `protobuf:"bytes,1,rep,name=names,proto3" json:"names,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BatchDeleteAttachmentsRequest) Reset() { + *x = BatchDeleteAttachmentsRequest{} + mi := &file_api_v1_attachment_service_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BatchDeleteAttachmentsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BatchDeleteAttachmentsRequest) ProtoMessage() {} + +func (x *BatchDeleteAttachmentsRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_v1_attachment_service_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BatchDeleteAttachmentsRequest.ProtoReflect.Descriptor instead. +func (*BatchDeleteAttachmentsRequest) Descriptor() ([]byte, []int) { + return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{8} +} + +func (x *BatchDeleteAttachmentsRequest) GetNames() []string { + if x != nil { + return x.Names + } + return nil +} + var File_api_v1_attachment_service_proto protoreflect.FileDescriptor const file_api_v1_attachment_service_proto_rawDesc = "" + "\n" + - "\x1fapi/v1/attachment_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xfb\x02\n" + + "\x1fapi/v1/attachment_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xfe\x01\n" + + "\vMotionMedia\x127\n" + + "\x06family\x18\x01 \x01(\x0e2\x1f.memos.api.v1.MotionMediaFamilyR\x06family\x121\n" + + "\x04role\x18\x02 \x01(\x0e2\x1d.memos.api.v1.MotionMediaRoleR\x04role\x12\x19\n" + + "\bgroup_id\x18\x03 \x01(\tR\agroupId\x12:\n" + + "\x19presentation_timestamp_us\x18\x04 \x01(\x03R\x17presentationTimestampUs\x12,\n" + + "\x12has_embedded_video\x18\x05 \x01(\bR\x10hasEmbeddedVideo\"\xbe\x03\n" + "\n" + "Attachment\x12\x17\n" + "\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12@\n" + @@ -495,7 +731,8 @@ const file_api_v1_attachment_service_proto_rawDesc = "" + "\rexternal_link\x18\x05 \x01(\tB\x03\xe0A\x01R\fexternalLink\x12\x17\n" + "\x04type\x18\x06 \x01(\tB\x03\xe0A\x02R\x04type\x12\x17\n" + "\x04size\x18\a \x01(\x03B\x03\xe0A\x03R\x04size\x12\x1c\n" + - "\x04memo\x18\b \x01(\tB\x03\xe0A\x01H\x00R\x04memo\x88\x01\x01:O\xeaAL\n" + + "\x04memo\x18\b \x01(\tB\x03\xe0A\x01H\x00R\x04memo\x88\x01\x01\x12A\n" + + "\fmotion_media\x18\t \x01(\v2\x19.memos.api.v1.MotionMediaB\x03\xe0A\x01R\vmotionMedia:O\xeaAL\n" + "\x17memos.api.v1/Attachment\x12\x18attachments/{attachment}*\vattachments2\n" + "attachmentB\a\n" + "\x05_memo\"\x82\x01\n" + @@ -526,7 +763,18 @@ const file_api_v1_attachment_service_proto_rawDesc = "" + "updateMask\"N\n" + "\x17DeleteAttachmentRequest\x123\n" + "\x04name\x18\x01 \x01(\tB\x1f\xe0A\x02\xfaA\x19\n" + - "\x17memos.api.v1/AttachmentR\x04name2\xc4\x05\n" + + "\x17memos.api.v1/AttachmentR\x04name\":\n" + + "\x1dBatchDeleteAttachmentsRequest\x12\x19\n" + + "\x05names\x18\x01 \x03(\tB\x03\xe0A\x02R\x05names*h\n" + + "\x11MotionMediaFamily\x12#\n" + + "\x1fMOTION_MEDIA_FAMILY_UNSPECIFIED\x10\x00\x12\x14\n" + + "\x10APPLE_LIVE_PHOTO\x10\x01\x12\x18\n" + + "\x14ANDROID_MOTION_PHOTO\x10\x02*Y\n" + + "\x0fMotionMediaRole\x12!\n" + + "\x1dMOTION_MEDIA_ROLE_UNSPECIFIED\x10\x00\x12\t\n" + + "\x05STILL\x10\x01\x12\t\n" + + "\x05VIDEO\x10\x02\x12\r\n" + + "\tCONTAINER\x10\x032\xd0\x06\n" + "\x11AttachmentService\x12\x89\x01\n" + "\x10CreateAttachment\x12%.memos.api.v1.CreateAttachmentRequest\x1a\x18.memos.api.v1.Attachment\"4\xdaA\n" + "attachment\x82\xd3\xe4\x93\x02!:\n" + @@ -535,7 +783,8 @@ const file_api_v1_attachment_service_proto_rawDesc = "" + "\rGetAttachment\x12\".memos.api.v1.GetAttachmentRequest\x1a\x18.memos.api.v1.Attachment\"+\xdaA\x04name\x82\xd3\xe4\x93\x02\x1e\x12\x1c/api/v1/{name=attachments/*}\x12\xa9\x01\n" + "\x10UpdateAttachment\x12%.memos.api.v1.UpdateAttachmentRequest\x1a\x18.memos.api.v1.Attachment\"T\xdaA\x16attachment,update_mask\x82\xd3\xe4\x93\x025:\n" + "attachment2'/api/v1/{attachment.name=attachments/*}\x12~\n" + - "\x10DeleteAttachment\x12%.memos.api.v1.DeleteAttachmentRequest\x1a\x16.google.protobuf.Empty\"+\xdaA\x04name\x82\xd3\xe4\x93\x02\x1e*\x1c/api/v1/{name=attachments/*}B\xae\x01\n" + + "\x10DeleteAttachment\x12%.memos.api.v1.DeleteAttachmentRequest\x1a\x16.google.protobuf.Empty\"+\xdaA\x04name\x82\xd3\xe4\x93\x02\x1e*\x1c/api/v1/{name=attachments/*}\x12\x89\x01\n" + + "\x16BatchDeleteAttachments\x12+.memos.api.v1.BatchDeleteAttachmentsRequest\x1a\x16.google.protobuf.Empty\"*\x82\xd3\xe4\x93\x02$:\x01*\"\x1f/api/v1/attachments:batchDeleteB\xae\x01\n" + "\x10com.memos.api.v1B\x16AttachmentServiceProtoP\x01Z0github.com/usememos/memos/proto/gen/api/v1;apiv1\xa2\x02\x03MAX\xaa\x02\fMemos.Api.V1\xca\x02\fMemos\\Api\\V1\xe2\x02\x18Memos\\Api\\V1\\GPBMetadata\xea\x02\x0eMemos::Api::V1b\x06proto3" var ( @@ -550,40 +799,50 @@ func file_api_v1_attachment_service_proto_rawDescGZIP() []byte { return file_api_v1_attachment_service_proto_rawDescData } -var file_api_v1_attachment_service_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_api_v1_attachment_service_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_api_v1_attachment_service_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_api_v1_attachment_service_proto_goTypes = []any{ - (*Attachment)(nil), // 0: memos.api.v1.Attachment - (*CreateAttachmentRequest)(nil), // 1: memos.api.v1.CreateAttachmentRequest - (*ListAttachmentsRequest)(nil), // 2: memos.api.v1.ListAttachmentsRequest - (*ListAttachmentsResponse)(nil), // 3: memos.api.v1.ListAttachmentsResponse - (*GetAttachmentRequest)(nil), // 4: memos.api.v1.GetAttachmentRequest - (*UpdateAttachmentRequest)(nil), // 5: memos.api.v1.UpdateAttachmentRequest - (*DeleteAttachmentRequest)(nil), // 6: memos.api.v1.DeleteAttachmentRequest - (*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp - (*fieldmaskpb.FieldMask)(nil), // 8: google.protobuf.FieldMask - (*emptypb.Empty)(nil), // 9: google.protobuf.Empty + (MotionMediaFamily)(0), // 0: memos.api.v1.MotionMediaFamily + (MotionMediaRole)(0), // 1: memos.api.v1.MotionMediaRole + (*MotionMedia)(nil), // 2: memos.api.v1.MotionMedia + (*Attachment)(nil), // 3: memos.api.v1.Attachment + (*CreateAttachmentRequest)(nil), // 4: memos.api.v1.CreateAttachmentRequest + (*ListAttachmentsRequest)(nil), // 5: memos.api.v1.ListAttachmentsRequest + (*ListAttachmentsResponse)(nil), // 6: memos.api.v1.ListAttachmentsResponse + (*GetAttachmentRequest)(nil), // 7: memos.api.v1.GetAttachmentRequest + (*UpdateAttachmentRequest)(nil), // 8: memos.api.v1.UpdateAttachmentRequest + (*DeleteAttachmentRequest)(nil), // 9: memos.api.v1.DeleteAttachmentRequest + (*BatchDeleteAttachmentsRequest)(nil), // 10: memos.api.v1.BatchDeleteAttachmentsRequest + (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp + (*fieldmaskpb.FieldMask)(nil), // 12: google.protobuf.FieldMask + (*emptypb.Empty)(nil), // 13: google.protobuf.Empty } var file_api_v1_attachment_service_proto_depIdxs = []int32{ - 7, // 0: memos.api.v1.Attachment.create_time:type_name -> google.protobuf.Timestamp - 0, // 1: memos.api.v1.CreateAttachmentRequest.attachment:type_name -> memos.api.v1.Attachment - 0, // 2: memos.api.v1.ListAttachmentsResponse.attachments:type_name -> memos.api.v1.Attachment - 0, // 3: memos.api.v1.UpdateAttachmentRequest.attachment:type_name -> memos.api.v1.Attachment - 8, // 4: memos.api.v1.UpdateAttachmentRequest.update_mask:type_name -> google.protobuf.FieldMask - 1, // 5: memos.api.v1.AttachmentService.CreateAttachment:input_type -> memos.api.v1.CreateAttachmentRequest - 2, // 6: memos.api.v1.AttachmentService.ListAttachments:input_type -> memos.api.v1.ListAttachmentsRequest - 4, // 7: memos.api.v1.AttachmentService.GetAttachment:input_type -> memos.api.v1.GetAttachmentRequest - 5, // 8: memos.api.v1.AttachmentService.UpdateAttachment:input_type -> memos.api.v1.UpdateAttachmentRequest - 6, // 9: memos.api.v1.AttachmentService.DeleteAttachment:input_type -> memos.api.v1.DeleteAttachmentRequest - 0, // 10: memos.api.v1.AttachmentService.CreateAttachment:output_type -> memos.api.v1.Attachment - 3, // 11: memos.api.v1.AttachmentService.ListAttachments:output_type -> memos.api.v1.ListAttachmentsResponse - 0, // 12: memos.api.v1.AttachmentService.GetAttachment:output_type -> memos.api.v1.Attachment - 0, // 13: memos.api.v1.AttachmentService.UpdateAttachment:output_type -> memos.api.v1.Attachment - 9, // 14: memos.api.v1.AttachmentService.DeleteAttachment:output_type -> google.protobuf.Empty - 10, // [10:15] is the sub-list for method output_type - 5, // [5:10] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name + 0, // 0: memos.api.v1.MotionMedia.family:type_name -> memos.api.v1.MotionMediaFamily + 1, // 1: memos.api.v1.MotionMedia.role:type_name -> memos.api.v1.MotionMediaRole + 11, // 2: memos.api.v1.Attachment.create_time:type_name -> google.protobuf.Timestamp + 2, // 3: memos.api.v1.Attachment.motion_media:type_name -> memos.api.v1.MotionMedia + 3, // 4: memos.api.v1.CreateAttachmentRequest.attachment:type_name -> memos.api.v1.Attachment + 3, // 5: memos.api.v1.ListAttachmentsResponse.attachments:type_name -> memos.api.v1.Attachment + 3, // 6: memos.api.v1.UpdateAttachmentRequest.attachment:type_name -> memos.api.v1.Attachment + 12, // 7: memos.api.v1.UpdateAttachmentRequest.update_mask:type_name -> google.protobuf.FieldMask + 4, // 8: memos.api.v1.AttachmentService.CreateAttachment:input_type -> memos.api.v1.CreateAttachmentRequest + 5, // 9: memos.api.v1.AttachmentService.ListAttachments:input_type -> memos.api.v1.ListAttachmentsRequest + 7, // 10: memos.api.v1.AttachmentService.GetAttachment:input_type -> memos.api.v1.GetAttachmentRequest + 8, // 11: memos.api.v1.AttachmentService.UpdateAttachment:input_type -> memos.api.v1.UpdateAttachmentRequest + 9, // 12: memos.api.v1.AttachmentService.DeleteAttachment:input_type -> memos.api.v1.DeleteAttachmentRequest + 10, // 13: memos.api.v1.AttachmentService.BatchDeleteAttachments:input_type -> memos.api.v1.BatchDeleteAttachmentsRequest + 3, // 14: memos.api.v1.AttachmentService.CreateAttachment:output_type -> memos.api.v1.Attachment + 6, // 15: memos.api.v1.AttachmentService.ListAttachments:output_type -> memos.api.v1.ListAttachmentsResponse + 3, // 16: memos.api.v1.AttachmentService.GetAttachment:output_type -> memos.api.v1.Attachment + 3, // 17: memos.api.v1.AttachmentService.UpdateAttachment:output_type -> memos.api.v1.Attachment + 13, // 18: memos.api.v1.AttachmentService.DeleteAttachment:output_type -> google.protobuf.Empty + 13, // 19: memos.api.v1.AttachmentService.BatchDeleteAttachments:output_type -> google.protobuf.Empty + 14, // [14:20] is the sub-list for method output_type + 8, // [8:14] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name } func init() { file_api_v1_attachment_service_proto_init() } @@ -591,19 +850,20 @@ func file_api_v1_attachment_service_proto_init() { if File_api_v1_attachment_service_proto != nil { return } - file_api_v1_attachment_service_proto_msgTypes[0].OneofWrappers = []any{} + file_api_v1_attachment_service_proto_msgTypes[1].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_v1_attachment_service_proto_rawDesc), len(file_api_v1_attachment_service_proto_rawDesc)), - NumEnums: 0, - NumMessages: 7, + NumEnums: 2, + NumMessages: 9, NumExtensions: 0, NumServices: 1, }, GoTypes: file_api_v1_attachment_service_proto_goTypes, DependencyIndexes: file_api_v1_attachment_service_proto_depIdxs, + EnumInfos: file_api_v1_attachment_service_proto_enumTypes, MessageInfos: file_api_v1_attachment_service_proto_msgTypes, }.Build() File_api_v1_attachment_service_proto = out.File diff --git a/proto/gen/api/v1/attachment_service.pb.gw.go b/proto/gen/api/v1/attachment_service.pb.gw.go index 9d6f6d8c4..10377c955 100644 --- a/proto/gen/api/v1/attachment_service.pb.gw.go +++ b/proto/gen/api/v1/attachment_service.pb.gw.go @@ -270,6 +270,33 @@ func local_request_AttachmentService_DeleteAttachment_0(ctx context.Context, mar return msg, metadata, err } +func request_AttachmentService_BatchDeleteAttachments_0(ctx context.Context, marshaler runtime.Marshaler, client AttachmentServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq BatchDeleteAttachmentsRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.BatchDeleteAttachments(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AttachmentService_BatchDeleteAttachments_0(ctx context.Context, marshaler runtime.Marshaler, server AttachmentServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq BatchDeleteAttachmentsRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.BatchDeleteAttachments(ctx, &protoReq) + return msg, metadata, err +} + // RegisterAttachmentServiceHandlerServer registers the http handlers for service AttachmentService to "mux". // UnaryRPC :call AttachmentServiceServer directly. // StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. @@ -376,6 +403,26 @@ func RegisterAttachmentServiceHandlerServer(ctx context.Context, mux *runtime.Se } forward_AttachmentService_DeleteAttachment_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_AttachmentService_BatchDeleteAttachments_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/memos.api.v1.AttachmentService/BatchDeleteAttachments", runtime.WithHTTPPathPattern("/api/v1/attachments:batchDelete")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AttachmentService_BatchDeleteAttachments_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AttachmentService_BatchDeleteAttachments_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -501,21 +548,40 @@ func RegisterAttachmentServiceHandlerClient(ctx context.Context, mux *runtime.Se } forward_AttachmentService_DeleteAttachment_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_AttachmentService_BatchDeleteAttachments_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/memos.api.v1.AttachmentService/BatchDeleteAttachments", runtime.WithHTTPPathPattern("/api/v1/attachments:batchDelete")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AttachmentService_BatchDeleteAttachments_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AttachmentService_BatchDeleteAttachments_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } var ( - pattern_AttachmentService_CreateAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "attachments"}, "")) - pattern_AttachmentService_ListAttachments_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "attachments"}, "")) - pattern_AttachmentService_GetAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "name"}, "")) - pattern_AttachmentService_UpdateAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "attachment.name"}, "")) - pattern_AttachmentService_DeleteAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "name"}, "")) + pattern_AttachmentService_CreateAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "attachments"}, "")) + pattern_AttachmentService_ListAttachments_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "attachments"}, "")) + pattern_AttachmentService_GetAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "name"}, "")) + pattern_AttachmentService_UpdateAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "attachment.name"}, "")) + pattern_AttachmentService_DeleteAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "name"}, "")) + pattern_AttachmentService_BatchDeleteAttachments_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "attachments"}, "batchDelete")) ) var ( - forward_AttachmentService_CreateAttachment_0 = runtime.ForwardResponseMessage - forward_AttachmentService_ListAttachments_0 = runtime.ForwardResponseMessage - forward_AttachmentService_GetAttachment_0 = runtime.ForwardResponseMessage - forward_AttachmentService_UpdateAttachment_0 = runtime.ForwardResponseMessage - forward_AttachmentService_DeleteAttachment_0 = runtime.ForwardResponseMessage + forward_AttachmentService_CreateAttachment_0 = runtime.ForwardResponseMessage + forward_AttachmentService_ListAttachments_0 = runtime.ForwardResponseMessage + forward_AttachmentService_GetAttachment_0 = runtime.ForwardResponseMessage + forward_AttachmentService_UpdateAttachment_0 = runtime.ForwardResponseMessage + forward_AttachmentService_DeleteAttachment_0 = runtime.ForwardResponseMessage + forward_AttachmentService_BatchDeleteAttachments_0 = runtime.ForwardResponseMessage ) diff --git a/proto/gen/api/v1/attachment_service_grpc.pb.go b/proto/gen/api/v1/attachment_service_grpc.pb.go index 07de09332..55f8c9101 100644 --- a/proto/gen/api/v1/attachment_service_grpc.pb.go +++ b/proto/gen/api/v1/attachment_service_grpc.pb.go @@ -20,11 +20,12 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - AttachmentService_CreateAttachment_FullMethodName = "/memos.api.v1.AttachmentService/CreateAttachment" - AttachmentService_ListAttachments_FullMethodName = "/memos.api.v1.AttachmentService/ListAttachments" - AttachmentService_GetAttachment_FullMethodName = "/memos.api.v1.AttachmentService/GetAttachment" - AttachmentService_UpdateAttachment_FullMethodName = "/memos.api.v1.AttachmentService/UpdateAttachment" - AttachmentService_DeleteAttachment_FullMethodName = "/memos.api.v1.AttachmentService/DeleteAttachment" + AttachmentService_CreateAttachment_FullMethodName = "/memos.api.v1.AttachmentService/CreateAttachment" + AttachmentService_ListAttachments_FullMethodName = "/memos.api.v1.AttachmentService/ListAttachments" + AttachmentService_GetAttachment_FullMethodName = "/memos.api.v1.AttachmentService/GetAttachment" + AttachmentService_UpdateAttachment_FullMethodName = "/memos.api.v1.AttachmentService/UpdateAttachment" + AttachmentService_DeleteAttachment_FullMethodName = "/memos.api.v1.AttachmentService/DeleteAttachment" + AttachmentService_BatchDeleteAttachments_FullMethodName = "/memos.api.v1.AttachmentService/BatchDeleteAttachments" ) // AttachmentServiceClient is the client API for AttachmentService service. @@ -41,6 +42,8 @@ type AttachmentServiceClient interface { UpdateAttachment(ctx context.Context, in *UpdateAttachmentRequest, opts ...grpc.CallOption) (*Attachment, error) // DeleteAttachment deletes an attachment by name. DeleteAttachment(ctx context.Context, in *DeleteAttachmentRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + // BatchDeleteAttachments deletes multiple attachments in one request. + BatchDeleteAttachments(ctx context.Context, in *BatchDeleteAttachmentsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) } type attachmentServiceClient struct { @@ -101,6 +104,16 @@ func (c *attachmentServiceClient) DeleteAttachment(ctx context.Context, in *Dele return out, nil } +func (c *attachmentServiceClient) BatchDeleteAttachments(ctx context.Context, in *BatchDeleteAttachmentsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, AttachmentService_BatchDeleteAttachments_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // AttachmentServiceServer is the server API for AttachmentService service. // All implementations must embed UnimplementedAttachmentServiceServer // for forward compatibility. @@ -115,6 +128,8 @@ type AttachmentServiceServer interface { UpdateAttachment(context.Context, *UpdateAttachmentRequest) (*Attachment, error) // DeleteAttachment deletes an attachment by name. DeleteAttachment(context.Context, *DeleteAttachmentRequest) (*emptypb.Empty, error) + // BatchDeleteAttachments deletes multiple attachments in one request. + BatchDeleteAttachments(context.Context, *BatchDeleteAttachmentsRequest) (*emptypb.Empty, error) mustEmbedUnimplementedAttachmentServiceServer() } @@ -140,6 +155,9 @@ func (UnimplementedAttachmentServiceServer) UpdateAttachment(context.Context, *U func (UnimplementedAttachmentServiceServer) DeleteAttachment(context.Context, *DeleteAttachmentRequest) (*emptypb.Empty, error) { return nil, status.Error(codes.Unimplemented, "method DeleteAttachment not implemented") } +func (UnimplementedAttachmentServiceServer) BatchDeleteAttachments(context.Context, *BatchDeleteAttachmentsRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method BatchDeleteAttachments not implemented") +} func (UnimplementedAttachmentServiceServer) mustEmbedUnimplementedAttachmentServiceServer() {} func (UnimplementedAttachmentServiceServer) testEmbeddedByValue() {} @@ -251,6 +269,24 @@ func _AttachmentService_DeleteAttachment_Handler(srv interface{}, ctx context.Co return interceptor(ctx, in, info, handler) } +func _AttachmentService_BatchDeleteAttachments_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(BatchDeleteAttachmentsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AttachmentServiceServer).BatchDeleteAttachments(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AttachmentService_BatchDeleteAttachments_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AttachmentServiceServer).BatchDeleteAttachments(ctx, req.(*BatchDeleteAttachmentsRequest)) + } + return interceptor(ctx, in, info, handler) +} + // AttachmentService_ServiceDesc is the grpc.ServiceDesc for AttachmentService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -278,6 +314,10 @@ var AttachmentService_ServiceDesc = grpc.ServiceDesc{ MethodName: "DeleteAttachment", Handler: _AttachmentService_DeleteAttachment_Handler, }, + { + MethodName: "BatchDeleteAttachments", + Handler: _AttachmentService_BatchDeleteAttachments_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "api/v1/attachment_service.proto", diff --git a/proto/gen/openapi.yaml b/proto/gen/openapi.yaml index a315fcf95..b48d1d903 100644 --- a/proto/gen/openapi.yaml +++ b/proto/gen/openapi.yaml @@ -176,6 +176,28 @@ paths: application/json: schema: $ref: '#/components/schemas/Status' + /api/v1/attachments:batchDelete: + post: + tags: + - AttachmentService + description: BatchDeleteAttachments deletes multiple attachments in one request. + operationId: AttachmentService_BatchDeleteAttachments + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/BatchDeleteAttachmentsRequest' + required: true + responses: + "200": + description: OK + content: {} + default: + description: Default error response + content: + application/json: + schema: + $ref: '#/components/schemas/Status' /api/v1/auth/me: get: tags: @@ -2015,6 +2037,19 @@ components: description: |- Optional. The related memo. Refer to `Memo.name`. Format: memos/{memo} + motionMedia: + allOf: + - $ref: '#/components/schemas/MotionMedia' + description: Optional. Motion media metadata. + BatchDeleteAttachmentsRequest: + required: + - names + type: object + properties: + names: + type: array + items: + type: string Color: type: object properties: @@ -2781,6 +2816,30 @@ components: type: string description: The title extracted from the first H1 heading, if present. description: Computed properties of a memo. + MotionMedia: + type: object + properties: + family: + enum: + - MOTION_MEDIA_FAMILY_UNSPECIFIED + - APPLE_LIVE_PHOTO + - ANDROID_MOTION_PHOTO + type: string + format: enum + role: + enum: + - MOTION_MEDIA_ROLE_UNSPECIFIED + - STILL + - VIDEO + - CONTAINER + type: string + format: enum + groupId: + type: string + presentationTimestampUs: + type: string + hasEmbeddedVideo: + type: boolean NotificationSetting_EmailSetting: type: object properties: diff --git a/proto/gen/store/attachment.pb.go b/proto/gen/store/attachment.pb.go index 1c734ec5a..605ff6c5a 100644 --- a/proto/gen/store/attachment.pb.go +++ b/proto/gen/store/attachment.pb.go @@ -77,19 +77,197 @@ func (AttachmentStorageType) EnumDescriptor() ([]byte, []int) { return file_store_attachment_proto_rawDescGZIP(), []int{0} } +type MotionMediaFamily int32 + +const ( + MotionMediaFamily_MOTION_MEDIA_FAMILY_UNSPECIFIED MotionMediaFamily = 0 + MotionMediaFamily_APPLE_LIVE_PHOTO MotionMediaFamily = 1 + MotionMediaFamily_ANDROID_MOTION_PHOTO MotionMediaFamily = 2 +) + +// Enum value maps for MotionMediaFamily. +var ( + MotionMediaFamily_name = map[int32]string{ + 0: "MOTION_MEDIA_FAMILY_UNSPECIFIED", + 1: "APPLE_LIVE_PHOTO", + 2: "ANDROID_MOTION_PHOTO", + } + MotionMediaFamily_value = map[string]int32{ + "MOTION_MEDIA_FAMILY_UNSPECIFIED": 0, + "APPLE_LIVE_PHOTO": 1, + "ANDROID_MOTION_PHOTO": 2, + } +) + +func (x MotionMediaFamily) Enum() *MotionMediaFamily { + p := new(MotionMediaFamily) + *p = x + return p +} + +func (x MotionMediaFamily) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (MotionMediaFamily) Descriptor() protoreflect.EnumDescriptor { + return file_store_attachment_proto_enumTypes[1].Descriptor() +} + +func (MotionMediaFamily) Type() protoreflect.EnumType { + return &file_store_attachment_proto_enumTypes[1] +} + +func (x MotionMediaFamily) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use MotionMediaFamily.Descriptor instead. +func (MotionMediaFamily) EnumDescriptor() ([]byte, []int) { + return file_store_attachment_proto_rawDescGZIP(), []int{1} +} + +type MotionMediaRole int32 + +const ( + MotionMediaRole_MOTION_MEDIA_ROLE_UNSPECIFIED MotionMediaRole = 0 + MotionMediaRole_STILL MotionMediaRole = 1 + MotionMediaRole_VIDEO MotionMediaRole = 2 + MotionMediaRole_CONTAINER MotionMediaRole = 3 +) + +// Enum value maps for MotionMediaRole. +var ( + MotionMediaRole_name = map[int32]string{ + 0: "MOTION_MEDIA_ROLE_UNSPECIFIED", + 1: "STILL", + 2: "VIDEO", + 3: "CONTAINER", + } + MotionMediaRole_value = map[string]int32{ + "MOTION_MEDIA_ROLE_UNSPECIFIED": 0, + "STILL": 1, + "VIDEO": 2, + "CONTAINER": 3, + } +) + +func (x MotionMediaRole) Enum() *MotionMediaRole { + p := new(MotionMediaRole) + *p = x + return p +} + +func (x MotionMediaRole) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (MotionMediaRole) Descriptor() protoreflect.EnumDescriptor { + return file_store_attachment_proto_enumTypes[2].Descriptor() +} + +func (MotionMediaRole) Type() protoreflect.EnumType { + return &file_store_attachment_proto_enumTypes[2] +} + +func (x MotionMediaRole) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use MotionMediaRole.Descriptor instead. +func (MotionMediaRole) EnumDescriptor() ([]byte, []int) { + return file_store_attachment_proto_rawDescGZIP(), []int{2} +} + +type MotionMedia struct { + state protoimpl.MessageState `protogen:"open.v1"` + Family MotionMediaFamily `protobuf:"varint,1,opt,name=family,proto3,enum=memos.store.MotionMediaFamily" json:"family,omitempty"` + Role MotionMediaRole `protobuf:"varint,2,opt,name=role,proto3,enum=memos.store.MotionMediaRole" json:"role,omitempty"` + GroupId string `protobuf:"bytes,3,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + PresentationTimestampUs int64 `protobuf:"varint,4,opt,name=presentation_timestamp_us,json=presentationTimestampUs,proto3" json:"presentation_timestamp_us,omitempty"` + HasEmbeddedVideo bool `protobuf:"varint,5,opt,name=has_embedded_video,json=hasEmbeddedVideo,proto3" json:"has_embedded_video,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MotionMedia) Reset() { + *x = MotionMedia{} + mi := &file_store_attachment_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MotionMedia) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MotionMedia) ProtoMessage() {} + +func (x *MotionMedia) ProtoReflect() protoreflect.Message { + mi := &file_store_attachment_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MotionMedia.ProtoReflect.Descriptor instead. +func (*MotionMedia) Descriptor() ([]byte, []int) { + return file_store_attachment_proto_rawDescGZIP(), []int{0} +} + +func (x *MotionMedia) GetFamily() MotionMediaFamily { + if x != nil { + return x.Family + } + return MotionMediaFamily_MOTION_MEDIA_FAMILY_UNSPECIFIED +} + +func (x *MotionMedia) GetRole() MotionMediaRole { + if x != nil { + return x.Role + } + return MotionMediaRole_MOTION_MEDIA_ROLE_UNSPECIFIED +} + +func (x *MotionMedia) GetGroupId() string { + if x != nil { + return x.GroupId + } + return "" +} + +func (x *MotionMedia) GetPresentationTimestampUs() int64 { + if x != nil { + return x.PresentationTimestampUs + } + return 0 +} + +func (x *MotionMedia) GetHasEmbeddedVideo() bool { + if x != nil { + return x.HasEmbeddedVideo + } + return false +} + type AttachmentPayload struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Payload: // // *AttachmentPayload_S3Object_ Payload isAttachmentPayload_Payload `protobuf_oneof:"payload"` + MotionMedia *MotionMedia `protobuf:"bytes,10,opt,name=motion_media,json=motionMedia,proto3" json:"motion_media,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AttachmentPayload) Reset() { *x = AttachmentPayload{} - mi := &file_store_attachment_proto_msgTypes[0] + mi := &file_store_attachment_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -101,7 +279,7 @@ func (x *AttachmentPayload) String() string { func (*AttachmentPayload) ProtoMessage() {} func (x *AttachmentPayload) ProtoReflect() protoreflect.Message { - mi := &file_store_attachment_proto_msgTypes[0] + mi := &file_store_attachment_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -114,7 +292,7 @@ func (x *AttachmentPayload) ProtoReflect() protoreflect.Message { // Deprecated: Use AttachmentPayload.ProtoReflect.Descriptor instead. func (*AttachmentPayload) Descriptor() ([]byte, []int) { - return file_store_attachment_proto_rawDescGZIP(), []int{0} + return file_store_attachment_proto_rawDescGZIP(), []int{1} } func (x *AttachmentPayload) GetPayload() isAttachmentPayload_Payload { @@ -133,6 +311,13 @@ func (x *AttachmentPayload) GetS3Object() *AttachmentPayload_S3Object { return nil } +func (x *AttachmentPayload) GetMotionMedia() *MotionMedia { + if x != nil { + return x.MotionMedia + } + return nil +} + type isAttachmentPayload_Payload interface { isAttachmentPayload_Payload() } @@ -157,7 +342,7 @@ type AttachmentPayload_S3Object struct { func (x *AttachmentPayload_S3Object) Reset() { *x = AttachmentPayload_S3Object{} - mi := &file_store_attachment_proto_msgTypes[1] + mi := &file_store_attachment_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -169,7 +354,7 @@ func (x *AttachmentPayload_S3Object) String() string { func (*AttachmentPayload_S3Object) ProtoMessage() {} func (x *AttachmentPayload_S3Object) ProtoReflect() protoreflect.Message { - mi := &file_store_attachment_proto_msgTypes[1] + mi := &file_store_attachment_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -182,7 +367,7 @@ func (x *AttachmentPayload_S3Object) ProtoReflect() protoreflect.Message { // Deprecated: Use AttachmentPayload_S3Object.ProtoReflect.Descriptor instead. func (*AttachmentPayload_S3Object) Descriptor() ([]byte, []int) { - return file_store_attachment_proto_rawDescGZIP(), []int{0, 0} + return file_store_attachment_proto_rawDescGZIP(), []int{1, 0} } func (x *AttachmentPayload_S3Object) GetS3Config() *StorageS3Config { @@ -210,9 +395,17 @@ var File_store_attachment_proto protoreflect.FileDescriptor const file_store_attachment_proto_rawDesc = "" + "\n" + - "\x16store/attachment.proto\x12\vmemos.store\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1cstore/instance_setting.proto\"\x8c\x02\n" + + "\x16store/attachment.proto\x12\vmemos.store\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1cstore/instance_setting.proto\"\xfc\x01\n" + + "\vMotionMedia\x126\n" + + "\x06family\x18\x01 \x01(\x0e2\x1e.memos.store.MotionMediaFamilyR\x06family\x120\n" + + "\x04role\x18\x02 \x01(\x0e2\x1c.memos.store.MotionMediaRoleR\x04role\x12\x19\n" + + "\bgroup_id\x18\x03 \x01(\tR\agroupId\x12:\n" + + "\x19presentation_timestamp_us\x18\x04 \x01(\x03R\x17presentationTimestampUs\x12,\n" + + "\x12has_embedded_video\x18\x05 \x01(\bR\x10hasEmbeddedVideo\"\xc9\x02\n" + "\x11AttachmentPayload\x12F\n" + - "\ts3_object\x18\x01 \x01(\v2'.memos.store.AttachmentPayload.S3ObjectH\x00R\bs3Object\x1a\xa3\x01\n" + + "\ts3_object\x18\x01 \x01(\v2'.memos.store.AttachmentPayload.S3ObjectH\x00R\bs3Object\x12;\n" + + "\fmotion_media\x18\n" + + " \x01(\v2\x18.memos.store.MotionMediaR\vmotionMedia\x1a\xa3\x01\n" + "\bS3Object\x129\n" + "\ts3_config\x18\x01 \x01(\v2\x1c.memos.store.StorageS3ConfigR\bs3Config\x12\x10\n" + "\x03key\x18\x02 \x01(\tR\x03key\x12J\n" + @@ -222,7 +415,16 @@ const file_store_attachment_proto_rawDesc = "" + "#ATTACHMENT_STORAGE_TYPE_UNSPECIFIED\x10\x00\x12\t\n" + "\x05LOCAL\x10\x01\x12\x06\n" + "\x02S3\x10\x02\x12\f\n" + - "\bEXTERNAL\x10\x03B\x9a\x01\n" + + "\bEXTERNAL\x10\x03*h\n" + + "\x11MotionMediaFamily\x12#\n" + + "\x1fMOTION_MEDIA_FAMILY_UNSPECIFIED\x10\x00\x12\x14\n" + + "\x10APPLE_LIVE_PHOTO\x10\x01\x12\x18\n" + + "\x14ANDROID_MOTION_PHOTO\x10\x02*Y\n" + + "\x0fMotionMediaRole\x12!\n" + + "\x1dMOTION_MEDIA_ROLE_UNSPECIFIED\x10\x00\x12\t\n" + + "\x05STILL\x10\x01\x12\t\n" + + "\x05VIDEO\x10\x02\x12\r\n" + + "\tCONTAINER\x10\x03B\x9a\x01\n" + "\x0fcom.memos.storeB\x0fAttachmentProtoP\x01Z)github.com/usememos/memos/proto/gen/store\xa2\x02\x03MSX\xaa\x02\vMemos.Store\xca\x02\vMemos\\Store\xe2\x02\x17Memos\\Store\\GPBMetadata\xea\x02\fMemos::Storeb\x06proto3" var ( @@ -237,24 +439,30 @@ func file_store_attachment_proto_rawDescGZIP() []byte { return file_store_attachment_proto_rawDescData } -var file_store_attachment_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_store_attachment_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_store_attachment_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_store_attachment_proto_msgTypes = make([]protoimpl.MessageInfo, 3) var file_store_attachment_proto_goTypes = []any{ (AttachmentStorageType)(0), // 0: memos.store.AttachmentStorageType - (*AttachmentPayload)(nil), // 1: memos.store.AttachmentPayload - (*AttachmentPayload_S3Object)(nil), // 2: memos.store.AttachmentPayload.S3Object - (*StorageS3Config)(nil), // 3: memos.store.StorageS3Config - (*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp + (MotionMediaFamily)(0), // 1: memos.store.MotionMediaFamily + (MotionMediaRole)(0), // 2: memos.store.MotionMediaRole + (*MotionMedia)(nil), // 3: memos.store.MotionMedia + (*AttachmentPayload)(nil), // 4: memos.store.AttachmentPayload + (*AttachmentPayload_S3Object)(nil), // 5: memos.store.AttachmentPayload.S3Object + (*StorageS3Config)(nil), // 6: memos.store.StorageS3Config + (*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp } var file_store_attachment_proto_depIdxs = []int32{ - 2, // 0: memos.store.AttachmentPayload.s3_object:type_name -> memos.store.AttachmentPayload.S3Object - 3, // 1: memos.store.AttachmentPayload.S3Object.s3_config:type_name -> memos.store.StorageS3Config - 4, // 2: memos.store.AttachmentPayload.S3Object.last_presigned_time:type_name -> google.protobuf.Timestamp - 3, // [3:3] is the sub-list for method output_type - 3, // [3:3] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 1, // 0: memos.store.MotionMedia.family:type_name -> memos.store.MotionMediaFamily + 2, // 1: memos.store.MotionMedia.role:type_name -> memos.store.MotionMediaRole + 5, // 2: memos.store.AttachmentPayload.s3_object:type_name -> memos.store.AttachmentPayload.S3Object + 3, // 3: memos.store.AttachmentPayload.motion_media:type_name -> memos.store.MotionMedia + 6, // 4: memos.store.AttachmentPayload.S3Object.s3_config:type_name -> memos.store.StorageS3Config + 7, // 5: memos.store.AttachmentPayload.S3Object.last_presigned_time:type_name -> google.protobuf.Timestamp + 6, // [6:6] is the sub-list for method output_type + 6, // [6:6] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name } func init() { file_store_attachment_proto_init() } @@ -263,7 +471,7 @@ func file_store_attachment_proto_init() { return } file_store_instance_setting_proto_init() - file_store_attachment_proto_msgTypes[0].OneofWrappers = []any{ + file_store_attachment_proto_msgTypes[1].OneofWrappers = []any{ (*AttachmentPayload_S3Object_)(nil), } type x struct{} @@ -271,8 +479,8 @@ func file_store_attachment_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_store_attachment_proto_rawDesc), len(file_store_attachment_proto_rawDesc)), - NumEnums: 1, - NumMessages: 2, + NumEnums: 3, + NumMessages: 3, NumExtensions: 0, NumServices: 0, }, diff --git a/proto/store/attachment.proto b/proto/store/attachment.proto index 018f854d2..8438bf48f 100644 --- a/proto/store/attachment.proto +++ b/proto/store/attachment.proto @@ -17,11 +17,34 @@ enum AttachmentStorageType { EXTERNAL = 3; } +enum MotionMediaFamily { + MOTION_MEDIA_FAMILY_UNSPECIFIED = 0; + APPLE_LIVE_PHOTO = 1; + ANDROID_MOTION_PHOTO = 2; +} + +enum MotionMediaRole { + MOTION_MEDIA_ROLE_UNSPECIFIED = 0; + STILL = 1; + VIDEO = 2; + CONTAINER = 3; +} + +message MotionMedia { + MotionMediaFamily family = 1; + MotionMediaRole role = 2; + string group_id = 3; + int64 presentation_timestamp_us = 4; + bool has_embedded_video = 5; +} + message AttachmentPayload { oneof payload { S3Object s3_object = 1; } + MotionMedia motion_media = 10; + message S3Object { StorageS3Config s3_config = 1; // key is the S3 object key. diff --git a/server/router/api/v1/attachment_motion.go b/server/router/api/v1/attachment_motion.go new file mode 100644 index 000000000..9d0f849d9 --- /dev/null +++ b/server/router/api/v1/attachment_motion.go @@ -0,0 +1,69 @@ +package v1 + +import ( + v1pb "github.com/usememos/memos/proto/gen/api/v1" + storepb "github.com/usememos/memos/proto/gen/store" + "github.com/usememos/memos/store" +) + +func convertMotionMediaFromStore(motion *storepb.MotionMedia) *v1pb.MotionMedia { + if motion == nil { + return nil + } + + return &v1pb.MotionMedia{ + Family: v1pb.MotionMediaFamily(motion.Family), + Role: v1pb.MotionMediaRole(motion.Role), + GroupId: motion.GroupId, + PresentationTimestampUs: motion.PresentationTimestampUs, + HasEmbeddedVideo: motion.HasEmbeddedVideo, + } +} + +func convertMotionMediaToStore(motion *v1pb.MotionMedia) *storepb.MotionMedia { + if motion == nil { + return nil + } + + return &storepb.MotionMedia{ + Family: storepb.MotionMediaFamily(motion.Family), + Role: storepb.MotionMediaRole(motion.Role), + GroupId: motion.GroupId, + PresentationTimestampUs: motion.PresentationTimestampUs, + HasEmbeddedVideo: motion.HasEmbeddedVideo, + } +} + +func getAttachmentMotionMedia(attachment *store.Attachment) *storepb.MotionMedia { + if attachment == nil || attachment.Payload == nil { + return nil + } + return attachment.Payload.MotionMedia +} + +func isAndroidMotionContainer(motion *storepb.MotionMedia) bool { + return motion != nil && + motion.Family == storepb.MotionMediaFamily_ANDROID_MOTION_PHOTO && + motion.Role == storepb.MotionMediaRole_CONTAINER && + motion.HasEmbeddedVideo +} + +func ensureAttachmentPayload(payload *storepb.AttachmentPayload) *storepb.AttachmentPayload { + if payload != nil { + return payload + } + return &storepb.AttachmentPayload{} +} + +func isMultiMemberMotionGroup(attachments []*store.Attachment) bool { + if len(attachments) < 2 { + return false + } + for _, attachment := range attachments { + motion := getAttachmentMotionMedia(attachment) + if motion == nil || motion.GroupId == "" { + return false + } + } + return true +} diff --git a/server/router/api/v1/attachment_service.go b/server/router/api/v1/attachment_service.go index 5bef72b79..f895f128d 100644 --- a/server/router/api/v1/attachment_service.go +++ b/server/router/api/v1/attachment_service.go @@ -22,6 +22,7 @@ import ( "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/usememos/memos/internal/motionphoto" "github.com/usememos/memos/internal/profile" "github.com/usememos/memos/internal/util" "github.com/usememos/memos/plugin/filter" @@ -42,7 +43,8 @@ const ( // defaultJPEGQuality is the JPEG quality used when re-encoding images for EXIF stripping. // Quality 95 maintains visual quality while ensuring metadata is removed. - defaultJPEGQuality = 95 + defaultJPEGQuality = 95 + maxBatchDeleteAttachments = 100 ) var SupportedThumbnailMimeTypes = []string{ @@ -111,6 +113,15 @@ func (s *APIV1Service) CreateAttachment(ctx context.Context, request *v1pb.Creat Type: request.Attachment.Type, } + inputMotionMedia, err := validateClientMotionMedia(request.Attachment.MotionMedia, attachmentUID) + if err != nil { + return nil, err + } + if inputMotionMedia != nil { + create.Payload = ensureAttachmentPayload(create.Payload) + create.Payload.MotionMedia = inputMotionMedia + } + instanceStorageSetting, err := s.Store.GetInstanceStorageSetting(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get instance storage setting: %v", err) @@ -126,9 +137,16 @@ func (s *APIV1Service) CreateAttachment(ctx context.Context, request *v1pb.Creat create.Size = int64(size) create.Blob = request.Attachment.Content + if create.Payload == nil || create.Payload.MotionMedia == nil { + if detectedMotion := detectAndroidMotionMedia(create.Blob, create.Type, attachmentUID); detectedMotion != nil { + create.Payload = ensureAttachmentPayload(create.Payload) + create.Payload.MotionMedia = detectedMotion + } + } + // Strip EXIF metadata from images for privacy protection. // This removes sensitive information like GPS location, device details, etc. - if shouldStripExif(create.Type) { + if shouldStripExif(create.Type) && !isAndroidMotionContainer(create.Payload.GetMotionMedia()) { if strippedBlob, err := stripImageExif(create.Blob, create.Type); err != nil { // Log warning but continue with original image to ensure uploads don't fail. slog.Warn("failed to strip EXIF metadata from image", @@ -333,13 +351,64 @@ func (s *APIV1Service) DeleteAttachment(ctx context.Context, request *v1pb.Delet return &emptypb.Empty{}, nil } +func (s *APIV1Service) BatchDeleteAttachments(ctx context.Context, request *v1pb.BatchDeleteAttachmentsRequest) (*emptypb.Empty, error) { + user, err := s.fetchCurrentUser(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err) + } + if user == nil { + return nil, status.Errorf(codes.Unauthenticated, "user not authenticated") + } + if len(request.Names) == 0 { + return nil, status.Errorf(codes.InvalidArgument, "attachment names are required") + } + if len(request.Names) > maxBatchDeleteAttachments { + return nil, status.Errorf(codes.InvalidArgument, "too many attachment names; max %d", maxBatchDeleteAttachments) + } + + attachments := make([]*store.Attachment, 0, len(request.Names)) + seen := make(map[string]bool, len(request.Names)) + for _, name := range request.Names { + if name == "" { + return nil, status.Errorf(codes.InvalidArgument, "attachment name is required") + } + if seen[name] { + continue + } + seen[name] = true + + attachmentUID, err := ExtractAttachmentUIDFromName(name) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid attachment id: %v", err) + } + attachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{UID: &attachmentUID}) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get attachment: %v", err) + } + if attachment == nil { + return nil, status.Errorf(codes.NotFound, "attachment not found") + } + if attachment.CreatorID != user.ID && !isSuperUser(user) { + return nil, status.Errorf(codes.PermissionDenied, "permission denied") + } + attachments = append(attachments, attachment) + } + + if err := s.Store.DeleteAttachments(ctx, attachments); err != nil { + return nil, status.Errorf(codes.Internal, "failed to delete attachments: %v", err) + } + + return &emptypb.Empty{}, nil +} + func convertAttachmentFromStore(attachment *store.Attachment) *v1pb.Attachment { attachmentMessage := &v1pb.Attachment{ - Name: fmt.Sprintf("%s%s", AttachmentNamePrefix, attachment.UID), - CreateTime: timestamppb.New(time.Unix(attachment.CreatedTs, 0)), - Filename: attachment.Filename, - Type: attachment.Type, - Size: attachment.Size, + Name: fmt.Sprintf("%s%s", AttachmentNamePrefix, attachment.UID), + CreateTime: timestamppb.New(time.Unix(attachment.CreatedTs, 0)), + Filename: attachment.Filename, + Type: attachment.Type, + Size: attachment.Size, + MotionMedia: convertMotionMediaFromStore(getAttachmentMotionMedia(attachment)), } if attachment.MemoUID != nil && *attachment.MemoUID != "" { memoName := fmt.Sprintf("%s%s", MemoNamePrefix, *attachment.MemoUID) @@ -425,15 +494,15 @@ func SaveAttachmentBlob(ctx context.Context, profile *profile.Profile, stores *s create.Reference = presignURL create.Blob = nil create.StorageType = storepb.AttachmentStorageType_S3 - create.Payload = &storepb.AttachmentPayload{ - Payload: &storepb.AttachmentPayload_S3Object_{ - S3Object: &storepb.AttachmentPayload_S3Object{ - S3Config: s3Config, - Key: key, - LastPresignedTime: timestamppb.New(time.Now()), - }, + payload := ensureAttachmentPayload(create.Payload) + payload.Payload = &storepb.AttachmentPayload_S3Object_{ + S3Object: &storepb.AttachmentPayload_S3Object{ + S3Config: s3Config, + Key: key, + LastPresignedTime: timestamppb.New(time.Now()), }, } + create.Payload = payload } return nil @@ -624,6 +693,48 @@ func (s *APIV1Service) checkAttachmentAccess(ctx context.Context, attachment *st return nil } +func validateClientMotionMedia(motion *v1pb.MotionMedia, attachmentUID string) (*storepb.MotionMedia, error) { + if motion == nil { + return nil, nil + } + + if motion.Family != v1pb.MotionMediaFamily_APPLE_LIVE_PHOTO { + return nil, status.Errorf(codes.InvalidArgument, "only Apple Live Photo motion metadata can be provided by clients") + } + if motion.Role != v1pb.MotionMediaRole_STILL && motion.Role != v1pb.MotionMediaRole_VIDEO { + return nil, status.Errorf(codes.InvalidArgument, "invalid Apple Live Photo motion role") + } + + storeMotion := convertMotionMediaToStore(motion) + if storeMotion.GroupId == "" { + return nil, status.Errorf(codes.InvalidArgument, "motion media group_id is required") + } + if storeMotion.Family == storepb.MotionMediaFamily_ANDROID_MOTION_PHOTO && storeMotion.GroupId == "" { + storeMotion.GroupId = attachmentUID + } + + return storeMotion, nil +} + +func detectAndroidMotionMedia(blob []byte, mimeType, attachmentUID string) *storepb.MotionMedia { + if mimeType != "image/jpeg" && mimeType != "image/jpg" { + return nil + } + + detection := motionphoto.DetectJPEG(blob) + if detection == nil { + return nil + } + + return &storepb.MotionMedia{ + Family: storepb.MotionMediaFamily_ANDROID_MOTION_PHOTO, + Role: storepb.MotionMediaRole_CONTAINER, + GroupId: attachmentUID, + PresentationTimestampUs: detection.PresentationTimestampUs, + HasEmbeddedVideo: true, + } +} + // shouldStripExif checks if the MIME type is an image format that may contain EXIF metadata. // Returns true for formats like JPEG, TIFF, WebP, HEIC, and HEIF which commonly contain // privacy-sensitive metadata such as GPS coordinates, camera settings, and device information. diff --git a/server/router/api/v1/connect_services.go b/server/router/api/v1/connect_services.go index 9b19bfd7f..7d0cd7730 100644 --- a/server/router/api/v1/connect_services.go +++ b/server/router/api/v1/connect_services.go @@ -419,6 +419,14 @@ func (s *ConnectServiceHandler) DeleteAttachment(ctx context.Context, req *conne return connect.NewResponse(resp), nil } +func (s *ConnectServiceHandler) BatchDeleteAttachments(ctx context.Context, req *connect.Request[v1pb.BatchDeleteAttachmentsRequest]) (*connect.Response[emptypb.Empty], error) { + resp, err := s.APIV1Service.BatchDeleteAttachments(ctx, req.Msg) + if err != nil { + return nil, convertGRPCError(err) + } + return connect.NewResponse(resp), nil +} + // ShortcutService func (s *ConnectServiceHandler) ListShortcuts(ctx context.Context, req *connect.Request[v1pb.ListShortcutsRequest]) (*connect.Response[v1pb.ListShortcutsResponse], error) { diff --git a/server/router/api/v1/memo_attachment_service.go b/server/router/api/v1/memo_attachment_service.go index d687c59e9..8d2b6a04a 100644 --- a/server/router/api/v1/memo_attachment_service.go +++ b/server/router/api/v1/memo_attachment_service.go @@ -51,27 +51,26 @@ func (s *APIV1Service) SetMemoAttachments(ctx context.Context, request *v1pb.Set } func (s *APIV1Service) setMemoAttachmentsInternal(ctx context.Context, memo *store.Memo, requestAttachments []*v1pb.Attachment) error { - attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{ + currentAttachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{ MemoID: &memo.ID, }) if err != nil { return status.Errorf(codes.Internal, "failed to list attachments") } + normalizedAttachments, err := s.normalizeMemoAttachmentRequest(ctx, currentAttachments, requestAttachments) + if err != nil { + return err + } + + requestedIDs := make(map[int32]bool, len(normalizedAttachments)) + for _, attachment := range normalizedAttachments { + requestedIDs[attachment.ID] = true + } + // Delete attachments that are not in the request. - for _, attachment := range attachments { - found := false - for _, requestAttachment := range requestAttachments { - requestAttachmentUID, err := ExtractAttachmentUIDFromName(requestAttachment.Name) - if err != nil { - return status.Errorf(codes.InvalidArgument, "invalid attachment name: %v", err) - } - if attachment.UID == requestAttachmentUID { - found = true - break - } - } - if !found { + for _, attachment := range currentAttachments { + if !requestedIDs[attachment.ID] { if err = s.Store.DeleteAttachment(ctx, &store.DeleteAttachment{ ID: int32(attachment.ID), MemoID: &memo.ID, @@ -81,23 +80,12 @@ func (s *APIV1Service) setMemoAttachmentsInternal(ctx context.Context, memo *sto } } - slices.Reverse(requestAttachments) + slices.Reverse(normalizedAttachments) // Update attachments' memo_id in the request. - for index, attachment := range requestAttachments { - attachmentUID, err := ExtractAttachmentUIDFromName(attachment.Name) - if err != nil { - return status.Errorf(codes.InvalidArgument, "invalid attachment name: %v", err) - } - tempAttachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{UID: &attachmentUID}) - if err != nil { - return status.Errorf(codes.Internal, "failed to get attachment: %v", err) - } - if tempAttachment == nil { - return status.Errorf(codes.NotFound, "attachment not found: %s", attachmentUID) - } + for index, attachment := range normalizedAttachments { updatedTs := time.Now().Unix() + int64(index) if err := s.Store.UpdateAttachment(ctx, &store.UpdateAttachment{ - ID: tempAttachment.ID, + ID: attachment.ID, MemoID: &memo.ID, UpdatedTs: &updatedTs, }); err != nil { @@ -108,6 +96,100 @@ func (s *APIV1Service) setMemoAttachmentsInternal(ctx context.Context, memo *sto return nil } +func (s *APIV1Service) normalizeMemoAttachmentRequest( + ctx context.Context, + currentAttachments []*store.Attachment, + requestAttachments []*v1pb.Attachment, +) ([]*store.Attachment, error) { + requestedAttachments := make([]*store.Attachment, 0, len(requestAttachments)) + for _, requestAttachment := range requestAttachments { + attachmentUID, err := ExtractAttachmentUIDFromName(requestAttachment.Name) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid attachment name: %v", err) + } + attachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{UID: &attachmentUID}) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get attachment: %v", err) + } + if attachment == nil { + return nil, status.Errorf(codes.NotFound, "attachment not found: %s", attachmentUID) + } + requestedAttachments = append(requestedAttachments, attachment) + } + + currentGroups := make(map[string][]*store.Attachment) + for _, attachment := range currentAttachments { + motion := getAttachmentMotionMedia(attachment) + if motion == nil || motion.GroupId == "" { + continue + } + currentGroups[motion.GroupId] = append(currentGroups[motion.GroupId], attachment) + } + + requestGroups := make(map[string][]*store.Attachment) + requestNamesByGroup := make(map[string]map[string]bool) + for _, attachment := range requestedAttachments { + motion := getAttachmentMotionMedia(attachment) + if motion == nil || motion.GroupId == "" { + continue + } + requestGroups[motion.GroupId] = append(requestGroups[motion.GroupId], attachment) + if requestNamesByGroup[motion.GroupId] == nil { + requestNamesByGroup[motion.GroupId] = make(map[string]bool) + } + requestNamesByGroup[motion.GroupId][attachment.UID] = true + } + + normalized := make([]*store.Attachment, 0, len(requestedAttachments)) + appendedGroups := make(map[string]bool) + appendedAttachments := make(map[string]bool) + for _, attachment := range requestedAttachments { + motion := getAttachmentMotionMedia(attachment) + if motion == nil || motion.GroupId == "" { + if !appendedAttachments[attachment.UID] { + normalized = append(normalized, attachment) + appendedAttachments[attachment.UID] = true + } + continue + } + + groupID := motion.GroupId + if appendedGroups[groupID] { + continue + } + + currentGroup := currentGroups[groupID] + if isMultiMemberMotionGroup(currentGroup) && !allGroupMembersRequested(currentGroup, requestNamesByGroup[groupID]) { + appendedGroups[groupID] = true + continue + } + + for _, groupAttachment := range requestGroups[groupID] { + if appendedAttachments[groupAttachment.UID] { + continue + } + normalized = append(normalized, groupAttachment) + appendedAttachments[groupAttachment.UID] = true + } + appendedGroups[groupID] = true + } + + return normalized, nil +} + +func allGroupMembersRequested(group []*store.Attachment, requestedNames map[string]bool) bool { + if len(group) == 0 { + return false + } + + for _, attachment := range group { + if !requestedNames[attachment.UID] { + return false + } + } + return true +} + func (s *APIV1Service) ListMemoAttachments(ctx context.Context, request *v1pb.ListMemoAttachmentsRequest) (*v1pb.ListMemoAttachmentsResponse, error) { memoUID, err := ExtractMemoUIDFromName(request.Name) if err != nil { diff --git a/server/router/api/v1/test/attachment_service_test.go b/server/router/api/v1/test/attachment_service_test.go index 9df22eb13..c49b7efb8 100644 --- a/server/router/api/v1/test/attachment_service_test.go +++ b/server/router/api/v1/test/attachment_service_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/usememos/memos/internal/testutil" v1pb "github.com/usememos/memos/proto/gen/api/v1" storepb "github.com/usememos/memos/proto/gen/store" apiv1 "github.com/usememos/memos/server/router/api/v1" @@ -112,3 +113,118 @@ func TestCreateAttachment(t *testing.T) { require.Equal(t, []byte("second-image"), secondBlob) }) } + +func TestCreateAttachmentMotionMedia(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + ctx := context.Background() + + user, err := ts.CreateRegularUser(ctx, "motion_user") + require.NoError(t, err) + userCtx := ts.CreateUserContext(ctx, user.ID) + + t.Run("Apple live photo metadata roundtrip", func(t *testing.T) { + attachment, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{ + Attachment: &v1pb.Attachment{ + Filename: "live.heic", + Type: "image/heic", + Content: []byte("fake-heic-still"), + MotionMedia: &v1pb.MotionMedia{ + Family: v1pb.MotionMediaFamily_APPLE_LIVE_PHOTO, + Role: v1pb.MotionMediaRole_STILL, + GroupId: "apple-group-1", + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, attachment.MotionMedia) + require.Equal(t, v1pb.MotionMediaFamily_APPLE_LIVE_PHOTO, attachment.MotionMedia.Family) + require.Equal(t, v1pb.MotionMediaRole_STILL, attachment.MotionMedia.Role) + require.Equal(t, "apple-group-1", attachment.MotionMedia.GroupId) + }) + + t.Run("Android motion photo detection", func(t *testing.T) { + attachment, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{ + Attachment: &v1pb.Attachment{ + Filename: "motion.jpg", + Type: "image/jpeg", + Content: testutil.BuildMotionPhotoJPEG(), + }, + }) + require.NoError(t, err) + require.NotNil(t, attachment.MotionMedia) + require.Equal(t, v1pb.MotionMediaFamily_ANDROID_MOTION_PHOTO, attachment.MotionMedia.Family) + require.Equal(t, v1pb.MotionMediaRole_CONTAINER, attachment.MotionMedia.Role) + require.True(t, attachment.MotionMedia.HasEmbeddedVideo) + }) +} + +func TestBatchDeleteAttachments(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + ctx := context.Background() + + user, err := ts.CreateRegularUser(ctx, "delete_user") + require.NoError(t, err) + userCtx := ts.CreateUserContext(ctx, user.ID) + + first, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{ + Attachment: &v1pb.Attachment{Filename: "one.txt", Type: "text/plain", Content: []byte("one")}, + }) + require.NoError(t, err) + second, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{ + Attachment: &v1pb.Attachment{Filename: "two.txt", Type: "text/plain", Content: []byte("two")}, + }) + require.NoError(t, err) + + _, err = ts.Service.BatchDeleteAttachments(userCtx, &v1pb.BatchDeleteAttachmentsRequest{ + Names: []string{first.Name, second.Name}, + }) + require.NoError(t, err) + + firstUID, err := apiv1.ExtractAttachmentUIDFromName(first.Name) + require.NoError(t, err) + secondUID, err := apiv1.ExtractAttachmentUIDFromName(second.Name) + require.NoError(t, err) + storedFirst, err := ts.Store.GetAttachment(ctx, &store.FindAttachment{UID: &firstUID}) + require.NoError(t, err) + storedSecond, err := ts.Store.GetAttachment(ctx, &store.FindAttachment{UID: &secondUID}) + require.NoError(t, err) + require.Nil(t, storedFirst) + require.Nil(t, storedSecond) + + t.Run("deduplicates duplicate names", func(t *testing.T) { + third, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{ + Attachment: &v1pb.Attachment{Filename: "three.txt", Type: "text/plain", Content: []byte("three")}, + }) + require.NoError(t, err) + + _, err = ts.Service.BatchDeleteAttachments(userCtx, &v1pb.BatchDeleteAttachmentsRequest{ + Names: []string{third.Name, third.Name}, + }) + require.NoError(t, err) + + thirdUID, err := apiv1.ExtractAttachmentUIDFromName(third.Name) + require.NoError(t, err) + storedThird, err := ts.Store.GetAttachment(ctx, &store.FindAttachment{UID: &thirdUID}) + require.NoError(t, err) + require.Nil(t, storedThird) + }) + + t.Run("rejects unauthorized deletes", func(t *testing.T) { + ownerAttachment, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{ + Attachment: &v1pb.Attachment{Filename: "private.txt", Type: "text/plain", Content: []byte("private")}, + }) + require.NoError(t, err) + + otherUser, err := ts.CreateRegularUser(ctx, "other_delete_user") + require.NoError(t, err) + otherCtx := ts.CreateUserContext(ctx, otherUser.ID) + + _, err = ts.Service.BatchDeleteAttachments(otherCtx, &v1pb.BatchDeleteAttachmentsRequest{ + Names: []string{ownerAttachment.Name}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "permission denied") + }) +} diff --git a/server/router/api/v1/test/memo_attachment_service_test.go b/server/router/api/v1/test/memo_attachment_service_test.go index 41abb629e..f14437b03 100644 --- a/server/router/api/v1/test/memo_attachment_service_test.go +++ b/server/router/api/v1/test/memo_attachment_service_test.go @@ -163,4 +163,64 @@ func TestSetMemoAttachments(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "not found") }) + + t.Run("SetMemoAttachments removes incomplete live photo groups", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + user, err := ts.CreateRegularUser(ctx, "live_group_user") + require.NoError(t, err) + userCtx := ts.CreateUserContext(ctx, user.ID) + + still, err := ts.Service.CreateAttachment(userCtx, &apiv1.CreateAttachmentRequest{ + Attachment: &apiv1.Attachment{ + Filename: "live.heic", + Type: "image/heic", + Content: []byte("still"), + MotionMedia: &apiv1.MotionMedia{ + Family: apiv1.MotionMediaFamily_APPLE_LIVE_PHOTO, + Role: apiv1.MotionMediaRole_STILL, + GroupId: "memo-live-group", + }, + }, + }) + require.NoError(t, err) + video, err := ts.Service.CreateAttachment(userCtx, &apiv1.CreateAttachmentRequest{ + Attachment: &apiv1.Attachment{ + Filename: "live.mov", + Type: "video/quicktime", + Content: []byte("video"), + MotionMedia: &apiv1.MotionMedia{ + Family: apiv1.MotionMediaFamily_APPLE_LIVE_PHOTO, + Role: apiv1.MotionMediaRole_VIDEO, + GroupId: "memo-live-group", + }, + }, + }) + require.NoError(t, err) + + memo, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{ + Memo: &apiv1.Memo{ + Content: "memo with live photo", + Visibility: apiv1.Visibility_PRIVATE, + Attachments: []*apiv1.Attachment{ + {Name: still.Name}, + {Name: video.Name}, + }, + }, + }) + require.NoError(t, err) + + _, err = ts.Service.SetMemoAttachments(userCtx, &apiv1.SetMemoAttachmentsRequest{ + Name: memo.Name, + Attachments: []*apiv1.Attachment{ + {Name: still.Name}, + }, + }) + require.NoError(t, err) + + response, err := ts.Service.ListMemoAttachments(userCtx, &apiv1.ListMemoAttachmentsRequest{Name: memo.Name}) + require.NoError(t, err) + require.Len(t, response.Attachments, 0) + }) } diff --git a/server/router/fileserver/fileserver.go b/server/router/fileserver/fileserver.go index fc1a8d80f..d3143b962 100644 --- a/server/router/fileserver/fileserver.go +++ b/server/router/fileserver/fileserver.go @@ -19,6 +19,7 @@ import ( "github.com/pkg/errors" "golang.org/x/sync/semaphore" + "github.com/usememos/memos/internal/motionphoto" "github.com/usememos/memos/internal/profile" "github.com/usememos/memos/plugin/storage/s3" storepb "github.com/usememos/memos/proto/gen/store" @@ -31,6 +32,9 @@ const ( // ThumbnailCacheFolder is the folder name where thumbnail images are stored. ThumbnailCacheFolder = ".thumbnail_cache" + // MotionCacheFolder is the folder name where extracted motion clips are stored. + MotionCacheFolder = ".motion_cache" + // thumbnailMaxSize is the maximum dimension (width or height) for thumbnails. thumbnailMaxSize = 600 @@ -122,6 +126,7 @@ func (s *FileServerService) serveAttachmentFile(c *echo.Context) error { ctx := c.Request().Context() uid := c.Param("uid") wantThumbnail := c.QueryParam("thumbnail") == "true" + wantMotion := c.QueryParam("motion") == "true" attachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{ UID: &uid, @@ -138,6 +143,10 @@ func (s *FileServerService) serveAttachmentFile(c *echo.Context) error { return err } + if wantMotion { + return s.serveMotionClip(c, attachment) + } + contentType := s.sanitizeContentType(attachment.Type) // Stream video/audio to avoid loading entire file into memory. @@ -226,6 +235,7 @@ func (s *FileServerService) serveStaticFile(c *echo.Context, attachment *store.A slog.Warn("failed to get thumbnail", "error", err) } else { blob = thumbnailBlob + contentType = "image/jpeg" } } @@ -411,7 +421,7 @@ func (s *FileServerService) getThumbnailPath(attachment *store.Attachment) (stri if err := os.MkdirAll(cacheFolder, os.ModePerm); err != nil { return "", errors.Wrap(err, "failed to create thumbnail cache folder") } - filename := fmt.Sprintf("%d%s", attachment.ID, filepath.Ext(attachment.Filename)) + filename := fmt.Sprintf("%s.jpeg", attachment.UID) return filepath.Join(cacheFolder, filename), nil } @@ -443,7 +453,13 @@ func (s *FileServerService) generateThumbnail(attachment *store.Attachment, thum thumbnailImage := imaging.Resize(img, thumbnailWidth, thumbnailHeight, imaging.Lanczos) - if err := imaging.Save(thumbnailImage, thumbnailPath); err != nil { + output, err := os.Create(thumbnailPath) + if err != nil { + return nil, errors.Wrap(err, "failed to create thumbnail file") + } + defer output.Close() + + if err := imaging.Encode(output, thumbnailImage, imaging.JPEG, imaging.JPEGQuality(90)); err != nil { return nil, errors.Wrap(err, "failed to save thumbnail") } @@ -463,6 +479,60 @@ func calculateThumbnailDimensions(width, height int) (int, int) { return 0, thumbnailMaxSize // Portrait: constrain height. } +func (s *FileServerService) serveMotionClip(c *echo.Context, attachment *store.Attachment) error { + motionMedia := attachment.Payload.GetMotionMedia() + if motionMedia == nil || motionMedia.Family != storepb.MotionMediaFamily_ANDROID_MOTION_PHOTO || !motionMedia.HasEmbeddedVideo { + return echo.NewHTTPError(http.StatusBadRequest, "attachment does not have motion clip") + } + + clipBlob, err := s.getOrExtractMotionClip(c.Request().Context(), attachment) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to get motion clip").Wrap(err) + } + + setSecurityHeaders(c) + setMediaHeaders(c, "video/mp4", "video/mp4") + modTime := time.Unix(attachment.UpdatedTs, 0) + http.ServeContent(c.Response(), c.Request(), attachment.UID+".mp4", modTime, bytes.NewReader(clipBlob)) + return nil +} + +func (s *FileServerService) getOrExtractMotionClip(_ context.Context, attachment *store.Attachment) ([]byte, error) { + motionPath, err := s.getMotionPath(attachment) + if err != nil { + return nil, err + } + + if blob, err := s.readCachedThumbnail(motionPath); err == nil { + return blob, nil + } + + blob, err := s.getAttachmentBlob(attachment) + if err != nil { + return nil, err + } + + videoBlob, _ := motionphoto.ExtractVideo(blob) + if len(videoBlob) == 0 { + return nil, errors.New("motion video not found") + } + + if err := os.WriteFile(motionPath, videoBlob, 0644); err != nil { + return nil, errors.Wrap(err, "failed to cache motion clip") + } + + return videoBlob, nil +} + +func (s *FileServerService) getMotionPath(attachment *store.Attachment) (string, error) { + cacheFolder := filepath.Join(s.Profile.Data, MotionCacheFolder) + if err := os.MkdirAll(cacheFolder, os.ModePerm); err != nil { + return "", errors.Wrap(err, "failed to create motion cache folder") + } + + return filepath.Join(cacheFolder, attachment.UID+".mp4"), nil +} + // ============================================================================= // Authentication & Authorization // ============================================================================= diff --git a/server/router/fileserver/fileserver_test.go b/server/router/fileserver/fileserver_test.go index ab90a31ee..c930775e2 100644 --- a/server/router/fileserver/fileserver_test.go +++ b/server/router/fileserver/fileserver_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/usememos/memos/internal/profile" + "github.com/usememos/memos/internal/testutil" "github.com/usememos/memos/plugin/markdown" apiv1 "github.com/usememos/memos/proto/gen/api/v1" "github.com/usememos/memos/server/auth" @@ -139,6 +140,51 @@ func TestServeAttachmentFile_ShareTokenRejectsCommentAttachment(t *testing.T) { require.Equal(t, http.StatusUnauthorized, rec.Code) } +func TestServeAttachmentFile_MotionClip(t *testing.T) { + ctx := context.Background() + svc, fs, _, cleanup := newShareAttachmentTestServices(ctx, t) + defer cleanup() + + creator, err := svc.Store.CreateUser(ctx, &store.User{ + Username: "motion-owner", + Role: store.RoleUser, + Email: "motion-owner@example.com", + }) + require.NoError(t, err) + creatorCtx := context.WithValue(ctx, auth.UserIDContextKey, creator.ID) + + attachment, err := svc.CreateAttachment(creatorCtx, &apiv1.CreateAttachmentRequest{ + Attachment: &apiv1.Attachment{ + Filename: "motion.jpg", + Type: "image/jpeg", + Content: testutil.BuildMotionPhotoJPEG(), + }, + }) + require.NoError(t, err) + + _, err = svc.CreateMemo(creatorCtx, &apiv1.CreateMemoRequest{ + Memo: &apiv1.Memo{ + Content: "motion memo", + Visibility: apiv1.Visibility_PUBLIC, + Attachments: []*apiv1.Attachment{ + {Name: attachment.Name}, + }, + }, + }) + require.NoError(t, err) + + e := echo.New() + fs.RegisterRoutes(e) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/file/%s/%s?motion=true", attachment.Name, attachment.Filename), nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "video/mp4", rec.Header().Get("Content-Type")) + require.Contains(t, rec.Body.String(), "ftyp") +} + func newShareAttachmentTestServices(ctx context.Context, t *testing.T) (*apiv1service.APIV1Service, *FileServerService, *store.Store, func()) { t.Helper() diff --git a/store/attachment.go b/store/attachment.go index 41f84abfa..a237dc4b6 100644 --- a/store/attachment.go +++ b/store/attachment.go @@ -71,6 +71,11 @@ type DeleteAttachment struct { MemoID *int32 } +const ( + thumbnailCacheFolder = ".thumbnail_cache" + motionCacheFolder = ".motion_cache" +) + func (s *Store) CreateAttachment(ctx context.Context, create *Attachment) (*Attachment, error) { if !base.UIDMatcher.MatchString(create.UID) { return nil, errors.New("invalid uid") @@ -123,6 +128,56 @@ func (s *Store) DeleteAttachment(ctx context.Context, delete *DeleteAttachment) return errors.New("attachment not found") } + if err := s.DeleteAttachmentStorage(ctx, attachment); err != nil { + if attachment.StorageType == storepb.AttachmentStorageType_LOCAL { + return errors.Wrap(err, "failed to delete local file") + } + slog.Warn("Failed to delete attachment storage", slog.Any("err", err)) + } + + return s.driver.DeleteAttachment(ctx, delete) +} + +func (s *Store) DeleteAttachments(ctx context.Context, attachments []*Attachment) error { + if len(attachments) == 0 { + return nil + } + + deletes := make([]*DeleteAttachment, 0, len(attachments)) + for _, attachment := range attachments { + if attachment == nil { + continue + } + deletes = append(deletes, &DeleteAttachment{ID: attachment.ID, MemoID: attachment.MemoID}) + } + if len(deletes) == 0 { + return nil + } + + if err := s.driver.DeleteAttachments(ctx, deletes); err != nil { + return err + } + + for _, attachment := range attachments { + if attachment == nil { + continue + } + if err := s.DeleteAttachmentStorage(ctx, attachment); err != nil { + if attachment.StorageType == storepb.AttachmentStorageType_LOCAL { + return errors.Wrap(err, "failed to delete local file") + } + slog.Warn("Failed to delete attachment storage", slog.Any("err", err)) + } + } + + return nil +} + +func (s *Store) DeleteAttachmentStorage(ctx context.Context, attachment *Attachment) error { + if attachment == nil { + return nil + } + if attachment.StorageType == storepb.AttachmentStorageType_LOCAL { if err := func() error { p := filepath.FromSlash(attachment.Reference) @@ -135,7 +190,7 @@ func (s *Store) DeleteAttachment(ctx context.Context, delete *DeleteAttachment) } return nil }(); err != nil { - return errors.Wrap(err, "failed to delete local file") + return err } } else if attachment.StorageType == storepb.AttachmentStorageType_S3 { if err := func() error { @@ -164,9 +219,21 @@ func (s *Store) DeleteAttachment(ctx context.Context, delete *DeleteAttachment) } return nil }(); err != nil { - slog.Warn("Failed to delete s3 object", slog.Any("err", err)) + return err } } - return s.driver.DeleteAttachment(ctx, delete) + s.deleteAttachmentDerivedCaches(attachment) + return nil +} + +func (s *Store) deleteAttachmentDerivedCaches(attachment *Attachment) { + for _, cachePath := range []string{ + filepath.Join(s.profile.Data, thumbnailCacheFolder, attachment.UID+".jpeg"), + filepath.Join(s.profile.Data, motionCacheFolder, attachment.UID+".mp4"), + } { + if err := os.Remove(cachePath); err != nil && !os.IsNotExist(err) { + slog.Warn("Failed to delete derived attachment cache", slog.String("path", cachePath), slog.Any("err", err)) + } + } } diff --git a/store/db/mysql/attachment.go b/store/db/mysql/attachment.go index b313d34af..d445ec745 100644 --- a/store/db/mysql/attachment.go +++ b/store/db/mysql/attachment.go @@ -239,3 +239,36 @@ func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachmen return nil } + +func (d *DB) DeleteAttachments(ctx context.Context, deletes []*store.DeleteAttachment) error { + if len(deletes) == 0 { + return nil + } + + tx, err := d.db.BeginTx(ctx, nil) + if err != nil { + return errors.Wrap(err, "failed to start attachment delete transaction") + } + defer func() { + if tx != nil { + _ = tx.Rollback() + } + }() + + stmt := "DELETE FROM `attachment` WHERE `id` = ?" + for _, delete := range deletes { + result, err := tx.ExecContext(ctx, stmt, delete.ID) + if err != nil { + return err + } + if _, err := result.RowsAffected(); err != nil { + return err + } + } + + if err := tx.Commit(); err != nil { + return err + } + tx = nil + return nil +} diff --git a/store/db/postgres/attachment.go b/store/db/postgres/attachment.go index 3d51acd2d..e779adae0 100644 --- a/store/db/postgres/attachment.go +++ b/store/db/postgres/attachment.go @@ -219,3 +219,36 @@ func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachmen } return nil } + +func (d *DB) DeleteAttachments(ctx context.Context, deletes []*store.DeleteAttachment) error { + if len(deletes) == 0 { + return nil + } + + tx, err := d.db.BeginTx(ctx, nil) + if err != nil { + return errors.Wrap(err, "failed to start attachment delete transaction") + } + defer func() { + if tx != nil { + _ = tx.Rollback() + } + }() + + stmt := `DELETE FROM attachment WHERE id = $1` + for _, delete := range deletes { + result, err := tx.ExecContext(ctx, stmt, delete.ID) + if err != nil { + return err + } + if _, err := result.RowsAffected(); err != nil { + return err + } + } + + if err := tx.Commit(); err != nil { + return err + } + tx = nil + return nil +} diff --git a/store/db/sqlite/attachment.go b/store/db/sqlite/attachment.go index 3ac8afd6f..5f9c9112b 100644 --- a/store/db/sqlite/attachment.go +++ b/store/db/sqlite/attachment.go @@ -219,3 +219,36 @@ func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachmen } return nil } + +func (d *DB) DeleteAttachments(ctx context.Context, deletes []*store.DeleteAttachment) error { + if len(deletes) == 0 { + return nil + } + + tx, err := d.db.BeginTx(ctx, nil) + if err != nil { + return errors.Wrap(err, "failed to start attachment delete transaction") + } + defer func() { + if tx != nil { + _ = tx.Rollback() + } + }() + + stmt := "DELETE FROM `attachment` WHERE `id` = ?" + for _, delete := range deletes { + result, err := tx.ExecContext(ctx, stmt, delete.ID) + if err != nil { + return err + } + if _, err := result.RowsAffected(); err != nil { + return err + } + } + + if err := tx.Commit(); err != nil { + return err + } + tx = nil + return nil +} diff --git a/store/driver.go b/store/driver.go index cd59012b5..649590bca 100644 --- a/store/driver.go +++ b/store/driver.go @@ -18,6 +18,7 @@ type Driver interface { ListAttachments(ctx context.Context, find *FindAttachment) ([]*Attachment, error) UpdateAttachment(ctx context.Context, update *UpdateAttachment) error DeleteAttachment(ctx context.Context, delete *DeleteAttachment) error + DeleteAttachments(ctx context.Context, deletes []*DeleteAttachment) error // Memo model related methods. CreateMemo(ctx context.Context, create *Memo) (*Memo, error) diff --git a/web/src/components/MemoEditor/components/EditorMetadata.tsx b/web/src/components/MemoEditor/components/EditorMetadata.tsx index df01ddf96..ee984e13d 100644 --- a/web/src/components/MemoEditor/components/EditorMetadata.tsx +++ b/web/src/components/MemoEditor/components/EditorMetadata.tsx @@ -12,6 +12,7 @@ export const EditorMetadata: FC = ({ memoName }) => { attachments={state.metadata.attachments} localFiles={state.localFiles} onAttachmentsChange={(attachments) => dispatch(actions.setMetadata({ attachments }))} + onLocalFilesChange={(localFiles) => dispatch(actions.setLocalFiles(localFiles))} onRemoveLocalFile={(previewUrl) => dispatch(actions.removeLocalFile(previewUrl))} /> diff --git a/web/src/components/MemoEditor/hooks/useFileUpload.ts b/web/src/components/MemoEditor/hooks/useFileUpload.ts index 66bda31c9..a850c372a 100644 --- a/web/src/components/MemoEditor/hooks/useFileUpload.ts +++ b/web/src/components/MemoEditor/hooks/useFileUpload.ts @@ -1,4 +1,6 @@ +import { create } from "@bufbuild/protobuf"; import { useRef } from "react"; +import { type MotionMedia, MotionMediaFamily, MotionMediaRole, MotionMediaSchema } from "@/types/proto/api/v1/attachment_service_pb"; import type { LocalFile } from "../types/attachment"; export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void) => { @@ -11,10 +13,12 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void return; } selectingFlagRef.current = true; - const localFiles: LocalFile[] = files.map((file) => ({ - file, - previewUrl: URL.createObjectURL(file), - })); + const localFiles: LocalFile[] = pairAppleLivePhotoFiles( + files.map((file) => ({ + file, + previewUrl: URL.createObjectURL(file), + })), + ); onFilesSelected(localFiles); selectingFlagRef.current = false; // Optionally clear input value to allow re-selecting the same file @@ -32,3 +36,53 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void handleUploadClick, }; }; + +const pairAppleLivePhotoFiles = (localFiles: LocalFile[]): LocalFile[] => { + const stemMap = new Map(); + for (const localFile of localFiles) { + const stem = normalizeFilenameStem(localFile.file.name); + const group = stemMap.get(stem) ?? []; + group.push(localFile); + stemMap.set(stem, group); + } + + const groupIds = new Map(); + return localFiles.map((localFile) => { + const stem = normalizeFilenameStem(localFile.file.name); + const group = stemMap.get(stem) ?? []; + const images = group.filter((item) => item.file.type.startsWith("image/")); + const videos = group.filter((item) => item.file.type.startsWith("video/")); + if (images.length !== 1 || videos.length !== 1) { + return localFile; + } + + const image = images[0]; + const video = videos[0]; + const groupId = groupIds.get(stem) ?? `${stem}-${crypto.randomUUID()}`; + groupIds.set(stem, groupId); + if (localFile.previewUrl === image.previewUrl) { + return { ...localFile, motionMedia: buildLocalMotionMedia(groupId, MotionMediaRole.STILL) }; + } + if (localFile.previewUrl === video.previewUrl) { + return { ...localFile, motionMedia: buildLocalMotionMedia(groupId, MotionMediaRole.VIDEO) }; + } + return localFile; + }); +}; + +const buildLocalMotionMedia = (groupId: string, role: MotionMediaRole): MotionMedia => + create(MotionMediaSchema, { + family: MotionMediaFamily.APPLE_LIVE_PHOTO, + role, + groupId, + presentationTimestampUs: 0n, + hasEmbeddedVideo: false, + }); + +const normalizeFilenameStem = (filename: string): string => { + const parts = filename.split("."); + if (parts.length <= 1) { + return filename.toLowerCase(); + } + return parts.slice(0, -1).join(".").toLowerCase(); +}; diff --git a/web/src/components/MemoEditor/services/uploadService.ts b/web/src/components/MemoEditor/services/uploadService.ts index 6c466f66d..404eae584 100644 --- a/web/src/components/MemoEditor/services/uploadService.ts +++ b/web/src/components/MemoEditor/services/uploadService.ts @@ -1,7 +1,7 @@ import { create } from "@bufbuild/protobuf"; import { attachmentServiceClient } from "@/connect"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; -import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb"; +import { AttachmentSchema, MotionMediaSchema } from "@/types/proto/api/v1/attachment_service_pb"; import type { LocalFile } from "../types/attachment"; export const uploadService = { @@ -10,7 +10,8 @@ export const uploadService = { const attachments: Attachment[] = []; - for (const { file } of localFiles) { + for (const localFile of localFiles) { + const { file, motionMedia } = localFile; const buffer = new Uint8Array(await file.arrayBuffer()); const attachment = await attachmentServiceClient.createAttachment({ attachment: create(AttachmentSchema, { @@ -18,6 +19,7 @@ export const uploadService = { size: BigInt(file.size), type: file.type, content: buffer, + motionMedia: motionMedia ? create(MotionMediaSchema, motionMedia) : undefined, }), }); attachments.push(attachment); diff --git a/web/src/components/MemoEditor/state/actions.ts b/web/src/components/MemoEditor/state/actions.ts index 3b0b1be90..b58e6b7e2 100644 --- a/web/src/components/MemoEditor/state/actions.ts +++ b/web/src/components/MemoEditor/state/actions.ts @@ -49,6 +49,11 @@ export const editorActions = { payload: previewUrl, }), + setLocalFiles: (files: LocalFile[]): EditorAction => ({ + type: "SET_LOCAL_FILES", + payload: files, + }), + clearLocalFiles: (): EditorAction => ({ type: "CLEAR_LOCAL_FILES", }), diff --git a/web/src/components/MemoEditor/state/reducer.ts b/web/src/components/MemoEditor/state/reducer.ts index 2ad6ee49d..59d4c0c0c 100644 --- a/web/src/components/MemoEditor/state/reducer.ts +++ b/web/src/components/MemoEditor/state/reducer.ts @@ -74,6 +74,12 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS localFiles: state.localFiles.filter((f) => f.previewUrl !== action.payload), }; + case "SET_LOCAL_FILES": + return { + ...state, + localFiles: action.payload, + }; + case "CLEAR_LOCAL_FILES": return { ...state, diff --git a/web/src/components/MemoEditor/state/types.ts b/web/src/components/MemoEditor/state/types.ts index bae16313f..3a38b545b 100644 --- a/web/src/components/MemoEditor/state/types.ts +++ b/web/src/components/MemoEditor/state/types.ts @@ -55,6 +55,7 @@ export type EditorAction = | { type: "REMOVE_RELATION"; payload: string } | { type: "ADD_LOCAL_FILE"; payload: LocalFile } | { type: "REMOVE_LOCAL_FILE"; payload: string } + | { type: "SET_LOCAL_FILES"; payload: LocalFile[] } | { type: "CLEAR_LOCAL_FILES" } | { type: "TOGGLE_FOCUS_MODE" } | { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } } diff --git a/web/src/components/MemoEditor/types/attachment.ts b/web/src/components/MemoEditor/types/attachment.ts index c41849c33..63cd2d8d6 100644 --- a/web/src/components/MemoEditor/types/attachment.ts +++ b/web/src/components/MemoEditor/types/attachment.ts @@ -1,11 +1,13 @@ -import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import type { Attachment, MotionMedia } from "@/types/proto/api/v1/attachment_service_pb"; +import { MotionMediaFamily, MotionMediaRole } from "@/types/proto/api/v1/attachment_service_pb"; import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; +import { buildAttachmentVisualItems } from "@/utils/media-item"; -export type FileCategory = "image" | "video" | "audio" | "document"; +export type FileCategory = "image" | "video" | "motion" | "audio" | "document"; -// Unified view model for rendering attachments and local files export interface AttachmentItem { readonly id: string; + readonly memberIds: string[]; readonly filename: string; readonly category: FileCategory; readonly mimeType: string; @@ -15,25 +17,27 @@ export interface AttachmentItem { readonly isLocal: boolean; } -// For MemoEditor: local files being uploaded export interface LocalFile { readonly file: File; readonly previewUrl: string; + readonly motionMedia?: MotionMedia; } -function categorizeFile(mimeType: string): FileCategory { +function categorizeFile(mimeType: string, motionMedia?: MotionMedia): FileCategory { + if (motionMedia) return "motion"; if (mimeType.startsWith("image/")) return "image"; if (mimeType.startsWith("video/")) return "video"; if (mimeType.startsWith("audio/")) return "audio"; return "document"; } -export function attachmentToItem(attachment: Attachment): AttachmentItem { +function attachmentGroupToItem(attachment: Attachment): AttachmentItem { const attachmentType = getAttachmentType(attachment); const sourceUrl = getAttachmentUrl(attachment); return { id: attachment.name, + memberIds: [attachment.name], filename: attachment.filename, category: categorizeFile(attachment.type), mimeType: attachment.type, @@ -44,21 +48,96 @@ export function attachmentToItem(attachment: Attachment): AttachmentItem { }; } -export function fileToItem(file: File, blobUrl: string): AttachmentItem { +function visualItemToAttachmentItem(item: ReturnType[number]): AttachmentItem { return { - id: blobUrl, - filename: file.name, - category: categorizeFile(file.type), - mimeType: file.type, - thumbnailUrl: blobUrl, - sourceUrl: blobUrl, - size: file.size, + id: item.id, + memberIds: item.attachmentNames, + filename: item.filename, + category: item.kind === "motion" ? "motion" : item.kind, + mimeType: item.mimeType, + thumbnailUrl: item.posterUrl, + sourceUrl: item.sourceUrl, + size: item.attachments.reduce((total, attachment) => total + Number(attachment.size), 0), + isLocal: false, + }; +} + +function fileToItem(file: LocalFile): AttachmentItem { + return { + id: file.motionMedia?.groupId || file.previewUrl, + memberIds: [file.previewUrl], + filename: file.file.name, + category: categorizeFile(file.file.type, file.motionMedia), + mimeType: file.file.type, + thumbnailUrl: file.previewUrl, + sourceUrl: file.previewUrl, + size: file.file.size, isLocal: true, }; } +function toLocalMotionItems(localFiles: LocalFile[]): AttachmentItem[] { + const grouped = new Map(); + const singles: AttachmentItem[] = []; + + for (const localFile of localFiles) { + const groupId = localFile.motionMedia?.groupId; + if (!groupId) { + singles.push(fileToItem(localFile)); + continue; + } + + const group = grouped.get(groupId) ?? []; + group.push(localFile); + grouped.set(groupId, group); + } + + const groupedItems = Array.from(grouped.entries()).flatMap(([groupId, files]) => { + const still = files.find( + (file) => file.motionMedia?.family === MotionMediaFamily.APPLE_LIVE_PHOTO && file.motionMedia.role === MotionMediaRole.STILL, + ); + const video = files.find( + (file) => file.motionMedia?.family === MotionMediaFamily.APPLE_LIVE_PHOTO && file.motionMedia.role === MotionMediaRole.VIDEO, + ); + if (still && video && files.length === 2) { + return [ + { + id: groupId, + memberIds: [still.previewUrl, video.previewUrl], + filename: still.file.name, + category: "motion" as const, + mimeType: still.file.type, + thumbnailUrl: still.previewUrl, + sourceUrl: video.previewUrl, + size: still.file.size + video.file.size, + isLocal: true, + }, + ]; + } + + return files.map(fileToItem); + }); + + return [...groupedItems, ...singles]; +} + export function toAttachmentItems(attachments: Attachment[], localFiles: LocalFile[] = []): AttachmentItem[] { - return [...attachments.map(attachmentToItem), ...localFiles.map(({ file, previewUrl }) => fileToItem(file, previewUrl))]; + const visualAttachments = attachments.filter((attachment) => { + const attachmentType = getAttachmentType(attachment); + return attachmentType === "image/*" || attachmentType === "video/*" || attachment.motionMedia !== undefined; + }); + const attachmentVisualIds = new Set(); + const attachmentVisualItems = buildAttachmentVisualItems(visualAttachments).map((item) => { + item.attachmentNames.forEach((name) => attachmentVisualIds.add(name)); + return visualItemToAttachmentItem(item); + }); + + const nonVisualAttachmentItems = attachments + .filter((attachment) => !attachmentVisualIds.has(attachment.name)) + .map(attachmentGroupToItem) + .filter((item) => item.category === "audio" || item.category === "document"); + + return [...attachmentVisualItems, ...nonVisualAttachmentItems, ...toLocalMotionItems(localFiles)]; } export function filterByCategory(items: AttachmentItem[], categories: FileCategory[]): AttachmentItem[] { @@ -71,7 +150,7 @@ export function separateMediaAndDocs(items: AttachmentItem[]): { media: Attachme const docs: AttachmentItem[] = []; for (const item of items) { - if (item.category === "image" || item.category === "video") { + if (item.category === "image" || item.category === "video" || item.category === "motion") { media.push(item); } else { docs.push(item); diff --git a/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx b/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx index bdbb8a20e..ee5c992d1 100644 --- a/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx +++ b/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx @@ -11,6 +11,7 @@ interface AttachmentListEditorProps { attachments: Attachment[]; localFiles?: LocalFile[]; onAttachmentsChange?: (attachments: Attachment[]) => void; + onLocalFilesChange?: (localFiles: LocalFile[]) => void; onRemoveLocalFile?: (previewUrl: string) => void; } @@ -23,19 +24,24 @@ const AttachmentItemCard: FC<{ canMoveDown?: boolean; }> = ({ item, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => { const { category, filename, thumbnailUrl, mimeType, size } = item; - const fileTypeLabel = getFileTypeLabel(mimeType); + const fileTypeLabel = item.category === "motion" ? "Live Photo" : getFileTypeLabel(mimeType); const fileSizeLabel = size ? formatFileSize(size) : undefined; const displayName = category === "audio" && /^voice-(recording|note)-/i.test(filename) ? "Voice note" : filename; return (
-
- {category === "image" && thumbnailUrl ? ( +
+ {(category === "image" || category === "motion") && thumbnailUrl ? ( ) : ( )} + {category === "motion" && ( + + Live + + )}
@@ -104,58 +110,87 @@ const AttachmentItemCard: FC<{ ); }; -const AttachmentListEditor: FC = ({ attachments, localFiles = [], onAttachmentsChange, onRemoveLocalFile }) => { +const AttachmentListEditor: FC = ({ + attachments, + localFiles = [], + onAttachmentsChange, + onLocalFilesChange, + onRemoveLocalFile, +}) => { if (attachments.length === 0 && localFiles.length === 0) { return null; } const items = toAttachmentItems(attachments, localFiles); + const attachmentItems = items.filter((item) => !item.isLocal); + const localItems = items.filter((item) => item.isLocal); - const handleMoveUp = (index: number) => { - if (index === 0 || !onAttachmentsChange) return; + const handleMoveAttachments = (itemId: string, direction: -1 | 1) => { + if (!onAttachmentsChange) return; - const newAttachments = [...attachments]; - [newAttachments[index - 1], newAttachments[index]] = [newAttachments[index], newAttachments[index - 1]]; - onAttachmentsChange(newAttachments); - }; - - const handleMoveDown = (index: number) => { - if (index === attachments.length - 1 || !onAttachmentsChange) return; - - const newAttachments = [...attachments]; - [newAttachments[index], newAttachments[index + 1]] = [newAttachments[index + 1], newAttachments[index]]; - onAttachmentsChange(newAttachments); - }; - - const handleRemoveAttachment = (name: string) => { - if (onAttachmentsChange) { - onAttachmentsChange(attachments.filter((attachment) => attachment.name !== name)); + const itemIndex = attachmentItems.findIndex((item) => item.id === itemId); + const targetIndex = itemIndex + direction; + if (itemIndex < 0 || targetIndex < 0 || targetIndex >= attachmentItems.length) { + return; } + + const reorderedItems = [...attachmentItems]; + [reorderedItems[itemIndex], reorderedItems[targetIndex]] = [reorderedItems[targetIndex], reorderedItems[itemIndex]]; + + const attachmentMap = new Map(attachments.map((attachment) => [attachment.name, attachment])); + onAttachmentsChange( + reorderedItems.flatMap((item) => item.memberIds.map((memberId) => attachmentMap.get(memberId)).filter(Boolean) as Attachment[]), + ); }; - const handleRemoveItem = (item: (typeof items)[0]) => { + const handleMoveLocalFiles = (itemId: string, direction: -1 | 1) => { + if (!onLocalFilesChange) return; + + const itemIndex = localItems.findIndex((item) => item.id === itemId); + const targetIndex = itemIndex + direction; + if (itemIndex < 0 || targetIndex < 0 || targetIndex >= localItems.length) { + return; + } + + const reorderedItems = [...localItems]; + [reorderedItems[itemIndex], reorderedItems[targetIndex]] = [reorderedItems[targetIndex], reorderedItems[itemIndex]]; + + const localFileMap = new Map(localFiles.map((localFile) => [localFile.previewUrl, localFile])); + onLocalFilesChange( + reorderedItems.flatMap((item) => item.memberIds.map((memberId) => localFileMap.get(memberId)).filter(Boolean) as LocalFile[]), + ); + }; + + const handleRemoveItem = (item: AttachmentItem) => { if (item.isLocal) { - onRemoveLocalFile?.(item.id); - } else { - handleRemoveAttachment(item.id); + const nextLocalFiles = localFiles.filter((file) => !item.memberIds.includes(file.previewUrl)); + onLocalFilesChange?.(nextLocalFiles); + if (!onLocalFilesChange) { + item.memberIds.forEach((previewUrl) => onRemoveLocalFile?.(previewUrl)); + } + return; + } + + if (onAttachmentsChange) { + onAttachmentsChange(attachments.filter((attachment) => !item.memberIds.includes(attachment.name))); } }; return ( {items.map((item) => { - const isLocalFile = item.isLocal; - const attachmentIndex = isLocalFile ? -1 : attachments.findIndex((a) => a.name === item.id); + const itemList = item.isLocal ? localItems : attachmentItems; + const itemIndex = itemList.findIndex((entry) => entry.id === item.id); return ( handleRemoveItem(item)} - onMoveUp={!isLocalFile ? () => handleMoveUp(attachmentIndex) : undefined} - onMoveDown={!isLocalFile ? () => handleMoveDown(attachmentIndex) : undefined} - canMoveUp={!isLocalFile && attachmentIndex > 0} - canMoveDown={!isLocalFile && attachmentIndex < attachments.length - 1} + onMoveUp={item.isLocal ? () => handleMoveLocalFiles(item.id, -1) : () => handleMoveAttachments(item.id, -1)} + onMoveDown={item.isLocal ? () => handleMoveLocalFiles(item.id, 1) : () => handleMoveAttachments(item.id, 1)} + canMoveUp={itemIndex > 0} + canMoveDown={itemIndex >= 0 && itemIndex < itemList.length - 1} /> ); })} diff --git a/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx b/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx index 5a6dec8a4..944cf877a 100644 --- a/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx +++ b/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx @@ -1,16 +1,17 @@ -import { DownloadIcon, FileIcon, Maximize2Icon, PaperclipIcon, PlayIcon } from "lucide-react"; +import { DownloadIcon, FileIcon, PaperclipIcon, PlayIcon } from "lucide-react"; import { useMemo } from "react"; import MetadataSection from "@/components/MemoMetadata/MetadataSection"; import { cn } from "@/lib/utils"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import { getAttachmentUrl } from "@/utils/attachment"; -import AttachmentCard from "./AttachmentCard"; +import type { PreviewMediaItem } from "@/utils/media-item"; +import { buildAttachmentVisualItems } from "@/utils/media-item"; import AudioAttachmentItem from "./AudioAttachmentItem"; -import { getAttachmentMetadata, isImageAttachment, isVideoAttachment, separateAttachments } from "./attachmentHelpers"; +import { getAttachmentMetadata, isAudioAttachment, separateAttachments } from "./attachmentHelpers"; interface AttachmentListViewProps { attachments: Attachment[]; - onImagePreview?: (urls: string[], index: number) => void; + onImagePreview?: (items: PreviewMediaItem[], index: number) => void; } const AttachmentMeta = ({ attachment }: { attachment: Attachment }) => { @@ -48,21 +49,26 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => { ); }; -interface VisualItemProps { - attachment: Attachment; +const MotionBadge = () => ( + + LIVE + +); + +const MotionItem = ({ + item, + featured = false, + onPreview, +}: { + item: ReturnType[number]; featured?: boolean; -} - -const ImageItem = ({ attachment, onImageClick, featured = false }: VisualItemProps & { onImageClick?: (url: string) => void }) => { - const handleClick = () => { - onImageClick?.(getAttachmentUrl(attachment)); - }; - + onPreview?: () => void; +}) => { return ( ); }; -const ImageGallery = ({ attachments, onImageClick }: { attachments: Attachment[]; onImageClick?: (url: string) => void }) => { - if (attachments.length === 1) { +const VisualGallery = ({ + items, + onPreview, +}: { + items: ReturnType; + onPreview?: (itemId: string) => void; +}) => { + if (items.length === 1) { return (
- + onPreview?.(items[0].id)} />
); } return (
- {attachments.map((attachment) => ( - + {items.map((item) => ( + onPreview?.(item.id)} /> ))}
); }; -const VideoItem = ({ attachment }: VisualItemProps) => ( -
-
- - - - -
-
-
- {attachment.filename} -
- -
-
-); - -const VideoList = ({ attachments }: { attachments: Attachment[] }) => ( -
- {attachments.map((attachment) => ( - - ))} -
-); - -const VisualSection = ({ attachments, onImageClick }: { attachments: Attachment[]; onImageClick?: (url: string) => void }) => { - const images = attachments.filter(isImageAttachment); - const videos = attachments.filter(isVideoAttachment); - - return ( -
- {images.length > 0 && } - {videos.length > 0 && ( -
- {images.length > 0 && } - -
- )} -
- ); -}; - const AudioList = ({ attachments }: { attachments: Attachment[] }) => (
{attachments.map((attachment) => ( @@ -172,9 +155,9 @@ const Divider = () =>
; const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewProps) => { const { visual, audio, docs } = useMemo(() => separateAttachments(attachments), [attachments]); - const imageAttachments = useMemo(() => visual.filter(isImageAttachment), [visual]); - const imageUrls = useMemo(() => imageAttachments.map(getAttachmentUrl), [imageAttachments]); - const hasVisual = visual.length > 0; + const visualItems = useMemo(() => buildAttachmentVisualItems(visual), [visual]); + const previewItems = useMemo(() => visualItems.map((item) => item.previewItem), [visualItems]); + const hasVisual = visualItems.length > 0; const hasAudio = audio.length > 0; const hasDocs = docs.length > 0; const sectionCount = [hasVisual, hasAudio, hasDocs].filter(Boolean).length; @@ -183,16 +166,21 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP return null; } - const handleImageClick = (imgUrl: string) => { - const index = imageUrls.findIndex((url) => url === imgUrl); - onImagePreview?.(imageUrls, index >= 0 ? index : 0); + const handlePreview = (itemId: string) => { + const index = previewItems.findIndex((item) => item.id === itemId); + onImagePreview?.(previewItems, index >= 0 ? index : 0); }; return ( - - {hasVisual && } + + {hasVisual && } {hasVisual && sectionCount > 1 && } - {hasAudio && } + {hasAudio && } {hasAudio && hasDocs && } {hasDocs && } diff --git a/web/src/components/MemoPreview/MemoPreview.tsx b/web/src/components/MemoPreview/MemoPreview.tsx index 7149a5a04..5da49f3ba 100644 --- a/web/src/components/MemoPreview/MemoPreview.tsx +++ b/web/src/components/MemoPreview/MemoPreview.tsx @@ -5,7 +5,8 @@ import { cn } from "@/lib/utils"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb"; import type { User } from "@/types/proto/api/v1/user_service_pb"; -import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; +import { getAttachmentType, isMotionAttachment } from "@/utils/attachment"; +import { buildAttachmentVisualItems, countLogicalAttachmentItems } from "@/utils/media-item"; import MemoContent from "../MemoContent"; import { MemoViewContext, type MemoViewContextValue } from "../MemoView/MemoViewContext"; @@ -36,28 +37,35 @@ const STUB_CONTEXT: MemoViewContextValue = { }; const AttachmentThumbnails = ({ attachments }: { attachments: Attachment[] }) => { - const images: Attachment[] = []; - const others: Attachment[] = []; - for (const a of attachments) { - if (getAttachmentType(a) === "image/*") images.push(a); - else others.push(a); - } + const visualAttachments = attachments.filter( + (attachment) => + getAttachmentType(attachment) === "image/*" || getAttachmentType(attachment) === "video/*" || isMotionAttachment(attachment), + ); + const items = buildAttachmentVisualItems(visualAttachments); + const images = items.filter((item) => item.kind === "image" || item.kind === "motion"); + const others = items.filter((item) => item.kind === "video"); return (
- {images.map((a) => ( - {a.filename} + {images.map((item) => ( +
+ {item.filename} + {item.kind === "motion" && ( + + LIVE + + )} +
))} - {others.map((a) => ( -
+ {others.map((item) => ( +
- {a.filename} + {item.filename}
))}
@@ -138,7 +146,7 @@ const MemoPreview = ({ (truncate ? (
- {attachments.length} + {countLogicalAttachmentItems(attachments)}
) : ( diff --git a/web/src/components/MemoView/MemoView.tsx b/web/src/components/MemoView/MemoView.tsx index 4010ba8da..e5c43997a 100644 --- a/web/src/components/MemoView/MemoView.tsx +++ b/web/src/components/MemoView/MemoView.tsx @@ -97,7 +97,7 @@ const MemoView: React.FC = (props: MemoViewProps) => { diff --git a/web/src/components/MemoView/MemoViewContext.tsx b/web/src/components/MemoView/MemoViewContext.tsx index d9c29ca2f..79413313e 100644 --- a/web/src/components/MemoView/MemoViewContext.tsx +++ b/web/src/components/MemoView/MemoViewContext.tsx @@ -4,6 +4,7 @@ import { useLocation } from "react-router-dom"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb"; import type { User } from "@/types/proto/api/v1/user_service_pb"; +import type { PreviewMediaItem } from "@/utils/media-item"; import { RELATIVE_TIME_THRESHOLD_MS } from "./constants"; export interface MemoViewContextValue { @@ -17,7 +18,7 @@ export interface MemoViewContextValue { blurred: boolean; openEditor: () => void; toggleBlurVisibility: () => void; - openPreview: (urls: string | string[], index?: number) => void; + openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void; } export const MemoViewContext = createContext(null); diff --git a/web/src/components/MemoView/hooks/useImagePreview.ts b/web/src/components/MemoView/hooks/useImagePreview.ts index 7a0e975ae..75fcb84fe 100644 --- a/web/src/components/MemoView/hooks/useImagePreview.ts +++ b/web/src/components/MemoView/hooks/useImagePreview.ts @@ -1,22 +1,24 @@ import { useCallback, useState } from "react"; +import type { PreviewMediaItem } from "@/utils/media-item"; export interface ImagePreviewState { open: boolean; - urls: string[]; + items: PreviewMediaItem[]; index: number; } export interface UseImagePreviewReturn { previewState: ImagePreviewState; - openPreview: (urls: string | string[], index?: number) => void; + openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void; setPreviewOpen: (open: boolean) => void; } export const useImagePreview = (): UseImagePreviewReturn => { - const [previewState, setPreviewState] = useState({ open: false, urls: [], index: 0 }); + const [previewState, setPreviewState] = useState({ open: false, items: [], index: 0 }); - const openPreview = useCallback((urls: string | string[], index = 0) => { - setPreviewState({ open: true, urls: Array.isArray(urls) ? urls : [urls], index }); + const openPreview = useCallback((items: string | string[] | PreviewMediaItem[], index = 0) => { + const normalizedItems = normalizePreviewItems(items); + setPreviewState({ open: true, items: normalizedItems, index }); }, []); const setPreviewOpen = useCallback((open: boolean) => { @@ -25,3 +27,31 @@ export const useImagePreview = (): UseImagePreviewReturn => { return { previewState, openPreview, setPreviewOpen }; }; + +function normalizePreviewItems(items: string | string[] | PreviewMediaItem[]): PreviewMediaItem[] { + if (typeof items === "string") { + return [ + { + id: items, + kind: "image", + sourceUrl: items, + posterUrl: items, + filename: "Image", + isMotion: false, + }, + ]; + } + + if (Array.isArray(items) && (items.length === 0 || typeof items[0] === "string")) { + return (items as string[]).map((url) => ({ + id: url, + kind: "image", + sourceUrl: url, + posterUrl: url, + filename: "Image", + isMotion: false, + })); + } + + return items as PreviewMediaItem[]; +} diff --git a/web/src/components/MemoView/hooks/useMemoHandlers.ts b/web/src/components/MemoView/hooks/useMemoHandlers.ts index a57516124..b701606b7 100644 --- a/web/src/components/MemoView/hooks/useMemoHandlers.ts +++ b/web/src/components/MemoView/hooks/useMemoHandlers.ts @@ -1,10 +1,11 @@ import { useCallback } from "react"; import { useInstance } from "@/contexts/InstanceContext"; +import type { PreviewMediaItem } from "@/utils/media-item"; interface UseMemoHandlersOptions { readonly: boolean; openEditor: () => void; - openPreview: (urls: string | string[], index?: number) => void; + openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void; } export const useMemoHandlers = (options: UseMemoHandlersOptions) => { diff --git a/web/src/components/PreviewImageDialog.tsx b/web/src/components/PreviewImageDialog.tsx index 794df77a1..f34169da3 100644 --- a/web/src/components/PreviewImageDialog.tsx +++ b/web/src/components/PreviewImageDialog.tsx @@ -2,16 +2,21 @@ import { X } from "lucide-react"; import React, { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent } from "@/components/ui/dialog"; +import type { PreviewMediaItem } from "@/utils/media-item"; interface Props { open: boolean; onOpenChange: (open: boolean) => void; - imgUrls: string[]; + imgUrls?: string[]; + items?: PreviewMediaItem[]; initialIndex?: number; } -function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: Props) { +function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIndex = 0 }: Props) { const [currentIndex, setCurrentIndex] = useState(initialIndex); + const previewItems = + items ?? + imgUrls.map((url) => ({ id: url, kind: "image" as const, sourceUrl: url, posterUrl: url, filename: "Image", isMotion: false })); // Update current index when initialIndex prop changes useEffect(() => { @@ -28,7 +33,7 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P onOpenChange(false); break; case "ArrowRight": - setCurrentIndex((prev) => Math.min(prev + 1, imgUrls.length - 1)); + setCurrentIndex((prev) => Math.min(prev + 1, previewItems.length - 1)); break; case "ArrowLeft": setCurrentIndex((prev) => Math.max(prev - 1, 0)); @@ -53,10 +58,11 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P }; // Return early if no images provided - if (!imgUrls.length) return null; + if (!previewItems.length) return null; // Ensure currentIndex is within bounds - const safeIndex = Math.max(0, Math.min(currentIndex, imgUrls.length - 1)); + const safeIndex = Math.max(0, Math.min(currentIndex, previewItems.length - 1)); + const currentItem = previewItems[safeIndex]; return ( @@ -79,14 +85,30 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P {/* Image container */}
- {`Preview + {currentItem.kind === "video" ? ( +
{/* Screen reader description */} diff --git a/web/src/types/proto/api/v1/attachment_service_pb.ts b/web/src/types/proto/api/v1/attachment_service_pb.ts index 73777e3fb..9dadd2840 100644 --- a/web/src/types/proto/api/v1/attachment_service_pb.ts +++ b/web/src/types/proto/api/v1/attachment_service_pb.ts @@ -2,8 +2,8 @@ // @generated from file api/v1/attachment_service.proto (package memos.api.v1, syntax proto3) /* eslint-disable */ -import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; -import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; import { file_google_api_annotations } from "../../google/api/annotations_pb"; import { file_google_api_client } from "../../google/api/client_pb"; import { file_google_api_field_behavior } from "../../google/api/field_behavior_pb"; @@ -16,7 +16,44 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file api/v1/attachment_service.proto. */ export const file_api_v1_attachment_service: GenFile = /*@__PURE__*/ - fileDesc("Ch9hcGkvdjEvYXR0YWNobWVudF9zZXJ2aWNlLnByb3RvEgxtZW1vcy5hcGkudjEitgIKCkF0dGFjaG1lbnQSEQoEbmFtZRgBIAEoCUID4EEIEjQKC2NyZWF0ZV90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEID4EEDEhUKCGZpbGVuYW1lGAMgASgJQgPgQQISFAoHY29udGVudBgEIAEoDEID4EEEEhoKDWV4dGVybmFsX2xpbmsYBSABKAlCA+BBARIRCgR0eXBlGAYgASgJQgPgQQISEQoEc2l6ZRgHIAEoA0ID4EEDEhYKBG1lbW8YCCABKAlCA+BBAUgAiAEBOk/qQUwKF21lbW9zLmFwaS52MS9BdHRhY2htZW50EhhhdHRhY2htZW50cy97YXR0YWNobWVudH0qC2F0dGFjaG1lbnRzMgphdHRhY2htZW50QgcKBV9tZW1vImgKF0NyZWF0ZUF0dGFjaG1lbnRSZXF1ZXN0EjEKCmF0dGFjaG1lbnQYASABKAsyGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudEID4EECEhoKDWF0dGFjaG1lbnRfaWQYAiABKAlCA+BBASJ1ChZMaXN0QXR0YWNobWVudHNSZXF1ZXN0EhYKCXBhZ2Vfc2l6ZRgBIAEoBUID4EEBEhcKCnBhZ2VfdG9rZW4YAiABKAlCA+BBARITCgZmaWx0ZXIYAyABKAlCA+BBARIVCghvcmRlcl9ieRgEIAEoCUID4EEBInUKF0xpc3RBdHRhY2htZW50c1Jlc3BvbnNlEi0KC2F0dGFjaG1lbnRzGAEgAygLMhgubWVtb3MuYXBpLnYxLkF0dGFjaG1lbnQSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJEhIKCnRvdGFsX3NpemUYAyABKAUiRQoUR2V0QXR0YWNobWVudFJlcXVlc3QSLQoEbmFtZRgBIAEoCUIf4EEC+kEZChdtZW1vcy5hcGkudjEvQXR0YWNobWVudCKCAQoXVXBkYXRlQXR0YWNobWVudFJlcXVlc3QSMQoKYXR0YWNobWVudBgBIAEoCzIYLm1lbW9zLmFwaS52MS5BdHRhY2htZW50QgPgQQISNAoLdXBkYXRlX21hc2sYAiABKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrQgPgQQIiSAoXRGVsZXRlQXR0YWNobWVudFJlcXVlc3QSLQoEbmFtZRgBIAEoCUIf4EEC+kEZChdtZW1vcy5hcGkudjEvQXR0YWNobWVudDLEBQoRQXR0YWNobWVudFNlcnZpY2USiQEKEENyZWF0ZUF0dGFjaG1lbnQSJS5tZW1vcy5hcGkudjEuQ3JlYXRlQXR0YWNobWVudFJlcXVlc3QaGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudCI02kEKYXR0YWNobWVudILT5JMCIToKYXR0YWNobWVudCITL2FwaS92MS9hdHRhY2htZW50cxJ7Cg9MaXN0QXR0YWNobWVudHMSJC5tZW1vcy5hcGkudjEuTGlzdEF0dGFjaG1lbnRzUmVxdWVzdBolLm1lbW9zLmFwaS52MS5MaXN0QXR0YWNobWVudHNSZXNwb25zZSIbgtPkkwIVEhMvYXBpL3YxL2F0dGFjaG1lbnRzEnoKDUdldEF0dGFjaG1lbnQSIi5tZW1vcy5hcGkudjEuR2V0QXR0YWNobWVudFJlcXVlc3QaGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudCIr2kEEbmFtZYLT5JMCHhIcL2FwaS92MS97bmFtZT1hdHRhY2htZW50cy8qfRKpAQoQVXBkYXRlQXR0YWNobWVudBIlLm1lbW9zLmFwaS52MS5VcGRhdGVBdHRhY2htZW50UmVxdWVzdBoYLm1lbW9zLmFwaS52MS5BdHRhY2htZW50IlTaQRZhdHRhY2htZW50LHVwZGF0ZV9tYXNrgtPkkwI1OgphdHRhY2htZW50MicvYXBpL3YxL3thdHRhY2htZW50Lm5hbWU9YXR0YWNobWVudHMvKn0SfgoQRGVsZXRlQXR0YWNobWVudBIlLm1lbW9zLmFwaS52MS5EZWxldGVBdHRhY2htZW50UmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIr2kEEbmFtZYLT5JMCHiocL2FwaS92MS97bmFtZT1hdHRhY2htZW50cy8qfUKuAQoQY29tLm1lbW9zLmFwaS52MUIWQXR0YWNobWVudFNlcnZpY2VQcm90b1ABWjBnaXRodWIuY29tL3VzZW1lbW9zL21lbW9zL3Byb3RvL2dlbi9hcGkvdjE7YXBpdjGiAgNNQViqAgxNZW1vcy5BcGkuVjHKAgxNZW1vc1xBcGlcVjHiAhhNZW1vc1xBcGlcVjFcR1BCTWV0YWRhdGHqAg5NZW1vczo6QXBpOjpWMWIGcHJvdG8z", [file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask, file_google_protobuf_timestamp]); + fileDesc("Ch9hcGkvdjEvYXR0YWNobWVudF9zZXJ2aWNlLnByb3RvEgxtZW1vcy5hcGkudjEivAEKC01vdGlvbk1lZGlhEi8KBmZhbWlseRgBIAEoDjIfLm1lbW9zLmFwaS52MS5Nb3Rpb25NZWRpYUZhbWlseRIrCgRyb2xlGAIgASgOMh0ubWVtb3MuYXBpLnYxLk1vdGlvbk1lZGlhUm9sZRIQCghncm91cF9pZBgDIAEoCRIhChlwcmVzZW50YXRpb25fdGltZXN0YW1wX3VzGAQgASgDEhoKEmhhc19lbWJlZGRlZF92aWRlbxgFIAEoCCLsAgoKQXR0YWNobWVudBIRCgRuYW1lGAEgASgJQgPgQQgSNAoLY3JlYXRlX3RpbWUYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQMSFQoIZmlsZW5hbWUYAyABKAlCA+BBAhIUCgdjb250ZW50GAQgASgMQgPgQQQSGgoNZXh0ZXJuYWxfbGluaxgFIAEoCUID4EEBEhEKBHR5cGUYBiABKAlCA+BBAhIRCgRzaXplGAcgASgDQgPgQQMSFgoEbWVtbxgIIAEoCUID4EEBSACIAQESNAoMbW90aW9uX21lZGlhGAkgASgLMhkubWVtb3MuYXBpLnYxLk1vdGlvbk1lZGlhQgPgQQE6T+pBTAoXbWVtb3MuYXBpLnYxL0F0dGFjaG1lbnQSGGF0dGFjaG1lbnRzL3thdHRhY2htZW50fSoLYXR0YWNobWVudHMyCmF0dGFjaG1lbnRCBwoFX21lbW8iaAoXQ3JlYXRlQXR0YWNobWVudFJlcXVlc3QSMQoKYXR0YWNobWVudBgBIAEoCzIYLm1lbW9zLmFwaS52MS5BdHRhY2htZW50QgPgQQISGgoNYXR0YWNobWVudF9pZBgCIAEoCUID4EEBInUKFkxpc3RBdHRhY2htZW50c1JlcXVlc3QSFgoJcGFnZV9zaXplGAEgASgFQgPgQQESFwoKcGFnZV90b2tlbhgCIAEoCUID4EEBEhMKBmZpbHRlchgDIAEoCUID4EEBEhUKCG9yZGVyX2J5GAQgASgJQgPgQQEidQoXTGlzdEF0dGFjaG1lbnRzUmVzcG9uc2USLQoLYXR0YWNobWVudHMYASADKAsyGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudBIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkSEgoKdG90YWxfc2l6ZRgDIAEoBSJFChRHZXRBdHRhY2htZW50UmVxdWVzdBItCgRuYW1lGAEgASgJQh/gQQL6QRkKF21lbW9zLmFwaS52MS9BdHRhY2htZW50IoIBChdVcGRhdGVBdHRhY2htZW50UmVxdWVzdBIxCgphdHRhY2htZW50GAEgASgLMhgubWVtb3MuYXBpLnYxLkF0dGFjaG1lbnRCA+BBAhI0Cgt1cGRhdGVfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tCA+BBAiJIChdEZWxldGVBdHRhY2htZW50UmVxdWVzdBItCgRuYW1lGAEgASgJQh/gQQL6QRkKF21lbW9zLmFwaS52MS9BdHRhY2htZW50IjMKHUJhdGNoRGVsZXRlQXR0YWNobWVudHNSZXF1ZXN0EhIKBW5hbWVzGAEgAygJQgPgQQIqaAoRTW90aW9uTWVkaWFGYW1pbHkSIwofTU9USU9OX01FRElBX0ZBTUlMWV9VTlNQRUNJRklFRBAAEhQKEEFQUExFX0xJVkVfUEhPVE8QARIYChRBTkRST0lEX01PVElPTl9QSE9UTxACKlkKD01vdGlvbk1lZGlhUm9sZRIhCh1NT1RJT05fTUVESUFfUk9MRV9VTlNQRUNJRklFRBAAEgkKBVNUSUxMEAESCQoFVklERU8QAhINCglDT05UQUlORVIQAzLQBgoRQXR0YWNobWVudFNlcnZpY2USiQEKEENyZWF0ZUF0dGFjaG1lbnQSJS5tZW1vcy5hcGkudjEuQ3JlYXRlQXR0YWNobWVudFJlcXVlc3QaGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudCI02kEKYXR0YWNobWVudILT5JMCIToKYXR0YWNobWVudCITL2FwaS92MS9hdHRhY2htZW50cxJ7Cg9MaXN0QXR0YWNobWVudHMSJC5tZW1vcy5hcGkudjEuTGlzdEF0dGFjaG1lbnRzUmVxdWVzdBolLm1lbW9zLmFwaS52MS5MaXN0QXR0YWNobWVudHNSZXNwb25zZSIbgtPkkwIVEhMvYXBpL3YxL2F0dGFjaG1lbnRzEnoKDUdldEF0dGFjaG1lbnQSIi5tZW1vcy5hcGkudjEuR2V0QXR0YWNobWVudFJlcXVlc3QaGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudCIr2kEEbmFtZYLT5JMCHhIcL2FwaS92MS97bmFtZT1hdHRhY2htZW50cy8qfRKpAQoQVXBkYXRlQXR0YWNobWVudBIlLm1lbW9zLmFwaS52MS5VcGRhdGVBdHRhY2htZW50UmVxdWVzdBoYLm1lbW9zLmFwaS52MS5BdHRhY2htZW50IlTaQRZhdHRhY2htZW50LHVwZGF0ZV9tYXNrgtPkkwI1OgphdHRhY2htZW50MicvYXBpL3YxL3thdHRhY2htZW50Lm5hbWU9YXR0YWNobWVudHMvKn0SfgoQRGVsZXRlQXR0YWNobWVudBIlLm1lbW9zLmFwaS52MS5EZWxldGVBdHRhY2htZW50UmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIr2kEEbmFtZYLT5JMCHiocL2FwaS92MS97bmFtZT1hdHRhY2htZW50cy8qfRKJAQoWQmF0Y2hEZWxldGVBdHRhY2htZW50cxIrLm1lbW9zLmFwaS52MS5CYXRjaERlbGV0ZUF0dGFjaG1lbnRzUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIqgtPkkwIkOgEqIh8vYXBpL3YxL2F0dGFjaG1lbnRzOmJhdGNoRGVsZXRlQq4BChBjb20ubWVtb3MuYXBpLnYxQhZBdHRhY2htZW50U2VydmljZVByb3RvUAFaMGdpdGh1Yi5jb20vdXNlbWVtb3MvbWVtb3MvcHJvdG8vZ2VuL2FwaS92MTthcGl2MaICA01BWKoCDE1lbW9zLkFwaS5WMcoCDE1lbW9zXEFwaVxWMeICGE1lbW9zXEFwaVxWMVxHUEJNZXRhZGF0YeoCDk1lbW9zOjpBcGk6OlYxYgZwcm90bzM", [file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask, file_google_protobuf_timestamp]); + +/** + * @generated from message memos.api.v1.MotionMedia + */ +export type MotionMedia = Message<"memos.api.v1.MotionMedia"> & { + /** + * @generated from field: memos.api.v1.MotionMediaFamily family = 1; + */ + family: MotionMediaFamily; + + /** + * @generated from field: memos.api.v1.MotionMediaRole role = 2; + */ + role: MotionMediaRole; + + /** + * @generated from field: string group_id = 3; + */ + groupId: string; + + /** + * @generated from field: int64 presentation_timestamp_us = 4; + */ + presentationTimestampUs: bigint; + + /** + * @generated from field: bool has_embedded_video = 5; + */ + hasEmbeddedVideo: boolean; +}; + +/** + * Describes the message memos.api.v1.MotionMedia. + * Use `create(MotionMediaSchema)` to create a new message. + */ +export const MotionMediaSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_v1_attachment_service, 0); /** * @generated from message memos.api.v1.Attachment @@ -79,6 +116,13 @@ export type Attachment = Message<"memos.api.v1.Attachment"> & { * @generated from field: optional string memo = 8; */ memo?: string; + + /** + * Optional. Motion media metadata. + * + * @generated from field: memos.api.v1.MotionMedia motion_media = 9; + */ + motionMedia?: MotionMedia; }; /** @@ -86,7 +130,7 @@ export type Attachment = Message<"memos.api.v1.Attachment"> & { * Use `create(AttachmentSchema)` to create a new message. */ export const AttachmentSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_api_v1_attachment_service, 0); + messageDesc(file_api_v1_attachment_service, 1); /** * @generated from message memos.api.v1.CreateAttachmentRequest @@ -113,7 +157,7 @@ export type CreateAttachmentRequest = Message<"memos.api.v1.CreateAttachmentRequ * Use `create(CreateAttachmentRequestSchema)` to create a new message. */ export const CreateAttachmentRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_api_v1_attachment_service, 1); + messageDesc(file_api_v1_attachment_service, 2); /** * @generated from message memos.api.v1.ListAttachmentsRequest @@ -161,7 +205,7 @@ export type ListAttachmentsRequest = Message<"memos.api.v1.ListAttachmentsReques * Use `create(ListAttachmentsRequestSchema)` to create a new message. */ export const ListAttachmentsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_api_v1_attachment_service, 2); + messageDesc(file_api_v1_attachment_service, 3); /** * @generated from message memos.api.v1.ListAttachmentsResponse @@ -195,7 +239,7 @@ export type ListAttachmentsResponse = Message<"memos.api.v1.ListAttachmentsRespo * Use `create(ListAttachmentsResponseSchema)` to create a new message. */ export const ListAttachmentsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_api_v1_attachment_service, 3); + messageDesc(file_api_v1_attachment_service, 4); /** * @generated from message memos.api.v1.GetAttachmentRequest @@ -215,7 +259,7 @@ export type GetAttachmentRequest = Message<"memos.api.v1.GetAttachmentRequest"> * Use `create(GetAttachmentRequestSchema)` to create a new message. */ export const GetAttachmentRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_api_v1_attachment_service, 4); + messageDesc(file_api_v1_attachment_service, 5); /** * @generated from message memos.api.v1.UpdateAttachmentRequest @@ -241,7 +285,7 @@ export type UpdateAttachmentRequest = Message<"memos.api.v1.UpdateAttachmentRequ * Use `create(UpdateAttachmentRequestSchema)` to create a new message. */ export const UpdateAttachmentRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_api_v1_attachment_service, 5); + messageDesc(file_api_v1_attachment_service, 6); /** * @generated from message memos.api.v1.DeleteAttachmentRequest @@ -261,7 +305,81 @@ export type DeleteAttachmentRequest = Message<"memos.api.v1.DeleteAttachmentRequ * Use `create(DeleteAttachmentRequestSchema)` to create a new message. */ export const DeleteAttachmentRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_api_v1_attachment_service, 6); + messageDesc(file_api_v1_attachment_service, 7); + +/** + * @generated from message memos.api.v1.BatchDeleteAttachmentsRequest + */ +export type BatchDeleteAttachmentsRequest = Message<"memos.api.v1.BatchDeleteAttachmentsRequest"> & { + /** + * @generated from field: repeated string names = 1; + */ + names: string[]; +}; + +/** + * Describes the message memos.api.v1.BatchDeleteAttachmentsRequest. + * Use `create(BatchDeleteAttachmentsRequestSchema)` to create a new message. + */ +export const BatchDeleteAttachmentsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_v1_attachment_service, 8); + +/** + * @generated from enum memos.api.v1.MotionMediaFamily + */ +export enum MotionMediaFamily { + /** + * @generated from enum value: MOTION_MEDIA_FAMILY_UNSPECIFIED = 0; + */ + MOTION_MEDIA_FAMILY_UNSPECIFIED = 0, + + /** + * @generated from enum value: APPLE_LIVE_PHOTO = 1; + */ + APPLE_LIVE_PHOTO = 1, + + /** + * @generated from enum value: ANDROID_MOTION_PHOTO = 2; + */ + ANDROID_MOTION_PHOTO = 2, +} + +/** + * Describes the enum memos.api.v1.MotionMediaFamily. + */ +export const MotionMediaFamilySchema: GenEnum = /*@__PURE__*/ + enumDesc(file_api_v1_attachment_service, 0); + +/** + * @generated from enum memos.api.v1.MotionMediaRole + */ +export enum MotionMediaRole { + /** + * @generated from enum value: MOTION_MEDIA_ROLE_UNSPECIFIED = 0; + */ + MOTION_MEDIA_ROLE_UNSPECIFIED = 0, + + /** + * @generated from enum value: STILL = 1; + */ + STILL = 1, + + /** + * @generated from enum value: VIDEO = 2; + */ + VIDEO = 2, + + /** + * @generated from enum value: CONTAINER = 3; + */ + CONTAINER = 3, +} + +/** + * Describes the enum memos.api.v1.MotionMediaRole. + */ +export const MotionMediaRoleSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_api_v1_attachment_service, 1); /** * @generated from service memos.api.v1.AttachmentService @@ -317,6 +435,16 @@ export const AttachmentService: GenService<{ input: typeof DeleteAttachmentRequestSchema; output: typeof EmptySchema; }, + /** + * BatchDeleteAttachments deletes multiple attachments in one request. + * + * @generated from rpc memos.api.v1.AttachmentService.BatchDeleteAttachments + */ + batchDeleteAttachments: { + methodKind: "unary"; + input: typeof BatchDeleteAttachmentsRequestSchema; + output: typeof EmptySchema; + }, }> = /*@__PURE__*/ serviceDesc(file_api_v1_attachment_service, 0); diff --git a/web/src/utils/attachment.ts b/web/src/utils/attachment.ts index 5f3f1c64e..ce1ce7a62 100644 --- a/web/src/utils/attachment.ts +++ b/web/src/utils/attachment.ts @@ -1,4 +1,4 @@ -import { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import { Attachment, MotionMediaFamily, MotionMediaRole } from "@/types/proto/api/v1/attachment_service_pb"; export const getAttachmentUrl = (attachment: Attachment) => { if (attachment.externalLink) { @@ -12,6 +12,10 @@ export const getAttachmentThumbnailUrl = (attachment: Attachment) => { return `${window.location.origin}/file/${attachment.name}/${attachment.filename}?thumbnail=true`; }; +export const getAttachmentMotionClipUrl = (attachment: Attachment) => { + return `${window.location.origin}/file/${attachment.name}/${attachment.filename}?motion=true`; +}; + export const getAttachmentType = (attachment: Attachment) => { if (isImage(attachment.type)) { return "image/*"; @@ -52,3 +56,21 @@ export const isMidiFile = (mimeType: string): boolean => { const isPSD = (t: string) => { return t === "image/vnd.adobe.photoshop" || t === "image/x-photoshop" || t === "image/photoshop"; }; + +export const getAttachmentMotionGroupId = (attachment: Attachment): string | undefined => { + return attachment.motionMedia?.groupId || undefined; +}; + +export const isAppleLivePhotoStill = (attachment: Attachment): boolean => + attachment.motionMedia?.family === MotionMediaFamily.APPLE_LIVE_PHOTO && attachment.motionMedia.role === MotionMediaRole.STILL; + +export const isAppleLivePhotoVideo = (attachment: Attachment): boolean => + attachment.motionMedia?.family === MotionMediaFamily.APPLE_LIVE_PHOTO && attachment.motionMedia.role === MotionMediaRole.VIDEO; + +export const isAndroidMotionContainer = (attachment: Attachment): boolean => + attachment.motionMedia?.family === MotionMediaFamily.ANDROID_MOTION_PHOTO && + attachment.motionMedia.role === MotionMediaRole.CONTAINER && + attachment.motionMedia.hasEmbeddedVideo; + +export const isMotionAttachment = (attachment: Attachment): boolean => + isAppleLivePhotoStill(attachment) || isAppleLivePhotoVideo(attachment) || isAndroidMotionContainer(attachment); diff --git a/web/src/utils/media-item.ts b/web/src/utils/media-item.ts new file mode 100644 index 000000000..bf744c00b --- /dev/null +++ b/web/src/utils/media-item.ts @@ -0,0 +1,179 @@ +import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import { + getAttachmentMotionClipUrl, + getAttachmentMotionGroupId, + getAttachmentThumbnailUrl, + getAttachmentType, + getAttachmentUrl, + isAndroidMotionContainer, + isAppleLivePhotoStill, + isAppleLivePhotoVideo, + isMotionAttachment, +} from "./attachment"; + +export interface PreviewMediaItem { + id: string; + kind: "image" | "video"; + sourceUrl: string; + posterUrl?: string; + filename: string; + isMotion: boolean; + presentationTimestampUs?: bigint; +} + +export interface AttachmentVisualItem { + id: string; + kind: "image" | "video" | "motion"; + filename: string; + posterUrl: string; + sourceUrl: string; + attachmentNames: string[]; + attachments: Attachment[]; + previewItem: PreviewMediaItem; + mimeType: string; +} + +export function buildAttachmentVisualItems(attachments: Attachment[]): AttachmentVisualItem[] { + const attachmentsByGroup = new Map(); + for (const attachment of attachments) { + const groupId = getAttachmentMotionGroupId(attachment); + if (!groupId) { + continue; + } + const group = attachmentsByGroup.get(groupId) ?? []; + group.push(attachment); + attachmentsByGroup.set(groupId, group); + } + + const consumedGroups = new Set(); + const items: AttachmentVisualItem[] = []; + + for (const attachment of attachments) { + if (isAndroidMotionContainer(attachment)) { + items.push(buildAndroidMotionItem(attachment)); + continue; + } + + const groupId = getAttachmentMotionGroupId(attachment); + if (!groupId || consumedGroups.has(groupId)) { + if (!groupId) { + items.push(buildSingleAttachmentItem(attachment)); + } + continue; + } + + const group = attachmentsByGroup.get(groupId) ?? []; + const still = group.find(isAppleLivePhotoStill); + const video = group.find(isAppleLivePhotoVideo); + if (still && video && group.length === 2) { + items.push(buildAppleMotionItem(still, video)); + consumedGroups.add(groupId); + continue; + } + + items.push(buildSingleAttachmentItem(attachment)); + consumedGroups.add(groupId); + for (const member of group) { + if (member.name === attachment.name) { + continue; + } + items.push(buildSingleAttachmentItem(member)); + } + } + + return dedupeVisualItems(items); +} + +export function countLogicalAttachmentItems(attachments: Attachment[]): number { + const visualAttachments = attachments.filter( + (attachment) => + getAttachmentType(attachment) === "image/*" || getAttachmentType(attachment) === "video/*" || isMotionAttachment(attachment), + ); + const visualNames = new Set(visualAttachments.map((attachment) => attachment.name)); + const visualCount = buildAttachmentVisualItems(visualAttachments).length; + const nonVisualCount = attachments.filter((attachment) => !visualNames.has(attachment.name)).length; + return visualCount + nonVisualCount; +} + +function buildSingleAttachmentItem(attachment: Attachment): AttachmentVisualItem { + const attachmentType = getAttachmentType(attachment); + const sourceUrl = getAttachmentUrl(attachment); + const posterUrl = attachmentType === "image/*" ? getAttachmentThumbnailUrl(attachment) : sourceUrl; + const previewKind = attachmentType === "video/*" ? "video" : "image"; + + return { + id: attachment.name, + kind: attachmentType === "video/*" ? "video" : "image", + filename: attachment.filename, + posterUrl, + sourceUrl, + attachmentNames: [attachment.name], + attachments: [attachment], + previewItem: { + id: attachment.name, + kind: previewKind, + sourceUrl, + posterUrl, + filename: attachment.filename, + isMotion: false, + }, + mimeType: attachment.type, + }; +} + +function buildAppleMotionItem(still: Attachment, video: Attachment): AttachmentVisualItem { + const sourceUrl = getAttachmentUrl(video); + const posterUrl = getAttachmentThumbnailUrl(still); + + return { + id: getAttachmentMotionGroupId(still) ?? still.name, + kind: "motion", + filename: still.filename, + posterUrl, + sourceUrl, + attachmentNames: [still.name, video.name], + attachments: [still, video], + previewItem: { + id: getAttachmentMotionGroupId(still) ?? still.name, + kind: "video", + sourceUrl, + posterUrl, + filename: still.filename, + isMotion: true, + }, + mimeType: still.type, + }; +} + +function buildAndroidMotionItem(attachment: Attachment): AttachmentVisualItem { + return { + id: attachment.name, + kind: "motion", + filename: attachment.filename, + posterUrl: getAttachmentThumbnailUrl(attachment), + sourceUrl: getAttachmentMotionClipUrl(attachment), + attachmentNames: [attachment.name], + attachments: [attachment], + previewItem: { + id: attachment.name, + kind: "video", + sourceUrl: getAttachmentMotionClipUrl(attachment), + posterUrl: getAttachmentThumbnailUrl(attachment), + filename: attachment.filename, + isMotion: true, + presentationTimestampUs: attachment.motionMedia?.presentationTimestampUs, + }, + mimeType: attachment.type, + }; +} + +function dedupeVisualItems(items: AttachmentVisualItem[]): AttachmentVisualItem[] { + const seen = new Set(); + return items.filter((item) => { + if (seen.has(item.id)) { + return false; + } + seen.add(item.id); + return true; + }); +}