mirror of https://github.com/usememos/memos.git
feat(attachments): add Live Photo and Motion Photo support (#5810)
This commit is contained in:
parent
894b3eb045
commit
4b4e719470
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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,
|
||||
}...,
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue