Unverified Commit 4b4e7194 authored by boojack's avatar boojack Committed by GitHub

feat(attachments): add Live Photo and Motion Photo support (#5810)

parent 894b3eb0
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
}
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])
}
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(`<?xpacket begin=""?><rdf:Description GCamera:MotionPhoto="1" GCamera:MotionPhotoPresentationTimestampUs="123456"></rdf:Description>`),
[]byte{
0xFF, 0xD9,
0x00, 0x00, 0x00, 0x10, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm', 0x00, 0x00, 0x00, 0x00,
}...,
)...,
)
}
...@@ -43,6 +43,34 @@ service AttachmentService { ...@@ -43,6 +43,34 @@ service AttachmentService {
option (google.api.http) = {delete: "/api/v1/{name=attachments/*}"}; option (google.api.http) = {delete: "/api/v1/{name=attachments/*}"};
option (google.api.method_signature) = "name"; 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 { message Attachment {
...@@ -78,6 +106,9 @@ message Attachment { ...@@ -78,6 +106,9 @@ message Attachment {
// Optional. The related memo. Refer to `Memo.name`. // Optional. The related memo. Refer to `Memo.name`.
// Format: memos/{memo} // Format: memos/{memo}
optional string memo = 8 [(google.api.field_behavior) = OPTIONAL]; optional string memo = 8 [(google.api.field_behavior) = OPTIONAL];
// Optional. Motion media metadata.
MotionMedia motion_media = 9 [(google.api.field_behavior) = OPTIONAL];
} }
message CreateAttachmentRequest { message CreateAttachmentRequest {
...@@ -148,3 +179,7 @@ message DeleteAttachmentRequest { ...@@ -148,3 +179,7 @@ message DeleteAttachmentRequest {
(google.api.resource_reference) = {type: "memos.api.v1/Attachment"} (google.api.resource_reference) = {type: "memos.api.v1/Attachment"}
]; ];
} }
message BatchDeleteAttachmentsRequest {
repeated string names = 1 [(google.api.field_behavior) = REQUIRED];
}
...@@ -49,6 +49,9 @@ const ( ...@@ -49,6 +49,9 @@ const (
// AttachmentServiceDeleteAttachmentProcedure is the fully-qualified name of the AttachmentService's // AttachmentServiceDeleteAttachmentProcedure is the fully-qualified name of the AttachmentService's
// DeleteAttachment RPC. // DeleteAttachment RPC.
AttachmentServiceDeleteAttachmentProcedure = "/memos.api.v1.AttachmentService/DeleteAttachment" 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. // AttachmentServiceClient is a client for the memos.api.v1.AttachmentService service.
...@@ -63,6 +66,8 @@ type AttachmentServiceClient interface { ...@@ -63,6 +66,8 @@ type AttachmentServiceClient interface {
UpdateAttachment(context.Context, *connect.Request[v1.UpdateAttachmentRequest]) (*connect.Response[v1.Attachment], error) UpdateAttachment(context.Context, *connect.Request[v1.UpdateAttachmentRequest]) (*connect.Response[v1.Attachment], error)
// DeleteAttachment deletes an attachment by name. // DeleteAttachment deletes an attachment by name.
DeleteAttachment(context.Context, *connect.Request[v1.DeleteAttachmentRequest]) (*connect.Response[emptypb.Empty], error) 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 // NewAttachmentServiceClient constructs a client for the memos.api.v1.AttachmentService service. By
...@@ -106,16 +111,23 @@ func NewAttachmentServiceClient(httpClient connect.HTTPClient, baseURL string, o ...@@ -106,16 +111,23 @@ func NewAttachmentServiceClient(httpClient connect.HTTPClient, baseURL string, o
connect.WithSchema(attachmentServiceMethods.ByName("DeleteAttachment")), connect.WithSchema(attachmentServiceMethods.ByName("DeleteAttachment")),
connect.WithClientOptions(opts...), connect.WithClientOptions(opts...),
), ),
batchDeleteAttachments: connect.NewClient[v1.BatchDeleteAttachmentsRequest, emptypb.Empty](
httpClient,
baseURL+AttachmentServiceBatchDeleteAttachmentsProcedure,
connect.WithSchema(attachmentServiceMethods.ByName("BatchDeleteAttachments")),
connect.WithClientOptions(opts...),
),
} }
} }
// attachmentServiceClient implements AttachmentServiceClient. // attachmentServiceClient implements AttachmentServiceClient.
type attachmentServiceClient struct { type attachmentServiceClient struct {
createAttachment *connect.Client[v1.CreateAttachmentRequest, v1.Attachment] createAttachment *connect.Client[v1.CreateAttachmentRequest, v1.Attachment]
listAttachments *connect.Client[v1.ListAttachmentsRequest, v1.ListAttachmentsResponse] listAttachments *connect.Client[v1.ListAttachmentsRequest, v1.ListAttachmentsResponse]
getAttachment *connect.Client[v1.GetAttachmentRequest, v1.Attachment] getAttachment *connect.Client[v1.GetAttachmentRequest, v1.Attachment]
updateAttachment *connect.Client[v1.UpdateAttachmentRequest, v1.Attachment] updateAttachment *connect.Client[v1.UpdateAttachmentRequest, v1.Attachment]
deleteAttachment *connect.Client[v1.DeleteAttachmentRequest, emptypb.Empty] deleteAttachment *connect.Client[v1.DeleteAttachmentRequest, emptypb.Empty]
batchDeleteAttachments *connect.Client[v1.BatchDeleteAttachmentsRequest, emptypb.Empty]
} }
// CreateAttachment calls memos.api.v1.AttachmentService.CreateAttachment. // CreateAttachment calls memos.api.v1.AttachmentService.CreateAttachment.
...@@ -143,6 +155,11 @@ func (c *attachmentServiceClient) DeleteAttachment(ctx context.Context, req *con ...@@ -143,6 +155,11 @@ func (c *attachmentServiceClient) DeleteAttachment(ctx context.Context, req *con
return c.deleteAttachment.CallUnary(ctx, req) 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. // AttachmentServiceHandler is an implementation of the memos.api.v1.AttachmentService service.
type AttachmentServiceHandler interface { type AttachmentServiceHandler interface {
// CreateAttachment creates a new attachment. // CreateAttachment creates a new attachment.
...@@ -155,6 +172,8 @@ type AttachmentServiceHandler interface { ...@@ -155,6 +172,8 @@ type AttachmentServiceHandler interface {
UpdateAttachment(context.Context, *connect.Request[v1.UpdateAttachmentRequest]) (*connect.Response[v1.Attachment], error) UpdateAttachment(context.Context, *connect.Request[v1.UpdateAttachmentRequest]) (*connect.Response[v1.Attachment], error)
// DeleteAttachment deletes an attachment by name. // DeleteAttachment deletes an attachment by name.
DeleteAttachment(context.Context, *connect.Request[v1.DeleteAttachmentRequest]) (*connect.Response[emptypb.Empty], error) 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 // NewAttachmentServiceHandler builds an HTTP handler from the service implementation. It returns
...@@ -194,6 +213,12 @@ func NewAttachmentServiceHandler(svc AttachmentServiceHandler, opts ...connect.H ...@@ -194,6 +213,12 @@ func NewAttachmentServiceHandler(svc AttachmentServiceHandler, opts ...connect.H
connect.WithSchema(attachmentServiceMethods.ByName("DeleteAttachment")), connect.WithSchema(attachmentServiceMethods.ByName("DeleteAttachment")),
connect.WithHandlerOptions(opts...), 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) { return "/memos.api.v1.AttachmentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case AttachmentServiceCreateAttachmentProcedure: case AttachmentServiceCreateAttachmentProcedure:
...@@ -206,6 +231,8 @@ func NewAttachmentServiceHandler(svc AttachmentServiceHandler, opts ...connect.H ...@@ -206,6 +231,8 @@ func NewAttachmentServiceHandler(svc AttachmentServiceHandler, opts ...connect.H
attachmentServiceUpdateAttachmentHandler.ServeHTTP(w, r) attachmentServiceUpdateAttachmentHandler.ServeHTTP(w, r)
case AttachmentServiceDeleteAttachmentProcedure: case AttachmentServiceDeleteAttachmentProcedure:
attachmentServiceDeleteAttachmentHandler.ServeHTTP(w, r) attachmentServiceDeleteAttachmentHandler.ServeHTTP(w, r)
case AttachmentServiceBatchDeleteAttachmentsProcedure:
attachmentServiceBatchDeleteAttachmentsHandler.ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
...@@ -234,3 +261,7 @@ func (UnimplementedAttachmentServiceHandler) UpdateAttachment(context.Context, * ...@@ -234,3 +261,7 @@ func (UnimplementedAttachmentServiceHandler) UpdateAttachment(context.Context, *
func (UnimplementedAttachmentServiceHandler) DeleteAttachment(context.Context, *connect.Request[v1.DeleteAttachmentRequest]) (*connect.Response[emptypb.Empty], error) { 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")) 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"))
}
...@@ -25,6 +25,183 @@ const ( ...@@ -25,6 +25,183 @@ const (
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) _ = 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 { type Attachment struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// The name of the attachment. // The name of the attachment.
...@@ -44,14 +221,16 @@ type Attachment struct { ...@@ -44,14 +221,16 @@ type Attachment struct {
Size int64 `protobuf:"varint,7,opt,name=size,proto3" json:"size,omitempty"` Size int64 `protobuf:"varint,7,opt,name=size,proto3" json:"size,omitempty"`
// Optional. The related memo. Refer to `Memo.name`. // Optional. The related memo. Refer to `Memo.name`.
// Format: memos/{memo} // 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
func (x *Attachment) Reset() { func (x *Attachment) Reset() {
*x = Attachment{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
...@@ -63,7 +242,7 @@ func (x *Attachment) String() string { ...@@ -63,7 +242,7 @@ func (x *Attachment) String() string {
func (*Attachment) ProtoMessage() {} func (*Attachment) ProtoMessage() {}
func (x *Attachment) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
...@@ -76,7 +255,7 @@ func (x *Attachment) ProtoReflect() protoreflect.Message { ...@@ -76,7 +255,7 @@ func (x *Attachment) ProtoReflect() protoreflect.Message {
// Deprecated: Use Attachment.ProtoReflect.Descriptor instead. // Deprecated: Use Attachment.ProtoReflect.Descriptor instead.
func (*Attachment) Descriptor() ([]byte, []int) { 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 { func (x *Attachment) GetName() string {
...@@ -135,6 +314,13 @@ func (x *Attachment) GetMemo() string { ...@@ -135,6 +314,13 @@ func (x *Attachment) GetMemo() string {
return "" return ""
} }
func (x *Attachment) GetMotionMedia() *MotionMedia {
if x != nil {
return x.MotionMedia
}
return nil
}
type CreateAttachmentRequest struct { type CreateAttachmentRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Required. The attachment to create. // Required. The attachment to create.
...@@ -148,7 +334,7 @@ type CreateAttachmentRequest struct { ...@@ -148,7 +334,7 @@ type CreateAttachmentRequest struct {
func (x *CreateAttachmentRequest) Reset() { func (x *CreateAttachmentRequest) Reset() {
*x = CreateAttachmentRequest{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
...@@ -160,7 +346,7 @@ func (x *CreateAttachmentRequest) String() string { ...@@ -160,7 +346,7 @@ func (x *CreateAttachmentRequest) String() string {
func (*CreateAttachmentRequest) ProtoMessage() {} func (*CreateAttachmentRequest) ProtoMessage() {}
func (x *CreateAttachmentRequest) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
...@@ -173,7 +359,7 @@ func (x *CreateAttachmentRequest) ProtoReflect() protoreflect.Message { ...@@ -173,7 +359,7 @@ func (x *CreateAttachmentRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateAttachmentRequest.ProtoReflect.Descriptor instead. // Deprecated: Use CreateAttachmentRequest.ProtoReflect.Descriptor instead.
func (*CreateAttachmentRequest) Descriptor() ([]byte, []int) { 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 { func (x *CreateAttachmentRequest) GetAttachment() *Attachment {
...@@ -214,7 +400,7 @@ type ListAttachmentsRequest struct { ...@@ -214,7 +400,7 @@ type ListAttachmentsRequest struct {
func (x *ListAttachmentsRequest) Reset() { func (x *ListAttachmentsRequest) Reset() {
*x = ListAttachmentsRequest{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
...@@ -226,7 +412,7 @@ func (x *ListAttachmentsRequest) String() string { ...@@ -226,7 +412,7 @@ func (x *ListAttachmentsRequest) String() string {
func (*ListAttachmentsRequest) ProtoMessage() {} func (*ListAttachmentsRequest) ProtoMessage() {}
func (x *ListAttachmentsRequest) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
...@@ -239,7 +425,7 @@ func (x *ListAttachmentsRequest) ProtoReflect() protoreflect.Message { ...@@ -239,7 +425,7 @@ func (x *ListAttachmentsRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListAttachmentsRequest.ProtoReflect.Descriptor instead. // Deprecated: Use ListAttachmentsRequest.ProtoReflect.Descriptor instead.
func (*ListAttachmentsRequest) Descriptor() ([]byte, []int) { 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 { func (x *ListAttachmentsRequest) GetPageSize() int32 {
...@@ -285,7 +471,7 @@ type ListAttachmentsResponse struct { ...@@ -285,7 +471,7 @@ type ListAttachmentsResponse struct {
func (x *ListAttachmentsResponse) Reset() { func (x *ListAttachmentsResponse) Reset() {
*x = ListAttachmentsResponse{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
...@@ -297,7 +483,7 @@ func (x *ListAttachmentsResponse) String() string { ...@@ -297,7 +483,7 @@ func (x *ListAttachmentsResponse) String() string {
func (*ListAttachmentsResponse) ProtoMessage() {} func (*ListAttachmentsResponse) ProtoMessage() {}
func (x *ListAttachmentsResponse) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
...@@ -310,7 +496,7 @@ func (x *ListAttachmentsResponse) ProtoReflect() protoreflect.Message { ...@@ -310,7 +496,7 @@ func (x *ListAttachmentsResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListAttachmentsResponse.ProtoReflect.Descriptor instead. // Deprecated: Use ListAttachmentsResponse.ProtoReflect.Descriptor instead.
func (*ListAttachmentsResponse) Descriptor() ([]byte, []int) { 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 { func (x *ListAttachmentsResponse) GetAttachments() []*Attachment {
...@@ -345,7 +531,7 @@ type GetAttachmentRequest struct { ...@@ -345,7 +531,7 @@ type GetAttachmentRequest struct {
func (x *GetAttachmentRequest) Reset() { func (x *GetAttachmentRequest) Reset() {
*x = GetAttachmentRequest{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
...@@ -357,7 +543,7 @@ func (x *GetAttachmentRequest) String() string { ...@@ -357,7 +543,7 @@ func (x *GetAttachmentRequest) String() string {
func (*GetAttachmentRequest) ProtoMessage() {} func (*GetAttachmentRequest) ProtoMessage() {}
func (x *GetAttachmentRequest) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
...@@ -370,7 +556,7 @@ func (x *GetAttachmentRequest) ProtoReflect() protoreflect.Message { ...@@ -370,7 +556,7 @@ func (x *GetAttachmentRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetAttachmentRequest.ProtoReflect.Descriptor instead. // Deprecated: Use GetAttachmentRequest.ProtoReflect.Descriptor instead.
func (*GetAttachmentRequest) Descriptor() ([]byte, []int) { 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 { func (x *GetAttachmentRequest) GetName() string {
...@@ -392,7 +578,7 @@ type UpdateAttachmentRequest struct { ...@@ -392,7 +578,7 @@ type UpdateAttachmentRequest struct {
func (x *UpdateAttachmentRequest) Reset() { func (x *UpdateAttachmentRequest) Reset() {
*x = UpdateAttachmentRequest{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
...@@ -404,7 +590,7 @@ func (x *UpdateAttachmentRequest) String() string { ...@@ -404,7 +590,7 @@ func (x *UpdateAttachmentRequest) String() string {
func (*UpdateAttachmentRequest) ProtoMessage() {} func (*UpdateAttachmentRequest) ProtoMessage() {}
func (x *UpdateAttachmentRequest) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
...@@ -417,7 +603,7 @@ func (x *UpdateAttachmentRequest) ProtoReflect() protoreflect.Message { ...@@ -417,7 +603,7 @@ func (x *UpdateAttachmentRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use UpdateAttachmentRequest.ProtoReflect.Descriptor instead. // Deprecated: Use UpdateAttachmentRequest.ProtoReflect.Descriptor instead.
func (*UpdateAttachmentRequest) Descriptor() ([]byte, []int) { 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 { func (x *UpdateAttachmentRequest) GetAttachment() *Attachment {
...@@ -445,7 +631,7 @@ type DeleteAttachmentRequest struct { ...@@ -445,7 +631,7 @@ type DeleteAttachmentRequest struct {
func (x *DeleteAttachmentRequest) Reset() { func (x *DeleteAttachmentRequest) Reset() {
*x = DeleteAttachmentRequest{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
...@@ -457,7 +643,7 @@ func (x *DeleteAttachmentRequest) String() string { ...@@ -457,7 +643,7 @@ func (x *DeleteAttachmentRequest) String() string {
func (*DeleteAttachmentRequest) ProtoMessage() {} func (*DeleteAttachmentRequest) ProtoMessage() {}
func (x *DeleteAttachmentRequest) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
...@@ -470,7 +656,7 @@ func (x *DeleteAttachmentRequest) ProtoReflect() protoreflect.Message { ...@@ -470,7 +656,7 @@ func (x *DeleteAttachmentRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeleteAttachmentRequest.ProtoReflect.Descriptor instead. // Deprecated: Use DeleteAttachmentRequest.ProtoReflect.Descriptor instead.
func (*DeleteAttachmentRequest) Descriptor() ([]byte, []int) { 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 { func (x *DeleteAttachmentRequest) GetName() string {
...@@ -480,11 +666,61 @@ func (x *DeleteAttachmentRequest) GetName() string { ...@@ -480,11 +666,61 @@ func (x *DeleteAttachmentRequest) GetName() string {
return "" 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 var File_api_v1_attachment_service_proto protoreflect.FileDescriptor
const file_api_v1_attachment_service_proto_rawDesc = "" + const file_api_v1_attachment_service_proto_rawDesc = "" +
"\n" + "\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" + "\n" +
"Attachment\x12\x17\n" + "Attachment\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12@\n" + "\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12@\n" +
...@@ -495,7 +731,8 @@ const file_api_v1_attachment_service_proto_rawDesc = "" + ...@@ -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" + "\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" + "\x04type\x18\x06 \x01(\tB\x03\xe0A\x02R\x04type\x12\x17\n" +
"\x04size\x18\a \x01(\x03B\x03\xe0A\x03R\x04size\x12\x1c\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" + "\x17memos.api.v1/Attachment\x12\x18attachments/{attachment}*\vattachments2\n" +
"attachmentB\a\n" + "attachmentB\a\n" +
"\x05_memo\"\x82\x01\n" + "\x05_memo\"\x82\x01\n" +
...@@ -526,7 +763,18 @@ const file_api_v1_attachment_service_proto_rawDesc = "" + ...@@ -526,7 +763,18 @@ const file_api_v1_attachment_service_proto_rawDesc = "" +
"updateMask\"N\n" + "updateMask\"N\n" +
"\x17DeleteAttachmentRequest\x123\n" + "\x17DeleteAttachmentRequest\x123\n" +
"\x04name\x18\x01 \x01(\tB\x1f\xe0A\x02\xfaA\x19\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" + "\x11AttachmentService\x12\x89\x01\n" +
"\x10CreateAttachment\x12%.memos.api.v1.CreateAttachmentRequest\x1a\x18.memos.api.v1.Attachment\"4\xdaA\n" + "\x10CreateAttachment\x12%.memos.api.v1.CreateAttachmentRequest\x1a\x18.memos.api.v1.Attachment\"4\xdaA\n" +
"attachment\x82\xd3\xe4\x93\x02!:\n" + "attachment\x82\xd3\xe4\x93\x02!:\n" +
...@@ -535,7 +783,8 @@ const file_api_v1_attachment_service_proto_rawDesc = "" + ...@@ -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" + "\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" + "\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" + "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" "\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 ( var (
...@@ -550,40 +799,50 @@ func file_api_v1_attachment_service_proto_rawDescGZIP() []byte { ...@@ -550,40 +799,50 @@ func file_api_v1_attachment_service_proto_rawDescGZIP() []byte {
return file_api_v1_attachment_service_proto_rawDescData 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{ var file_api_v1_attachment_service_proto_goTypes = []any{
(*Attachment)(nil), // 0: memos.api.v1.Attachment (MotionMediaFamily)(0), // 0: memos.api.v1.MotionMediaFamily
(*CreateAttachmentRequest)(nil), // 1: memos.api.v1.CreateAttachmentRequest (MotionMediaRole)(0), // 1: memos.api.v1.MotionMediaRole
(*ListAttachmentsRequest)(nil), // 2: memos.api.v1.ListAttachmentsRequest (*MotionMedia)(nil), // 2: memos.api.v1.MotionMedia
(*ListAttachmentsResponse)(nil), // 3: memos.api.v1.ListAttachmentsResponse (*Attachment)(nil), // 3: memos.api.v1.Attachment
(*GetAttachmentRequest)(nil), // 4: memos.api.v1.GetAttachmentRequest (*CreateAttachmentRequest)(nil), // 4: memos.api.v1.CreateAttachmentRequest
(*UpdateAttachmentRequest)(nil), // 5: memos.api.v1.UpdateAttachmentRequest (*ListAttachmentsRequest)(nil), // 5: memos.api.v1.ListAttachmentsRequest
(*DeleteAttachmentRequest)(nil), // 6: memos.api.v1.DeleteAttachmentRequest (*ListAttachmentsResponse)(nil), // 6: memos.api.v1.ListAttachmentsResponse
(*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp (*GetAttachmentRequest)(nil), // 7: memos.api.v1.GetAttachmentRequest
(*fieldmaskpb.FieldMask)(nil), // 8: google.protobuf.FieldMask (*UpdateAttachmentRequest)(nil), // 8: memos.api.v1.UpdateAttachmentRequest
(*emptypb.Empty)(nil), // 9: google.protobuf.Empty (*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{ var file_api_v1_attachment_service_proto_depIdxs = []int32{
7, // 0: memos.api.v1.Attachment.create_time:type_name -> google.protobuf.Timestamp 0, // 0: memos.api.v1.MotionMedia.family:type_name -> memos.api.v1.MotionMediaFamily
0, // 1: memos.api.v1.CreateAttachmentRequest.attachment:type_name -> memos.api.v1.Attachment 1, // 1: memos.api.v1.MotionMedia.role:type_name -> memos.api.v1.MotionMediaRole
0, // 2: memos.api.v1.ListAttachmentsResponse.attachments:type_name -> memos.api.v1.Attachment 11, // 2: memos.api.v1.Attachment.create_time:type_name -> google.protobuf.Timestamp
0, // 3: memos.api.v1.UpdateAttachmentRequest.attachment:type_name -> memos.api.v1.Attachment 2, // 3: memos.api.v1.Attachment.motion_media:type_name -> memos.api.v1.MotionMedia
8, // 4: memos.api.v1.UpdateAttachmentRequest.update_mask:type_name -> google.protobuf.FieldMask 3, // 4: memos.api.v1.CreateAttachmentRequest.attachment:type_name -> memos.api.v1.Attachment
1, // 5: memos.api.v1.AttachmentService.CreateAttachment:input_type -> memos.api.v1.CreateAttachmentRequest 3, // 5: memos.api.v1.ListAttachmentsResponse.attachments:type_name -> memos.api.v1.Attachment
2, // 6: memos.api.v1.AttachmentService.ListAttachments:input_type -> memos.api.v1.ListAttachmentsRequest 3, // 6: memos.api.v1.UpdateAttachmentRequest.attachment:type_name -> memos.api.v1.Attachment
4, // 7: memos.api.v1.AttachmentService.GetAttachment:input_type -> memos.api.v1.GetAttachmentRequest 12, // 7: memos.api.v1.UpdateAttachmentRequest.update_mask:type_name -> google.protobuf.FieldMask
5, // 8: memos.api.v1.AttachmentService.UpdateAttachment:input_type -> memos.api.v1.UpdateAttachmentRequest 4, // 8: memos.api.v1.AttachmentService.CreateAttachment:input_type -> memos.api.v1.CreateAttachmentRequest
6, // 9: memos.api.v1.AttachmentService.DeleteAttachment:input_type -> memos.api.v1.DeleteAttachmentRequest 5, // 9: memos.api.v1.AttachmentService.ListAttachments:input_type -> memos.api.v1.ListAttachmentsRequest
0, // 10: memos.api.v1.AttachmentService.CreateAttachment:output_type -> memos.api.v1.Attachment 7, // 10: memos.api.v1.AttachmentService.GetAttachment:input_type -> memos.api.v1.GetAttachmentRequest
3, // 11: memos.api.v1.AttachmentService.ListAttachments:output_type -> memos.api.v1.ListAttachmentsResponse 8, // 11: memos.api.v1.AttachmentService.UpdateAttachment:input_type -> memos.api.v1.UpdateAttachmentRequest
0, // 12: memos.api.v1.AttachmentService.GetAttachment:output_type -> memos.api.v1.Attachment 9, // 12: memos.api.v1.AttachmentService.DeleteAttachment:input_type -> memos.api.v1.DeleteAttachmentRequest
0, // 13: memos.api.v1.AttachmentService.UpdateAttachment:output_type -> memos.api.v1.Attachment 10, // 13: memos.api.v1.AttachmentService.BatchDeleteAttachments:input_type -> memos.api.v1.BatchDeleteAttachmentsRequest
9, // 14: memos.api.v1.AttachmentService.DeleteAttachment:output_type -> google.protobuf.Empty 3, // 14: memos.api.v1.AttachmentService.CreateAttachment:output_type -> memos.api.v1.Attachment
10, // [10:15] is the sub-list for method output_type 6, // 15: memos.api.v1.AttachmentService.ListAttachments:output_type -> memos.api.v1.ListAttachmentsResponse
5, // [5:10] is the sub-list for method input_type 3, // 16: memos.api.v1.AttachmentService.GetAttachment:output_type -> memos.api.v1.Attachment
5, // [5:5] is the sub-list for extension type_name 3, // 17: memos.api.v1.AttachmentService.UpdateAttachment:output_type -> memos.api.v1.Attachment
5, // [5:5] is the sub-list for extension extendee 13, // 18: memos.api.v1.AttachmentService.DeleteAttachment:output_type -> google.protobuf.Empty
0, // [0:5] is the sub-list for field type_name 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() } func init() { file_api_v1_attachment_service_proto_init() }
...@@ -591,19 +850,20 @@ func 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 { if File_api_v1_attachment_service_proto != nil {
return return
} }
file_api_v1_attachment_service_proto_msgTypes[0].OneofWrappers = []any{} file_api_v1_attachment_service_proto_msgTypes[1].OneofWrappers = []any{}
type x struct{} type x struct{}
out := protoimpl.TypeBuilder{ out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{ File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 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)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_v1_attachment_service_proto_rawDesc), len(file_api_v1_attachment_service_proto_rawDesc)),
NumEnums: 0, NumEnums: 2,
NumMessages: 7, NumMessages: 9,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },
GoTypes: file_api_v1_attachment_service_proto_goTypes, GoTypes: file_api_v1_attachment_service_proto_goTypes,
DependencyIndexes: file_api_v1_attachment_service_proto_depIdxs, DependencyIndexes: file_api_v1_attachment_service_proto_depIdxs,
EnumInfos: file_api_v1_attachment_service_proto_enumTypes,
MessageInfos: file_api_v1_attachment_service_proto_msgTypes, MessageInfos: file_api_v1_attachment_service_proto_msgTypes,
}.Build() }.Build()
File_api_v1_attachment_service_proto = out.File File_api_v1_attachment_service_proto = out.File
......
...@@ -270,6 +270,33 @@ func local_request_AttachmentService_DeleteAttachment_0(ctx context.Context, mar ...@@ -270,6 +270,33 @@ func local_request_AttachmentService_DeleteAttachment_0(ctx context.Context, mar
return msg, metadata, err 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". // RegisterAttachmentServiceHandlerServer registers the http handlers for service AttachmentService to "mux".
// UnaryRPC :call AttachmentServiceServer directly. // UnaryRPC :call AttachmentServiceServer directly.
// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. // StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906.
...@@ -376,6 +403,26 @@ func RegisterAttachmentServiceHandlerServer(ctx context.Context, mux *runtime.Se ...@@ -376,6 +403,26 @@ func RegisterAttachmentServiceHandlerServer(ctx context.Context, mux *runtime.Se
} }
forward_AttachmentService_DeleteAttachment_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) 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 return nil
} }
...@@ -501,21 +548,40 @@ func RegisterAttachmentServiceHandlerClient(ctx context.Context, mux *runtime.Se ...@@ -501,21 +548,40 @@ func RegisterAttachmentServiceHandlerClient(ctx context.Context, mux *runtime.Se
} }
forward_AttachmentService_DeleteAttachment_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) 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 return nil
} }
var ( var (
pattern_AttachmentService_CreateAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "attachments"}, "")) 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_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_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_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_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 ( var (
forward_AttachmentService_CreateAttachment_0 = runtime.ForwardResponseMessage forward_AttachmentService_CreateAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_ListAttachments_0 = runtime.ForwardResponseMessage forward_AttachmentService_ListAttachments_0 = runtime.ForwardResponseMessage
forward_AttachmentService_GetAttachment_0 = runtime.ForwardResponseMessage forward_AttachmentService_GetAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_UpdateAttachment_0 = runtime.ForwardResponseMessage forward_AttachmentService_UpdateAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_DeleteAttachment_0 = runtime.ForwardResponseMessage forward_AttachmentService_DeleteAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_BatchDeleteAttachments_0 = runtime.ForwardResponseMessage
) )
...@@ -20,11 +20,12 @@ import ( ...@@ -20,11 +20,12 @@ import (
const _ = grpc.SupportPackageIsVersion9 const _ = grpc.SupportPackageIsVersion9
const ( const (
AttachmentService_CreateAttachment_FullMethodName = "/memos.api.v1.AttachmentService/CreateAttachment" AttachmentService_CreateAttachment_FullMethodName = "/memos.api.v1.AttachmentService/CreateAttachment"
AttachmentService_ListAttachments_FullMethodName = "/memos.api.v1.AttachmentService/ListAttachments" AttachmentService_ListAttachments_FullMethodName = "/memos.api.v1.AttachmentService/ListAttachments"
AttachmentService_GetAttachment_FullMethodName = "/memos.api.v1.AttachmentService/GetAttachment" AttachmentService_GetAttachment_FullMethodName = "/memos.api.v1.AttachmentService/GetAttachment"
AttachmentService_UpdateAttachment_FullMethodName = "/memos.api.v1.AttachmentService/UpdateAttachment" AttachmentService_UpdateAttachment_FullMethodName = "/memos.api.v1.AttachmentService/UpdateAttachment"
AttachmentService_DeleteAttachment_FullMethodName = "/memos.api.v1.AttachmentService/DeleteAttachment" AttachmentService_DeleteAttachment_FullMethodName = "/memos.api.v1.AttachmentService/DeleteAttachment"
AttachmentService_BatchDeleteAttachments_FullMethodName = "/memos.api.v1.AttachmentService/BatchDeleteAttachments"
) )
// AttachmentServiceClient is the client API for AttachmentService service. // AttachmentServiceClient is the client API for AttachmentService service.
...@@ -41,6 +42,8 @@ type AttachmentServiceClient interface { ...@@ -41,6 +42,8 @@ type AttachmentServiceClient interface {
UpdateAttachment(ctx context.Context, in *UpdateAttachmentRequest, opts ...grpc.CallOption) (*Attachment, error) UpdateAttachment(ctx context.Context, in *UpdateAttachmentRequest, opts ...grpc.CallOption) (*Attachment, error)
// DeleteAttachment deletes an attachment by name. // DeleteAttachment deletes an attachment by name.
DeleteAttachment(ctx context.Context, in *DeleteAttachmentRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) 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 { type attachmentServiceClient struct {
...@@ -101,6 +104,16 @@ func (c *attachmentServiceClient) DeleteAttachment(ctx context.Context, in *Dele ...@@ -101,6 +104,16 @@ func (c *attachmentServiceClient) DeleteAttachment(ctx context.Context, in *Dele
return out, nil 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. // AttachmentServiceServer is the server API for AttachmentService service.
// All implementations must embed UnimplementedAttachmentServiceServer // All implementations must embed UnimplementedAttachmentServiceServer
// for forward compatibility. // for forward compatibility.
...@@ -115,6 +128,8 @@ type AttachmentServiceServer interface { ...@@ -115,6 +128,8 @@ type AttachmentServiceServer interface {
UpdateAttachment(context.Context, *UpdateAttachmentRequest) (*Attachment, error) UpdateAttachment(context.Context, *UpdateAttachmentRequest) (*Attachment, error)
// DeleteAttachment deletes an attachment by name. // DeleteAttachment deletes an attachment by name.
DeleteAttachment(context.Context, *DeleteAttachmentRequest) (*emptypb.Empty, error) DeleteAttachment(context.Context, *DeleteAttachmentRequest) (*emptypb.Empty, error)
// BatchDeleteAttachments deletes multiple attachments in one request.
BatchDeleteAttachments(context.Context, *BatchDeleteAttachmentsRequest) (*emptypb.Empty, error)
mustEmbedUnimplementedAttachmentServiceServer() mustEmbedUnimplementedAttachmentServiceServer()
} }
...@@ -140,6 +155,9 @@ func (UnimplementedAttachmentServiceServer) UpdateAttachment(context.Context, *U ...@@ -140,6 +155,9 @@ func (UnimplementedAttachmentServiceServer) UpdateAttachment(context.Context, *U
func (UnimplementedAttachmentServiceServer) DeleteAttachment(context.Context, *DeleteAttachmentRequest) (*emptypb.Empty, error) { func (UnimplementedAttachmentServiceServer) DeleteAttachment(context.Context, *DeleteAttachmentRequest) (*emptypb.Empty, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteAttachment not implemented") 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) mustEmbedUnimplementedAttachmentServiceServer() {}
func (UnimplementedAttachmentServiceServer) testEmbeddedByValue() {} func (UnimplementedAttachmentServiceServer) testEmbeddedByValue() {}
...@@ -251,6 +269,24 @@ func _AttachmentService_DeleteAttachment_Handler(srv interface{}, ctx context.Co ...@@ -251,6 +269,24 @@ func _AttachmentService_DeleteAttachment_Handler(srv interface{}, ctx context.Co
return interceptor(ctx, in, info, handler) 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. // AttachmentService_ServiceDesc is the grpc.ServiceDesc for AttachmentService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
...@@ -278,6 +314,10 @@ var AttachmentService_ServiceDesc = grpc.ServiceDesc{ ...@@ -278,6 +314,10 @@ var AttachmentService_ServiceDesc = grpc.ServiceDesc{
MethodName: "DeleteAttachment", MethodName: "DeleteAttachment",
Handler: _AttachmentService_DeleteAttachment_Handler, Handler: _AttachmentService_DeleteAttachment_Handler,
}, },
{
MethodName: "BatchDeleteAttachments",
Handler: _AttachmentService_BatchDeleteAttachments_Handler,
},
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "api/v1/attachment_service.proto", Metadata: "api/v1/attachment_service.proto",
......
...@@ -176,6 +176,28 @@ paths: ...@@ -176,6 +176,28 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Status' $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: /api/v1/auth/me:
get: get:
tags: tags:
...@@ -2015,6 +2037,19 @@ components: ...@@ -2015,6 +2037,19 @@ components:
description: |- description: |-
Optional. The related memo. Refer to `Memo.name`. Optional. The related memo. Refer to `Memo.name`.
Format: memos/{memo} 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: Color:
type: object type: object
properties: properties:
...@@ -2781,6 +2816,30 @@ components: ...@@ -2781,6 +2816,30 @@ components:
type: string type: string
description: The title extracted from the first H1 heading, if present. description: The title extracted from the first H1 heading, if present.
description: Computed properties of a memo. 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: NotificationSetting_EmailSetting:
type: object type: object
properties: properties:
......
...@@ -77,19 +77,197 @@ func (AttachmentStorageType) EnumDescriptor() ([]byte, []int) { ...@@ -77,19 +77,197 @@ func (AttachmentStorageType) EnumDescriptor() ([]byte, []int) {
return file_store_attachment_proto_rawDescGZIP(), []int{0} 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 { type AttachmentPayload struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Payload: // Types that are valid to be assigned to Payload:
// //
// *AttachmentPayload_S3Object_ // *AttachmentPayload_S3Object_
Payload isAttachmentPayload_Payload `protobuf_oneof:"payload"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
func (x *AttachmentPayload) Reset() { func (x *AttachmentPayload) Reset() {
*x = AttachmentPayload{} *x = AttachmentPayload{}
mi := &file_store_attachment_proto_msgTypes[0] mi := &file_store_attachment_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
...@@ -101,7 +279,7 @@ func (x *AttachmentPayload) String() string { ...@@ -101,7 +279,7 @@ func (x *AttachmentPayload) String() string {
func (*AttachmentPayload) ProtoMessage() {} func (*AttachmentPayload) ProtoMessage() {}
func (x *AttachmentPayload) ProtoReflect() protoreflect.Message { func (x *AttachmentPayload) ProtoReflect() protoreflect.Message {
mi := &file_store_attachment_proto_msgTypes[0] mi := &file_store_attachment_proto_msgTypes[1]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
...@@ -114,7 +292,7 @@ func (x *AttachmentPayload) ProtoReflect() protoreflect.Message { ...@@ -114,7 +292,7 @@ func (x *AttachmentPayload) ProtoReflect() protoreflect.Message {
// Deprecated: Use AttachmentPayload.ProtoReflect.Descriptor instead. // Deprecated: Use AttachmentPayload.ProtoReflect.Descriptor instead.
func (*AttachmentPayload) Descriptor() ([]byte, []int) { 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 { func (x *AttachmentPayload) GetPayload() isAttachmentPayload_Payload {
...@@ -133,6 +311,13 @@ func (x *AttachmentPayload) GetS3Object() *AttachmentPayload_S3Object { ...@@ -133,6 +311,13 @@ func (x *AttachmentPayload) GetS3Object() *AttachmentPayload_S3Object {
return nil return nil
} }
func (x *AttachmentPayload) GetMotionMedia() *MotionMedia {
if x != nil {
return x.MotionMedia
}
return nil
}
type isAttachmentPayload_Payload interface { type isAttachmentPayload_Payload interface {
isAttachmentPayload_Payload() isAttachmentPayload_Payload()
} }
...@@ -157,7 +342,7 @@ type AttachmentPayload_S3Object struct { ...@@ -157,7 +342,7 @@ type AttachmentPayload_S3Object struct {
func (x *AttachmentPayload_S3Object) Reset() { func (x *AttachmentPayload_S3Object) Reset() {
*x = AttachmentPayload_S3Object{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
...@@ -169,7 +354,7 @@ func (x *AttachmentPayload_S3Object) String() string { ...@@ -169,7 +354,7 @@ func (x *AttachmentPayload_S3Object) String() string {
func (*AttachmentPayload_S3Object) ProtoMessage() {} func (*AttachmentPayload_S3Object) ProtoMessage() {}
func (x *AttachmentPayload_S3Object) ProtoReflect() protoreflect.Message { func (x *AttachmentPayload_S3Object) ProtoReflect() protoreflect.Message {
mi := &file_store_attachment_proto_msgTypes[1] mi := &file_store_attachment_proto_msgTypes[2]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
...@@ -182,7 +367,7 @@ func (x *AttachmentPayload_S3Object) ProtoReflect() protoreflect.Message { ...@@ -182,7 +367,7 @@ func (x *AttachmentPayload_S3Object) ProtoReflect() protoreflect.Message {
// Deprecated: Use AttachmentPayload_S3Object.ProtoReflect.Descriptor instead. // Deprecated: Use AttachmentPayload_S3Object.ProtoReflect.Descriptor instead.
func (*AttachmentPayload_S3Object) Descriptor() ([]byte, []int) { 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 { func (x *AttachmentPayload_S3Object) GetS3Config() *StorageS3Config {
...@@ -210,9 +395,17 @@ var File_store_attachment_proto protoreflect.FileDescriptor ...@@ -210,9 +395,17 @@ var File_store_attachment_proto protoreflect.FileDescriptor
const file_store_attachment_proto_rawDesc = "" + const file_store_attachment_proto_rawDesc = "" +
"\n" + "\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" + "\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" + "\bS3Object\x129\n" +
"\ts3_config\x18\x01 \x01(\v2\x1c.memos.store.StorageS3ConfigR\bs3Config\x12\x10\n" + "\ts3_config\x18\x01 \x01(\v2\x1c.memos.store.StorageS3ConfigR\bs3Config\x12\x10\n" +
"\x03key\x18\x02 \x01(\tR\x03key\x12J\n" + "\x03key\x18\x02 \x01(\tR\x03key\x12J\n" +
...@@ -222,7 +415,16 @@ const file_store_attachment_proto_rawDesc = "" + ...@@ -222,7 +415,16 @@ const file_store_attachment_proto_rawDesc = "" +
"#ATTACHMENT_STORAGE_TYPE_UNSPECIFIED\x10\x00\x12\t\n" + "#ATTACHMENT_STORAGE_TYPE_UNSPECIFIED\x10\x00\x12\t\n" +
"\x05LOCAL\x10\x01\x12\x06\n" + "\x05LOCAL\x10\x01\x12\x06\n" +
"\x02S3\x10\x02\x12\f\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" "\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 ( var (
...@@ -237,24 +439,30 @@ func file_store_attachment_proto_rawDescGZIP() []byte { ...@@ -237,24 +439,30 @@ func file_store_attachment_proto_rawDescGZIP() []byte {
return file_store_attachment_proto_rawDescData return file_store_attachment_proto_rawDescData
} }
var file_store_attachment_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_store_attachment_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
var file_store_attachment_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_store_attachment_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
var file_store_attachment_proto_goTypes = []any{ var file_store_attachment_proto_goTypes = []any{
(AttachmentStorageType)(0), // 0: memos.store.AttachmentStorageType (AttachmentStorageType)(0), // 0: memos.store.AttachmentStorageType
(*AttachmentPayload)(nil), // 1: memos.store.AttachmentPayload (MotionMediaFamily)(0), // 1: memos.store.MotionMediaFamily
(*AttachmentPayload_S3Object)(nil), // 2: memos.store.AttachmentPayload.S3Object (MotionMediaRole)(0), // 2: memos.store.MotionMediaRole
(*StorageS3Config)(nil), // 3: memos.store.StorageS3Config (*MotionMedia)(nil), // 3: memos.store.MotionMedia
(*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp (*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{ var file_store_attachment_proto_depIdxs = []int32{
2, // 0: memos.store.AttachmentPayload.s3_object:type_name -> memos.store.AttachmentPayload.S3Object 1, // 0: memos.store.MotionMedia.family:type_name -> memos.store.MotionMediaFamily
3, // 1: memos.store.AttachmentPayload.S3Object.s3_config:type_name -> memos.store.StorageS3Config 2, // 1: memos.store.MotionMedia.role:type_name -> memos.store.MotionMediaRole
4, // 2: memos.store.AttachmentPayload.S3Object.last_presigned_time:type_name -> google.protobuf.Timestamp 5, // 2: memos.store.AttachmentPayload.s3_object:type_name -> memos.store.AttachmentPayload.S3Object
3, // [3:3] is the sub-list for method output_type 3, // 3: memos.store.AttachmentPayload.motion_media:type_name -> memos.store.MotionMedia
3, // [3:3] is the sub-list for method input_type 6, // 4: memos.store.AttachmentPayload.S3Object.s3_config:type_name -> memos.store.StorageS3Config
3, // [3:3] is the sub-list for extension type_name 7, // 5: memos.store.AttachmentPayload.S3Object.last_presigned_time:type_name -> google.protobuf.Timestamp
3, // [3:3] is the sub-list for extension extendee 6, // [6:6] is the sub-list for method output_type
0, // [0:3] is the sub-list for field type_name 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() } func init() { file_store_attachment_proto_init() }
...@@ -263,7 +471,7 @@ func file_store_attachment_proto_init() { ...@@ -263,7 +471,7 @@ func file_store_attachment_proto_init() {
return return
} }
file_store_instance_setting_proto_init() 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), (*AttachmentPayload_S3Object_)(nil),
} }
type x struct{} type x struct{}
...@@ -271,8 +479,8 @@ func file_store_attachment_proto_init() { ...@@ -271,8 +479,8 @@ func file_store_attachment_proto_init() {
File: protoimpl.DescBuilder{ File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_store_attachment_proto_rawDesc), len(file_store_attachment_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_store_attachment_proto_rawDesc), len(file_store_attachment_proto_rawDesc)),
NumEnums: 1, NumEnums: 3,
NumMessages: 2, NumMessages: 3,
NumExtensions: 0, NumExtensions: 0,
NumServices: 0, NumServices: 0,
}, },
......
...@@ -17,11 +17,34 @@ enum AttachmentStorageType { ...@@ -17,11 +17,34 @@ enum AttachmentStorageType {
EXTERNAL = 3; 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 { message AttachmentPayload {
oneof payload { oneof payload {
S3Object s3_object = 1; S3Object s3_object = 1;
} }
MotionMedia motion_media = 10;
message S3Object { message S3Object {
StorageS3Config s3_config = 1; StorageS3Config s3_config = 1;
// key is the S3 object key. // key is the S3 object key.
......
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
}
...@@ -22,6 +22,7 @@ import ( ...@@ -22,6 +22,7 @@ import (
"google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
"github.com/usememos/memos/internal/motionphoto"
"github.com/usememos/memos/internal/profile" "github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/internal/util" "github.com/usememos/memos/internal/util"
"github.com/usememos/memos/plugin/filter" "github.com/usememos/memos/plugin/filter"
...@@ -42,7 +43,8 @@ const ( ...@@ -42,7 +43,8 @@ const (
// defaultJPEGQuality is the JPEG quality used when re-encoding images for EXIF stripping. // defaultJPEGQuality is the JPEG quality used when re-encoding images for EXIF stripping.
// Quality 95 maintains visual quality while ensuring metadata is removed. // Quality 95 maintains visual quality while ensuring metadata is removed.
defaultJPEGQuality = 95 defaultJPEGQuality = 95
maxBatchDeleteAttachments = 100
) )
var SupportedThumbnailMimeTypes = []string{ var SupportedThumbnailMimeTypes = []string{
...@@ -111,6 +113,15 @@ func (s *APIV1Service) CreateAttachment(ctx context.Context, request *v1pb.Creat ...@@ -111,6 +113,15 @@ func (s *APIV1Service) CreateAttachment(ctx context.Context, request *v1pb.Creat
Type: request.Attachment.Type, 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) instanceStorageSetting, err := s.Store.GetInstanceStorageSetting(ctx)
if err != nil { if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get instance storage setting: %v", err) 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 ...@@ -126,9 +137,16 @@ func (s *APIV1Service) CreateAttachment(ctx context.Context, request *v1pb.Creat
create.Size = int64(size) create.Size = int64(size)
create.Blob = request.Attachment.Content 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. // Strip EXIF metadata from images for privacy protection.
// This removes sensitive information like GPS location, device details, etc. // 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 { if strippedBlob, err := stripImageExif(create.Blob, create.Type); err != nil {
// Log warning but continue with original image to ensure uploads don't fail. // Log warning but continue with original image to ensure uploads don't fail.
slog.Warn("failed to strip EXIF metadata from image", slog.Warn("failed to strip EXIF metadata from image",
...@@ -333,13 +351,64 @@ func (s *APIV1Service) DeleteAttachment(ctx context.Context, request *v1pb.Delet ...@@ -333,13 +351,64 @@ func (s *APIV1Service) DeleteAttachment(ctx context.Context, request *v1pb.Delet
return &emptypb.Empty{}, nil 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 { func convertAttachmentFromStore(attachment *store.Attachment) *v1pb.Attachment {
attachmentMessage := &v1pb.Attachment{ attachmentMessage := &v1pb.Attachment{
Name: fmt.Sprintf("%s%s", AttachmentNamePrefix, attachment.UID), Name: fmt.Sprintf("%s%s", AttachmentNamePrefix, attachment.UID),
CreateTime: timestamppb.New(time.Unix(attachment.CreatedTs, 0)), CreateTime: timestamppb.New(time.Unix(attachment.CreatedTs, 0)),
Filename: attachment.Filename, Filename: attachment.Filename,
Type: attachment.Type, Type: attachment.Type,
Size: attachment.Size, Size: attachment.Size,
MotionMedia: convertMotionMediaFromStore(getAttachmentMotionMedia(attachment)),
} }
if attachment.MemoUID != nil && *attachment.MemoUID != "" { if attachment.MemoUID != nil && *attachment.MemoUID != "" {
memoName := fmt.Sprintf("%s%s", MemoNamePrefix, *attachment.MemoUID) memoName := fmt.Sprintf("%s%s", MemoNamePrefix, *attachment.MemoUID)
...@@ -425,15 +494,15 @@ func SaveAttachmentBlob(ctx context.Context, profile *profile.Profile, stores *s ...@@ -425,15 +494,15 @@ func SaveAttachmentBlob(ctx context.Context, profile *profile.Profile, stores *s
create.Reference = presignURL create.Reference = presignURL
create.Blob = nil create.Blob = nil
create.StorageType = storepb.AttachmentStorageType_S3 create.StorageType = storepb.AttachmentStorageType_S3
create.Payload = &storepb.AttachmentPayload{ payload := ensureAttachmentPayload(create.Payload)
Payload: &storepb.AttachmentPayload_S3Object_{ payload.Payload = &storepb.AttachmentPayload_S3Object_{
S3Object: &storepb.AttachmentPayload_S3Object{ S3Object: &storepb.AttachmentPayload_S3Object{
S3Config: s3Config, S3Config: s3Config,
Key: key, Key: key,
LastPresignedTime: timestamppb.New(time.Now()), LastPresignedTime: timestamppb.New(time.Now()),
},
}, },
} }
create.Payload = payload
} }
return nil return nil
...@@ -624,6 +693,48 @@ func (s *APIV1Service) checkAttachmentAccess(ctx context.Context, attachment *st ...@@ -624,6 +693,48 @@ func (s *APIV1Service) checkAttachmentAccess(ctx context.Context, attachment *st
return nil 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. // 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 // 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. // privacy-sensitive metadata such as GPS coordinates, camera settings, and device information.
......
...@@ -419,6 +419,14 @@ func (s *ConnectServiceHandler) DeleteAttachment(ctx context.Context, req *conne ...@@ -419,6 +419,14 @@ func (s *ConnectServiceHandler) DeleteAttachment(ctx context.Context, req *conne
return connect.NewResponse(resp), nil 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 // ShortcutService
func (s *ConnectServiceHandler) ListShortcuts(ctx context.Context, req *connect.Request[v1pb.ListShortcutsRequest]) (*connect.Response[v1pb.ListShortcutsResponse], error) { func (s *ConnectServiceHandler) ListShortcuts(ctx context.Context, req *connect.Request[v1pb.ListShortcutsRequest]) (*connect.Response[v1pb.ListShortcutsResponse], error) {
......
...@@ -51,27 +51,26 @@ func (s *APIV1Service) SetMemoAttachments(ctx context.Context, request *v1pb.Set ...@@ -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 { 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, MemoID: &memo.ID,
}) })
if err != nil { if err != nil {
return status.Errorf(codes.Internal, "failed to list attachments") 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. // Delete attachments that are not in the request.
for _, attachment := range attachments { for _, attachment := range currentAttachments {
found := false if !requestedIDs[attachment.ID] {
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 {
if err = s.Store.DeleteAttachment(ctx, &store.DeleteAttachment{ if err = s.Store.DeleteAttachment(ctx, &store.DeleteAttachment{
ID: int32(attachment.ID), ID: int32(attachment.ID),
MemoID: &memo.ID, MemoID: &memo.ID,
...@@ -81,23 +80,12 @@ func (s *APIV1Service) setMemoAttachmentsInternal(ctx context.Context, memo *sto ...@@ -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. // Update attachments' memo_id in the request.
for index, attachment := range requestAttachments { for index, attachment := range normalizedAttachments {
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)
}
updatedTs := time.Now().Unix() + int64(index) updatedTs := time.Now().Unix() + int64(index)
if err := s.Store.UpdateAttachment(ctx, &store.UpdateAttachment{ if err := s.Store.UpdateAttachment(ctx, &store.UpdateAttachment{
ID: tempAttachment.ID, ID: attachment.ID,
MemoID: &memo.ID, MemoID: &memo.ID,
UpdatedTs: &updatedTs, UpdatedTs: &updatedTs,
}); err != nil { }); err != nil {
...@@ -108,6 +96,100 @@ func (s *APIV1Service) setMemoAttachmentsInternal(ctx context.Context, memo *sto ...@@ -108,6 +96,100 @@ func (s *APIV1Service) setMemoAttachmentsInternal(ctx context.Context, memo *sto
return nil 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) { func (s *APIV1Service) ListMemoAttachments(ctx context.Context, request *v1pb.ListMemoAttachmentsRequest) (*v1pb.ListMemoAttachmentsResponse, error) {
memoUID, err := ExtractMemoUIDFromName(request.Name) memoUID, err := ExtractMemoUIDFromName(request.Name)
if err != nil { if err != nil {
......
...@@ -6,6 +6,7 @@ import ( ...@@ -6,6 +6,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/usememos/memos/internal/testutil"
v1pb "github.com/usememos/memos/proto/gen/api/v1" v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store" storepb "github.com/usememos/memos/proto/gen/store"
apiv1 "github.com/usememos/memos/server/router/api/v1" apiv1 "github.com/usememos/memos/server/router/api/v1"
...@@ -112,3 +113,118 @@ func TestCreateAttachment(t *testing.T) { ...@@ -112,3 +113,118 @@ func TestCreateAttachment(t *testing.T) {
require.Equal(t, []byte("second-image"), secondBlob) 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")
})
}
...@@ -163,4 +163,64 @@ func TestSetMemoAttachments(t *testing.T) { ...@@ -163,4 +163,64 @@ func TestSetMemoAttachments(t *testing.T) {
require.Error(t, err) require.Error(t, err)
require.Contains(t, err.Error(), "not found") 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)
})
} }
...@@ -19,6 +19,7 @@ import ( ...@@ -19,6 +19,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"golang.org/x/sync/semaphore" "golang.org/x/sync/semaphore"
"github.com/usememos/memos/internal/motionphoto"
"github.com/usememos/memos/internal/profile" "github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/plugin/storage/s3" "github.com/usememos/memos/plugin/storage/s3"
storepb "github.com/usememos/memos/proto/gen/store" storepb "github.com/usememos/memos/proto/gen/store"
...@@ -31,6 +32,9 @@ const ( ...@@ -31,6 +32,9 @@ const (
// ThumbnailCacheFolder is the folder name where thumbnail images are stored. // ThumbnailCacheFolder is the folder name where thumbnail images are stored.
ThumbnailCacheFolder = ".thumbnail_cache" 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 is the maximum dimension (width or height) for thumbnails.
thumbnailMaxSize = 600 thumbnailMaxSize = 600
...@@ -122,6 +126,7 @@ func (s *FileServerService) serveAttachmentFile(c *echo.Context) error { ...@@ -122,6 +126,7 @@ func (s *FileServerService) serveAttachmentFile(c *echo.Context) error {
ctx := c.Request().Context() ctx := c.Request().Context()
uid := c.Param("uid") uid := c.Param("uid")
wantThumbnail := c.QueryParam("thumbnail") == "true" wantThumbnail := c.QueryParam("thumbnail") == "true"
wantMotion := c.QueryParam("motion") == "true"
attachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{ attachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{
UID: &uid, UID: &uid,
...@@ -138,6 +143,10 @@ func (s *FileServerService) serveAttachmentFile(c *echo.Context) error { ...@@ -138,6 +143,10 @@ func (s *FileServerService) serveAttachmentFile(c *echo.Context) error {
return err return err
} }
if wantMotion {
return s.serveMotionClip(c, attachment)
}
contentType := s.sanitizeContentType(attachment.Type) contentType := s.sanitizeContentType(attachment.Type)
// Stream video/audio to avoid loading entire file into memory. // Stream video/audio to avoid loading entire file into memory.
...@@ -226,6 +235,7 @@ func (s *FileServerService) serveStaticFile(c *echo.Context, attachment *store.A ...@@ -226,6 +235,7 @@ func (s *FileServerService) serveStaticFile(c *echo.Context, attachment *store.A
slog.Warn("failed to get thumbnail", "error", err) slog.Warn("failed to get thumbnail", "error", err)
} else { } else {
blob = thumbnailBlob blob = thumbnailBlob
contentType = "image/jpeg"
} }
} }
...@@ -411,7 +421,7 @@ func (s *FileServerService) getThumbnailPath(attachment *store.Attachment) (stri ...@@ -411,7 +421,7 @@ func (s *FileServerService) getThumbnailPath(attachment *store.Attachment) (stri
if err := os.MkdirAll(cacheFolder, os.ModePerm); err != nil { if err := os.MkdirAll(cacheFolder, os.ModePerm); err != nil {
return "", errors.Wrap(err, "failed to create thumbnail cache folder") 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 return filepath.Join(cacheFolder, filename), nil
} }
...@@ -443,7 +453,13 @@ func (s *FileServerService) generateThumbnail(attachment *store.Attachment, thum ...@@ -443,7 +453,13 @@ func (s *FileServerService) generateThumbnail(attachment *store.Attachment, thum
thumbnailImage := imaging.Resize(img, thumbnailWidth, thumbnailHeight, imaging.Lanczos) 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") return nil, errors.Wrap(err, "failed to save thumbnail")
} }
...@@ -463,6 +479,60 @@ func calculateThumbnailDimensions(width, height int) (int, int) { ...@@ -463,6 +479,60 @@ func calculateThumbnailDimensions(width, height int) (int, int) {
return 0, thumbnailMaxSize // Portrait: constrain height. 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 // Authentication & Authorization
// ============================================================================= // =============================================================================
......
...@@ -12,6 +12,7 @@ import ( ...@@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/usememos/memos/internal/profile" "github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/internal/testutil"
"github.com/usememos/memos/plugin/markdown" "github.com/usememos/memos/plugin/markdown"
apiv1 "github.com/usememos/memos/proto/gen/api/v1" apiv1 "github.com/usememos/memos/proto/gen/api/v1"
"github.com/usememos/memos/server/auth" "github.com/usememos/memos/server/auth"
...@@ -139,6 +140,51 @@ func TestServeAttachmentFile_ShareTokenRejectsCommentAttachment(t *testing.T) { ...@@ -139,6 +140,51 @@ func TestServeAttachmentFile_ShareTokenRejectsCommentAttachment(t *testing.T) {
require.Equal(t, http.StatusUnauthorized, rec.Code) 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()) { func newShareAttachmentTestServices(ctx context.Context, t *testing.T) (*apiv1service.APIV1Service, *FileServerService, *store.Store, func()) {
t.Helper() t.Helper()
......
...@@ -71,6 +71,11 @@ type DeleteAttachment struct { ...@@ -71,6 +71,11 @@ type DeleteAttachment struct {
MemoID *int32 MemoID *int32
} }
const (
thumbnailCacheFolder = ".thumbnail_cache"
motionCacheFolder = ".motion_cache"
)
func (s *Store) CreateAttachment(ctx context.Context, create *Attachment) (*Attachment, error) { func (s *Store) CreateAttachment(ctx context.Context, create *Attachment) (*Attachment, error) {
if !base.UIDMatcher.MatchString(create.UID) { if !base.UIDMatcher.MatchString(create.UID) {
return nil, errors.New("invalid uid") return nil, errors.New("invalid uid")
...@@ -123,6 +128,56 @@ func (s *Store) DeleteAttachment(ctx context.Context, delete *DeleteAttachment) ...@@ -123,6 +128,56 @@ func (s *Store) DeleteAttachment(ctx context.Context, delete *DeleteAttachment)
return errors.New("attachment not found") 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 attachment.StorageType == storepb.AttachmentStorageType_LOCAL {
if err := func() error { if err := func() error {
p := filepath.FromSlash(attachment.Reference) p := filepath.FromSlash(attachment.Reference)
...@@ -135,7 +190,7 @@ func (s *Store) DeleteAttachment(ctx context.Context, delete *DeleteAttachment) ...@@ -135,7 +190,7 @@ func (s *Store) DeleteAttachment(ctx context.Context, delete *DeleteAttachment)
} }
return nil return nil
}(); err != nil { }(); err != nil {
return errors.Wrap(err, "failed to delete local file") return err
} }
} else if attachment.StorageType == storepb.AttachmentStorageType_S3 { } else if attachment.StorageType == storepb.AttachmentStorageType_S3 {
if err := func() error { if err := func() error {
...@@ -164,9 +219,21 @@ func (s *Store) DeleteAttachment(ctx context.Context, delete *DeleteAttachment) ...@@ -164,9 +219,21 @@ func (s *Store) DeleteAttachment(ctx context.Context, delete *DeleteAttachment)
} }
return nil return nil
}(); err != 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))
}
}
} }
...@@ -239,3 +239,36 @@ func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachmen ...@@ -239,3 +239,36 @@ func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachmen
return nil 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
}
...@@ -219,3 +219,36 @@ func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachmen ...@@ -219,3 +219,36 @@ func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachmen
} }
return nil 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
}
...@@ -219,3 +219,36 @@ func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachmen ...@@ -219,3 +219,36 @@ func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachmen
} }
return nil 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
}
...@@ -18,6 +18,7 @@ type Driver interface { ...@@ -18,6 +18,7 @@ type Driver interface {
ListAttachments(ctx context.Context, find *FindAttachment) ([]*Attachment, error) ListAttachments(ctx context.Context, find *FindAttachment) ([]*Attachment, error)
UpdateAttachment(ctx context.Context, update *UpdateAttachment) error UpdateAttachment(ctx context.Context, update *UpdateAttachment) error
DeleteAttachment(ctx context.Context, delete *DeleteAttachment) error DeleteAttachment(ctx context.Context, delete *DeleteAttachment) error
DeleteAttachments(ctx context.Context, deletes []*DeleteAttachment) error
// Memo model related methods. // Memo model related methods.
CreateMemo(ctx context.Context, create *Memo) (*Memo, error) CreateMemo(ctx context.Context, create *Memo) (*Memo, error)
......
...@@ -12,6 +12,7 @@ export const EditorMetadata: FC<EditorMetadataProps> = ({ memoName }) => { ...@@ -12,6 +12,7 @@ export const EditorMetadata: FC<EditorMetadataProps> = ({ memoName }) => {
attachments={state.metadata.attachments} attachments={state.metadata.attachments}
localFiles={state.localFiles} localFiles={state.localFiles}
onAttachmentsChange={(attachments) => dispatch(actions.setMetadata({ attachments }))} onAttachmentsChange={(attachments) => dispatch(actions.setMetadata({ attachments }))}
onLocalFilesChange={(localFiles) => dispatch(actions.setLocalFiles(localFiles))}
onRemoveLocalFile={(previewUrl) => dispatch(actions.removeLocalFile(previewUrl))} onRemoveLocalFile={(previewUrl) => dispatch(actions.removeLocalFile(previewUrl))}
/> />
......
import { create } from "@bufbuild/protobuf";
import { useRef } from "react"; import { useRef } from "react";
import { type MotionMedia, MotionMediaFamily, MotionMediaRole, MotionMediaSchema } from "@/types/proto/api/v1/attachment_service_pb";
import type { LocalFile } from "../types/attachment"; import type { LocalFile } from "../types/attachment";
export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void) => { export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void) => {
...@@ -11,10 +13,12 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void ...@@ -11,10 +13,12 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void
return; return;
} }
selectingFlagRef.current = true; selectingFlagRef.current = true;
const localFiles: LocalFile[] = files.map((file) => ({ const localFiles: LocalFile[] = pairAppleLivePhotoFiles(
file, files.map((file) => ({
previewUrl: URL.createObjectURL(file), file,
})); previewUrl: URL.createObjectURL(file),
})),
);
onFilesSelected(localFiles); onFilesSelected(localFiles);
selectingFlagRef.current = false; selectingFlagRef.current = false;
// Optionally clear input value to allow re-selecting the same file // Optionally clear input value to allow re-selecting the same file
...@@ -32,3 +36,53 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void ...@@ -32,3 +36,53 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void
handleUploadClick, handleUploadClick,
}; };
}; };
const pairAppleLivePhotoFiles = (localFiles: LocalFile[]): LocalFile[] => {
const stemMap = new Map<string, LocalFile[]>();
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<string, string>();
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();
};
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { attachmentServiceClient } from "@/connect"; import { attachmentServiceClient } from "@/connect";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; 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"; import type { LocalFile } from "../types/attachment";
export const uploadService = { export const uploadService = {
...@@ -10,7 +10,8 @@ export const uploadService = { ...@@ -10,7 +10,8 @@ export const uploadService = {
const attachments: Attachment[] = []; 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 buffer = new Uint8Array(await file.arrayBuffer());
const attachment = await attachmentServiceClient.createAttachment({ const attachment = await attachmentServiceClient.createAttachment({
attachment: create(AttachmentSchema, { attachment: create(AttachmentSchema, {
...@@ -18,6 +19,7 @@ export const uploadService = { ...@@ -18,6 +19,7 @@ export const uploadService = {
size: BigInt(file.size), size: BigInt(file.size),
type: file.type, type: file.type,
content: buffer, content: buffer,
motionMedia: motionMedia ? create(MotionMediaSchema, motionMedia) : undefined,
}), }),
}); });
attachments.push(attachment); attachments.push(attachment);
......
...@@ -49,6 +49,11 @@ export const editorActions = { ...@@ -49,6 +49,11 @@ export const editorActions = {
payload: previewUrl, payload: previewUrl,
}), }),
setLocalFiles: (files: LocalFile[]): EditorAction => ({
type: "SET_LOCAL_FILES",
payload: files,
}),
clearLocalFiles: (): EditorAction => ({ clearLocalFiles: (): EditorAction => ({
type: "CLEAR_LOCAL_FILES", type: "CLEAR_LOCAL_FILES",
}), }),
......
...@@ -74,6 +74,12 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS ...@@ -74,6 +74,12 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
localFiles: state.localFiles.filter((f) => f.previewUrl !== action.payload), localFiles: state.localFiles.filter((f) => f.previewUrl !== action.payload),
}; };
case "SET_LOCAL_FILES":
return {
...state,
localFiles: action.payload,
};
case "CLEAR_LOCAL_FILES": case "CLEAR_LOCAL_FILES":
return { return {
...state, ...state,
......
...@@ -55,6 +55,7 @@ export type EditorAction = ...@@ -55,6 +55,7 @@ export type EditorAction =
| { type: "REMOVE_RELATION"; payload: string } | { type: "REMOVE_RELATION"; payload: string }
| { type: "ADD_LOCAL_FILE"; payload: LocalFile } | { type: "ADD_LOCAL_FILE"; payload: LocalFile }
| { type: "REMOVE_LOCAL_FILE"; payload: string } | { type: "REMOVE_LOCAL_FILE"; payload: string }
| { type: "SET_LOCAL_FILES"; payload: LocalFile[] }
| { type: "CLEAR_LOCAL_FILES" } | { type: "CLEAR_LOCAL_FILES" }
| { type: "TOGGLE_FOCUS_MODE" } | { type: "TOGGLE_FOCUS_MODE" }
| { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } } | { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } }
......
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 { 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 { export interface AttachmentItem {
readonly id: string; readonly id: string;
readonly memberIds: string[];
readonly filename: string; readonly filename: string;
readonly category: FileCategory; readonly category: FileCategory;
readonly mimeType: string; readonly mimeType: string;
...@@ -15,25 +17,27 @@ export interface AttachmentItem { ...@@ -15,25 +17,27 @@ export interface AttachmentItem {
readonly isLocal: boolean; readonly isLocal: boolean;
} }
// For MemoEditor: local files being uploaded
export interface LocalFile { export interface LocalFile {
readonly file: File; readonly file: File;
readonly previewUrl: string; 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("image/")) return "image";
if (mimeType.startsWith("video/")) return "video"; if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("audio/")) return "audio"; if (mimeType.startsWith("audio/")) return "audio";
return "document"; return "document";
} }
export function attachmentToItem(attachment: Attachment): AttachmentItem { function attachmentGroupToItem(attachment: Attachment): AttachmentItem {
const attachmentType = getAttachmentType(attachment); const attachmentType = getAttachmentType(attachment);
const sourceUrl = getAttachmentUrl(attachment); const sourceUrl = getAttachmentUrl(attachment);
return { return {
id: attachment.name, id: attachment.name,
memberIds: [attachment.name],
filename: attachment.filename, filename: attachment.filename,
category: categorizeFile(attachment.type), category: categorizeFile(attachment.type),
mimeType: attachment.type, mimeType: attachment.type,
...@@ -44,21 +48,96 @@ export function attachmentToItem(attachment: Attachment): AttachmentItem { ...@@ -44,21 +48,96 @@ export function attachmentToItem(attachment: Attachment): AttachmentItem {
}; };
} }
export function fileToItem(file: File, blobUrl: string): AttachmentItem { function visualItemToAttachmentItem(item: ReturnType<typeof buildAttachmentVisualItems>[number]): AttachmentItem {
return { return {
id: blobUrl, id: item.id,
filename: file.name, memberIds: item.attachmentNames,
category: categorizeFile(file.type), filename: item.filename,
mimeType: file.type, category: item.kind === "motion" ? "motion" : item.kind,
thumbnailUrl: blobUrl, mimeType: item.mimeType,
sourceUrl: blobUrl, thumbnailUrl: item.posterUrl,
size: file.size, 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, isLocal: true,
}; };
} }
function toLocalMotionItems(localFiles: LocalFile[]): AttachmentItem[] {
const grouped = new Map<string, LocalFile[]>();
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[] { 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<string>();
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[] { export function filterByCategory(items: AttachmentItem[], categories: FileCategory[]): AttachmentItem[] {
...@@ -71,7 +150,7 @@ export function separateMediaAndDocs(items: AttachmentItem[]): { media: Attachme ...@@ -71,7 +150,7 @@ export function separateMediaAndDocs(items: AttachmentItem[]): { media: Attachme
const docs: AttachmentItem[] = []; const docs: AttachmentItem[] = [];
for (const item of items) { 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); media.push(item);
} else { } else {
docs.push(item); docs.push(item);
......
...@@ -11,6 +11,7 @@ interface AttachmentListEditorProps { ...@@ -11,6 +11,7 @@ interface AttachmentListEditorProps {
attachments: Attachment[]; attachments: Attachment[];
localFiles?: LocalFile[]; localFiles?: LocalFile[];
onAttachmentsChange?: (attachments: Attachment[]) => void; onAttachmentsChange?: (attachments: Attachment[]) => void;
onLocalFilesChange?: (localFiles: LocalFile[]) => void;
onRemoveLocalFile?: (previewUrl: string) => void; onRemoveLocalFile?: (previewUrl: string) => void;
} }
...@@ -23,19 +24,24 @@ const AttachmentItemCard: FC<{ ...@@ -23,19 +24,24 @@ const AttachmentItemCard: FC<{
canMoveDown?: boolean; canMoveDown?: boolean;
}> = ({ item, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => { }> = ({ item, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => {
const { category, filename, thumbnailUrl, mimeType, size } = item; 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 fileSizeLabel = size ? formatFileSize(size) : undefined;
const displayName = category === "audio" && /^voice-(recording|note)-/i.test(filename) ? "Voice note" : filename; const displayName = category === "audio" && /^voice-(recording|note)-/i.test(filename) ? "Voice note" : filename;
return ( return (
<div className="relative rounded border border-transparent px-1.5 py-1 transition-all hover:border-border hover:bg-accent/20"> <div className="relative rounded border border-transparent px-1.5 py-1 transition-all hover:border-border hover:bg-accent/20">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-muted/40"> <div className="relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-muted/40">
{category === "image" && thumbnailUrl ? ( {(category === "image" || category === "motion") && thumbnailUrl ? (
<img src={thumbnailUrl} alt="" className="h-full w-full object-cover" /> <img src={thumbnailUrl} alt="" className="h-full w-full object-cover" />
) : ( ) : (
<FileIcon className="h-3.5 w-3.5 text-muted-foreground" /> <FileIcon className="h-3.5 w-3.5 text-muted-foreground" />
)} )}
{category === "motion" && (
<span className="absolute inset-x-0 bottom-0 bg-black/70 text-center text-[7px] font-semibold uppercase tracking-wide text-white">
Live
</span>
)}
</div> </div>
<div className="min-w-0 flex-1 flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:gap-1.5"> <div className="min-w-0 flex-1 flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:gap-1.5">
...@@ -104,58 +110,87 @@ const AttachmentItemCard: FC<{ ...@@ -104,58 +110,87 @@ const AttachmentItemCard: FC<{
); );
}; };
const AttachmentListEditor: FC<AttachmentListEditorProps> = ({ attachments, localFiles = [], onAttachmentsChange, onRemoveLocalFile }) => { const AttachmentListEditor: FC<AttachmentListEditorProps> = ({
attachments,
localFiles = [],
onAttachmentsChange,
onLocalFilesChange,
onRemoveLocalFile,
}) => {
if (attachments.length === 0 && localFiles.length === 0) { if (attachments.length === 0 && localFiles.length === 0) {
return null; return null;
} }
const items = toAttachmentItems(attachments, localFiles); const items = toAttachmentItems(attachments, localFiles);
const attachmentItems = items.filter((item) => !item.isLocal);
const localItems = items.filter((item) => item.isLocal);
const handleMoveUp = (index: number) => { const handleMoveAttachments = (itemId: string, direction: -1 | 1) => {
if (index === 0 || !onAttachmentsChange) return; if (!onAttachmentsChange) return;
const newAttachments = [...attachments]; const itemIndex = attachmentItems.findIndex((item) => item.id === itemId);
[newAttachments[index - 1], newAttachments[index]] = [newAttachments[index], newAttachments[index - 1]]; const targetIndex = itemIndex + direction;
onAttachmentsChange(newAttachments); if (itemIndex < 0 || targetIndex < 0 || targetIndex >= attachmentItems.length) {
}; return;
}
const handleMoveDown = (index: number) => { const reorderedItems = [...attachmentItems];
if (index === attachments.length - 1 || !onAttachmentsChange) return; [reorderedItems[itemIndex], reorderedItems[targetIndex]] = [reorderedItems[targetIndex], reorderedItems[itemIndex]];
const newAttachments = [...attachments]; const attachmentMap = new Map(attachments.map((attachment) => [attachment.name, attachment]));
[newAttachments[index], newAttachments[index + 1]] = [newAttachments[index + 1], newAttachments[index]]; onAttachmentsChange(
onAttachmentsChange(newAttachments); reorderedItems.flatMap((item) => item.memberIds.map((memberId) => attachmentMap.get(memberId)).filter(Boolean) as Attachment[]),
);
}; };
const handleRemoveAttachment = (name: string) => { const handleMoveLocalFiles = (itemId: string, direction: -1 | 1) => {
if (onAttachmentsChange) { if (!onLocalFilesChange) return;
onAttachmentsChange(attachments.filter((attachment) => attachment.name !== name));
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: (typeof items)[0]) => { const handleRemoveItem = (item: AttachmentItem) => {
if (item.isLocal) { if (item.isLocal) {
onRemoveLocalFile?.(item.id); const nextLocalFiles = localFiles.filter((file) => !item.memberIds.includes(file.previewUrl));
} else { onLocalFilesChange?.(nextLocalFiles);
handleRemoveAttachment(item.id); if (!onLocalFilesChange) {
item.memberIds.forEach((previewUrl) => onRemoveLocalFile?.(previewUrl));
}
return;
}
if (onAttachmentsChange) {
onAttachmentsChange(attachments.filter((attachment) => !item.memberIds.includes(attachment.name)));
} }
}; };
return ( return (
<MetadataSection icon={PaperclipIcon} title="Attachments" count={items.length} contentClassName="flex flex-col gap-0.5 p-1 sm:p-1.5"> <MetadataSection icon={PaperclipIcon} title="Attachments" count={items.length} contentClassName="flex flex-col gap-0.5 p-1 sm:p-1.5">
{items.map((item) => { {items.map((item) => {
const isLocalFile = item.isLocal; const itemList = item.isLocal ? localItems : attachmentItems;
const attachmentIndex = isLocalFile ? -1 : attachments.findIndex((a) => a.name === item.id); const itemIndex = itemList.findIndex((entry) => entry.id === item.id);
return ( return (
<AttachmentItemCard <AttachmentItemCard
key={item.id} key={item.id}
item={item} item={item}
onRemove={() => handleRemoveItem(item)} onRemove={() => handleRemoveItem(item)}
onMoveUp={!isLocalFile ? () => handleMoveUp(attachmentIndex) : undefined} onMoveUp={item.isLocal ? () => handleMoveLocalFiles(item.id, -1) : () => handleMoveAttachments(item.id, -1)}
onMoveDown={!isLocalFile ? () => handleMoveDown(attachmentIndex) : undefined} onMoveDown={item.isLocal ? () => handleMoveLocalFiles(item.id, 1) : () => handleMoveAttachments(item.id, 1)}
canMoveUp={!isLocalFile && attachmentIndex > 0} canMoveUp={itemIndex > 0}
canMoveDown={!isLocalFile && attachmentIndex < attachments.length - 1} canMoveDown={itemIndex >= 0 && itemIndex < itemList.length - 1}
/> />
); );
})} })}
......
import { DownloadIcon, FileIcon, Maximize2Icon, PaperclipIcon, PlayIcon } from "lucide-react"; import { DownloadIcon, FileIcon, PaperclipIcon, PlayIcon } from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
import MetadataSection from "@/components/MemoMetadata/MetadataSection"; import MetadataSection from "@/components/MemoMetadata/MetadataSection";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentUrl } from "@/utils/attachment"; 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 AudioAttachmentItem from "./AudioAttachmentItem";
import { getAttachmentMetadata, isImageAttachment, isVideoAttachment, separateAttachments } from "./attachmentHelpers"; import { getAttachmentMetadata, isAudioAttachment, separateAttachments } from "./attachmentHelpers";
interface AttachmentListViewProps { interface AttachmentListViewProps {
attachments: Attachment[]; attachments: Attachment[];
onImagePreview?: (urls: string[], index: number) => void; onImagePreview?: (items: PreviewMediaItem[], index: number) => void;
} }
const AttachmentMeta = ({ attachment }: { attachment: Attachment }) => { const AttachmentMeta = ({ attachment }: { attachment: Attachment }) => {
...@@ -48,21 +49,26 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => { ...@@ -48,21 +49,26 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => {
); );
}; };
interface VisualItemProps { const MotionBadge = () => (
attachment: Attachment; <span className="pointer-events-none absolute left-2 top-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white backdrop-blur-sm">
featured?: boolean; LIVE
} </span>
);
const ImageItem = ({ attachment, onImageClick, featured = false }: VisualItemProps & { onImageClick?: (url: string) => void }) => {
const handleClick = () => {
onImageClick?.(getAttachmentUrl(attachment));
};
const MotionItem = ({
item,
featured = false,
onPreview,
}: {
item: ReturnType<typeof buildAttachmentVisualItems>[number];
featured?: boolean;
onPreview?: () => void;
}) => {
return ( return (
<button <button
type="button" type="button"
className={cn("group block w-full text-left", featured ? "max-w-[18rem] sm:max-w-[20rem]" : "")} className={cn("group block w-full text-left", featured ? "max-w-[18rem] sm:max-w-[20rem]" : "")}
onClick={handleClick} onClick={onPreview}
> >
<div <div
className={cn( className={cn(
...@@ -70,80 +76,57 @@ const ImageItem = ({ attachment, onImageClick, featured = false }: VisualItemPro ...@@ -70,80 +76,57 @@ const ImageItem = ({ attachment, onImageClick, featured = false }: VisualItemPro
featured ? "aspect-[4/3]" : "aspect-square", featured ? "aspect-[4/3]" : "aspect-square",
)} )}
> >
<AttachmentCard {item.kind === "video" ? (
attachment={attachment} <video
className="h-full w-full rounded-none transition-transform duration-300 group-hover:scale-[1.02]" src={item.sourceUrl}
/> className="h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"
preload="metadata"
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/15 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100" /> />
<span className="pointer-events-none absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-background/80 text-foreground/70 backdrop-blur-sm"> ) : (
<Maximize2Icon className="h-3.5 w-3.5" /> <img
</span> src={item.posterUrl}
alt={item.filename}
className="h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"
loading="lazy"
decoding="async"
/>
)}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/20 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
{item.kind === "motion" && <MotionBadge />}
{item.previewItem.kind === "video" && (
<span className="pointer-events-none absolute bottom-2 right-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-background/80 text-foreground/70 backdrop-blur-sm">
<PlayIcon className="h-3.5 w-3.5 fill-current" />
</span>
)}
</div> </div>
</button> </button>
); );
}; };
const ImageGallery = ({ attachments, onImageClick }: { attachments: Attachment[]; onImageClick?: (url: string) => void }) => { const VisualGallery = ({
if (attachments.length === 1) { items,
onPreview,
}: {
items: ReturnType<typeof buildAttachmentVisualItems>;
onPreview?: (itemId: string) => void;
}) => {
if (items.length === 1) {
return ( return (
<div className="flex"> <div className="flex">
<ImageItem attachment={attachments[0]} featured onImageClick={onImageClick} /> <MotionItem item={items[0]} featured onPreview={() => onPreview?.(items[0].id)} />
</div> </div>
); );
} }
return ( return (
<div className="grid max-w-[22rem] grid-cols-2 gap-1.5 sm:max-w-[24rem]"> <div className="grid max-w-[22rem] grid-cols-2 gap-1.5 sm:max-w-[24rem]">
{attachments.map((attachment) => ( {items.map((item) => (
<ImageItem key={attachment.name} attachment={attachment} onImageClick={onImageClick} /> <MotionItem key={item.id} item={item} onPreview={() => onPreview?.(item.id)} />
))} ))}
</div> </div>
); );
}; };
const VideoItem = ({ attachment }: VisualItemProps) => (
<div className="w-full max-w-[20rem] overflow-hidden rounded-xl border border-border/70 bg-background/80">
<div className="relative aspect-video bg-muted/40">
<AttachmentCard attachment={attachment} className="h-full w-full rounded-none" />
<span className="pointer-events-none absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-background/80 text-foreground/70 backdrop-blur-sm">
<PlayIcon className="h-3.5 w-3.5 fill-current" />
</span>
</div>
<div className="border-t border-border/60 px-3 py-2.5">
<div className="truncate text-sm font-medium leading-tight text-foreground" title={attachment.filename}>
{attachment.filename}
</div>
<AttachmentMeta attachment={attachment} />
</div>
</div>
);
const VideoList = ({ attachments }: { attachments: Attachment[] }) => (
<div className="flex flex-wrap gap-2">
{attachments.map((attachment) => (
<VideoItem key={attachment.name} attachment={attachment} />
))}
</div>
);
const VisualSection = ({ attachments, onImageClick }: { attachments: Attachment[]; onImageClick?: (url: string) => void }) => {
const images = attachments.filter(isImageAttachment);
const videos = attachments.filter(isVideoAttachment);
return (
<div className="flex flex-col gap-2">
{images.length > 0 && <ImageGallery attachments={images} onImageClick={onImageClick} />}
{videos.length > 0 && (
<div className="flex flex-col gap-2">
{images.length > 0 && <Divider />}
<VideoList attachments={videos} />
</div>
)}
</div>
);
};
const AudioList = ({ attachments }: { attachments: Attachment[] }) => ( const AudioList = ({ attachments }: { attachments: Attachment[] }) => (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{attachments.map((attachment) => ( {attachments.map((attachment) => (
...@@ -172,9 +155,9 @@ const Divider = () => <div className="border-t border-border/70 opacity-80" />; ...@@ -172,9 +155,9 @@ const Divider = () => <div className="border-t border-border/70 opacity-80" />;
const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewProps) => { const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewProps) => {
const { visual, audio, docs } = useMemo(() => separateAttachments(attachments), [attachments]); const { visual, audio, docs } = useMemo(() => separateAttachments(attachments), [attachments]);
const imageAttachments = useMemo(() => visual.filter(isImageAttachment), [visual]); const visualItems = useMemo(() => buildAttachmentVisualItems(visual), [visual]);
const imageUrls = useMemo(() => imageAttachments.map(getAttachmentUrl), [imageAttachments]); const previewItems = useMemo(() => visualItems.map((item) => item.previewItem), [visualItems]);
const hasVisual = visual.length > 0; const hasVisual = visualItems.length > 0;
const hasAudio = audio.length > 0; const hasAudio = audio.length > 0;
const hasDocs = docs.length > 0; const hasDocs = docs.length > 0;
const sectionCount = [hasVisual, hasAudio, hasDocs].filter(Boolean).length; const sectionCount = [hasVisual, hasAudio, hasDocs].filter(Boolean).length;
...@@ -183,16 +166,21 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP ...@@ -183,16 +166,21 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP
return null; return null;
} }
const handleImageClick = (imgUrl: string) => { const handlePreview = (itemId: string) => {
const index = imageUrls.findIndex((url) => url === imgUrl); const index = previewItems.findIndex((item) => item.id === itemId);
onImagePreview?.(imageUrls, index >= 0 ? index : 0); onImagePreview?.(previewItems, index >= 0 ? index : 0);
}; };
return ( return (
<MetadataSection icon={PaperclipIcon} title="Attachments" count={attachments.length} contentClassName="flex flex-col gap-2 p-2"> <MetadataSection
{hasVisual && <VisualSection attachments={visual} onImageClick={handleImageClick} />} icon={PaperclipIcon}
title="Attachments"
count={visualItems.length + audio.length + docs.length}
contentClassName="flex flex-col gap-2 p-2"
>
{hasVisual && <VisualGallery items={visualItems} onPreview={handlePreview} />}
{hasVisual && sectionCount > 1 && <Divider />} {hasVisual && sectionCount > 1 && <Divider />}
{hasAudio && <AudioList attachments={audio} />} {hasAudio && <AudioList attachments={audio.filter(isAudioAttachment)} />}
{hasAudio && hasDocs && <Divider />} {hasAudio && hasDocs && <Divider />}
{hasDocs && <DocsList attachments={docs} />} {hasDocs && <DocsList attachments={docs} />}
</MetadataSection> </MetadataSection>
......
...@@ -5,7 +5,8 @@ import { cn } from "@/lib/utils"; ...@@ -5,7 +5,8 @@ import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb"; import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_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 MemoContent from "../MemoContent";
import { MemoViewContext, type MemoViewContextValue } from "../MemoView/MemoViewContext"; import { MemoViewContext, type MemoViewContextValue } from "../MemoView/MemoViewContext";
...@@ -36,28 +37,35 @@ const STUB_CONTEXT: MemoViewContextValue = { ...@@ -36,28 +37,35 @@ const STUB_CONTEXT: MemoViewContextValue = {
}; };
const AttachmentThumbnails = ({ attachments }: { attachments: Attachment[] }) => { const AttachmentThumbnails = ({ attachments }: { attachments: Attachment[] }) => {
const images: Attachment[] = []; const visualAttachments = attachments.filter(
const others: Attachment[] = []; (attachment) =>
for (const a of attachments) { getAttachmentType(attachment) === "image/*" || getAttachmentType(attachment) === "video/*" || isMotionAttachment(attachment),
if (getAttachmentType(a) === "image/*") images.push(a); );
else others.push(a); const items = buildAttachmentVisualItems(visualAttachments);
} const images = items.filter((item) => item.kind === "image" || item.kind === "motion");
const others = items.filter((item) => item.kind === "video");
return ( return (
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
{images.map((a) => ( {images.map((item) => (
<img <div key={item.id} className="relative">
key={a.name} <img
src={getAttachmentUrl(a)} src={item.posterUrl}
alt={a.filename} alt={item.filename}
className="w-10 h-10 rounded border border-border object-cover bg-muted/40" className="w-10 h-10 rounded border border-border object-cover bg-muted/40"
loading="lazy" loading="lazy"
/> />
{item.kind === "motion" && (
<span className="absolute left-1 top-1 rounded bg-black/70 px-1 py-0.5 text-[8px] font-semibold leading-none text-white">
LIVE
</span>
)}
</div>
))} ))}
{others.map((a) => ( {others.map((item) => (
<div key={a.name} className="flex items-center gap-1 text-[10px] text-muted-foreground"> <div key={item.id} className="flex items-center gap-1 text-[10px] text-muted-foreground">
<FileIcon className="w-3 h-3 shrink-0" /> <FileIcon className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[80px]">{a.filename}</span> <span className="truncate max-w-[80px]">{item.filename}</span>
</div> </div>
))} ))}
</div> </div>
...@@ -138,7 +146,7 @@ const MemoPreview = ({ ...@@ -138,7 +146,7 @@ const MemoPreview = ({
(truncate ? ( (truncate ? (
<div className="shrink-0 text-muted-foreground/70 inline-flex justify-center items-center gap-0.5"> <div className="shrink-0 text-muted-foreground/70 inline-flex justify-center items-center gap-0.5">
<FileIcon className="w-3 h-3 inline-block" /> <FileIcon className="w-3 h-3 inline-block" />
<span className="text-xs">{attachments.length}</span> <span className="text-xs">{countLogicalAttachmentItems(attachments)}</span>
</div> </div>
) : ( ) : (
<AttachmentThumbnails attachments={attachments} /> <AttachmentThumbnails attachments={attachments} />
......
...@@ -97,7 +97,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => { ...@@ -97,7 +97,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
<PreviewImageDialog <PreviewImageDialog
open={previewState.open} open={previewState.open}
onOpenChange={setPreviewOpen} onOpenChange={setPreviewOpen}
imgUrls={previewState.urls} items={previewState.items}
initialIndex={previewState.index} initialIndex={previewState.index}
/> />
</article> </article>
......
...@@ -4,6 +4,7 @@ import { useLocation } from "react-router-dom"; ...@@ -4,6 +4,7 @@ import { useLocation } from "react-router-dom";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { MemoRelation_Type } 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 { User } from "@/types/proto/api/v1/user_service_pb";
import type { PreviewMediaItem } from "@/utils/media-item";
import { RELATIVE_TIME_THRESHOLD_MS } from "./constants"; import { RELATIVE_TIME_THRESHOLD_MS } from "./constants";
export interface MemoViewContextValue { export interface MemoViewContextValue {
...@@ -17,7 +18,7 @@ export interface MemoViewContextValue { ...@@ -17,7 +18,7 @@ export interface MemoViewContextValue {
blurred: boolean; blurred: boolean;
openEditor: () => void; openEditor: () => void;
toggleBlurVisibility: () => void; toggleBlurVisibility: () => void;
openPreview: (urls: string | string[], index?: number) => void; openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void;
} }
export const MemoViewContext = createContext<MemoViewContextValue | null>(null); export const MemoViewContext = createContext<MemoViewContextValue | null>(null);
......
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import type { PreviewMediaItem } from "@/utils/media-item";
export interface ImagePreviewState { export interface ImagePreviewState {
open: boolean; open: boolean;
urls: string[]; items: PreviewMediaItem[];
index: number; index: number;
} }
export interface UseImagePreviewReturn { export interface UseImagePreviewReturn {
previewState: ImagePreviewState; previewState: ImagePreviewState;
openPreview: (urls: string | string[], index?: number) => void; openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void;
setPreviewOpen: (open: boolean) => void; setPreviewOpen: (open: boolean) => void;
} }
export const useImagePreview = (): UseImagePreviewReturn => { export const useImagePreview = (): UseImagePreviewReturn => {
const [previewState, setPreviewState] = useState<ImagePreviewState>({ open: false, urls: [], index: 0 }); const [previewState, setPreviewState] = useState<ImagePreviewState>({ open: false, items: [], index: 0 });
const openPreview = useCallback((urls: string | string[], index = 0) => { const openPreview = useCallback((items: string | string[] | PreviewMediaItem[], index = 0) => {
setPreviewState({ open: true, urls: Array.isArray(urls) ? urls : [urls], index }); const normalizedItems = normalizePreviewItems(items);
setPreviewState({ open: true, items: normalizedItems, index });
}, []); }, []);
const setPreviewOpen = useCallback((open: boolean) => { const setPreviewOpen = useCallback((open: boolean) => {
...@@ -25,3 +27,31 @@ export const useImagePreview = (): UseImagePreviewReturn => { ...@@ -25,3 +27,31 @@ export const useImagePreview = (): UseImagePreviewReturn => {
return { previewState, openPreview, setPreviewOpen }; 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[];
}
import { useCallback } from "react"; import { useCallback } from "react";
import { useInstance } from "@/contexts/InstanceContext"; import { useInstance } from "@/contexts/InstanceContext";
import type { PreviewMediaItem } from "@/utils/media-item";
interface UseMemoHandlersOptions { interface UseMemoHandlersOptions {
readonly: boolean; readonly: boolean;
openEditor: () => void; openEditor: () => void;
openPreview: (urls: string | string[], index?: number) => void; openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void;
} }
export const useMemoHandlers = (options: UseMemoHandlersOptions) => { export const useMemoHandlers = (options: UseMemoHandlersOptions) => {
......
...@@ -2,16 +2,21 @@ import { X } from "lucide-react"; ...@@ -2,16 +2,21 @@ import { X } from "lucide-react";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import type { PreviewMediaItem } from "@/utils/media-item";
interface Props { interface Props {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
imgUrls: string[]; imgUrls?: string[];
items?: PreviewMediaItem[];
initialIndex?: number; 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 [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 // Update current index when initialIndex prop changes
useEffect(() => { useEffect(() => {
...@@ -28,7 +33,7 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P ...@@ -28,7 +33,7 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P
onOpenChange(false); onOpenChange(false);
break; break;
case "ArrowRight": case "ArrowRight":
setCurrentIndex((prev) => Math.min(prev + 1, imgUrls.length - 1)); setCurrentIndex((prev) => Math.min(prev + 1, previewItems.length - 1));
break; break;
case "ArrowLeft": case "ArrowLeft":
setCurrentIndex((prev) => Math.max(prev - 1, 0)); setCurrentIndex((prev) => Math.max(prev - 1, 0));
...@@ -53,10 +58,11 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P ...@@ -53,10 +58,11 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P
}; };
// Return early if no images provided // Return early if no images provided
if (!imgUrls.length) return null; if (!previewItems.length) return null;
// Ensure currentIndex is within bounds // 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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
...@@ -79,14 +85,30 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P ...@@ -79,14 +85,30 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P
{/* Image container */} {/* Image container */}
<div className="w-full h-full flex items-center justify-center p-4 sm:p-8 overflow-auto" onClick={handleBackdropClick}> <div className="w-full h-full flex items-center justify-center p-4 sm:p-8 overflow-auto" onClick={handleBackdropClick}>
<img {currentItem.kind === "video" ? (
src={imgUrls[safeIndex]} <video
alt={`Preview image ${safeIndex + 1} of ${imgUrls.length}`} key={currentItem.id}
className="max-w-full max-h-full object-contain select-none" src={currentItem.sourceUrl}
draggable={false} poster={currentItem.posterUrl}
loading="eager" className="max-w-full max-h-full object-contain"
decoding="async" controls
/> autoPlay
onLoadedMetadata={(event) => {
if (currentItem.presentationTimestampUs && currentItem.presentationTimestampUs > 0n) {
event.currentTarget.currentTime = Number(currentItem.presentationTimestampUs) / 1_000_000;
}
}}
/>
) : (
<img
src={currentItem.sourceUrl}
alt={`Preview image ${safeIndex + 1} of ${previewItems.length}`}
className="max-w-full max-h-full object-contain select-none"
draggable={false}
loading="eager"
decoding="async"
/>
)}
</div> </div>
{/* Screen reader description */} {/* Screen reader description */}
......
...@@ -2,8 +2,8 @@ ...@@ -2,8 +2,8 @@
// @generated from file api/v1/attachment_service.proto (package memos.api.v1, syntax proto3) // @generated from file api/v1/attachment_service.proto (package memos.api.v1, syntax proto3)
/* eslint-disable */ /* eslint-disable */
import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc, serviceDesc } 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_annotations } from "../../google/api/annotations_pb";
import { file_google_api_client } from "../../google/api/client_pb"; import { file_google_api_client } from "../../google/api/client_pb";
import { file_google_api_field_behavior } from "../../google/api/field_behavior_pb"; import { file_google_api_field_behavior } from "../../google/api/field_behavior_pb";
...@@ -16,7 +16,44 @@ import type { Message } from "@bufbuild/protobuf"; ...@@ -16,7 +16,44 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file api/v1/attachment_service.proto. * Describes the file api/v1/attachment_service.proto.
*/ */
export const file_api_v1_attachment_service: GenFile = /*@__PURE__*/ 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<MotionMedia> = /*@__PURE__*/
messageDesc(file_api_v1_attachment_service, 0);
/** /**
* @generated from message memos.api.v1.Attachment * @generated from message memos.api.v1.Attachment
...@@ -79,6 +116,13 @@ export type Attachment = Message<"memos.api.v1.Attachment"> & { ...@@ -79,6 +116,13 @@ export type Attachment = Message<"memos.api.v1.Attachment"> & {
* @generated from field: optional string memo = 8; * @generated from field: optional string memo = 8;
*/ */
memo?: string; 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"> & { ...@@ -86,7 +130,7 @@ export type Attachment = Message<"memos.api.v1.Attachment"> & {
* Use `create(AttachmentSchema)` to create a new message. * Use `create(AttachmentSchema)` to create a new message.
*/ */
export const AttachmentSchema: GenMessage<Attachment> = /*@__PURE__*/ export const AttachmentSchema: GenMessage<Attachment> = /*@__PURE__*/
messageDesc(file_api_v1_attachment_service, 0); messageDesc(file_api_v1_attachment_service, 1);
/** /**
* @generated from message memos.api.v1.CreateAttachmentRequest * @generated from message memos.api.v1.CreateAttachmentRequest
...@@ -113,7 +157,7 @@ export type CreateAttachmentRequest = Message<"memos.api.v1.CreateAttachmentRequ ...@@ -113,7 +157,7 @@ export type CreateAttachmentRequest = Message<"memos.api.v1.CreateAttachmentRequ
* Use `create(CreateAttachmentRequestSchema)` to create a new message. * Use `create(CreateAttachmentRequestSchema)` to create a new message.
*/ */
export const CreateAttachmentRequestSchema: GenMessage<CreateAttachmentRequest> = /*@__PURE__*/ export const CreateAttachmentRequestSchema: GenMessage<CreateAttachmentRequest> = /*@__PURE__*/
messageDesc(file_api_v1_attachment_service, 1); messageDesc(file_api_v1_attachment_service, 2);
/** /**
* @generated from message memos.api.v1.ListAttachmentsRequest * @generated from message memos.api.v1.ListAttachmentsRequest
...@@ -161,7 +205,7 @@ export type ListAttachmentsRequest = Message<"memos.api.v1.ListAttachmentsReques ...@@ -161,7 +205,7 @@ export type ListAttachmentsRequest = Message<"memos.api.v1.ListAttachmentsReques
* Use `create(ListAttachmentsRequestSchema)` to create a new message. * Use `create(ListAttachmentsRequestSchema)` to create a new message.
*/ */
export const ListAttachmentsRequestSchema: GenMessage<ListAttachmentsRequest> = /*@__PURE__*/ export const ListAttachmentsRequestSchema: GenMessage<ListAttachmentsRequest> = /*@__PURE__*/
messageDesc(file_api_v1_attachment_service, 2); messageDesc(file_api_v1_attachment_service, 3);
/** /**
* @generated from message memos.api.v1.ListAttachmentsResponse * @generated from message memos.api.v1.ListAttachmentsResponse
...@@ -195,7 +239,7 @@ export type ListAttachmentsResponse = Message<"memos.api.v1.ListAttachmentsRespo ...@@ -195,7 +239,7 @@ export type ListAttachmentsResponse = Message<"memos.api.v1.ListAttachmentsRespo
* Use `create(ListAttachmentsResponseSchema)` to create a new message. * Use `create(ListAttachmentsResponseSchema)` to create a new message.
*/ */
export const ListAttachmentsResponseSchema: GenMessage<ListAttachmentsResponse> = /*@__PURE__*/ export const ListAttachmentsResponseSchema: GenMessage<ListAttachmentsResponse> = /*@__PURE__*/
messageDesc(file_api_v1_attachment_service, 3); messageDesc(file_api_v1_attachment_service, 4);
/** /**
* @generated from message memos.api.v1.GetAttachmentRequest * @generated from message memos.api.v1.GetAttachmentRequest
...@@ -215,7 +259,7 @@ export type GetAttachmentRequest = 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. * Use `create(GetAttachmentRequestSchema)` to create a new message.
*/ */
export const GetAttachmentRequestSchema: GenMessage<GetAttachmentRequest> = /*@__PURE__*/ export const GetAttachmentRequestSchema: GenMessage<GetAttachmentRequest> = /*@__PURE__*/
messageDesc(file_api_v1_attachment_service, 4); messageDesc(file_api_v1_attachment_service, 5);
/** /**
* @generated from message memos.api.v1.UpdateAttachmentRequest * @generated from message memos.api.v1.UpdateAttachmentRequest
...@@ -241,7 +285,7 @@ export type UpdateAttachmentRequest = Message<"memos.api.v1.UpdateAttachmentRequ ...@@ -241,7 +285,7 @@ export type UpdateAttachmentRequest = Message<"memos.api.v1.UpdateAttachmentRequ
* Use `create(UpdateAttachmentRequestSchema)` to create a new message. * Use `create(UpdateAttachmentRequestSchema)` to create a new message.
*/ */
export const UpdateAttachmentRequestSchema: GenMessage<UpdateAttachmentRequest> = /*@__PURE__*/ export const UpdateAttachmentRequestSchema: GenMessage<UpdateAttachmentRequest> = /*@__PURE__*/
messageDesc(file_api_v1_attachment_service, 5); messageDesc(file_api_v1_attachment_service, 6);
/** /**
* @generated from message memos.api.v1.DeleteAttachmentRequest * @generated from message memos.api.v1.DeleteAttachmentRequest
...@@ -261,7 +305,81 @@ export type DeleteAttachmentRequest = Message<"memos.api.v1.DeleteAttachmentRequ ...@@ -261,7 +305,81 @@ export type DeleteAttachmentRequest = Message<"memos.api.v1.DeleteAttachmentRequ
* Use `create(DeleteAttachmentRequestSchema)` to create a new message. * Use `create(DeleteAttachmentRequestSchema)` to create a new message.
*/ */
export const DeleteAttachmentRequestSchema: GenMessage<DeleteAttachmentRequest> = /*@__PURE__*/ export const DeleteAttachmentRequestSchema: GenMessage<DeleteAttachmentRequest> = /*@__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<BatchDeleteAttachmentsRequest> = /*@__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<MotionMediaFamily> = /*@__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<MotionMediaRole> = /*@__PURE__*/
enumDesc(file_api_v1_attachment_service, 1);
/** /**
* @generated from service memos.api.v1.AttachmentService * @generated from service memos.api.v1.AttachmentService
...@@ -317,6 +435,16 @@ export const AttachmentService: GenService<{ ...@@ -317,6 +435,16 @@ export const AttachmentService: GenService<{
input: typeof DeleteAttachmentRequestSchema; input: typeof DeleteAttachmentRequestSchema;
output: typeof EmptySchema; 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__*/ }> = /*@__PURE__*/
serviceDesc(file_api_v1_attachment_service, 0); serviceDesc(file_api_v1_attachment_service, 0);
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) => { export const getAttachmentUrl = (attachment: Attachment) => {
if (attachment.externalLink) { if (attachment.externalLink) {
...@@ -12,6 +12,10 @@ export const getAttachmentThumbnailUrl = (attachment: Attachment) => { ...@@ -12,6 +12,10 @@ export const getAttachmentThumbnailUrl = (attachment: Attachment) => {
return `${window.location.origin}/file/${attachment.name}/${attachment.filename}?thumbnail=true`; 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) => { export const getAttachmentType = (attachment: Attachment) => {
if (isImage(attachment.type)) { if (isImage(attachment.type)) {
return "image/*"; return "image/*";
...@@ -52,3 +56,21 @@ export const isMidiFile = (mimeType: string): boolean => { ...@@ -52,3 +56,21 @@ export const isMidiFile = (mimeType: string): boolean => {
const isPSD = (t: string) => { const isPSD = (t: string) => {
return t === "image/vnd.adobe.photoshop" || t === "image/x-photoshop" || t === "image/photoshop"; 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);
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<string, Attachment[]>();
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<string>();
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<string>();
return items.filter((item) => {
if (seen.has(item.id)) {
return false;
}
seen.add(item.id);
return true;
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment