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

This commit is contained in:
boojack 2026-04-06 10:47:01 +08:00 committed by GitHub
parent 894b3eb045
commit 4b4e719470
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 2503 additions and 337 deletions

View File

@ -0,0 +1,110 @@
package motionphoto
import (
"bytes"
"encoding/binary"
"regexp"
"strconv"
)
type Detection struct {
VideoStart int
PresentationTimestampUs int64
}
var (
motionPhotoMarkerRegex = regexp.MustCompile(`(?i)(?:Camera:MotionPhoto|GCamera:MotionPhoto|MicroVideo)["'=:\s>]+1`)
presentationRegex = regexp.MustCompile(`(?i)(?:Camera:MotionPhotoPresentationTimestampUs|GCamera:MotionPhotoPresentationTimestampUs)["'=:\s>]+(-?\d+)`)
microVideoOffsetRegex = regexp.MustCompile(`(?i)(?:Camera:MicroVideoOffset|GCamera:MicroVideoOffset)["'=:\s>]+(\d+)`)
)
const maxMetadataScanBytes = 256 * 1024
func DetectJPEG(blob []byte) *Detection {
if len(blob) < 16 || !bytes.HasPrefix(blob, []byte{0xFF, 0xD8}) {
return nil
}
text := string(blob[:min(len(blob), maxMetadataScanBytes)])
if !motionPhotoMarkerRegex.MatchString(text) {
return nil
}
videoStart := detectVideoStart(blob, text)
if videoStart < 0 || videoStart >= len(blob) {
return nil
}
return &Detection{
VideoStart: videoStart,
PresentationTimestampUs: parsePresentationTimestampUs(text),
}
}
func ExtractVideo(blob []byte) ([]byte, *Detection) {
detection := DetectJPEG(blob)
if detection == nil {
return nil, nil
}
videoBlob := blob[detection.VideoStart:]
if !looksLikeMP4(videoBlob) {
return nil, nil
}
return videoBlob, detection
}
func detectVideoStart(blob []byte, text string) int {
if matches := microVideoOffsetRegex.FindStringSubmatch(text); len(matches) == 2 {
if offset, err := strconv.Atoi(matches[1]); err == nil && offset > 0 && offset < len(blob) {
start := len(blob) - offset
if looksLikeMP4(blob[start:]) {
return start
}
}
}
return findEmbeddedMP4Start(blob)
}
func parsePresentationTimestampUs(text string) int64 {
matches := presentationRegex.FindStringSubmatch(text)
if len(matches) != 2 {
return 0
}
value, err := strconv.ParseInt(matches[1], 10, 64)
if err != nil {
return 0
}
return value
}
func findEmbeddedMP4Start(blob []byte) int {
searchFrom := len(blob)
for searchFrom > 8 {
index := bytes.LastIndex(blob[:searchFrom], []byte("ftyp"))
if index < 4 {
return -1
}
start := index - 4
if looksLikeMP4(blob[start:]) {
return start
}
searchFrom = index - 1
}
return -1
}
func looksLikeMP4(blob []byte) bool {
if len(blob) < 12 || !bytes.Equal(blob[4:8], []byte("ftyp")) {
return false
}
size := binary.BigEndian.Uint32(blob[:4])
return size == 1 || size >= 8
}

View File

@ -0,0 +1,25 @@
package motionphoto
import (
"bytes"
"testing"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/internal/testutil"
)
func TestDetectJPEG(t *testing.T) {
t.Parallel()
blob := testutil.BuildMotionPhotoJPEG()
detection := DetectJPEG(blob)
require.NotNil(t, detection)
require.Positive(t, detection.VideoStart)
require.EqualValues(t, 123456, detection.PresentationTimestampUs)
videoBlob, extracted := ExtractVideo(blob)
require.NotNil(t, extracted)
require.True(t, bytes.Equal(videoBlob[:4], []byte{0x00, 0x00, 0x00, 0x10}))
require.Equal(t, []byte("ftyp"), videoBlob[4:8])
}

View File

@ -0,0 +1,18 @@
package testutil
// BuildMotionPhotoJPEG returns a minimal JPEG blob with Motion Photo metadata
// and an embedded MP4 header for tests.
func BuildMotionPhotoJPEG() []byte {
return append(
[]byte{
0xFF, 0xD8, 0xFF, 0xE1,
},
append(
[]byte(`<?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,
}...,
)...,
)
}

View File

@ -43,6 +43,34 @@ service AttachmentService {
option (google.api.http) = {delete: "/api/v1/{name=attachments/*}"};
option (google.api.method_signature) = "name";
}
// BatchDeleteAttachments deletes multiple attachments in one request.
rpc BatchDeleteAttachments(BatchDeleteAttachmentsRequest) returns (google.protobuf.Empty) {
option (google.api.http) = {
post: "/api/v1/attachments:batchDelete"
body: "*"
};
}
}
enum MotionMediaFamily {
MOTION_MEDIA_FAMILY_UNSPECIFIED = 0;
APPLE_LIVE_PHOTO = 1;
ANDROID_MOTION_PHOTO = 2;
}
enum MotionMediaRole {
MOTION_MEDIA_ROLE_UNSPECIFIED = 0;
STILL = 1;
VIDEO = 2;
CONTAINER = 3;
}
message MotionMedia {
MotionMediaFamily family = 1;
MotionMediaRole role = 2;
string group_id = 3;
int64 presentation_timestamp_us = 4;
bool has_embedded_video = 5;
}
message Attachment {
@ -78,6 +106,9 @@ message Attachment {
// Optional. The related memo. Refer to `Memo.name`.
// Format: memos/{memo}
optional string memo = 8 [(google.api.field_behavior) = OPTIONAL];
// Optional. Motion media metadata.
MotionMedia motion_media = 9 [(google.api.field_behavior) = OPTIONAL];
}
message CreateAttachmentRequest {
@ -148,3 +179,7 @@ message DeleteAttachmentRequest {
(google.api.resource_reference) = {type: "memos.api.v1/Attachment"}
];
}
message BatchDeleteAttachmentsRequest {
repeated string names = 1 [(google.api.field_behavior) = REQUIRED];
}

View File

@ -49,6 +49,9 @@ const (
// AttachmentServiceDeleteAttachmentProcedure is the fully-qualified name of the AttachmentService's
// DeleteAttachment RPC.
AttachmentServiceDeleteAttachmentProcedure = "/memos.api.v1.AttachmentService/DeleteAttachment"
// AttachmentServiceBatchDeleteAttachmentsProcedure is the fully-qualified name of the
// AttachmentService's BatchDeleteAttachments RPC.
AttachmentServiceBatchDeleteAttachmentsProcedure = "/memos.api.v1.AttachmentService/BatchDeleteAttachments"
)
// AttachmentServiceClient is a client for the memos.api.v1.AttachmentService service.
@ -63,6 +66,8 @@ type AttachmentServiceClient interface {
UpdateAttachment(context.Context, *connect.Request[v1.UpdateAttachmentRequest]) (*connect.Response[v1.Attachment], error)
// DeleteAttachment deletes an attachment by name.
DeleteAttachment(context.Context, *connect.Request[v1.DeleteAttachmentRequest]) (*connect.Response[emptypb.Empty], error)
// BatchDeleteAttachments deletes multiple attachments in one request.
BatchDeleteAttachments(context.Context, *connect.Request[v1.BatchDeleteAttachmentsRequest]) (*connect.Response[emptypb.Empty], error)
}
// NewAttachmentServiceClient constructs a client for the memos.api.v1.AttachmentService service. By
@ -106,16 +111,23 @@ func NewAttachmentServiceClient(httpClient connect.HTTPClient, baseURL string, o
connect.WithSchema(attachmentServiceMethods.ByName("DeleteAttachment")),
connect.WithClientOptions(opts...),
),
batchDeleteAttachments: connect.NewClient[v1.BatchDeleteAttachmentsRequest, emptypb.Empty](
httpClient,
baseURL+AttachmentServiceBatchDeleteAttachmentsProcedure,
connect.WithSchema(attachmentServiceMethods.ByName("BatchDeleteAttachments")),
connect.WithClientOptions(opts...),
),
}
}
// attachmentServiceClient implements AttachmentServiceClient.
type attachmentServiceClient struct {
createAttachment *connect.Client[v1.CreateAttachmentRequest, v1.Attachment]
listAttachments *connect.Client[v1.ListAttachmentsRequest, v1.ListAttachmentsResponse]
getAttachment *connect.Client[v1.GetAttachmentRequest, v1.Attachment]
updateAttachment *connect.Client[v1.UpdateAttachmentRequest, v1.Attachment]
deleteAttachment *connect.Client[v1.DeleteAttachmentRequest, emptypb.Empty]
createAttachment *connect.Client[v1.CreateAttachmentRequest, v1.Attachment]
listAttachments *connect.Client[v1.ListAttachmentsRequest, v1.ListAttachmentsResponse]
getAttachment *connect.Client[v1.GetAttachmentRequest, v1.Attachment]
updateAttachment *connect.Client[v1.UpdateAttachmentRequest, v1.Attachment]
deleteAttachment *connect.Client[v1.DeleteAttachmentRequest, emptypb.Empty]
batchDeleteAttachments *connect.Client[v1.BatchDeleteAttachmentsRequest, emptypb.Empty]
}
// CreateAttachment calls memos.api.v1.AttachmentService.CreateAttachment.
@ -143,6 +155,11 @@ func (c *attachmentServiceClient) DeleteAttachment(ctx context.Context, req *con
return c.deleteAttachment.CallUnary(ctx, req)
}
// BatchDeleteAttachments calls memos.api.v1.AttachmentService.BatchDeleteAttachments.
func (c *attachmentServiceClient) BatchDeleteAttachments(ctx context.Context, req *connect.Request[v1.BatchDeleteAttachmentsRequest]) (*connect.Response[emptypb.Empty], error) {
return c.batchDeleteAttachments.CallUnary(ctx, req)
}
// AttachmentServiceHandler is an implementation of the memos.api.v1.AttachmentService service.
type AttachmentServiceHandler interface {
// CreateAttachment creates a new attachment.
@ -155,6 +172,8 @@ type AttachmentServiceHandler interface {
UpdateAttachment(context.Context, *connect.Request[v1.UpdateAttachmentRequest]) (*connect.Response[v1.Attachment], error)
// DeleteAttachment deletes an attachment by name.
DeleteAttachment(context.Context, *connect.Request[v1.DeleteAttachmentRequest]) (*connect.Response[emptypb.Empty], error)
// BatchDeleteAttachments deletes multiple attachments in one request.
BatchDeleteAttachments(context.Context, *connect.Request[v1.BatchDeleteAttachmentsRequest]) (*connect.Response[emptypb.Empty], error)
}
// NewAttachmentServiceHandler builds an HTTP handler from the service implementation. It returns
@ -194,6 +213,12 @@ func NewAttachmentServiceHandler(svc AttachmentServiceHandler, opts ...connect.H
connect.WithSchema(attachmentServiceMethods.ByName("DeleteAttachment")),
connect.WithHandlerOptions(opts...),
)
attachmentServiceBatchDeleteAttachmentsHandler := connect.NewUnaryHandler(
AttachmentServiceBatchDeleteAttachmentsProcedure,
svc.BatchDeleteAttachments,
connect.WithSchema(attachmentServiceMethods.ByName("BatchDeleteAttachments")),
connect.WithHandlerOptions(opts...),
)
return "/memos.api.v1.AttachmentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case AttachmentServiceCreateAttachmentProcedure:
@ -206,6 +231,8 @@ func NewAttachmentServiceHandler(svc AttachmentServiceHandler, opts ...connect.H
attachmentServiceUpdateAttachmentHandler.ServeHTTP(w, r)
case AttachmentServiceDeleteAttachmentProcedure:
attachmentServiceDeleteAttachmentHandler.ServeHTTP(w, r)
case AttachmentServiceBatchDeleteAttachmentsProcedure:
attachmentServiceBatchDeleteAttachmentsHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -234,3 +261,7 @@ func (UnimplementedAttachmentServiceHandler) UpdateAttachment(context.Context, *
func (UnimplementedAttachmentServiceHandler) DeleteAttachment(context.Context, *connect.Request[v1.DeleteAttachmentRequest]) (*connect.Response[emptypb.Empty], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("memos.api.v1.AttachmentService.DeleteAttachment is not implemented"))
}
func (UnimplementedAttachmentServiceHandler) BatchDeleteAttachments(context.Context, *connect.Request[v1.BatchDeleteAttachmentsRequest]) (*connect.Response[emptypb.Empty], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("memos.api.v1.AttachmentService.BatchDeleteAttachments is not implemented"))
}

View File

@ -25,6 +25,183 @@ const (
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type MotionMediaFamily int32
const (
MotionMediaFamily_MOTION_MEDIA_FAMILY_UNSPECIFIED MotionMediaFamily = 0
MotionMediaFamily_APPLE_LIVE_PHOTO MotionMediaFamily = 1
MotionMediaFamily_ANDROID_MOTION_PHOTO MotionMediaFamily = 2
)
// Enum value maps for MotionMediaFamily.
var (
MotionMediaFamily_name = map[int32]string{
0: "MOTION_MEDIA_FAMILY_UNSPECIFIED",
1: "APPLE_LIVE_PHOTO",
2: "ANDROID_MOTION_PHOTO",
}
MotionMediaFamily_value = map[string]int32{
"MOTION_MEDIA_FAMILY_UNSPECIFIED": 0,
"APPLE_LIVE_PHOTO": 1,
"ANDROID_MOTION_PHOTO": 2,
}
)
func (x MotionMediaFamily) Enum() *MotionMediaFamily {
p := new(MotionMediaFamily)
*p = x
return p
}
func (x MotionMediaFamily) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (MotionMediaFamily) Descriptor() protoreflect.EnumDescriptor {
return file_api_v1_attachment_service_proto_enumTypes[0].Descriptor()
}
func (MotionMediaFamily) Type() protoreflect.EnumType {
return &file_api_v1_attachment_service_proto_enumTypes[0]
}
func (x MotionMediaFamily) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use MotionMediaFamily.Descriptor instead.
func (MotionMediaFamily) EnumDescriptor() ([]byte, []int) {
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{0}
}
type MotionMediaRole int32
const (
MotionMediaRole_MOTION_MEDIA_ROLE_UNSPECIFIED MotionMediaRole = 0
MotionMediaRole_STILL MotionMediaRole = 1
MotionMediaRole_VIDEO MotionMediaRole = 2
MotionMediaRole_CONTAINER MotionMediaRole = 3
)
// Enum value maps for MotionMediaRole.
var (
MotionMediaRole_name = map[int32]string{
0: "MOTION_MEDIA_ROLE_UNSPECIFIED",
1: "STILL",
2: "VIDEO",
3: "CONTAINER",
}
MotionMediaRole_value = map[string]int32{
"MOTION_MEDIA_ROLE_UNSPECIFIED": 0,
"STILL": 1,
"VIDEO": 2,
"CONTAINER": 3,
}
)
func (x MotionMediaRole) Enum() *MotionMediaRole {
p := new(MotionMediaRole)
*p = x
return p
}
func (x MotionMediaRole) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (MotionMediaRole) Descriptor() protoreflect.EnumDescriptor {
return file_api_v1_attachment_service_proto_enumTypes[1].Descriptor()
}
func (MotionMediaRole) Type() protoreflect.EnumType {
return &file_api_v1_attachment_service_proto_enumTypes[1]
}
func (x MotionMediaRole) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use MotionMediaRole.Descriptor instead.
func (MotionMediaRole) EnumDescriptor() ([]byte, []int) {
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{1}
}
type MotionMedia struct {
state protoimpl.MessageState `protogen:"open.v1"`
Family MotionMediaFamily `protobuf:"varint,1,opt,name=family,proto3,enum=memos.api.v1.MotionMediaFamily" json:"family,omitempty"`
Role MotionMediaRole `protobuf:"varint,2,opt,name=role,proto3,enum=memos.api.v1.MotionMediaRole" json:"role,omitempty"`
GroupId string `protobuf:"bytes,3,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"`
PresentationTimestampUs int64 `protobuf:"varint,4,opt,name=presentation_timestamp_us,json=presentationTimestampUs,proto3" json:"presentation_timestamp_us,omitempty"`
HasEmbeddedVideo bool `protobuf:"varint,5,opt,name=has_embedded_video,json=hasEmbeddedVideo,proto3" json:"has_embedded_video,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MotionMedia) Reset() {
*x = MotionMedia{}
mi := &file_api_v1_attachment_service_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MotionMedia) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MotionMedia) ProtoMessage() {}
func (x *MotionMedia) ProtoReflect() protoreflect.Message {
mi := &file_api_v1_attachment_service_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MotionMedia.ProtoReflect.Descriptor instead.
func (*MotionMedia) Descriptor() ([]byte, []int) {
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{0}
}
func (x *MotionMedia) GetFamily() MotionMediaFamily {
if x != nil {
return x.Family
}
return MotionMediaFamily_MOTION_MEDIA_FAMILY_UNSPECIFIED
}
func (x *MotionMedia) GetRole() MotionMediaRole {
if x != nil {
return x.Role
}
return MotionMediaRole_MOTION_MEDIA_ROLE_UNSPECIFIED
}
func (x *MotionMedia) GetGroupId() string {
if x != nil {
return x.GroupId
}
return ""
}
func (x *MotionMedia) GetPresentationTimestampUs() int64 {
if x != nil {
return x.PresentationTimestampUs
}
return 0
}
func (x *MotionMedia) GetHasEmbeddedVideo() bool {
if x != nil {
return x.HasEmbeddedVideo
}
return false
}
type Attachment struct {
state protoimpl.MessageState `protogen:"open.v1"`
// The name of the attachment.
@ -44,14 +221,16 @@ type Attachment struct {
Size int64 `protobuf:"varint,7,opt,name=size,proto3" json:"size,omitempty"`
// Optional. The related memo. Refer to `Memo.name`.
// Format: memos/{memo}
Memo *string `protobuf:"bytes,8,opt,name=memo,proto3,oneof" json:"memo,omitempty"`
Memo *string `protobuf:"bytes,8,opt,name=memo,proto3,oneof" json:"memo,omitempty"`
// Optional. Motion media metadata.
MotionMedia *MotionMedia `protobuf:"bytes,9,opt,name=motion_media,json=motionMedia,proto3" json:"motion_media,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Attachment) Reset() {
*x = Attachment{}
mi := &file_api_v1_attachment_service_proto_msgTypes[0]
mi := &file_api_v1_attachment_service_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -63,7 +242,7 @@ func (x *Attachment) String() string {
func (*Attachment) ProtoMessage() {}
func (x *Attachment) ProtoReflect() protoreflect.Message {
mi := &file_api_v1_attachment_service_proto_msgTypes[0]
mi := &file_api_v1_attachment_service_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -76,7 +255,7 @@ func (x *Attachment) ProtoReflect() protoreflect.Message {
// Deprecated: Use Attachment.ProtoReflect.Descriptor instead.
func (*Attachment) Descriptor() ([]byte, []int) {
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{0}
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{1}
}
func (x *Attachment) GetName() string {
@ -135,6 +314,13 @@ func (x *Attachment) GetMemo() string {
return ""
}
func (x *Attachment) GetMotionMedia() *MotionMedia {
if x != nil {
return x.MotionMedia
}
return nil
}
type CreateAttachmentRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Required. The attachment to create.
@ -148,7 +334,7 @@ type CreateAttachmentRequest struct {
func (x *CreateAttachmentRequest) Reset() {
*x = CreateAttachmentRequest{}
mi := &file_api_v1_attachment_service_proto_msgTypes[1]
mi := &file_api_v1_attachment_service_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -160,7 +346,7 @@ func (x *CreateAttachmentRequest) String() string {
func (*CreateAttachmentRequest) ProtoMessage() {}
func (x *CreateAttachmentRequest) ProtoReflect() protoreflect.Message {
mi := &file_api_v1_attachment_service_proto_msgTypes[1]
mi := &file_api_v1_attachment_service_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -173,7 +359,7 @@ func (x *CreateAttachmentRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateAttachmentRequest.ProtoReflect.Descriptor instead.
func (*CreateAttachmentRequest) Descriptor() ([]byte, []int) {
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{1}
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{2}
}
func (x *CreateAttachmentRequest) GetAttachment() *Attachment {
@ -214,7 +400,7 @@ type ListAttachmentsRequest struct {
func (x *ListAttachmentsRequest) Reset() {
*x = ListAttachmentsRequest{}
mi := &file_api_v1_attachment_service_proto_msgTypes[2]
mi := &file_api_v1_attachment_service_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -226,7 +412,7 @@ func (x *ListAttachmentsRequest) String() string {
func (*ListAttachmentsRequest) ProtoMessage() {}
func (x *ListAttachmentsRequest) ProtoReflect() protoreflect.Message {
mi := &file_api_v1_attachment_service_proto_msgTypes[2]
mi := &file_api_v1_attachment_service_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -239,7 +425,7 @@ func (x *ListAttachmentsRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListAttachmentsRequest.ProtoReflect.Descriptor instead.
func (*ListAttachmentsRequest) Descriptor() ([]byte, []int) {
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{2}
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{3}
}
func (x *ListAttachmentsRequest) GetPageSize() int32 {
@ -285,7 +471,7 @@ type ListAttachmentsResponse struct {
func (x *ListAttachmentsResponse) Reset() {
*x = ListAttachmentsResponse{}
mi := &file_api_v1_attachment_service_proto_msgTypes[3]
mi := &file_api_v1_attachment_service_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -297,7 +483,7 @@ func (x *ListAttachmentsResponse) String() string {
func (*ListAttachmentsResponse) ProtoMessage() {}
func (x *ListAttachmentsResponse) ProtoReflect() protoreflect.Message {
mi := &file_api_v1_attachment_service_proto_msgTypes[3]
mi := &file_api_v1_attachment_service_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -310,7 +496,7 @@ func (x *ListAttachmentsResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListAttachmentsResponse.ProtoReflect.Descriptor instead.
func (*ListAttachmentsResponse) Descriptor() ([]byte, []int) {
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{3}
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{4}
}
func (x *ListAttachmentsResponse) GetAttachments() []*Attachment {
@ -345,7 +531,7 @@ type GetAttachmentRequest struct {
func (x *GetAttachmentRequest) Reset() {
*x = GetAttachmentRequest{}
mi := &file_api_v1_attachment_service_proto_msgTypes[4]
mi := &file_api_v1_attachment_service_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -357,7 +543,7 @@ func (x *GetAttachmentRequest) String() string {
func (*GetAttachmentRequest) ProtoMessage() {}
func (x *GetAttachmentRequest) ProtoReflect() protoreflect.Message {
mi := &file_api_v1_attachment_service_proto_msgTypes[4]
mi := &file_api_v1_attachment_service_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -370,7 +556,7 @@ func (x *GetAttachmentRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetAttachmentRequest.ProtoReflect.Descriptor instead.
func (*GetAttachmentRequest) Descriptor() ([]byte, []int) {
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{4}
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{5}
}
func (x *GetAttachmentRequest) GetName() string {
@ -392,7 +578,7 @@ type UpdateAttachmentRequest struct {
func (x *UpdateAttachmentRequest) Reset() {
*x = UpdateAttachmentRequest{}
mi := &file_api_v1_attachment_service_proto_msgTypes[5]
mi := &file_api_v1_attachment_service_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -404,7 +590,7 @@ func (x *UpdateAttachmentRequest) String() string {
func (*UpdateAttachmentRequest) ProtoMessage() {}
func (x *UpdateAttachmentRequest) ProtoReflect() protoreflect.Message {
mi := &file_api_v1_attachment_service_proto_msgTypes[5]
mi := &file_api_v1_attachment_service_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -417,7 +603,7 @@ func (x *UpdateAttachmentRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use UpdateAttachmentRequest.ProtoReflect.Descriptor instead.
func (*UpdateAttachmentRequest) Descriptor() ([]byte, []int) {
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{5}
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{6}
}
func (x *UpdateAttachmentRequest) GetAttachment() *Attachment {
@ -445,7 +631,7 @@ type DeleteAttachmentRequest struct {
func (x *DeleteAttachmentRequest) Reset() {
*x = DeleteAttachmentRequest{}
mi := &file_api_v1_attachment_service_proto_msgTypes[6]
mi := &file_api_v1_attachment_service_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -457,7 +643,7 @@ func (x *DeleteAttachmentRequest) String() string {
func (*DeleteAttachmentRequest) ProtoMessage() {}
func (x *DeleteAttachmentRequest) ProtoReflect() protoreflect.Message {
mi := &file_api_v1_attachment_service_proto_msgTypes[6]
mi := &file_api_v1_attachment_service_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -470,7 +656,7 @@ func (x *DeleteAttachmentRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeleteAttachmentRequest.ProtoReflect.Descriptor instead.
func (*DeleteAttachmentRequest) Descriptor() ([]byte, []int) {
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{6}
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{7}
}
func (x *DeleteAttachmentRequest) GetName() string {
@ -480,11 +666,61 @@ func (x *DeleteAttachmentRequest) GetName() string {
return ""
}
type BatchDeleteAttachmentsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Names []string `protobuf:"bytes,1,rep,name=names,proto3" json:"names,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BatchDeleteAttachmentsRequest) Reset() {
*x = BatchDeleteAttachmentsRequest{}
mi := &file_api_v1_attachment_service_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *BatchDeleteAttachmentsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*BatchDeleteAttachmentsRequest) ProtoMessage() {}
func (x *BatchDeleteAttachmentsRequest) ProtoReflect() protoreflect.Message {
mi := &file_api_v1_attachment_service_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use BatchDeleteAttachmentsRequest.ProtoReflect.Descriptor instead.
func (*BatchDeleteAttachmentsRequest) Descriptor() ([]byte, []int) {
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{8}
}
func (x *BatchDeleteAttachmentsRequest) GetNames() []string {
if x != nil {
return x.Names
}
return nil
}
var File_api_v1_attachment_service_proto protoreflect.FileDescriptor
const file_api_v1_attachment_service_proto_rawDesc = "" +
"\n" +
"\x1fapi/v1/attachment_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xfb\x02\n" +
"\x1fapi/v1/attachment_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xfe\x01\n" +
"\vMotionMedia\x127\n" +
"\x06family\x18\x01 \x01(\x0e2\x1f.memos.api.v1.MotionMediaFamilyR\x06family\x121\n" +
"\x04role\x18\x02 \x01(\x0e2\x1d.memos.api.v1.MotionMediaRoleR\x04role\x12\x19\n" +
"\bgroup_id\x18\x03 \x01(\tR\agroupId\x12:\n" +
"\x19presentation_timestamp_us\x18\x04 \x01(\x03R\x17presentationTimestampUs\x12,\n" +
"\x12has_embedded_video\x18\x05 \x01(\bR\x10hasEmbeddedVideo\"\xbe\x03\n" +
"\n" +
"Attachment\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12@\n" +
@ -495,7 +731,8 @@ const file_api_v1_attachment_service_proto_rawDesc = "" +
"\rexternal_link\x18\x05 \x01(\tB\x03\xe0A\x01R\fexternalLink\x12\x17\n" +
"\x04type\x18\x06 \x01(\tB\x03\xe0A\x02R\x04type\x12\x17\n" +
"\x04size\x18\a \x01(\x03B\x03\xe0A\x03R\x04size\x12\x1c\n" +
"\x04memo\x18\b \x01(\tB\x03\xe0A\x01H\x00R\x04memo\x88\x01\x01:O\xeaAL\n" +
"\x04memo\x18\b \x01(\tB\x03\xe0A\x01H\x00R\x04memo\x88\x01\x01\x12A\n" +
"\fmotion_media\x18\t \x01(\v2\x19.memos.api.v1.MotionMediaB\x03\xe0A\x01R\vmotionMedia:O\xeaAL\n" +
"\x17memos.api.v1/Attachment\x12\x18attachments/{attachment}*\vattachments2\n" +
"attachmentB\a\n" +
"\x05_memo\"\x82\x01\n" +
@ -526,7 +763,18 @@ const file_api_v1_attachment_service_proto_rawDesc = "" +
"updateMask\"N\n" +
"\x17DeleteAttachmentRequest\x123\n" +
"\x04name\x18\x01 \x01(\tB\x1f\xe0A\x02\xfaA\x19\n" +
"\x17memos.api.v1/AttachmentR\x04name2\xc4\x05\n" +
"\x17memos.api.v1/AttachmentR\x04name\":\n" +
"\x1dBatchDeleteAttachmentsRequest\x12\x19\n" +
"\x05names\x18\x01 \x03(\tB\x03\xe0A\x02R\x05names*h\n" +
"\x11MotionMediaFamily\x12#\n" +
"\x1fMOTION_MEDIA_FAMILY_UNSPECIFIED\x10\x00\x12\x14\n" +
"\x10APPLE_LIVE_PHOTO\x10\x01\x12\x18\n" +
"\x14ANDROID_MOTION_PHOTO\x10\x02*Y\n" +
"\x0fMotionMediaRole\x12!\n" +
"\x1dMOTION_MEDIA_ROLE_UNSPECIFIED\x10\x00\x12\t\n" +
"\x05STILL\x10\x01\x12\t\n" +
"\x05VIDEO\x10\x02\x12\r\n" +
"\tCONTAINER\x10\x032\xd0\x06\n" +
"\x11AttachmentService\x12\x89\x01\n" +
"\x10CreateAttachment\x12%.memos.api.v1.CreateAttachmentRequest\x1a\x18.memos.api.v1.Attachment\"4\xdaA\n" +
"attachment\x82\xd3\xe4\x93\x02!:\n" +
@ -535,7 +783,8 @@ const file_api_v1_attachment_service_proto_rawDesc = "" +
"\rGetAttachment\x12\".memos.api.v1.GetAttachmentRequest\x1a\x18.memos.api.v1.Attachment\"+\xdaA\x04name\x82\xd3\xe4\x93\x02\x1e\x12\x1c/api/v1/{name=attachments/*}\x12\xa9\x01\n" +
"\x10UpdateAttachment\x12%.memos.api.v1.UpdateAttachmentRequest\x1a\x18.memos.api.v1.Attachment\"T\xdaA\x16attachment,update_mask\x82\xd3\xe4\x93\x025:\n" +
"attachment2'/api/v1/{attachment.name=attachments/*}\x12~\n" +
"\x10DeleteAttachment\x12%.memos.api.v1.DeleteAttachmentRequest\x1a\x16.google.protobuf.Empty\"+\xdaA\x04name\x82\xd3\xe4\x93\x02\x1e*\x1c/api/v1/{name=attachments/*}B\xae\x01\n" +
"\x10DeleteAttachment\x12%.memos.api.v1.DeleteAttachmentRequest\x1a\x16.google.protobuf.Empty\"+\xdaA\x04name\x82\xd3\xe4\x93\x02\x1e*\x1c/api/v1/{name=attachments/*}\x12\x89\x01\n" +
"\x16BatchDeleteAttachments\x12+.memos.api.v1.BatchDeleteAttachmentsRequest\x1a\x16.google.protobuf.Empty\"*\x82\xd3\xe4\x93\x02$:\x01*\"\x1f/api/v1/attachments:batchDeleteB\xae\x01\n" +
"\x10com.memos.api.v1B\x16AttachmentServiceProtoP\x01Z0github.com/usememos/memos/proto/gen/api/v1;apiv1\xa2\x02\x03MAX\xaa\x02\fMemos.Api.V1\xca\x02\fMemos\\Api\\V1\xe2\x02\x18Memos\\Api\\V1\\GPBMetadata\xea\x02\x0eMemos::Api::V1b\x06proto3"
var (
@ -550,40 +799,50 @@ func file_api_v1_attachment_service_proto_rawDescGZIP() []byte {
return file_api_v1_attachment_service_proto_rawDescData
}
var file_api_v1_attachment_service_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
var file_api_v1_attachment_service_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
var file_api_v1_attachment_service_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_api_v1_attachment_service_proto_goTypes = []any{
(*Attachment)(nil), // 0: memos.api.v1.Attachment
(*CreateAttachmentRequest)(nil), // 1: memos.api.v1.CreateAttachmentRequest
(*ListAttachmentsRequest)(nil), // 2: memos.api.v1.ListAttachmentsRequest
(*ListAttachmentsResponse)(nil), // 3: memos.api.v1.ListAttachmentsResponse
(*GetAttachmentRequest)(nil), // 4: memos.api.v1.GetAttachmentRequest
(*UpdateAttachmentRequest)(nil), // 5: memos.api.v1.UpdateAttachmentRequest
(*DeleteAttachmentRequest)(nil), // 6: memos.api.v1.DeleteAttachmentRequest
(*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp
(*fieldmaskpb.FieldMask)(nil), // 8: google.protobuf.FieldMask
(*emptypb.Empty)(nil), // 9: google.protobuf.Empty
(MotionMediaFamily)(0), // 0: memos.api.v1.MotionMediaFamily
(MotionMediaRole)(0), // 1: memos.api.v1.MotionMediaRole
(*MotionMedia)(nil), // 2: memos.api.v1.MotionMedia
(*Attachment)(nil), // 3: memos.api.v1.Attachment
(*CreateAttachmentRequest)(nil), // 4: memos.api.v1.CreateAttachmentRequest
(*ListAttachmentsRequest)(nil), // 5: memos.api.v1.ListAttachmentsRequest
(*ListAttachmentsResponse)(nil), // 6: memos.api.v1.ListAttachmentsResponse
(*GetAttachmentRequest)(nil), // 7: memos.api.v1.GetAttachmentRequest
(*UpdateAttachmentRequest)(nil), // 8: memos.api.v1.UpdateAttachmentRequest
(*DeleteAttachmentRequest)(nil), // 9: memos.api.v1.DeleteAttachmentRequest
(*BatchDeleteAttachmentsRequest)(nil), // 10: memos.api.v1.BatchDeleteAttachmentsRequest
(*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp
(*fieldmaskpb.FieldMask)(nil), // 12: google.protobuf.FieldMask
(*emptypb.Empty)(nil), // 13: google.protobuf.Empty
}
var file_api_v1_attachment_service_proto_depIdxs = []int32{
7, // 0: memos.api.v1.Attachment.create_time:type_name -> google.protobuf.Timestamp
0, // 1: memos.api.v1.CreateAttachmentRequest.attachment:type_name -> memos.api.v1.Attachment
0, // 2: memos.api.v1.ListAttachmentsResponse.attachments:type_name -> memos.api.v1.Attachment
0, // 3: memos.api.v1.UpdateAttachmentRequest.attachment:type_name -> memos.api.v1.Attachment
8, // 4: memos.api.v1.UpdateAttachmentRequest.update_mask:type_name -> google.protobuf.FieldMask
1, // 5: memos.api.v1.AttachmentService.CreateAttachment:input_type -> memos.api.v1.CreateAttachmentRequest
2, // 6: memos.api.v1.AttachmentService.ListAttachments:input_type -> memos.api.v1.ListAttachmentsRequest
4, // 7: memos.api.v1.AttachmentService.GetAttachment:input_type -> memos.api.v1.GetAttachmentRequest
5, // 8: memos.api.v1.AttachmentService.UpdateAttachment:input_type -> memos.api.v1.UpdateAttachmentRequest
6, // 9: memos.api.v1.AttachmentService.DeleteAttachment:input_type -> memos.api.v1.DeleteAttachmentRequest
0, // 10: memos.api.v1.AttachmentService.CreateAttachment:output_type -> memos.api.v1.Attachment
3, // 11: memos.api.v1.AttachmentService.ListAttachments:output_type -> memos.api.v1.ListAttachmentsResponse
0, // 12: memos.api.v1.AttachmentService.GetAttachment:output_type -> memos.api.v1.Attachment
0, // 13: memos.api.v1.AttachmentService.UpdateAttachment:output_type -> memos.api.v1.Attachment
9, // 14: memos.api.v1.AttachmentService.DeleteAttachment:output_type -> google.protobuf.Empty
10, // [10:15] is the sub-list for method output_type
5, // [5:10] is the sub-list for method input_type
5, // [5:5] is the sub-list for extension type_name
5, // [5:5] is the sub-list for extension extendee
0, // [0:5] is the sub-list for field type_name
0, // 0: memos.api.v1.MotionMedia.family:type_name -> memos.api.v1.MotionMediaFamily
1, // 1: memos.api.v1.MotionMedia.role:type_name -> memos.api.v1.MotionMediaRole
11, // 2: memos.api.v1.Attachment.create_time:type_name -> google.protobuf.Timestamp
2, // 3: memos.api.v1.Attachment.motion_media:type_name -> memos.api.v1.MotionMedia
3, // 4: memos.api.v1.CreateAttachmentRequest.attachment:type_name -> memos.api.v1.Attachment
3, // 5: memos.api.v1.ListAttachmentsResponse.attachments:type_name -> memos.api.v1.Attachment
3, // 6: memos.api.v1.UpdateAttachmentRequest.attachment:type_name -> memos.api.v1.Attachment
12, // 7: memos.api.v1.UpdateAttachmentRequest.update_mask:type_name -> google.protobuf.FieldMask
4, // 8: memos.api.v1.AttachmentService.CreateAttachment:input_type -> memos.api.v1.CreateAttachmentRequest
5, // 9: memos.api.v1.AttachmentService.ListAttachments:input_type -> memos.api.v1.ListAttachmentsRequest
7, // 10: memos.api.v1.AttachmentService.GetAttachment:input_type -> memos.api.v1.GetAttachmentRequest
8, // 11: memos.api.v1.AttachmentService.UpdateAttachment:input_type -> memos.api.v1.UpdateAttachmentRequest
9, // 12: memos.api.v1.AttachmentService.DeleteAttachment:input_type -> memos.api.v1.DeleteAttachmentRequest
10, // 13: memos.api.v1.AttachmentService.BatchDeleteAttachments:input_type -> memos.api.v1.BatchDeleteAttachmentsRequest
3, // 14: memos.api.v1.AttachmentService.CreateAttachment:output_type -> memos.api.v1.Attachment
6, // 15: memos.api.v1.AttachmentService.ListAttachments:output_type -> memos.api.v1.ListAttachmentsResponse
3, // 16: memos.api.v1.AttachmentService.GetAttachment:output_type -> memos.api.v1.Attachment
3, // 17: memos.api.v1.AttachmentService.UpdateAttachment:output_type -> memos.api.v1.Attachment
13, // 18: memos.api.v1.AttachmentService.DeleteAttachment:output_type -> google.protobuf.Empty
13, // 19: memos.api.v1.AttachmentService.BatchDeleteAttachments:output_type -> google.protobuf.Empty
14, // [14:20] is the sub-list for method output_type
8, // [8:14] is the sub-list for method input_type
8, // [8:8] is the sub-list for extension type_name
8, // [8:8] is the sub-list for extension extendee
0, // [0:8] is the sub-list for field type_name
}
func init() { file_api_v1_attachment_service_proto_init() }
@ -591,19 +850,20 @@ func file_api_v1_attachment_service_proto_init() {
if File_api_v1_attachment_service_proto != nil {
return
}
file_api_v1_attachment_service_proto_msgTypes[0].OneofWrappers = []any{}
file_api_v1_attachment_service_proto_msgTypes[1].OneofWrappers = []any{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_v1_attachment_service_proto_rawDesc), len(file_api_v1_attachment_service_proto_rawDesc)),
NumEnums: 0,
NumMessages: 7,
NumEnums: 2,
NumMessages: 9,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_api_v1_attachment_service_proto_goTypes,
DependencyIndexes: file_api_v1_attachment_service_proto_depIdxs,
EnumInfos: file_api_v1_attachment_service_proto_enumTypes,
MessageInfos: file_api_v1_attachment_service_proto_msgTypes,
}.Build()
File_api_v1_attachment_service_proto = out.File

View File

@ -270,6 +270,33 @@ func local_request_AttachmentService_DeleteAttachment_0(ctx context.Context, mar
return msg, metadata, err
}
func request_AttachmentService_BatchDeleteAttachments_0(ctx context.Context, marshaler runtime.Marshaler, client AttachmentServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq BatchDeleteAttachmentsRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
msg, err := client.BatchDeleteAttachments(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_AttachmentService_BatchDeleteAttachments_0(ctx context.Context, marshaler runtime.Marshaler, server AttachmentServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq BatchDeleteAttachmentsRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.BatchDeleteAttachments(ctx, &protoReq)
return msg, metadata, err
}
// RegisterAttachmentServiceHandlerServer registers the http handlers for service AttachmentService to "mux".
// UnaryRPC :call AttachmentServiceServer directly.
// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906.
@ -376,6 +403,26 @@ func RegisterAttachmentServiceHandlerServer(ctx context.Context, mux *runtime.Se
}
forward_AttachmentService_DeleteAttachment_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_AttachmentService_BatchDeleteAttachments_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/memos.api.v1.AttachmentService/BatchDeleteAttachments", runtime.WithHTTPPathPattern("/api/v1/attachments:batchDelete"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_AttachmentService_BatchDeleteAttachments_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_AttachmentService_BatchDeleteAttachments_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
return nil
}
@ -501,21 +548,40 @@ func RegisterAttachmentServiceHandlerClient(ctx context.Context, mux *runtime.Se
}
forward_AttachmentService_DeleteAttachment_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_AttachmentService_BatchDeleteAttachments_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/memos.api.v1.AttachmentService/BatchDeleteAttachments", runtime.WithHTTPPathPattern("/api/v1/attachments:batchDelete"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_AttachmentService_BatchDeleteAttachments_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_AttachmentService_BatchDeleteAttachments_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
return nil
}
var (
pattern_AttachmentService_CreateAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "attachments"}, ""))
pattern_AttachmentService_ListAttachments_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "attachments"}, ""))
pattern_AttachmentService_GetAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "name"}, ""))
pattern_AttachmentService_UpdateAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "attachment.name"}, ""))
pattern_AttachmentService_DeleteAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "name"}, ""))
pattern_AttachmentService_CreateAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "attachments"}, ""))
pattern_AttachmentService_ListAttachments_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "attachments"}, ""))
pattern_AttachmentService_GetAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "name"}, ""))
pattern_AttachmentService_UpdateAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "attachment.name"}, ""))
pattern_AttachmentService_DeleteAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "name"}, ""))
pattern_AttachmentService_BatchDeleteAttachments_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "attachments"}, "batchDelete"))
)
var (
forward_AttachmentService_CreateAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_ListAttachments_0 = runtime.ForwardResponseMessage
forward_AttachmentService_GetAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_UpdateAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_DeleteAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_CreateAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_ListAttachments_0 = runtime.ForwardResponseMessage
forward_AttachmentService_GetAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_UpdateAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_DeleteAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_BatchDeleteAttachments_0 = runtime.ForwardResponseMessage
)

View File

@ -20,11 +20,12 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
AttachmentService_CreateAttachment_FullMethodName = "/memos.api.v1.AttachmentService/CreateAttachment"
AttachmentService_ListAttachments_FullMethodName = "/memos.api.v1.AttachmentService/ListAttachments"
AttachmentService_GetAttachment_FullMethodName = "/memos.api.v1.AttachmentService/GetAttachment"
AttachmentService_UpdateAttachment_FullMethodName = "/memos.api.v1.AttachmentService/UpdateAttachment"
AttachmentService_DeleteAttachment_FullMethodName = "/memos.api.v1.AttachmentService/DeleteAttachment"
AttachmentService_CreateAttachment_FullMethodName = "/memos.api.v1.AttachmentService/CreateAttachment"
AttachmentService_ListAttachments_FullMethodName = "/memos.api.v1.AttachmentService/ListAttachments"
AttachmentService_GetAttachment_FullMethodName = "/memos.api.v1.AttachmentService/GetAttachment"
AttachmentService_UpdateAttachment_FullMethodName = "/memos.api.v1.AttachmentService/UpdateAttachment"
AttachmentService_DeleteAttachment_FullMethodName = "/memos.api.v1.AttachmentService/DeleteAttachment"
AttachmentService_BatchDeleteAttachments_FullMethodName = "/memos.api.v1.AttachmentService/BatchDeleteAttachments"
)
// AttachmentServiceClient is the client API for AttachmentService service.
@ -41,6 +42,8 @@ type AttachmentServiceClient interface {
UpdateAttachment(ctx context.Context, in *UpdateAttachmentRequest, opts ...grpc.CallOption) (*Attachment, error)
// DeleteAttachment deletes an attachment by name.
DeleteAttachment(ctx context.Context, in *DeleteAttachmentRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
// BatchDeleteAttachments deletes multiple attachments in one request.
BatchDeleteAttachments(ctx context.Context, in *BatchDeleteAttachmentsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
}
type attachmentServiceClient struct {
@ -101,6 +104,16 @@ func (c *attachmentServiceClient) DeleteAttachment(ctx context.Context, in *Dele
return out, nil
}
func (c *attachmentServiceClient) BatchDeleteAttachments(ctx context.Context, in *BatchDeleteAttachmentsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty)
err := c.cc.Invoke(ctx, AttachmentService_BatchDeleteAttachments_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AttachmentServiceServer is the server API for AttachmentService service.
// All implementations must embed UnimplementedAttachmentServiceServer
// for forward compatibility.
@ -115,6 +128,8 @@ type AttachmentServiceServer interface {
UpdateAttachment(context.Context, *UpdateAttachmentRequest) (*Attachment, error)
// DeleteAttachment deletes an attachment by name.
DeleteAttachment(context.Context, *DeleteAttachmentRequest) (*emptypb.Empty, error)
// BatchDeleteAttachments deletes multiple attachments in one request.
BatchDeleteAttachments(context.Context, *BatchDeleteAttachmentsRequest) (*emptypb.Empty, error)
mustEmbedUnimplementedAttachmentServiceServer()
}
@ -140,6 +155,9 @@ func (UnimplementedAttachmentServiceServer) UpdateAttachment(context.Context, *U
func (UnimplementedAttachmentServiceServer) DeleteAttachment(context.Context, *DeleteAttachmentRequest) (*emptypb.Empty, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteAttachment not implemented")
}
func (UnimplementedAttachmentServiceServer) BatchDeleteAttachments(context.Context, *BatchDeleteAttachmentsRequest) (*emptypb.Empty, error) {
return nil, status.Error(codes.Unimplemented, "method BatchDeleteAttachments not implemented")
}
func (UnimplementedAttachmentServiceServer) mustEmbedUnimplementedAttachmentServiceServer() {}
func (UnimplementedAttachmentServiceServer) testEmbeddedByValue() {}
@ -251,6 +269,24 @@ func _AttachmentService_DeleteAttachment_Handler(srv interface{}, ctx context.Co
return interceptor(ctx, in, info, handler)
}
func _AttachmentService_BatchDeleteAttachments_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchDeleteAttachmentsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AttachmentServiceServer).BatchDeleteAttachments(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AttachmentService_BatchDeleteAttachments_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AttachmentServiceServer).BatchDeleteAttachments(ctx, req.(*BatchDeleteAttachmentsRequest))
}
return interceptor(ctx, in, info, handler)
}
// AttachmentService_ServiceDesc is the grpc.ServiceDesc for AttachmentService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -278,6 +314,10 @@ var AttachmentService_ServiceDesc = grpc.ServiceDesc{
MethodName: "DeleteAttachment",
Handler: _AttachmentService_DeleteAttachment_Handler,
},
{
MethodName: "BatchDeleteAttachments",
Handler: _AttachmentService_BatchDeleteAttachments_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "api/v1/attachment_service.proto",

View File

@ -176,6 +176,28 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Status'
/api/v1/attachments:batchDelete:
post:
tags:
- AttachmentService
description: BatchDeleteAttachments deletes multiple attachments in one request.
operationId: AttachmentService_BatchDeleteAttachments
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/BatchDeleteAttachmentsRequest'
required: true
responses:
"200":
description: OK
content: {}
default:
description: Default error response
content:
application/json:
schema:
$ref: '#/components/schemas/Status'
/api/v1/auth/me:
get:
tags:
@ -2015,6 +2037,19 @@ components:
description: |-
Optional. The related memo. Refer to `Memo.name`.
Format: memos/{memo}
motionMedia:
allOf:
- $ref: '#/components/schemas/MotionMedia'
description: Optional. Motion media metadata.
BatchDeleteAttachmentsRequest:
required:
- names
type: object
properties:
names:
type: array
items:
type: string
Color:
type: object
properties:
@ -2781,6 +2816,30 @@ components:
type: string
description: The title extracted from the first H1 heading, if present.
description: Computed properties of a memo.
MotionMedia:
type: object
properties:
family:
enum:
- MOTION_MEDIA_FAMILY_UNSPECIFIED
- APPLE_LIVE_PHOTO
- ANDROID_MOTION_PHOTO
type: string
format: enum
role:
enum:
- MOTION_MEDIA_ROLE_UNSPECIFIED
- STILL
- VIDEO
- CONTAINER
type: string
format: enum
groupId:
type: string
presentationTimestampUs:
type: string
hasEmbeddedVideo:
type: boolean
NotificationSetting_EmailSetting:
type: object
properties:

View File

@ -77,19 +77,197 @@ func (AttachmentStorageType) EnumDescriptor() ([]byte, []int) {
return file_store_attachment_proto_rawDescGZIP(), []int{0}
}
type MotionMediaFamily int32
const (
MotionMediaFamily_MOTION_MEDIA_FAMILY_UNSPECIFIED MotionMediaFamily = 0
MotionMediaFamily_APPLE_LIVE_PHOTO MotionMediaFamily = 1
MotionMediaFamily_ANDROID_MOTION_PHOTO MotionMediaFamily = 2
)
// Enum value maps for MotionMediaFamily.
var (
MotionMediaFamily_name = map[int32]string{
0: "MOTION_MEDIA_FAMILY_UNSPECIFIED",
1: "APPLE_LIVE_PHOTO",
2: "ANDROID_MOTION_PHOTO",
}
MotionMediaFamily_value = map[string]int32{
"MOTION_MEDIA_FAMILY_UNSPECIFIED": 0,
"APPLE_LIVE_PHOTO": 1,
"ANDROID_MOTION_PHOTO": 2,
}
)
func (x MotionMediaFamily) Enum() *MotionMediaFamily {
p := new(MotionMediaFamily)
*p = x
return p
}
func (x MotionMediaFamily) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (MotionMediaFamily) Descriptor() protoreflect.EnumDescriptor {
return file_store_attachment_proto_enumTypes[1].Descriptor()
}
func (MotionMediaFamily) Type() protoreflect.EnumType {
return &file_store_attachment_proto_enumTypes[1]
}
func (x MotionMediaFamily) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use MotionMediaFamily.Descriptor instead.
func (MotionMediaFamily) EnumDescriptor() ([]byte, []int) {
return file_store_attachment_proto_rawDescGZIP(), []int{1}
}
type MotionMediaRole int32
const (
MotionMediaRole_MOTION_MEDIA_ROLE_UNSPECIFIED MotionMediaRole = 0
MotionMediaRole_STILL MotionMediaRole = 1
MotionMediaRole_VIDEO MotionMediaRole = 2
MotionMediaRole_CONTAINER MotionMediaRole = 3
)
// Enum value maps for MotionMediaRole.
var (
MotionMediaRole_name = map[int32]string{
0: "MOTION_MEDIA_ROLE_UNSPECIFIED",
1: "STILL",
2: "VIDEO",
3: "CONTAINER",
}
MotionMediaRole_value = map[string]int32{
"MOTION_MEDIA_ROLE_UNSPECIFIED": 0,
"STILL": 1,
"VIDEO": 2,
"CONTAINER": 3,
}
)
func (x MotionMediaRole) Enum() *MotionMediaRole {
p := new(MotionMediaRole)
*p = x
return p
}
func (x MotionMediaRole) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (MotionMediaRole) Descriptor() protoreflect.EnumDescriptor {
return file_store_attachment_proto_enumTypes[2].Descriptor()
}
func (MotionMediaRole) Type() protoreflect.EnumType {
return &file_store_attachment_proto_enumTypes[2]
}
func (x MotionMediaRole) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use MotionMediaRole.Descriptor instead.
func (MotionMediaRole) EnumDescriptor() ([]byte, []int) {
return file_store_attachment_proto_rawDescGZIP(), []int{2}
}
type MotionMedia struct {
state protoimpl.MessageState `protogen:"open.v1"`
Family MotionMediaFamily `protobuf:"varint,1,opt,name=family,proto3,enum=memos.store.MotionMediaFamily" json:"family,omitempty"`
Role MotionMediaRole `protobuf:"varint,2,opt,name=role,proto3,enum=memos.store.MotionMediaRole" json:"role,omitempty"`
GroupId string `protobuf:"bytes,3,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"`
PresentationTimestampUs int64 `protobuf:"varint,4,opt,name=presentation_timestamp_us,json=presentationTimestampUs,proto3" json:"presentation_timestamp_us,omitempty"`
HasEmbeddedVideo bool `protobuf:"varint,5,opt,name=has_embedded_video,json=hasEmbeddedVideo,proto3" json:"has_embedded_video,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MotionMedia) Reset() {
*x = MotionMedia{}
mi := &file_store_attachment_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MotionMedia) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MotionMedia) ProtoMessage() {}
func (x *MotionMedia) ProtoReflect() protoreflect.Message {
mi := &file_store_attachment_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MotionMedia.ProtoReflect.Descriptor instead.
func (*MotionMedia) Descriptor() ([]byte, []int) {
return file_store_attachment_proto_rawDescGZIP(), []int{0}
}
func (x *MotionMedia) GetFamily() MotionMediaFamily {
if x != nil {
return x.Family
}
return MotionMediaFamily_MOTION_MEDIA_FAMILY_UNSPECIFIED
}
func (x *MotionMedia) GetRole() MotionMediaRole {
if x != nil {
return x.Role
}
return MotionMediaRole_MOTION_MEDIA_ROLE_UNSPECIFIED
}
func (x *MotionMedia) GetGroupId() string {
if x != nil {
return x.GroupId
}
return ""
}
func (x *MotionMedia) GetPresentationTimestampUs() int64 {
if x != nil {
return x.PresentationTimestampUs
}
return 0
}
func (x *MotionMedia) GetHasEmbeddedVideo() bool {
if x != nil {
return x.HasEmbeddedVideo
}
return false
}
type AttachmentPayload struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Payload:
//
// *AttachmentPayload_S3Object_
Payload isAttachmentPayload_Payload `protobuf_oneof:"payload"`
MotionMedia *MotionMedia `protobuf:"bytes,10,opt,name=motion_media,json=motionMedia,proto3" json:"motion_media,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AttachmentPayload) Reset() {
*x = AttachmentPayload{}
mi := &file_store_attachment_proto_msgTypes[0]
mi := &file_store_attachment_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -101,7 +279,7 @@ func (x *AttachmentPayload) String() string {
func (*AttachmentPayload) ProtoMessage() {}
func (x *AttachmentPayload) ProtoReflect() protoreflect.Message {
mi := &file_store_attachment_proto_msgTypes[0]
mi := &file_store_attachment_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -114,7 +292,7 @@ func (x *AttachmentPayload) ProtoReflect() protoreflect.Message {
// Deprecated: Use AttachmentPayload.ProtoReflect.Descriptor instead.
func (*AttachmentPayload) Descriptor() ([]byte, []int) {
return file_store_attachment_proto_rawDescGZIP(), []int{0}
return file_store_attachment_proto_rawDescGZIP(), []int{1}
}
func (x *AttachmentPayload) GetPayload() isAttachmentPayload_Payload {
@ -133,6 +311,13 @@ func (x *AttachmentPayload) GetS3Object() *AttachmentPayload_S3Object {
return nil
}
func (x *AttachmentPayload) GetMotionMedia() *MotionMedia {
if x != nil {
return x.MotionMedia
}
return nil
}
type isAttachmentPayload_Payload interface {
isAttachmentPayload_Payload()
}
@ -157,7 +342,7 @@ type AttachmentPayload_S3Object struct {
func (x *AttachmentPayload_S3Object) Reset() {
*x = AttachmentPayload_S3Object{}
mi := &file_store_attachment_proto_msgTypes[1]
mi := &file_store_attachment_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -169,7 +354,7 @@ func (x *AttachmentPayload_S3Object) String() string {
func (*AttachmentPayload_S3Object) ProtoMessage() {}
func (x *AttachmentPayload_S3Object) ProtoReflect() protoreflect.Message {
mi := &file_store_attachment_proto_msgTypes[1]
mi := &file_store_attachment_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -182,7 +367,7 @@ func (x *AttachmentPayload_S3Object) ProtoReflect() protoreflect.Message {
// Deprecated: Use AttachmentPayload_S3Object.ProtoReflect.Descriptor instead.
func (*AttachmentPayload_S3Object) Descriptor() ([]byte, []int) {
return file_store_attachment_proto_rawDescGZIP(), []int{0, 0}
return file_store_attachment_proto_rawDescGZIP(), []int{1, 0}
}
func (x *AttachmentPayload_S3Object) GetS3Config() *StorageS3Config {
@ -210,9 +395,17 @@ var File_store_attachment_proto protoreflect.FileDescriptor
const file_store_attachment_proto_rawDesc = "" +
"\n" +
"\x16store/attachment.proto\x12\vmemos.store\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1cstore/instance_setting.proto\"\x8c\x02\n" +
"\x16store/attachment.proto\x12\vmemos.store\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1cstore/instance_setting.proto\"\xfc\x01\n" +
"\vMotionMedia\x126\n" +
"\x06family\x18\x01 \x01(\x0e2\x1e.memos.store.MotionMediaFamilyR\x06family\x120\n" +
"\x04role\x18\x02 \x01(\x0e2\x1c.memos.store.MotionMediaRoleR\x04role\x12\x19\n" +
"\bgroup_id\x18\x03 \x01(\tR\agroupId\x12:\n" +
"\x19presentation_timestamp_us\x18\x04 \x01(\x03R\x17presentationTimestampUs\x12,\n" +
"\x12has_embedded_video\x18\x05 \x01(\bR\x10hasEmbeddedVideo\"\xc9\x02\n" +
"\x11AttachmentPayload\x12F\n" +
"\ts3_object\x18\x01 \x01(\v2'.memos.store.AttachmentPayload.S3ObjectH\x00R\bs3Object\x1a\xa3\x01\n" +
"\ts3_object\x18\x01 \x01(\v2'.memos.store.AttachmentPayload.S3ObjectH\x00R\bs3Object\x12;\n" +
"\fmotion_media\x18\n" +
" \x01(\v2\x18.memos.store.MotionMediaR\vmotionMedia\x1a\xa3\x01\n" +
"\bS3Object\x129\n" +
"\ts3_config\x18\x01 \x01(\v2\x1c.memos.store.StorageS3ConfigR\bs3Config\x12\x10\n" +
"\x03key\x18\x02 \x01(\tR\x03key\x12J\n" +
@ -222,7 +415,16 @@ const file_store_attachment_proto_rawDesc = "" +
"#ATTACHMENT_STORAGE_TYPE_UNSPECIFIED\x10\x00\x12\t\n" +
"\x05LOCAL\x10\x01\x12\x06\n" +
"\x02S3\x10\x02\x12\f\n" +
"\bEXTERNAL\x10\x03B\x9a\x01\n" +
"\bEXTERNAL\x10\x03*h\n" +
"\x11MotionMediaFamily\x12#\n" +
"\x1fMOTION_MEDIA_FAMILY_UNSPECIFIED\x10\x00\x12\x14\n" +
"\x10APPLE_LIVE_PHOTO\x10\x01\x12\x18\n" +
"\x14ANDROID_MOTION_PHOTO\x10\x02*Y\n" +
"\x0fMotionMediaRole\x12!\n" +
"\x1dMOTION_MEDIA_ROLE_UNSPECIFIED\x10\x00\x12\t\n" +
"\x05STILL\x10\x01\x12\t\n" +
"\x05VIDEO\x10\x02\x12\r\n" +
"\tCONTAINER\x10\x03B\x9a\x01\n" +
"\x0fcom.memos.storeB\x0fAttachmentProtoP\x01Z)github.com/usememos/memos/proto/gen/store\xa2\x02\x03MSX\xaa\x02\vMemos.Store\xca\x02\vMemos\\Store\xe2\x02\x17Memos\\Store\\GPBMetadata\xea\x02\fMemos::Storeb\x06proto3"
var (
@ -237,24 +439,30 @@ func file_store_attachment_proto_rawDescGZIP() []byte {
return file_store_attachment_proto_rawDescData
}
var file_store_attachment_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_store_attachment_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_store_attachment_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
var file_store_attachment_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
var file_store_attachment_proto_goTypes = []any{
(AttachmentStorageType)(0), // 0: memos.store.AttachmentStorageType
(*AttachmentPayload)(nil), // 1: memos.store.AttachmentPayload
(*AttachmentPayload_S3Object)(nil), // 2: memos.store.AttachmentPayload.S3Object
(*StorageS3Config)(nil), // 3: memos.store.StorageS3Config
(*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp
(MotionMediaFamily)(0), // 1: memos.store.MotionMediaFamily
(MotionMediaRole)(0), // 2: memos.store.MotionMediaRole
(*MotionMedia)(nil), // 3: memos.store.MotionMedia
(*AttachmentPayload)(nil), // 4: memos.store.AttachmentPayload
(*AttachmentPayload_S3Object)(nil), // 5: memos.store.AttachmentPayload.S3Object
(*StorageS3Config)(nil), // 6: memos.store.StorageS3Config
(*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp
}
var file_store_attachment_proto_depIdxs = []int32{
2, // 0: memos.store.AttachmentPayload.s3_object:type_name -> memos.store.AttachmentPayload.S3Object
3, // 1: memos.store.AttachmentPayload.S3Object.s3_config:type_name -> memos.store.StorageS3Config
4, // 2: memos.store.AttachmentPayload.S3Object.last_presigned_time:type_name -> google.protobuf.Timestamp
3, // [3:3] is the sub-list for method output_type
3, // [3:3] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
1, // 0: memos.store.MotionMedia.family:type_name -> memos.store.MotionMediaFamily
2, // 1: memos.store.MotionMedia.role:type_name -> memos.store.MotionMediaRole
5, // 2: memos.store.AttachmentPayload.s3_object:type_name -> memos.store.AttachmentPayload.S3Object
3, // 3: memos.store.AttachmentPayload.motion_media:type_name -> memos.store.MotionMedia
6, // 4: memos.store.AttachmentPayload.S3Object.s3_config:type_name -> memos.store.StorageS3Config
7, // 5: memos.store.AttachmentPayload.S3Object.last_presigned_time:type_name -> google.protobuf.Timestamp
6, // [6:6] is the sub-list for method output_type
6, // [6:6] is the sub-list for method input_type
6, // [6:6] is the sub-list for extension type_name
6, // [6:6] is the sub-list for extension extendee
0, // [0:6] is the sub-list for field type_name
}
func init() { file_store_attachment_proto_init() }
@ -263,7 +471,7 @@ func file_store_attachment_proto_init() {
return
}
file_store_instance_setting_proto_init()
file_store_attachment_proto_msgTypes[0].OneofWrappers = []any{
file_store_attachment_proto_msgTypes[1].OneofWrappers = []any{
(*AttachmentPayload_S3Object_)(nil),
}
type x struct{}
@ -271,8 +479,8 @@ func file_store_attachment_proto_init() {
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_store_attachment_proto_rawDesc), len(file_store_attachment_proto_rawDesc)),
NumEnums: 1,
NumMessages: 2,
NumEnums: 3,
NumMessages: 3,
NumExtensions: 0,
NumServices: 0,
},

View File

@ -17,11 +17,34 @@ enum AttachmentStorageType {
EXTERNAL = 3;
}
enum MotionMediaFamily {
MOTION_MEDIA_FAMILY_UNSPECIFIED = 0;
APPLE_LIVE_PHOTO = 1;
ANDROID_MOTION_PHOTO = 2;
}
enum MotionMediaRole {
MOTION_MEDIA_ROLE_UNSPECIFIED = 0;
STILL = 1;
VIDEO = 2;
CONTAINER = 3;
}
message MotionMedia {
MotionMediaFamily family = 1;
MotionMediaRole role = 2;
string group_id = 3;
int64 presentation_timestamp_us = 4;
bool has_embedded_video = 5;
}
message AttachmentPayload {
oneof payload {
S3Object s3_object = 1;
}
MotionMedia motion_media = 10;
message S3Object {
StorageS3Config s3_config = 1;
// key is the S3 object key.

View File

@ -0,0 +1,69 @@
package v1
import (
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
func convertMotionMediaFromStore(motion *storepb.MotionMedia) *v1pb.MotionMedia {
if motion == nil {
return nil
}
return &v1pb.MotionMedia{
Family: v1pb.MotionMediaFamily(motion.Family),
Role: v1pb.MotionMediaRole(motion.Role),
GroupId: motion.GroupId,
PresentationTimestampUs: motion.PresentationTimestampUs,
HasEmbeddedVideo: motion.HasEmbeddedVideo,
}
}
func convertMotionMediaToStore(motion *v1pb.MotionMedia) *storepb.MotionMedia {
if motion == nil {
return nil
}
return &storepb.MotionMedia{
Family: storepb.MotionMediaFamily(motion.Family),
Role: storepb.MotionMediaRole(motion.Role),
GroupId: motion.GroupId,
PresentationTimestampUs: motion.PresentationTimestampUs,
HasEmbeddedVideo: motion.HasEmbeddedVideo,
}
}
func getAttachmentMotionMedia(attachment *store.Attachment) *storepb.MotionMedia {
if attachment == nil || attachment.Payload == nil {
return nil
}
return attachment.Payload.MotionMedia
}
func isAndroidMotionContainer(motion *storepb.MotionMedia) bool {
return motion != nil &&
motion.Family == storepb.MotionMediaFamily_ANDROID_MOTION_PHOTO &&
motion.Role == storepb.MotionMediaRole_CONTAINER &&
motion.HasEmbeddedVideo
}
func ensureAttachmentPayload(payload *storepb.AttachmentPayload) *storepb.AttachmentPayload {
if payload != nil {
return payload
}
return &storepb.AttachmentPayload{}
}
func isMultiMemberMotionGroup(attachments []*store.Attachment) bool {
if len(attachments) < 2 {
return false
}
for _, attachment := range attachments {
motion := getAttachmentMotionMedia(attachment)
if motion == nil || motion.GroupId == "" {
return false
}
}
return true
}

View File

@ -22,6 +22,7 @@ import (
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/usememos/memos/internal/motionphoto"
"github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/internal/util"
"github.com/usememos/memos/plugin/filter"
@ -42,7 +43,8 @@ const (
// defaultJPEGQuality is the JPEG quality used when re-encoding images for EXIF stripping.
// Quality 95 maintains visual quality while ensuring metadata is removed.
defaultJPEGQuality = 95
defaultJPEGQuality = 95
maxBatchDeleteAttachments = 100
)
var SupportedThumbnailMimeTypes = []string{
@ -111,6 +113,15 @@ func (s *APIV1Service) CreateAttachment(ctx context.Context, request *v1pb.Creat
Type: request.Attachment.Type,
}
inputMotionMedia, err := validateClientMotionMedia(request.Attachment.MotionMedia, attachmentUID)
if err != nil {
return nil, err
}
if inputMotionMedia != nil {
create.Payload = ensureAttachmentPayload(create.Payload)
create.Payload.MotionMedia = inputMotionMedia
}
instanceStorageSetting, err := s.Store.GetInstanceStorageSetting(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get instance storage setting: %v", err)
@ -126,9 +137,16 @@ func (s *APIV1Service) CreateAttachment(ctx context.Context, request *v1pb.Creat
create.Size = int64(size)
create.Blob = request.Attachment.Content
if create.Payload == nil || create.Payload.MotionMedia == nil {
if detectedMotion := detectAndroidMotionMedia(create.Blob, create.Type, attachmentUID); detectedMotion != nil {
create.Payload = ensureAttachmentPayload(create.Payload)
create.Payload.MotionMedia = detectedMotion
}
}
// Strip EXIF metadata from images for privacy protection.
// This removes sensitive information like GPS location, device details, etc.
if shouldStripExif(create.Type) {
if shouldStripExif(create.Type) && !isAndroidMotionContainer(create.Payload.GetMotionMedia()) {
if strippedBlob, err := stripImageExif(create.Blob, create.Type); err != nil {
// Log warning but continue with original image to ensure uploads don't fail.
slog.Warn("failed to strip EXIF metadata from image",
@ -333,13 +351,64 @@ func (s *APIV1Service) DeleteAttachment(ctx context.Context, request *v1pb.Delet
return &emptypb.Empty{}, nil
}
func (s *APIV1Service) BatchDeleteAttachments(ctx context.Context, request *v1pb.BatchDeleteAttachmentsRequest) (*emptypb.Empty, error) {
user, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if len(request.Names) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "attachment names are required")
}
if len(request.Names) > maxBatchDeleteAttachments {
return nil, status.Errorf(codes.InvalidArgument, "too many attachment names; max %d", maxBatchDeleteAttachments)
}
attachments := make([]*store.Attachment, 0, len(request.Names))
seen := make(map[string]bool, len(request.Names))
for _, name := range request.Names {
if name == "" {
return nil, status.Errorf(codes.InvalidArgument, "attachment name is required")
}
if seen[name] {
continue
}
seen[name] = true
attachmentUID, err := ExtractAttachmentUIDFromName(name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid attachment id: %v", err)
}
attachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{UID: &attachmentUID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get attachment: %v", err)
}
if attachment == nil {
return nil, status.Errorf(codes.NotFound, "attachment not found")
}
if attachment.CreatorID != user.ID && !isSuperUser(user) {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
attachments = append(attachments, attachment)
}
if err := s.Store.DeleteAttachments(ctx, attachments); err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete attachments: %v", err)
}
return &emptypb.Empty{}, nil
}
func convertAttachmentFromStore(attachment *store.Attachment) *v1pb.Attachment {
attachmentMessage := &v1pb.Attachment{
Name: fmt.Sprintf("%s%s", AttachmentNamePrefix, attachment.UID),
CreateTime: timestamppb.New(time.Unix(attachment.CreatedTs, 0)),
Filename: attachment.Filename,
Type: attachment.Type,
Size: attachment.Size,
Name: fmt.Sprintf("%s%s", AttachmentNamePrefix, attachment.UID),
CreateTime: timestamppb.New(time.Unix(attachment.CreatedTs, 0)),
Filename: attachment.Filename,
Type: attachment.Type,
Size: attachment.Size,
MotionMedia: convertMotionMediaFromStore(getAttachmentMotionMedia(attachment)),
}
if attachment.MemoUID != nil && *attachment.MemoUID != "" {
memoName := fmt.Sprintf("%s%s", MemoNamePrefix, *attachment.MemoUID)
@ -425,15 +494,15 @@ func SaveAttachmentBlob(ctx context.Context, profile *profile.Profile, stores *s
create.Reference = presignURL
create.Blob = nil
create.StorageType = storepb.AttachmentStorageType_S3
create.Payload = &storepb.AttachmentPayload{
Payload: &storepb.AttachmentPayload_S3Object_{
S3Object: &storepb.AttachmentPayload_S3Object{
S3Config: s3Config,
Key: key,
LastPresignedTime: timestamppb.New(time.Now()),
},
payload := ensureAttachmentPayload(create.Payload)
payload.Payload = &storepb.AttachmentPayload_S3Object_{
S3Object: &storepb.AttachmentPayload_S3Object{
S3Config: s3Config,
Key: key,
LastPresignedTime: timestamppb.New(time.Now()),
},
}
create.Payload = payload
}
return nil
@ -624,6 +693,48 @@ func (s *APIV1Service) checkAttachmentAccess(ctx context.Context, attachment *st
return nil
}
func validateClientMotionMedia(motion *v1pb.MotionMedia, attachmentUID string) (*storepb.MotionMedia, error) {
if motion == nil {
return nil, nil
}
if motion.Family != v1pb.MotionMediaFamily_APPLE_LIVE_PHOTO {
return nil, status.Errorf(codes.InvalidArgument, "only Apple Live Photo motion metadata can be provided by clients")
}
if motion.Role != v1pb.MotionMediaRole_STILL && motion.Role != v1pb.MotionMediaRole_VIDEO {
return nil, status.Errorf(codes.InvalidArgument, "invalid Apple Live Photo motion role")
}
storeMotion := convertMotionMediaToStore(motion)
if storeMotion.GroupId == "" {
return nil, status.Errorf(codes.InvalidArgument, "motion media group_id is required")
}
if storeMotion.Family == storepb.MotionMediaFamily_ANDROID_MOTION_PHOTO && storeMotion.GroupId == "" {
storeMotion.GroupId = attachmentUID
}
return storeMotion, nil
}
func detectAndroidMotionMedia(blob []byte, mimeType, attachmentUID string) *storepb.MotionMedia {
if mimeType != "image/jpeg" && mimeType != "image/jpg" {
return nil
}
detection := motionphoto.DetectJPEG(blob)
if detection == nil {
return nil
}
return &storepb.MotionMedia{
Family: storepb.MotionMediaFamily_ANDROID_MOTION_PHOTO,
Role: storepb.MotionMediaRole_CONTAINER,
GroupId: attachmentUID,
PresentationTimestampUs: detection.PresentationTimestampUs,
HasEmbeddedVideo: true,
}
}
// shouldStripExif checks if the MIME type is an image format that may contain EXIF metadata.
// Returns true for formats like JPEG, TIFF, WebP, HEIC, and HEIF which commonly contain
// privacy-sensitive metadata such as GPS coordinates, camera settings, and device information.

View File

@ -419,6 +419,14 @@ func (s *ConnectServiceHandler) DeleteAttachment(ctx context.Context, req *conne
return connect.NewResponse(resp), nil
}
func (s *ConnectServiceHandler) BatchDeleteAttachments(ctx context.Context, req *connect.Request[v1pb.BatchDeleteAttachmentsRequest]) (*connect.Response[emptypb.Empty], error) {
resp, err := s.APIV1Service.BatchDeleteAttachments(ctx, req.Msg)
if err != nil {
return nil, convertGRPCError(err)
}
return connect.NewResponse(resp), nil
}
// ShortcutService
func (s *ConnectServiceHandler) ListShortcuts(ctx context.Context, req *connect.Request[v1pb.ListShortcutsRequest]) (*connect.Response[v1pb.ListShortcutsResponse], error) {

View File

@ -51,27 +51,26 @@ func (s *APIV1Service) SetMemoAttachments(ctx context.Context, request *v1pb.Set
}
func (s *APIV1Service) setMemoAttachmentsInternal(ctx context.Context, memo *store.Memo, requestAttachments []*v1pb.Attachment) error {
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{
currentAttachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{
MemoID: &memo.ID,
})
if err != nil {
return status.Errorf(codes.Internal, "failed to list attachments")
}
normalizedAttachments, err := s.normalizeMemoAttachmentRequest(ctx, currentAttachments, requestAttachments)
if err != nil {
return err
}
requestedIDs := make(map[int32]bool, len(normalizedAttachments))
for _, attachment := range normalizedAttachments {
requestedIDs[attachment.ID] = true
}
// Delete attachments that are not in the request.
for _, attachment := range attachments {
found := false
for _, requestAttachment := range requestAttachments {
requestAttachmentUID, err := ExtractAttachmentUIDFromName(requestAttachment.Name)
if err != nil {
return status.Errorf(codes.InvalidArgument, "invalid attachment name: %v", err)
}
if attachment.UID == requestAttachmentUID {
found = true
break
}
}
if !found {
for _, attachment := range currentAttachments {
if !requestedIDs[attachment.ID] {
if err = s.Store.DeleteAttachment(ctx, &store.DeleteAttachment{
ID: int32(attachment.ID),
MemoID: &memo.ID,
@ -81,23 +80,12 @@ func (s *APIV1Service) setMemoAttachmentsInternal(ctx context.Context, memo *sto
}
}
slices.Reverse(requestAttachments)
slices.Reverse(normalizedAttachments)
// Update attachments' memo_id in the request.
for index, attachment := range requestAttachments {
attachmentUID, err := ExtractAttachmentUIDFromName(attachment.Name)
if err != nil {
return status.Errorf(codes.InvalidArgument, "invalid attachment name: %v", err)
}
tempAttachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{UID: &attachmentUID})
if err != nil {
return status.Errorf(codes.Internal, "failed to get attachment: %v", err)
}
if tempAttachment == nil {
return status.Errorf(codes.NotFound, "attachment not found: %s", attachmentUID)
}
for index, attachment := range normalizedAttachments {
updatedTs := time.Now().Unix() + int64(index)
if err := s.Store.UpdateAttachment(ctx, &store.UpdateAttachment{
ID: tempAttachment.ID,
ID: attachment.ID,
MemoID: &memo.ID,
UpdatedTs: &updatedTs,
}); err != nil {
@ -108,6 +96,100 @@ func (s *APIV1Service) setMemoAttachmentsInternal(ctx context.Context, memo *sto
return nil
}
func (s *APIV1Service) normalizeMemoAttachmentRequest(
ctx context.Context,
currentAttachments []*store.Attachment,
requestAttachments []*v1pb.Attachment,
) ([]*store.Attachment, error) {
requestedAttachments := make([]*store.Attachment, 0, len(requestAttachments))
for _, requestAttachment := range requestAttachments {
attachmentUID, err := ExtractAttachmentUIDFromName(requestAttachment.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid attachment name: %v", err)
}
attachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{UID: &attachmentUID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get attachment: %v", err)
}
if attachment == nil {
return nil, status.Errorf(codes.NotFound, "attachment not found: %s", attachmentUID)
}
requestedAttachments = append(requestedAttachments, attachment)
}
currentGroups := make(map[string][]*store.Attachment)
for _, attachment := range currentAttachments {
motion := getAttachmentMotionMedia(attachment)
if motion == nil || motion.GroupId == "" {
continue
}
currentGroups[motion.GroupId] = append(currentGroups[motion.GroupId], attachment)
}
requestGroups := make(map[string][]*store.Attachment)
requestNamesByGroup := make(map[string]map[string]bool)
for _, attachment := range requestedAttachments {
motion := getAttachmentMotionMedia(attachment)
if motion == nil || motion.GroupId == "" {
continue
}
requestGroups[motion.GroupId] = append(requestGroups[motion.GroupId], attachment)
if requestNamesByGroup[motion.GroupId] == nil {
requestNamesByGroup[motion.GroupId] = make(map[string]bool)
}
requestNamesByGroup[motion.GroupId][attachment.UID] = true
}
normalized := make([]*store.Attachment, 0, len(requestedAttachments))
appendedGroups := make(map[string]bool)
appendedAttachments := make(map[string]bool)
for _, attachment := range requestedAttachments {
motion := getAttachmentMotionMedia(attachment)
if motion == nil || motion.GroupId == "" {
if !appendedAttachments[attachment.UID] {
normalized = append(normalized, attachment)
appendedAttachments[attachment.UID] = true
}
continue
}
groupID := motion.GroupId
if appendedGroups[groupID] {
continue
}
currentGroup := currentGroups[groupID]
if isMultiMemberMotionGroup(currentGroup) && !allGroupMembersRequested(currentGroup, requestNamesByGroup[groupID]) {
appendedGroups[groupID] = true
continue
}
for _, groupAttachment := range requestGroups[groupID] {
if appendedAttachments[groupAttachment.UID] {
continue
}
normalized = append(normalized, groupAttachment)
appendedAttachments[groupAttachment.UID] = true
}
appendedGroups[groupID] = true
}
return normalized, nil
}
func allGroupMembersRequested(group []*store.Attachment, requestedNames map[string]bool) bool {
if len(group) == 0 {
return false
}
for _, attachment := range group {
if !requestedNames[attachment.UID] {
return false
}
}
return true
}
func (s *APIV1Service) ListMemoAttachments(ctx context.Context, request *v1pb.ListMemoAttachmentsRequest) (*v1pb.ListMemoAttachmentsResponse, error) {
memoUID, err := ExtractMemoUIDFromName(request.Name)
if err != nil {

View File

@ -6,6 +6,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/usememos/memos/internal/testutil"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
apiv1 "github.com/usememos/memos/server/router/api/v1"
@ -112,3 +113,118 @@ func TestCreateAttachment(t *testing.T) {
require.Equal(t, []byte("second-image"), secondBlob)
})
}
func TestCreateAttachmentMotionMedia(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
ctx := context.Background()
user, err := ts.CreateRegularUser(ctx, "motion_user")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, user.ID)
t.Run("Apple live photo metadata roundtrip", func(t *testing.T) {
attachment, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{
Attachment: &v1pb.Attachment{
Filename: "live.heic",
Type: "image/heic",
Content: []byte("fake-heic-still"),
MotionMedia: &v1pb.MotionMedia{
Family: v1pb.MotionMediaFamily_APPLE_LIVE_PHOTO,
Role: v1pb.MotionMediaRole_STILL,
GroupId: "apple-group-1",
},
},
})
require.NoError(t, err)
require.NotNil(t, attachment.MotionMedia)
require.Equal(t, v1pb.MotionMediaFamily_APPLE_LIVE_PHOTO, attachment.MotionMedia.Family)
require.Equal(t, v1pb.MotionMediaRole_STILL, attachment.MotionMedia.Role)
require.Equal(t, "apple-group-1", attachment.MotionMedia.GroupId)
})
t.Run("Android motion photo detection", func(t *testing.T) {
attachment, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{
Attachment: &v1pb.Attachment{
Filename: "motion.jpg",
Type: "image/jpeg",
Content: testutil.BuildMotionPhotoJPEG(),
},
})
require.NoError(t, err)
require.NotNil(t, attachment.MotionMedia)
require.Equal(t, v1pb.MotionMediaFamily_ANDROID_MOTION_PHOTO, attachment.MotionMedia.Family)
require.Equal(t, v1pb.MotionMediaRole_CONTAINER, attachment.MotionMedia.Role)
require.True(t, attachment.MotionMedia.HasEmbeddedVideo)
})
}
func TestBatchDeleteAttachments(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
ctx := context.Background()
user, err := ts.CreateRegularUser(ctx, "delete_user")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, user.ID)
first, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{
Attachment: &v1pb.Attachment{Filename: "one.txt", Type: "text/plain", Content: []byte("one")},
})
require.NoError(t, err)
second, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{
Attachment: &v1pb.Attachment{Filename: "two.txt", Type: "text/plain", Content: []byte("two")},
})
require.NoError(t, err)
_, err = ts.Service.BatchDeleteAttachments(userCtx, &v1pb.BatchDeleteAttachmentsRequest{
Names: []string{first.Name, second.Name},
})
require.NoError(t, err)
firstUID, err := apiv1.ExtractAttachmentUIDFromName(first.Name)
require.NoError(t, err)
secondUID, err := apiv1.ExtractAttachmentUIDFromName(second.Name)
require.NoError(t, err)
storedFirst, err := ts.Store.GetAttachment(ctx, &store.FindAttachment{UID: &firstUID})
require.NoError(t, err)
storedSecond, err := ts.Store.GetAttachment(ctx, &store.FindAttachment{UID: &secondUID})
require.NoError(t, err)
require.Nil(t, storedFirst)
require.Nil(t, storedSecond)
t.Run("deduplicates duplicate names", func(t *testing.T) {
third, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{
Attachment: &v1pb.Attachment{Filename: "three.txt", Type: "text/plain", Content: []byte("three")},
})
require.NoError(t, err)
_, err = ts.Service.BatchDeleteAttachments(userCtx, &v1pb.BatchDeleteAttachmentsRequest{
Names: []string{third.Name, third.Name},
})
require.NoError(t, err)
thirdUID, err := apiv1.ExtractAttachmentUIDFromName(third.Name)
require.NoError(t, err)
storedThird, err := ts.Store.GetAttachment(ctx, &store.FindAttachment{UID: &thirdUID})
require.NoError(t, err)
require.Nil(t, storedThird)
})
t.Run("rejects unauthorized deletes", func(t *testing.T) {
ownerAttachment, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{
Attachment: &v1pb.Attachment{Filename: "private.txt", Type: "text/plain", Content: []byte("private")},
})
require.NoError(t, err)
otherUser, err := ts.CreateRegularUser(ctx, "other_delete_user")
require.NoError(t, err)
otherCtx := ts.CreateUserContext(ctx, otherUser.ID)
_, err = ts.Service.BatchDeleteAttachments(otherCtx, &v1pb.BatchDeleteAttachmentsRequest{
Names: []string{ownerAttachment.Name},
})
require.Error(t, err)
require.Contains(t, err.Error(), "permission denied")
})
}

View File

@ -163,4 +163,64 @@ func TestSetMemoAttachments(t *testing.T) {
require.Error(t, err)
require.Contains(t, err.Error(), "not found")
})
t.Run("SetMemoAttachments removes incomplete live photo groups", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
user, err := ts.CreateRegularUser(ctx, "live_group_user")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, user.ID)
still, err := ts.Service.CreateAttachment(userCtx, &apiv1.CreateAttachmentRequest{
Attachment: &apiv1.Attachment{
Filename: "live.heic",
Type: "image/heic",
Content: []byte("still"),
MotionMedia: &apiv1.MotionMedia{
Family: apiv1.MotionMediaFamily_APPLE_LIVE_PHOTO,
Role: apiv1.MotionMediaRole_STILL,
GroupId: "memo-live-group",
},
},
})
require.NoError(t, err)
video, err := ts.Service.CreateAttachment(userCtx, &apiv1.CreateAttachmentRequest{
Attachment: &apiv1.Attachment{
Filename: "live.mov",
Type: "video/quicktime",
Content: []byte("video"),
MotionMedia: &apiv1.MotionMedia{
Family: apiv1.MotionMediaFamily_APPLE_LIVE_PHOTO,
Role: apiv1.MotionMediaRole_VIDEO,
GroupId: "memo-live-group",
},
},
})
require.NoError(t, err)
memo, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
Memo: &apiv1.Memo{
Content: "memo with live photo",
Visibility: apiv1.Visibility_PRIVATE,
Attachments: []*apiv1.Attachment{
{Name: still.Name},
{Name: video.Name},
},
},
})
require.NoError(t, err)
_, err = ts.Service.SetMemoAttachments(userCtx, &apiv1.SetMemoAttachmentsRequest{
Name: memo.Name,
Attachments: []*apiv1.Attachment{
{Name: still.Name},
},
})
require.NoError(t, err)
response, err := ts.Service.ListMemoAttachments(userCtx, &apiv1.ListMemoAttachmentsRequest{Name: memo.Name})
require.NoError(t, err)
require.Len(t, response.Attachments, 0)
})
}

View File

@ -19,6 +19,7 @@ import (
"github.com/pkg/errors"
"golang.org/x/sync/semaphore"
"github.com/usememos/memos/internal/motionphoto"
"github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/plugin/storage/s3"
storepb "github.com/usememos/memos/proto/gen/store"
@ -31,6 +32,9 @@ const (
// ThumbnailCacheFolder is the folder name where thumbnail images are stored.
ThumbnailCacheFolder = ".thumbnail_cache"
// MotionCacheFolder is the folder name where extracted motion clips are stored.
MotionCacheFolder = ".motion_cache"
// thumbnailMaxSize is the maximum dimension (width or height) for thumbnails.
thumbnailMaxSize = 600
@ -122,6 +126,7 @@ func (s *FileServerService) serveAttachmentFile(c *echo.Context) error {
ctx := c.Request().Context()
uid := c.Param("uid")
wantThumbnail := c.QueryParam("thumbnail") == "true"
wantMotion := c.QueryParam("motion") == "true"
attachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{
UID: &uid,
@ -138,6 +143,10 @@ func (s *FileServerService) serveAttachmentFile(c *echo.Context) error {
return err
}
if wantMotion {
return s.serveMotionClip(c, attachment)
}
contentType := s.sanitizeContentType(attachment.Type)
// Stream video/audio to avoid loading entire file into memory.
@ -226,6 +235,7 @@ func (s *FileServerService) serveStaticFile(c *echo.Context, attachment *store.A
slog.Warn("failed to get thumbnail", "error", err)
} else {
blob = thumbnailBlob
contentType = "image/jpeg"
}
}
@ -411,7 +421,7 @@ func (s *FileServerService) getThumbnailPath(attachment *store.Attachment) (stri
if err := os.MkdirAll(cacheFolder, os.ModePerm); err != nil {
return "", errors.Wrap(err, "failed to create thumbnail cache folder")
}
filename := fmt.Sprintf("%d%s", attachment.ID, filepath.Ext(attachment.Filename))
filename := fmt.Sprintf("%s.jpeg", attachment.UID)
return filepath.Join(cacheFolder, filename), nil
}
@ -443,7 +453,13 @@ func (s *FileServerService) generateThumbnail(attachment *store.Attachment, thum
thumbnailImage := imaging.Resize(img, thumbnailWidth, thumbnailHeight, imaging.Lanczos)
if err := imaging.Save(thumbnailImage, thumbnailPath); err != nil {
output, err := os.Create(thumbnailPath)
if err != nil {
return nil, errors.Wrap(err, "failed to create thumbnail file")
}
defer output.Close()
if err := imaging.Encode(output, thumbnailImage, imaging.JPEG, imaging.JPEGQuality(90)); err != nil {
return nil, errors.Wrap(err, "failed to save thumbnail")
}
@ -463,6 +479,60 @@ func calculateThumbnailDimensions(width, height int) (int, int) {
return 0, thumbnailMaxSize // Portrait: constrain height.
}
func (s *FileServerService) serveMotionClip(c *echo.Context, attachment *store.Attachment) error {
motionMedia := attachment.Payload.GetMotionMedia()
if motionMedia == nil || motionMedia.Family != storepb.MotionMediaFamily_ANDROID_MOTION_PHOTO || !motionMedia.HasEmbeddedVideo {
return echo.NewHTTPError(http.StatusBadRequest, "attachment does not have motion clip")
}
clipBlob, err := s.getOrExtractMotionClip(c.Request().Context(), attachment)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get motion clip").Wrap(err)
}
setSecurityHeaders(c)
setMediaHeaders(c, "video/mp4", "video/mp4")
modTime := time.Unix(attachment.UpdatedTs, 0)
http.ServeContent(c.Response(), c.Request(), attachment.UID+".mp4", modTime, bytes.NewReader(clipBlob))
return nil
}
func (s *FileServerService) getOrExtractMotionClip(_ context.Context, attachment *store.Attachment) ([]byte, error) {
motionPath, err := s.getMotionPath(attachment)
if err != nil {
return nil, err
}
if blob, err := s.readCachedThumbnail(motionPath); err == nil {
return blob, nil
}
blob, err := s.getAttachmentBlob(attachment)
if err != nil {
return nil, err
}
videoBlob, _ := motionphoto.ExtractVideo(blob)
if len(videoBlob) == 0 {
return nil, errors.New("motion video not found")
}
if err := os.WriteFile(motionPath, videoBlob, 0644); err != nil {
return nil, errors.Wrap(err, "failed to cache motion clip")
}
return videoBlob, nil
}
func (s *FileServerService) getMotionPath(attachment *store.Attachment) (string, error) {
cacheFolder := filepath.Join(s.Profile.Data, MotionCacheFolder)
if err := os.MkdirAll(cacheFolder, os.ModePerm); err != nil {
return "", errors.Wrap(err, "failed to create motion cache folder")
}
return filepath.Join(cacheFolder, attachment.UID+".mp4"), nil
}
// =============================================================================
// Authentication & Authorization
// =============================================================================

View File

@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/internal/testutil"
"github.com/usememos/memos/plugin/markdown"
apiv1 "github.com/usememos/memos/proto/gen/api/v1"
"github.com/usememos/memos/server/auth"
@ -139,6 +140,51 @@ func TestServeAttachmentFile_ShareTokenRejectsCommentAttachment(t *testing.T) {
require.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestServeAttachmentFile_MotionClip(t *testing.T) {
ctx := context.Background()
svc, fs, _, cleanup := newShareAttachmentTestServices(ctx, t)
defer cleanup()
creator, err := svc.Store.CreateUser(ctx, &store.User{
Username: "motion-owner",
Role: store.RoleUser,
Email: "motion-owner@example.com",
})
require.NoError(t, err)
creatorCtx := context.WithValue(ctx, auth.UserIDContextKey, creator.ID)
attachment, err := svc.CreateAttachment(creatorCtx, &apiv1.CreateAttachmentRequest{
Attachment: &apiv1.Attachment{
Filename: "motion.jpg",
Type: "image/jpeg",
Content: testutil.BuildMotionPhotoJPEG(),
},
})
require.NoError(t, err)
_, err = svc.CreateMemo(creatorCtx, &apiv1.CreateMemoRequest{
Memo: &apiv1.Memo{
Content: "motion memo",
Visibility: apiv1.Visibility_PUBLIC,
Attachments: []*apiv1.Attachment{
{Name: attachment.Name},
},
},
})
require.NoError(t, err)
e := echo.New()
fs.RegisterRoutes(e)
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/file/%s/%s?motion=true", attachment.Name, attachment.Filename), nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "video/mp4", rec.Header().Get("Content-Type"))
require.Contains(t, rec.Body.String(), "ftyp")
}
func newShareAttachmentTestServices(ctx context.Context, t *testing.T) (*apiv1service.APIV1Service, *FileServerService, *store.Store, func()) {
t.Helper()

View File

@ -71,6 +71,11 @@ type DeleteAttachment struct {
MemoID *int32
}
const (
thumbnailCacheFolder = ".thumbnail_cache"
motionCacheFolder = ".motion_cache"
)
func (s *Store) CreateAttachment(ctx context.Context, create *Attachment) (*Attachment, error) {
if !base.UIDMatcher.MatchString(create.UID) {
return nil, errors.New("invalid uid")
@ -123,6 +128,56 @@ func (s *Store) DeleteAttachment(ctx context.Context, delete *DeleteAttachment)
return errors.New("attachment not found")
}
if err := s.DeleteAttachmentStorage(ctx, attachment); err != nil {
if attachment.StorageType == storepb.AttachmentStorageType_LOCAL {
return errors.Wrap(err, "failed to delete local file")
}
slog.Warn("Failed to delete attachment storage", slog.Any("err", err))
}
return s.driver.DeleteAttachment(ctx, delete)
}
func (s *Store) DeleteAttachments(ctx context.Context, attachments []*Attachment) error {
if len(attachments) == 0 {
return nil
}
deletes := make([]*DeleteAttachment, 0, len(attachments))
for _, attachment := range attachments {
if attachment == nil {
continue
}
deletes = append(deletes, &DeleteAttachment{ID: attachment.ID, MemoID: attachment.MemoID})
}
if len(deletes) == 0 {
return nil
}
if err := s.driver.DeleteAttachments(ctx, deletes); err != nil {
return err
}
for _, attachment := range attachments {
if attachment == nil {
continue
}
if err := s.DeleteAttachmentStorage(ctx, attachment); err != nil {
if attachment.StorageType == storepb.AttachmentStorageType_LOCAL {
return errors.Wrap(err, "failed to delete local file")
}
slog.Warn("Failed to delete attachment storage", slog.Any("err", err))
}
}
return nil
}
func (s *Store) DeleteAttachmentStorage(ctx context.Context, attachment *Attachment) error {
if attachment == nil {
return nil
}
if attachment.StorageType == storepb.AttachmentStorageType_LOCAL {
if err := func() error {
p := filepath.FromSlash(attachment.Reference)
@ -135,7 +190,7 @@ func (s *Store) DeleteAttachment(ctx context.Context, delete *DeleteAttachment)
}
return nil
}(); err != nil {
return errors.Wrap(err, "failed to delete local file")
return err
}
} else if attachment.StorageType == storepb.AttachmentStorageType_S3 {
if err := func() error {
@ -164,9 +219,21 @@ func (s *Store) DeleteAttachment(ctx context.Context, delete *DeleteAttachment)
}
return nil
}(); err != nil {
slog.Warn("Failed to delete s3 object", slog.Any("err", err))
return err
}
}
return s.driver.DeleteAttachment(ctx, delete)
s.deleteAttachmentDerivedCaches(attachment)
return nil
}
func (s *Store) deleteAttachmentDerivedCaches(attachment *Attachment) {
for _, cachePath := range []string{
filepath.Join(s.profile.Data, thumbnailCacheFolder, attachment.UID+".jpeg"),
filepath.Join(s.profile.Data, motionCacheFolder, attachment.UID+".mp4"),
} {
if err := os.Remove(cachePath); err != nil && !os.IsNotExist(err) {
slog.Warn("Failed to delete derived attachment cache", slog.String("path", cachePath), slog.Any("err", err))
}
}
}

View File

@ -239,3 +239,36 @@ func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachmen
return nil
}
func (d *DB) DeleteAttachments(ctx context.Context, deletes []*store.DeleteAttachment) error {
if len(deletes) == 0 {
return nil
}
tx, err := d.db.BeginTx(ctx, nil)
if err != nil {
return errors.Wrap(err, "failed to start attachment delete transaction")
}
defer func() {
if tx != nil {
_ = tx.Rollback()
}
}()
stmt := "DELETE FROM `attachment` WHERE `id` = ?"
for _, delete := range deletes {
result, err := tx.ExecContext(ctx, stmt, delete.ID)
if err != nil {
return err
}
if _, err := result.RowsAffected(); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
tx = nil
return nil
}

View File

@ -219,3 +219,36 @@ func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachmen
}
return nil
}
func (d *DB) DeleteAttachments(ctx context.Context, deletes []*store.DeleteAttachment) error {
if len(deletes) == 0 {
return nil
}
tx, err := d.db.BeginTx(ctx, nil)
if err != nil {
return errors.Wrap(err, "failed to start attachment delete transaction")
}
defer func() {
if tx != nil {
_ = tx.Rollback()
}
}()
stmt := `DELETE FROM attachment WHERE id = $1`
for _, delete := range deletes {
result, err := tx.ExecContext(ctx, stmt, delete.ID)
if err != nil {
return err
}
if _, err := result.RowsAffected(); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
tx = nil
return nil
}

View File

@ -219,3 +219,36 @@ func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachmen
}
return nil
}
func (d *DB) DeleteAttachments(ctx context.Context, deletes []*store.DeleteAttachment) error {
if len(deletes) == 0 {
return nil
}
tx, err := d.db.BeginTx(ctx, nil)
if err != nil {
return errors.Wrap(err, "failed to start attachment delete transaction")
}
defer func() {
if tx != nil {
_ = tx.Rollback()
}
}()
stmt := "DELETE FROM `attachment` WHERE `id` = ?"
for _, delete := range deletes {
result, err := tx.ExecContext(ctx, stmt, delete.ID)
if err != nil {
return err
}
if _, err := result.RowsAffected(); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
tx = nil
return nil
}

View File

@ -18,6 +18,7 @@ type Driver interface {
ListAttachments(ctx context.Context, find *FindAttachment) ([]*Attachment, error)
UpdateAttachment(ctx context.Context, update *UpdateAttachment) error
DeleteAttachment(ctx context.Context, delete *DeleteAttachment) error
DeleteAttachments(ctx context.Context, deletes []*DeleteAttachment) error
// Memo model related methods.
CreateMemo(ctx context.Context, create *Memo) (*Memo, error)

View File

@ -12,6 +12,7 @@ export const EditorMetadata: FC<EditorMetadataProps> = ({ memoName }) => {
attachments={state.metadata.attachments}
localFiles={state.localFiles}
onAttachmentsChange={(attachments) => dispatch(actions.setMetadata({ attachments }))}
onLocalFilesChange={(localFiles) => dispatch(actions.setLocalFiles(localFiles))}
onRemoveLocalFile={(previewUrl) => dispatch(actions.removeLocalFile(previewUrl))}
/>

View File

@ -1,4 +1,6 @@
import { create } from "@bufbuild/protobuf";
import { useRef } from "react";
import { type MotionMedia, MotionMediaFamily, MotionMediaRole, MotionMediaSchema } from "@/types/proto/api/v1/attachment_service_pb";
import type { LocalFile } from "../types/attachment";
export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void) => {
@ -11,10 +13,12 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void
return;
}
selectingFlagRef.current = true;
const localFiles: LocalFile[] = files.map((file) => ({
file,
previewUrl: URL.createObjectURL(file),
}));
const localFiles: LocalFile[] = pairAppleLivePhotoFiles(
files.map((file) => ({
file,
previewUrl: URL.createObjectURL(file),
})),
);
onFilesSelected(localFiles);
selectingFlagRef.current = false;
// Optionally clear input value to allow re-selecting the same file
@ -32,3 +36,53 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void
handleUploadClick,
};
};
const pairAppleLivePhotoFiles = (localFiles: LocalFile[]): LocalFile[] => {
const stemMap = new Map<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();
};

View File

@ -1,7 +1,7 @@
import { create } from "@bufbuild/protobuf";
import { attachmentServiceClient } from "@/connect";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
import { AttachmentSchema, MotionMediaSchema } from "@/types/proto/api/v1/attachment_service_pb";
import type { LocalFile } from "../types/attachment";
export const uploadService = {
@ -10,7 +10,8 @@ export const uploadService = {
const attachments: Attachment[] = [];
for (const { file } of localFiles) {
for (const localFile of localFiles) {
const { file, motionMedia } = localFile;
const buffer = new Uint8Array(await file.arrayBuffer());
const attachment = await attachmentServiceClient.createAttachment({
attachment: create(AttachmentSchema, {
@ -18,6 +19,7 @@ export const uploadService = {
size: BigInt(file.size),
type: file.type,
content: buffer,
motionMedia: motionMedia ? create(MotionMediaSchema, motionMedia) : undefined,
}),
});
attachments.push(attachment);

View File

@ -49,6 +49,11 @@ export const editorActions = {
payload: previewUrl,
}),
setLocalFiles: (files: LocalFile[]): EditorAction => ({
type: "SET_LOCAL_FILES",
payload: files,
}),
clearLocalFiles: (): EditorAction => ({
type: "CLEAR_LOCAL_FILES",
}),

View File

@ -74,6 +74,12 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
localFiles: state.localFiles.filter((f) => f.previewUrl !== action.payload),
};
case "SET_LOCAL_FILES":
return {
...state,
localFiles: action.payload,
};
case "CLEAR_LOCAL_FILES":
return {
...state,

View File

@ -55,6 +55,7 @@ export type EditorAction =
| { type: "REMOVE_RELATION"; payload: string }
| { type: "ADD_LOCAL_FILE"; payload: LocalFile }
| { type: "REMOVE_LOCAL_FILE"; payload: string }
| { type: "SET_LOCAL_FILES"; payload: LocalFile[] }
| { type: "CLEAR_LOCAL_FILES" }
| { type: "TOGGLE_FOCUS_MODE" }
| { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } }

View File

@ -1,11 +1,13 @@
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import type { Attachment, MotionMedia } from "@/types/proto/api/v1/attachment_service_pb";
import { MotionMediaFamily, MotionMediaRole } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import { buildAttachmentVisualItems } from "@/utils/media-item";
export type FileCategory = "image" | "video" | "audio" | "document";
export type FileCategory = "image" | "video" | "motion" | "audio" | "document";
// Unified view model for rendering attachments and local files
export interface AttachmentItem {
readonly id: string;
readonly memberIds: string[];
readonly filename: string;
readonly category: FileCategory;
readonly mimeType: string;
@ -15,25 +17,27 @@ export interface AttachmentItem {
readonly isLocal: boolean;
}
// For MemoEditor: local files being uploaded
export interface LocalFile {
readonly file: File;
readonly previewUrl: string;
readonly motionMedia?: MotionMedia;
}
function categorizeFile(mimeType: string): FileCategory {
function categorizeFile(mimeType: string, motionMedia?: MotionMedia): FileCategory {
if (motionMedia) return "motion";
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("audio/")) return "audio";
return "document";
}
export function attachmentToItem(attachment: Attachment): AttachmentItem {
function attachmentGroupToItem(attachment: Attachment): AttachmentItem {
const attachmentType = getAttachmentType(attachment);
const sourceUrl = getAttachmentUrl(attachment);
return {
id: attachment.name,
memberIds: [attachment.name],
filename: attachment.filename,
category: categorizeFile(attachment.type),
mimeType: attachment.type,
@ -44,21 +48,96 @@ export function attachmentToItem(attachment: Attachment): AttachmentItem {
};
}
export function fileToItem(file: File, blobUrl: string): AttachmentItem {
function visualItemToAttachmentItem(item: ReturnType<typeof buildAttachmentVisualItems>[number]): AttachmentItem {
return {
id: blobUrl,
filename: file.name,
category: categorizeFile(file.type),
mimeType: file.type,
thumbnailUrl: blobUrl,
sourceUrl: blobUrl,
size: file.size,
id: item.id,
memberIds: item.attachmentNames,
filename: item.filename,
category: item.kind === "motion" ? "motion" : item.kind,
mimeType: item.mimeType,
thumbnailUrl: item.posterUrl,
sourceUrl: item.sourceUrl,
size: item.attachments.reduce((total, attachment) => total + Number(attachment.size), 0),
isLocal: false,
};
}
function fileToItem(file: LocalFile): AttachmentItem {
return {
id: file.motionMedia?.groupId || file.previewUrl,
memberIds: [file.previewUrl],
filename: file.file.name,
category: categorizeFile(file.file.type, file.motionMedia),
mimeType: file.file.type,
thumbnailUrl: file.previewUrl,
sourceUrl: file.previewUrl,
size: file.file.size,
isLocal: true,
};
}
function toLocalMotionItems(localFiles: LocalFile[]): AttachmentItem[] {
const grouped = new Map<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[] {
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[] {
@ -71,7 +150,7 @@ export function separateMediaAndDocs(items: AttachmentItem[]): { media: Attachme
const docs: AttachmentItem[] = [];
for (const item of items) {
if (item.category === "image" || item.category === "video") {
if (item.category === "image" || item.category === "video" || item.category === "motion") {
media.push(item);
} else {
docs.push(item);

View File

@ -11,6 +11,7 @@ interface AttachmentListEditorProps {
attachments: Attachment[];
localFiles?: LocalFile[];
onAttachmentsChange?: (attachments: Attachment[]) => void;
onLocalFilesChange?: (localFiles: LocalFile[]) => void;
onRemoveLocalFile?: (previewUrl: string) => void;
}
@ -23,19 +24,24 @@ const AttachmentItemCard: FC<{
canMoveDown?: boolean;
}> = ({ item, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => {
const { category, filename, thumbnailUrl, mimeType, size } = item;
const fileTypeLabel = getFileTypeLabel(mimeType);
const fileTypeLabel = item.category === "motion" ? "Live Photo" : getFileTypeLabel(mimeType);
const fileSizeLabel = size ? formatFileSize(size) : undefined;
const displayName = category === "audio" && /^voice-(recording|note)-/i.test(filename) ? "Voice note" : filename;
return (
<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 h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-muted/40">
{category === "image" && thumbnailUrl ? (
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-muted/40">
{(category === "image" || category === "motion") && thumbnailUrl ? (
<img src={thumbnailUrl} alt="" className="h-full w-full object-cover" />
) : (
<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 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<{
);
};
const AttachmentListEditor: FC<AttachmentListEditorProps> = ({ attachments, localFiles = [], onAttachmentsChange, onRemoveLocalFile }) => {
const AttachmentListEditor: FC<AttachmentListEditorProps> = ({
attachments,
localFiles = [],
onAttachmentsChange,
onLocalFilesChange,
onRemoveLocalFile,
}) => {
if (attachments.length === 0 && localFiles.length === 0) {
return null;
}
const items = toAttachmentItems(attachments, localFiles);
const attachmentItems = items.filter((item) => !item.isLocal);
const localItems = items.filter((item) => item.isLocal);
const handleMoveUp = (index: number) => {
if (index === 0 || !onAttachmentsChange) return;
const handleMoveAttachments = (itemId: string, direction: -1 | 1) => {
if (!onAttachmentsChange) return;
const newAttachments = [...attachments];
[newAttachments[index - 1], newAttachments[index]] = [newAttachments[index], newAttachments[index - 1]];
onAttachmentsChange(newAttachments);
};
const handleMoveDown = (index: number) => {
if (index === attachments.length - 1 || !onAttachmentsChange) return;
const newAttachments = [...attachments];
[newAttachments[index], newAttachments[index + 1]] = [newAttachments[index + 1], newAttachments[index]];
onAttachmentsChange(newAttachments);
};
const handleRemoveAttachment = (name: string) => {
if (onAttachmentsChange) {
onAttachmentsChange(attachments.filter((attachment) => attachment.name !== name));
const itemIndex = attachmentItems.findIndex((item) => item.id === itemId);
const targetIndex = itemIndex + direction;
if (itemIndex < 0 || targetIndex < 0 || targetIndex >= attachmentItems.length) {
return;
}
const reorderedItems = [...attachmentItems];
[reorderedItems[itemIndex], reorderedItems[targetIndex]] = [reorderedItems[targetIndex], reorderedItems[itemIndex]];
const attachmentMap = new Map(attachments.map((attachment) => [attachment.name, attachment]));
onAttachmentsChange(
reorderedItems.flatMap((item) => item.memberIds.map((memberId) => attachmentMap.get(memberId)).filter(Boolean) as Attachment[]),
);
};
const handleRemoveItem = (item: (typeof items)[0]) => {
const handleMoveLocalFiles = (itemId: string, direction: -1 | 1) => {
if (!onLocalFilesChange) return;
const itemIndex = localItems.findIndex((item) => item.id === itemId);
const targetIndex = itemIndex + direction;
if (itemIndex < 0 || targetIndex < 0 || targetIndex >= localItems.length) {
return;
}
const reorderedItems = [...localItems];
[reorderedItems[itemIndex], reorderedItems[targetIndex]] = [reorderedItems[targetIndex], reorderedItems[itemIndex]];
const localFileMap = new Map(localFiles.map((localFile) => [localFile.previewUrl, localFile]));
onLocalFilesChange(
reorderedItems.flatMap((item) => item.memberIds.map((memberId) => localFileMap.get(memberId)).filter(Boolean) as LocalFile[]),
);
};
const handleRemoveItem = (item: AttachmentItem) => {
if (item.isLocal) {
onRemoveLocalFile?.(item.id);
} else {
handleRemoveAttachment(item.id);
const nextLocalFiles = localFiles.filter((file) => !item.memberIds.includes(file.previewUrl));
onLocalFilesChange?.(nextLocalFiles);
if (!onLocalFilesChange) {
item.memberIds.forEach((previewUrl) => onRemoveLocalFile?.(previewUrl));
}
return;
}
if (onAttachmentsChange) {
onAttachmentsChange(attachments.filter((attachment) => !item.memberIds.includes(attachment.name)));
}
};
return (
<MetadataSection icon={PaperclipIcon} title="Attachments" count={items.length} contentClassName="flex flex-col gap-0.5 p-1 sm:p-1.5">
{items.map((item) => {
const isLocalFile = item.isLocal;
const attachmentIndex = isLocalFile ? -1 : attachments.findIndex((a) => a.name === item.id);
const itemList = item.isLocal ? localItems : attachmentItems;
const itemIndex = itemList.findIndex((entry) => entry.id === item.id);
return (
<AttachmentItemCard
key={item.id}
item={item}
onRemove={() => handleRemoveItem(item)}
onMoveUp={!isLocalFile ? () => handleMoveUp(attachmentIndex) : undefined}
onMoveDown={!isLocalFile ? () => handleMoveDown(attachmentIndex) : undefined}
canMoveUp={!isLocalFile && attachmentIndex > 0}
canMoveDown={!isLocalFile && attachmentIndex < attachments.length - 1}
onMoveUp={item.isLocal ? () => handleMoveLocalFiles(item.id, -1) : () => handleMoveAttachments(item.id, -1)}
onMoveDown={item.isLocal ? () => handleMoveLocalFiles(item.id, 1) : () => handleMoveAttachments(item.id, 1)}
canMoveUp={itemIndex > 0}
canMoveDown={itemIndex >= 0 && itemIndex < itemList.length - 1}
/>
);
})}

View File

@ -1,16 +1,17 @@
import { DownloadIcon, FileIcon, Maximize2Icon, PaperclipIcon, PlayIcon } from "lucide-react";
import { DownloadIcon, FileIcon, PaperclipIcon, PlayIcon } from "lucide-react";
import { useMemo } from "react";
import MetadataSection from "@/components/MemoMetadata/MetadataSection";
import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentUrl } from "@/utils/attachment";
import AttachmentCard from "./AttachmentCard";
import type { PreviewMediaItem } from "@/utils/media-item";
import { buildAttachmentVisualItems } from "@/utils/media-item";
import AudioAttachmentItem from "./AudioAttachmentItem";
import { getAttachmentMetadata, isImageAttachment, isVideoAttachment, separateAttachments } from "./attachmentHelpers";
import { getAttachmentMetadata, isAudioAttachment, separateAttachments } from "./attachmentHelpers";
interface AttachmentListViewProps {
attachments: Attachment[];
onImagePreview?: (urls: string[], index: number) => void;
onImagePreview?: (items: PreviewMediaItem[], index: number) => void;
}
const AttachmentMeta = ({ attachment }: { attachment: Attachment }) => {
@ -48,21 +49,26 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => {
);
};
interface VisualItemProps {
attachment: Attachment;
const MotionBadge = () => (
<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">
LIVE
</span>
);
const MotionItem = ({
item,
featured = false,
onPreview,
}: {
item: ReturnType<typeof buildAttachmentVisualItems>[number];
featured?: boolean;
}
const ImageItem = ({ attachment, onImageClick, featured = false }: VisualItemProps & { onImageClick?: (url: string) => void }) => {
const handleClick = () => {
onImageClick?.(getAttachmentUrl(attachment));
};
onPreview?: () => void;
}) => {
return (
<button
type="button"
className={cn("group block w-full text-left", featured ? "max-w-[18rem] sm:max-w-[20rem]" : "")}
onClick={handleClick}
onClick={onPreview}
>
<div
className={cn(
@ -70,80 +76,57 @@ const ImageItem = ({ attachment, onImageClick, featured = false }: VisualItemPro
featured ? "aspect-[4/3]" : "aspect-square",
)}
>
<AttachmentCard
attachment={attachment}
className="h-full w-full rounded-none transition-transform duration-300 group-hover:scale-[1.02]"
/>
<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" />
</span>
{item.kind === "video" ? (
<video
src={item.sourceUrl}
className="h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"
preload="metadata"
/>
) : (
<img
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>
</button>
);
};
const ImageGallery = ({ attachments, onImageClick }: { attachments: Attachment[]; onImageClick?: (url: string) => void }) => {
if (attachments.length === 1) {
const VisualGallery = ({
items,
onPreview,
}: {
items: ReturnType<typeof buildAttachmentVisualItems>;
onPreview?: (itemId: string) => void;
}) => {
if (items.length === 1) {
return (
<div className="flex">
<ImageItem attachment={attachments[0]} featured onImageClick={onImageClick} />
<MotionItem item={items[0]} featured onPreview={() => onPreview?.(items[0].id)} />
</div>
);
}
return (
<div className="grid max-w-[22rem] grid-cols-2 gap-1.5 sm:max-w-[24rem]">
{attachments.map((attachment) => (
<ImageItem key={attachment.name} attachment={attachment} onImageClick={onImageClick} />
{items.map((item) => (
<MotionItem key={item.id} item={item} onPreview={() => onPreview?.(item.id)} />
))}
</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[] }) => (
<div className="flex flex-col gap-2">
{attachments.map((attachment) => (
@ -172,9 +155,9 @@ const Divider = () => <div className="border-t border-border/70 opacity-80" />;
const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewProps) => {
const { visual, audio, docs } = useMemo(() => separateAttachments(attachments), [attachments]);
const imageAttachments = useMemo(() => visual.filter(isImageAttachment), [visual]);
const imageUrls = useMemo(() => imageAttachments.map(getAttachmentUrl), [imageAttachments]);
const hasVisual = visual.length > 0;
const visualItems = useMemo(() => buildAttachmentVisualItems(visual), [visual]);
const previewItems = useMemo(() => visualItems.map((item) => item.previewItem), [visualItems]);
const hasVisual = visualItems.length > 0;
const hasAudio = audio.length > 0;
const hasDocs = docs.length > 0;
const sectionCount = [hasVisual, hasAudio, hasDocs].filter(Boolean).length;
@ -183,16 +166,21 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP
return null;
}
const handleImageClick = (imgUrl: string) => {
const index = imageUrls.findIndex((url) => url === imgUrl);
onImagePreview?.(imageUrls, index >= 0 ? index : 0);
const handlePreview = (itemId: string) => {
const index = previewItems.findIndex((item) => item.id === itemId);
onImagePreview?.(previewItems, index >= 0 ? index : 0);
};
return (
<MetadataSection icon={PaperclipIcon} title="Attachments" count={attachments.length} contentClassName="flex flex-col gap-2 p-2">
{hasVisual && <VisualSection attachments={visual} onImageClick={handleImageClick} />}
<MetadataSection
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 />}
{hasAudio && <AudioList attachments={audio} />}
{hasAudio && <AudioList attachments={audio.filter(isAudioAttachment)} />}
{hasAudio && hasDocs && <Divider />}
{hasDocs && <DocsList attachments={docs} />}
</MetadataSection>

View File

@ -5,7 +5,8 @@ import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import { getAttachmentType, isMotionAttachment } from "@/utils/attachment";
import { buildAttachmentVisualItems, countLogicalAttachmentItems } from "@/utils/media-item";
import MemoContent from "../MemoContent";
import { MemoViewContext, type MemoViewContextValue } from "../MemoView/MemoViewContext";
@ -36,28 +37,35 @@ const STUB_CONTEXT: MemoViewContextValue = {
};
const AttachmentThumbnails = ({ attachments }: { attachments: Attachment[] }) => {
const images: Attachment[] = [];
const others: Attachment[] = [];
for (const a of attachments) {
if (getAttachmentType(a) === "image/*") images.push(a);
else others.push(a);
}
const visualAttachments = attachments.filter(
(attachment) =>
getAttachmentType(attachment) === "image/*" || getAttachmentType(attachment) === "video/*" || isMotionAttachment(attachment),
);
const items = buildAttachmentVisualItems(visualAttachments);
const images = items.filter((item) => item.kind === "image" || item.kind === "motion");
const others = items.filter((item) => item.kind === "video");
return (
<div className="flex items-center gap-1.5 flex-wrap">
{images.map((a) => (
<img
key={a.name}
src={getAttachmentUrl(a)}
alt={a.filename}
className="w-10 h-10 rounded border border-border object-cover bg-muted/40"
loading="lazy"
/>
{images.map((item) => (
<div key={item.id} className="relative">
<img
src={item.posterUrl}
alt={item.filename}
className="w-10 h-10 rounded border border-border object-cover bg-muted/40"
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) => (
<div key={a.name} className="flex items-center gap-1 text-[10px] text-muted-foreground">
{others.map((item) => (
<div key={item.id} className="flex items-center gap-1 text-[10px] text-muted-foreground">
<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>
@ -138,7 +146,7 @@ const MemoPreview = ({
(truncate ? (
<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" />
<span className="text-xs">{attachments.length}</span>
<span className="text-xs">{countLogicalAttachmentItems(attachments)}</span>
</div>
) : (
<AttachmentThumbnails attachments={attachments} />

View File

@ -97,7 +97,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
<PreviewImageDialog
open={previewState.open}
onOpenChange={setPreviewOpen}
imgUrls={previewState.urls}
items={previewState.items}
initialIndex={previewState.index}
/>
</article>

View File

@ -4,6 +4,7 @@ import { useLocation } from "react-router-dom";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";
import type { PreviewMediaItem } from "@/utils/media-item";
import { RELATIVE_TIME_THRESHOLD_MS } from "./constants";
export interface MemoViewContextValue {
@ -17,7 +18,7 @@ export interface MemoViewContextValue {
blurred: boolean;
openEditor: () => void;
toggleBlurVisibility: () => void;
openPreview: (urls: string | string[], index?: number) => void;
openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void;
}
export const MemoViewContext = createContext<MemoViewContextValue | null>(null);

View File

@ -1,22 +1,24 @@
import { useCallback, useState } from "react";
import type { PreviewMediaItem } from "@/utils/media-item";
export interface ImagePreviewState {
open: boolean;
urls: string[];
items: PreviewMediaItem[];
index: number;
}
export interface UseImagePreviewReturn {
previewState: ImagePreviewState;
openPreview: (urls: string | string[], index?: number) => void;
openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void;
setPreviewOpen: (open: boolean) => void;
}
export const useImagePreview = (): UseImagePreviewReturn => {
const [previewState, setPreviewState] = useState<ImagePreviewState>({ open: false, urls: [], index: 0 });
const [previewState, setPreviewState] = useState<ImagePreviewState>({ open: false, items: [], index: 0 });
const openPreview = useCallback((urls: string | string[], index = 0) => {
setPreviewState({ open: true, urls: Array.isArray(urls) ? urls : [urls], index });
const openPreview = useCallback((items: string | string[] | PreviewMediaItem[], index = 0) => {
const normalizedItems = normalizePreviewItems(items);
setPreviewState({ open: true, items: normalizedItems, index });
}, []);
const setPreviewOpen = useCallback((open: boolean) => {
@ -25,3 +27,31 @@ export const useImagePreview = (): UseImagePreviewReturn => {
return { previewState, openPreview, setPreviewOpen };
};
function normalizePreviewItems(items: string | string[] | PreviewMediaItem[]): PreviewMediaItem[] {
if (typeof items === "string") {
return [
{
id: items,
kind: "image",
sourceUrl: items,
posterUrl: items,
filename: "Image",
isMotion: false,
},
];
}
if (Array.isArray(items) && (items.length === 0 || typeof items[0] === "string")) {
return (items as string[]).map((url) => ({
id: url,
kind: "image",
sourceUrl: url,
posterUrl: url,
filename: "Image",
isMotion: false,
}));
}
return items as PreviewMediaItem[];
}

View File

@ -1,10 +1,11 @@
import { useCallback } from "react";
import { useInstance } from "@/contexts/InstanceContext";
import type { PreviewMediaItem } from "@/utils/media-item";
interface UseMemoHandlersOptions {
readonly: boolean;
openEditor: () => void;
openPreview: (urls: string | string[], index?: number) => void;
openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void;
}
export const useMemoHandlers = (options: UseMemoHandlersOptions) => {

View File

@ -2,16 +2,21 @@ import { X } from "lucide-react";
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import type { PreviewMediaItem } from "@/utils/media-item";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
imgUrls: string[];
imgUrls?: string[];
items?: PreviewMediaItem[];
initialIndex?: number;
}
function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: Props) {
function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIndex = 0 }: Props) {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const previewItems =
items ??
imgUrls.map((url) => ({ id: url, kind: "image" as const, sourceUrl: url, posterUrl: url, filename: "Image", isMotion: false }));
// Update current index when initialIndex prop changes
useEffect(() => {
@ -28,7 +33,7 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P
onOpenChange(false);
break;
case "ArrowRight":
setCurrentIndex((prev) => Math.min(prev + 1, imgUrls.length - 1));
setCurrentIndex((prev) => Math.min(prev + 1, previewItems.length - 1));
break;
case "ArrowLeft":
setCurrentIndex((prev) => Math.max(prev - 1, 0));
@ -53,10 +58,11 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P
};
// Return early if no images provided
if (!imgUrls.length) return null;
if (!previewItems.length) return null;
// Ensure currentIndex is within bounds
const safeIndex = Math.max(0, Math.min(currentIndex, imgUrls.length - 1));
const safeIndex = Math.max(0, Math.min(currentIndex, previewItems.length - 1));
const currentItem = previewItems[safeIndex];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -79,14 +85,30 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P
{/* Image container */}
<div className="w-full h-full flex items-center justify-center p-4 sm:p-8 overflow-auto" onClick={handleBackdropClick}>
<img
src={imgUrls[safeIndex]}
alt={`Preview image ${safeIndex + 1} of ${imgUrls.length}`}
className="max-w-full max-h-full object-contain select-none"
draggable={false}
loading="eager"
decoding="async"
/>
{currentItem.kind === "video" ? (
<video
key={currentItem.id}
src={currentItem.sourceUrl}
poster={currentItem.posterUrl}
className="max-w-full max-h-full object-contain"
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>
{/* Screen reader description */}

View File

@ -2,8 +2,8 @@
// @generated from file api/v1/attachment_service.proto (package memos.api.v1, syntax proto3)
/* eslint-disable */
import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
import { file_google_api_annotations } from "../../google/api/annotations_pb";
import { file_google_api_client } from "../../google/api/client_pb";
import { file_google_api_field_behavior } from "../../google/api/field_behavior_pb";
@ -16,7 +16,44 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file api/v1/attachment_service.proto.
*/
export const file_api_v1_attachment_service: GenFile = /*@__PURE__*/
fileDesc("Ch9hcGkvdjEvYXR0YWNobWVudF9zZXJ2aWNlLnByb3RvEgxtZW1vcy5hcGkudjEitgIKCkF0dGFjaG1lbnQSEQoEbmFtZRgBIAEoCUID4EEIEjQKC2NyZWF0ZV90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEID4EEDEhUKCGZpbGVuYW1lGAMgASgJQgPgQQISFAoHY29udGVudBgEIAEoDEID4EEEEhoKDWV4dGVybmFsX2xpbmsYBSABKAlCA+BBARIRCgR0eXBlGAYgASgJQgPgQQISEQoEc2l6ZRgHIAEoA0ID4EEDEhYKBG1lbW8YCCABKAlCA+BBAUgAiAEBOk/qQUwKF21lbW9zLmFwaS52MS9BdHRhY2htZW50EhhhdHRhY2htZW50cy97YXR0YWNobWVudH0qC2F0dGFjaG1lbnRzMgphdHRhY2htZW50QgcKBV9tZW1vImgKF0NyZWF0ZUF0dGFjaG1lbnRSZXF1ZXN0EjEKCmF0dGFjaG1lbnQYASABKAsyGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudEID4EECEhoKDWF0dGFjaG1lbnRfaWQYAiABKAlCA+BBASJ1ChZMaXN0QXR0YWNobWVudHNSZXF1ZXN0EhYKCXBhZ2Vfc2l6ZRgBIAEoBUID4EEBEhcKCnBhZ2VfdG9rZW4YAiABKAlCA+BBARITCgZmaWx0ZXIYAyABKAlCA+BBARIVCghvcmRlcl9ieRgEIAEoCUID4EEBInUKF0xpc3RBdHRhY2htZW50c1Jlc3BvbnNlEi0KC2F0dGFjaG1lbnRzGAEgAygLMhgubWVtb3MuYXBpLnYxLkF0dGFjaG1lbnQSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJEhIKCnRvdGFsX3NpemUYAyABKAUiRQoUR2V0QXR0YWNobWVudFJlcXVlc3QSLQoEbmFtZRgBIAEoCUIf4EEC+kEZChdtZW1vcy5hcGkudjEvQXR0YWNobWVudCKCAQoXVXBkYXRlQXR0YWNobWVudFJlcXVlc3QSMQoKYXR0YWNobWVudBgBIAEoCzIYLm1lbW9zLmFwaS52MS5BdHRhY2htZW50QgPgQQISNAoLdXBkYXRlX21hc2sYAiABKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrQgPgQQIiSAoXRGVsZXRlQXR0YWNobWVudFJlcXVlc3QSLQoEbmFtZRgBIAEoCUIf4EEC+kEZChdtZW1vcy5hcGkudjEvQXR0YWNobWVudDLEBQoRQXR0YWNobWVudFNlcnZpY2USiQEKEENyZWF0ZUF0dGFjaG1lbnQSJS5tZW1vcy5hcGkudjEuQ3JlYXRlQXR0YWNobWVudFJlcXVlc3QaGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudCI02kEKYXR0YWNobWVudILT5JMCIToKYXR0YWNobWVudCITL2FwaS92MS9hdHRhY2htZW50cxJ7Cg9MaXN0QXR0YWNobWVudHMSJC5tZW1vcy5hcGkudjEuTGlzdEF0dGFjaG1lbnRzUmVxdWVzdBolLm1lbW9zLmFwaS52MS5MaXN0QXR0YWNobWVudHNSZXNwb25zZSIbgtPkkwIVEhMvYXBpL3YxL2F0dGFjaG1lbnRzEnoKDUdldEF0dGFjaG1lbnQSIi5tZW1vcy5hcGkudjEuR2V0QXR0YWNobWVudFJlcXVlc3QaGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudCIr2kEEbmFtZYLT5JMCHhIcL2FwaS92MS97bmFtZT1hdHRhY2htZW50cy8qfRKpAQoQVXBkYXRlQXR0YWNobWVudBIlLm1lbW9zLmFwaS52MS5VcGRhdGVBdHRhY2htZW50UmVxdWVzdBoYLm1lbW9zLmFwaS52MS5BdHRhY2htZW50IlTaQRZhdHRhY2htZW50LHVwZGF0ZV9tYXNrgtPkkwI1OgphdHRhY2htZW50MicvYXBpL3YxL3thdHRhY2htZW50Lm5hbWU9YXR0YWNobWVudHMvKn0SfgoQRGVsZXRlQXR0YWNobWVudBIlLm1lbW9zLmFwaS52MS5EZWxldGVBdHRhY2htZW50UmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIr2kEEbmFtZYLT5JMCHiocL2FwaS92MS97bmFtZT1hdHRhY2htZW50cy8qfUKuAQoQY29tLm1lbW9zLmFwaS52MUIWQXR0YWNobWVudFNlcnZpY2VQcm90b1ABWjBnaXRodWIuY29tL3VzZW1lbW9zL21lbW9zL3Byb3RvL2dlbi9hcGkvdjE7YXBpdjGiAgNNQViqAgxNZW1vcy5BcGkuVjHKAgxNZW1vc1xBcGlcVjHiAhhNZW1vc1xBcGlcVjFcR1BCTWV0YWRhdGHqAg5NZW1vczo6QXBpOjpWMWIGcHJvdG8z", [file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask, file_google_protobuf_timestamp]);
fileDesc("Ch9hcGkvdjEvYXR0YWNobWVudF9zZXJ2aWNlLnByb3RvEgxtZW1vcy5hcGkudjEivAEKC01vdGlvbk1lZGlhEi8KBmZhbWlseRgBIAEoDjIfLm1lbW9zLmFwaS52MS5Nb3Rpb25NZWRpYUZhbWlseRIrCgRyb2xlGAIgASgOMh0ubWVtb3MuYXBpLnYxLk1vdGlvbk1lZGlhUm9sZRIQCghncm91cF9pZBgDIAEoCRIhChlwcmVzZW50YXRpb25fdGltZXN0YW1wX3VzGAQgASgDEhoKEmhhc19lbWJlZGRlZF92aWRlbxgFIAEoCCLsAgoKQXR0YWNobWVudBIRCgRuYW1lGAEgASgJQgPgQQgSNAoLY3JlYXRlX3RpbWUYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQMSFQoIZmlsZW5hbWUYAyABKAlCA+BBAhIUCgdjb250ZW50GAQgASgMQgPgQQQSGgoNZXh0ZXJuYWxfbGluaxgFIAEoCUID4EEBEhEKBHR5cGUYBiABKAlCA+BBAhIRCgRzaXplGAcgASgDQgPgQQMSFgoEbWVtbxgIIAEoCUID4EEBSACIAQESNAoMbW90aW9uX21lZGlhGAkgASgLMhkubWVtb3MuYXBpLnYxLk1vdGlvbk1lZGlhQgPgQQE6T+pBTAoXbWVtb3MuYXBpLnYxL0F0dGFjaG1lbnQSGGF0dGFjaG1lbnRzL3thdHRhY2htZW50fSoLYXR0YWNobWVudHMyCmF0dGFjaG1lbnRCBwoFX21lbW8iaAoXQ3JlYXRlQXR0YWNobWVudFJlcXVlc3QSMQoKYXR0YWNobWVudBgBIAEoCzIYLm1lbW9zLmFwaS52MS5BdHRhY2htZW50QgPgQQISGgoNYXR0YWNobWVudF9pZBgCIAEoCUID4EEBInUKFkxpc3RBdHRhY2htZW50c1JlcXVlc3QSFgoJcGFnZV9zaXplGAEgASgFQgPgQQESFwoKcGFnZV90b2tlbhgCIAEoCUID4EEBEhMKBmZpbHRlchgDIAEoCUID4EEBEhUKCG9yZGVyX2J5GAQgASgJQgPgQQEidQoXTGlzdEF0dGFjaG1lbnRzUmVzcG9uc2USLQoLYXR0YWNobWVudHMYASADKAsyGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudBIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkSEgoKdG90YWxfc2l6ZRgDIAEoBSJFChRHZXRBdHRhY2htZW50UmVxdWVzdBItCgRuYW1lGAEgASgJQh/gQQL6QRkKF21lbW9zLmFwaS52MS9BdHRhY2htZW50IoIBChdVcGRhdGVBdHRhY2htZW50UmVxdWVzdBIxCgphdHRhY2htZW50GAEgASgLMhgubWVtb3MuYXBpLnYxLkF0dGFjaG1lbnRCA+BBAhI0Cgt1cGRhdGVfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tCA+BBAiJIChdEZWxldGVBdHRhY2htZW50UmVxdWVzdBItCgRuYW1lGAEgASgJQh/gQQL6QRkKF21lbW9zLmFwaS52MS9BdHRhY2htZW50IjMKHUJhdGNoRGVsZXRlQXR0YWNobWVudHNSZXF1ZXN0EhIKBW5hbWVzGAEgAygJQgPgQQIqaAoRTW90aW9uTWVkaWFGYW1pbHkSIwofTU9USU9OX01FRElBX0ZBTUlMWV9VTlNQRUNJRklFRBAAEhQKEEFQUExFX0xJVkVfUEhPVE8QARIYChRBTkRST0lEX01PVElPTl9QSE9UTxACKlkKD01vdGlvbk1lZGlhUm9sZRIhCh1NT1RJT05fTUVESUFfUk9MRV9VTlNQRUNJRklFRBAAEgkKBVNUSUxMEAESCQoFVklERU8QAhINCglDT05UQUlORVIQAzLQBgoRQXR0YWNobWVudFNlcnZpY2USiQEKEENyZWF0ZUF0dGFjaG1lbnQSJS5tZW1vcy5hcGkudjEuQ3JlYXRlQXR0YWNobWVudFJlcXVlc3QaGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudCI02kEKYXR0YWNobWVudILT5JMCIToKYXR0YWNobWVudCITL2FwaS92MS9hdHRhY2htZW50cxJ7Cg9MaXN0QXR0YWNobWVudHMSJC5tZW1vcy5hcGkudjEuTGlzdEF0dGFjaG1lbnRzUmVxdWVzdBolLm1lbW9zLmFwaS52MS5MaXN0QXR0YWNobWVudHNSZXNwb25zZSIbgtPkkwIVEhMvYXBpL3YxL2F0dGFjaG1lbnRzEnoKDUdldEF0dGFjaG1lbnQSIi5tZW1vcy5hcGkudjEuR2V0QXR0YWNobWVudFJlcXVlc3QaGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudCIr2kEEbmFtZYLT5JMCHhIcL2FwaS92MS97bmFtZT1hdHRhY2htZW50cy8qfRKpAQoQVXBkYXRlQXR0YWNobWVudBIlLm1lbW9zLmFwaS52MS5VcGRhdGVBdHRhY2htZW50UmVxdWVzdBoYLm1lbW9zLmFwaS52MS5BdHRhY2htZW50IlTaQRZhdHRhY2htZW50LHVwZGF0ZV9tYXNrgtPkkwI1OgphdHRhY2htZW50MicvYXBpL3YxL3thdHRhY2htZW50Lm5hbWU9YXR0YWNobWVudHMvKn0SfgoQRGVsZXRlQXR0YWNobWVudBIlLm1lbW9zLmFwaS52MS5EZWxldGVBdHRhY2htZW50UmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIr2kEEbmFtZYLT5JMCHiocL2FwaS92MS97bmFtZT1hdHRhY2htZW50cy8qfRKJAQoWQmF0Y2hEZWxldGVBdHRhY2htZW50cxIrLm1lbW9zLmFwaS52MS5CYXRjaERlbGV0ZUF0dGFjaG1lbnRzUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIqgtPkkwIkOgEqIh8vYXBpL3YxL2F0dGFjaG1lbnRzOmJhdGNoRGVsZXRlQq4BChBjb20ubWVtb3MuYXBpLnYxQhZBdHRhY2htZW50U2VydmljZVByb3RvUAFaMGdpdGh1Yi5jb20vdXNlbWVtb3MvbWVtb3MvcHJvdG8vZ2VuL2FwaS92MTthcGl2MaICA01BWKoCDE1lbW9zLkFwaS5WMcoCDE1lbW9zXEFwaVxWMeICGE1lbW9zXEFwaVxWMVxHUEJNZXRhZGF0YeoCDk1lbW9zOjpBcGk6OlYxYgZwcm90bzM", [file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask, file_google_protobuf_timestamp]);
/**
* @generated from message memos.api.v1.MotionMedia
*/
export type MotionMedia = Message<"memos.api.v1.MotionMedia"> & {
/**
* @generated from field: memos.api.v1.MotionMediaFamily family = 1;
*/
family: MotionMediaFamily;
/**
* @generated from field: memos.api.v1.MotionMediaRole role = 2;
*/
role: MotionMediaRole;
/**
* @generated from field: string group_id = 3;
*/
groupId: string;
/**
* @generated from field: int64 presentation_timestamp_us = 4;
*/
presentationTimestampUs: bigint;
/**
* @generated from field: bool has_embedded_video = 5;
*/
hasEmbeddedVideo: boolean;
};
/**
* Describes the message memos.api.v1.MotionMedia.
* Use `create(MotionMediaSchema)` to create a new message.
*/
export const MotionMediaSchema: GenMessage<MotionMedia> = /*@__PURE__*/
messageDesc(file_api_v1_attachment_service, 0);
/**
* @generated from message memos.api.v1.Attachment
@ -79,6 +116,13 @@ export type Attachment = Message<"memos.api.v1.Attachment"> & {
* @generated from field: optional string memo = 8;
*/
memo?: string;
/**
* Optional. Motion media metadata.
*
* @generated from field: memos.api.v1.MotionMedia motion_media = 9;
*/
motionMedia?: MotionMedia;
};
/**
@ -86,7 +130,7 @@ export type Attachment = Message<"memos.api.v1.Attachment"> & {
* Use `create(AttachmentSchema)` to create a new message.
*/
export const AttachmentSchema: GenMessage<Attachment> = /*@__PURE__*/
messageDesc(file_api_v1_attachment_service, 0);
messageDesc(file_api_v1_attachment_service, 1);
/**
* @generated from message memos.api.v1.CreateAttachmentRequest
@ -113,7 +157,7 @@ export type CreateAttachmentRequest = Message<"memos.api.v1.CreateAttachmentRequ
* Use `create(CreateAttachmentRequestSchema)` to create a new message.
*/
export const CreateAttachmentRequestSchema: GenMessage<CreateAttachmentRequest> = /*@__PURE__*/
messageDesc(file_api_v1_attachment_service, 1);
messageDesc(file_api_v1_attachment_service, 2);
/**
* @generated from message memos.api.v1.ListAttachmentsRequest
@ -161,7 +205,7 @@ export type ListAttachmentsRequest = Message<"memos.api.v1.ListAttachmentsReques
* Use `create(ListAttachmentsRequestSchema)` to create a new message.
*/
export const ListAttachmentsRequestSchema: GenMessage<ListAttachmentsRequest> = /*@__PURE__*/
messageDesc(file_api_v1_attachment_service, 2);
messageDesc(file_api_v1_attachment_service, 3);
/**
* @generated from message memos.api.v1.ListAttachmentsResponse
@ -195,7 +239,7 @@ export type ListAttachmentsResponse = Message<"memos.api.v1.ListAttachmentsRespo
* Use `create(ListAttachmentsResponseSchema)` to create a new message.
*/
export const ListAttachmentsResponseSchema: GenMessage<ListAttachmentsResponse> = /*@__PURE__*/
messageDesc(file_api_v1_attachment_service, 3);
messageDesc(file_api_v1_attachment_service, 4);
/**
* @generated from message memos.api.v1.GetAttachmentRequest
@ -215,7 +259,7 @@ export type GetAttachmentRequest = Message<"memos.api.v1.GetAttachmentRequest">
* Use `create(GetAttachmentRequestSchema)` to create a new message.
*/
export const GetAttachmentRequestSchema: GenMessage<GetAttachmentRequest> = /*@__PURE__*/
messageDesc(file_api_v1_attachment_service, 4);
messageDesc(file_api_v1_attachment_service, 5);
/**
* @generated from message memos.api.v1.UpdateAttachmentRequest
@ -241,7 +285,7 @@ export type UpdateAttachmentRequest = Message<"memos.api.v1.UpdateAttachmentRequ
* Use `create(UpdateAttachmentRequestSchema)` to create a new message.
*/
export const UpdateAttachmentRequestSchema: GenMessage<UpdateAttachmentRequest> = /*@__PURE__*/
messageDesc(file_api_v1_attachment_service, 5);
messageDesc(file_api_v1_attachment_service, 6);
/**
* @generated from message memos.api.v1.DeleteAttachmentRequest
@ -261,7 +305,81 @@ export type DeleteAttachmentRequest = Message<"memos.api.v1.DeleteAttachmentRequ
* Use `create(DeleteAttachmentRequestSchema)` to create a new message.
*/
export const DeleteAttachmentRequestSchema: GenMessage<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
@ -317,6 +435,16 @@ export const AttachmentService: GenService<{
input: typeof DeleteAttachmentRequestSchema;
output: typeof EmptySchema;
},
/**
* BatchDeleteAttachments deletes multiple attachments in one request.
*
* @generated from rpc memos.api.v1.AttachmentService.BatchDeleteAttachments
*/
batchDeleteAttachments: {
methodKind: "unary";
input: typeof BatchDeleteAttachmentsRequestSchema;
output: typeof EmptySchema;
},
}> = /*@__PURE__*/
serviceDesc(file_api_v1_attachment_service, 0);

View File

@ -1,4 +1,4 @@
import { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { Attachment, MotionMediaFamily, MotionMediaRole } from "@/types/proto/api/v1/attachment_service_pb";
export const getAttachmentUrl = (attachment: Attachment) => {
if (attachment.externalLink) {
@ -12,6 +12,10 @@ export const getAttachmentThumbnailUrl = (attachment: Attachment) => {
return `${window.location.origin}/file/${attachment.name}/${attachment.filename}?thumbnail=true`;
};
export const getAttachmentMotionClipUrl = (attachment: Attachment) => {
return `${window.location.origin}/file/${attachment.name}/${attachment.filename}?motion=true`;
};
export const getAttachmentType = (attachment: Attachment) => {
if (isImage(attachment.type)) {
return "image/*";
@ -52,3 +56,21 @@ export const isMidiFile = (mimeType: string): boolean => {
const isPSD = (t: string) => {
return t === "image/vnd.adobe.photoshop" || t === "image/x-photoshop" || t === "image/photoshop";
};
export const getAttachmentMotionGroupId = (attachment: Attachment): string | undefined => {
return attachment.motionMedia?.groupId || undefined;
};
export const isAppleLivePhotoStill = (attachment: Attachment): boolean =>
attachment.motionMedia?.family === MotionMediaFamily.APPLE_LIVE_PHOTO && attachment.motionMedia.role === MotionMediaRole.STILL;
export const isAppleLivePhotoVideo = (attachment: Attachment): boolean =>
attachment.motionMedia?.family === MotionMediaFamily.APPLE_LIVE_PHOTO && attachment.motionMedia.role === MotionMediaRole.VIDEO;
export const isAndroidMotionContainer = (attachment: Attachment): boolean =>
attachment.motionMedia?.family === MotionMediaFamily.ANDROID_MOTION_PHOTO &&
attachment.motionMedia.role === MotionMediaRole.CONTAINER &&
attachment.motionMedia.hasEmbeddedVideo;
export const isMotionAttachment = (attachment: Attachment): boolean =>
isAppleLivePhotoStill(attachment) || isAppleLivePhotoVideo(attachment) || isAndroidMotionContainer(attachment);

179
web/src/utils/media-item.ts Normal file
View File

@ -0,0 +1,179 @@
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import {
getAttachmentMotionClipUrl,
getAttachmentMotionGroupId,
getAttachmentThumbnailUrl,
getAttachmentType,
getAttachmentUrl,
isAndroidMotionContainer,
isAppleLivePhotoStill,
isAppleLivePhotoVideo,
isMotionAttachment,
} from "./attachment";
export interface PreviewMediaItem {
id: string;
kind: "image" | "video";
sourceUrl: string;
posterUrl?: string;
filename: string;
isMotion: boolean;
presentationTimestampUs?: bigint;
}
export interface AttachmentVisualItem {
id: string;
kind: "image" | "video" | "motion";
filename: string;
posterUrl: string;
sourceUrl: string;
attachmentNames: string[];
attachments: Attachment[];
previewItem: PreviewMediaItem;
mimeType: string;
}
export function buildAttachmentVisualItems(attachments: Attachment[]): AttachmentVisualItem[] {
const attachmentsByGroup = new Map<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;
});
}