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

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

parent 894b3eb0
package motionphoto
import (
"bytes"
"encoding/binary"
"regexp"
"strconv"
)
type Detection struct {
VideoStart int
PresentationTimestampUs int64
}
var (
motionPhotoMarkerRegex = regexp.MustCompile(`(?i)(?:Camera:MotionPhoto|GCamera:MotionPhoto|MicroVideo)["'=:\s>]+1`)
presentationRegex = regexp.MustCompile(`(?i)(?:Camera:MotionPhotoPresentationTimestampUs|GCamera:MotionPhotoPresentationTimestampUs)["'=:\s>]+(-?\d+)`)
microVideoOffsetRegex = regexp.MustCompile(`(?i)(?:Camera:MicroVideoOffset|GCamera:MicroVideoOffset)["'=:\s>]+(\d+)`)
)
const maxMetadataScanBytes = 256 * 1024
func DetectJPEG(blob []byte) *Detection {
if len(blob) < 16 || !bytes.HasPrefix(blob, []byte{0xFF, 0xD8}) {
return nil
}
text := string(blob[:min(len(blob), maxMetadataScanBytes)])
if !motionPhotoMarkerRegex.MatchString(text) {
return nil
}
videoStart := detectVideoStart(blob, text)
if videoStart < 0 || videoStart >= len(blob) {
return nil
}
return &Detection{
VideoStart: videoStart,
PresentationTimestampUs: parsePresentationTimestampUs(text),
}
}
func ExtractVideo(blob []byte) ([]byte, *Detection) {
detection := DetectJPEG(blob)
if detection == nil {
return nil, nil
}
videoBlob := blob[detection.VideoStart:]
if !looksLikeMP4(videoBlob) {
return nil, nil
}
return videoBlob, detection
}
func detectVideoStart(blob []byte, text string) int {
if matches := microVideoOffsetRegex.FindStringSubmatch(text); len(matches) == 2 {
if offset, err := strconv.Atoi(matches[1]); err == nil && offset > 0 && offset < len(blob) {
start := len(blob) - offset
if looksLikeMP4(blob[start:]) {
return start
}
}
}
return findEmbeddedMP4Start(blob)
}
func parsePresentationTimestampUs(text string) int64 {
matches := presentationRegex.FindStringSubmatch(text)
if len(matches) != 2 {
return 0
}
value, err := strconv.ParseInt(matches[1], 10, 64)
if err != nil {
return 0
}
return value
}
func findEmbeddedMP4Start(blob []byte) int {
searchFrom := len(blob)
for searchFrom > 8 {
index := bytes.LastIndex(blob[:searchFrom], []byte("ftyp"))
if index < 4 {
return -1
}
start := index - 4
if looksLikeMP4(blob[start:]) {
return start
}
searchFrom = index - 1
}
return -1
}
func looksLikeMP4(blob []byte) bool {
if len(blob) < 12 || !bytes.Equal(blob[4:8], []byte("ftyp")) {
return false
}
size := binary.BigEndian.Uint32(blob[:4])
return size == 1 || size >= 8
}
package motionphoto
import (
"bytes"
"testing"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/internal/testutil"
)
func TestDetectJPEG(t *testing.T) {
t.Parallel()
blob := testutil.BuildMotionPhotoJPEG()
detection := DetectJPEG(blob)
require.NotNil(t, detection)
require.Positive(t, detection.VideoStart)
require.EqualValues(t, 123456, detection.PresentationTimestampUs)
videoBlob, extracted := ExtractVideo(blob)
require.NotNil(t, extracted)
require.True(t, bytes.Equal(videoBlob[:4], []byte{0x00, 0x00, 0x00, 0x10}))
require.Equal(t, []byte("ftyp"), videoBlob[4:8])
}
package testutil
// BuildMotionPhotoJPEG returns a minimal JPEG blob with Motion Photo metadata
// and an embedded MP4 header for tests.
func BuildMotionPhotoJPEG() []byte {
return append(
[]byte{
0xFF, 0xD8, 0xFF, 0xE1,
},
append(
[]byte(`<?xpacket begin=""?><rdf:Description GCamera:MotionPhoto="1" GCamera:MotionPhotoPresentationTimestampUs="123456"></rdf:Description>`),
[]byte{
0xFF, 0xD9,
0x00, 0x00, 0x00, 0x10, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm', 0x00, 0x00, 0x00, 0x00,
}...,
)...,
)
}
...@@ -43,6 +43,34 @@ service AttachmentService { ...@@ -43,6 +43,34 @@ service AttachmentService {
option (google.api.http) = {delete: "/api/v1/{name=attachments/*}"}; option (google.api.http) = {delete: "/api/v1/{name=attachments/*}"};
option (google.api.method_signature) = "name"; option (google.api.method_signature) = "name";
} }
// BatchDeleteAttachments deletes multiple attachments in one request.
rpc BatchDeleteAttachments(BatchDeleteAttachmentsRequest) returns (google.protobuf.Empty) {
option (google.api.http) = {
post: "/api/v1/attachments:batchDelete"
body: "*"
};
}
}
enum MotionMediaFamily {
MOTION_MEDIA_FAMILY_UNSPECIFIED = 0;
APPLE_LIVE_PHOTO = 1;
ANDROID_MOTION_PHOTO = 2;
}
enum MotionMediaRole {
MOTION_MEDIA_ROLE_UNSPECIFIED = 0;
STILL = 1;
VIDEO = 2;
CONTAINER = 3;
}
message MotionMedia {
MotionMediaFamily family = 1;
MotionMediaRole role = 2;
string group_id = 3;
int64 presentation_timestamp_us = 4;
bool has_embedded_video = 5;
} }
message Attachment { message Attachment {
...@@ -78,6 +106,9 @@ message Attachment { ...@@ -78,6 +106,9 @@ message Attachment {
// Optional. The related memo. Refer to `Memo.name`. // Optional. The related memo. Refer to `Memo.name`.
// Format: memos/{memo} // Format: memos/{memo}
optional string memo = 8 [(google.api.field_behavior) = OPTIONAL]; optional string memo = 8 [(google.api.field_behavior) = OPTIONAL];
// Optional. Motion media metadata.
MotionMedia motion_media = 9 [(google.api.field_behavior) = OPTIONAL];
} }
message CreateAttachmentRequest { message CreateAttachmentRequest {
...@@ -148,3 +179,7 @@ message DeleteAttachmentRequest { ...@@ -148,3 +179,7 @@ message DeleteAttachmentRequest {
(google.api.resource_reference) = {type: "memos.api.v1/Attachment"} (google.api.resource_reference) = {type: "memos.api.v1/Attachment"}
]; ];
} }
message BatchDeleteAttachmentsRequest {
repeated string names = 1 [(google.api.field_behavior) = REQUIRED];
}
...@@ -49,6 +49,9 @@ const ( ...@@ -49,6 +49,9 @@ const (
// AttachmentServiceDeleteAttachmentProcedure is the fully-qualified name of the AttachmentService's // AttachmentServiceDeleteAttachmentProcedure is the fully-qualified name of the AttachmentService's
// DeleteAttachment RPC. // DeleteAttachment RPC.
AttachmentServiceDeleteAttachmentProcedure = "/memos.api.v1.AttachmentService/DeleteAttachment" AttachmentServiceDeleteAttachmentProcedure = "/memos.api.v1.AttachmentService/DeleteAttachment"
// AttachmentServiceBatchDeleteAttachmentsProcedure is the fully-qualified name of the
// AttachmentService's BatchDeleteAttachments RPC.
AttachmentServiceBatchDeleteAttachmentsProcedure = "/memos.api.v1.AttachmentService/BatchDeleteAttachments"
) )
// AttachmentServiceClient is a client for the memos.api.v1.AttachmentService service. // AttachmentServiceClient is a client for the memos.api.v1.AttachmentService service.
...@@ -63,6 +66,8 @@ type AttachmentServiceClient interface { ...@@ -63,6 +66,8 @@ type AttachmentServiceClient interface {
UpdateAttachment(context.Context, *connect.Request[v1.UpdateAttachmentRequest]) (*connect.Response[v1.Attachment], error) UpdateAttachment(context.Context, *connect.Request[v1.UpdateAttachmentRequest]) (*connect.Response[v1.Attachment], error)
// DeleteAttachment deletes an attachment by name. // DeleteAttachment deletes an attachment by name.
DeleteAttachment(context.Context, *connect.Request[v1.DeleteAttachmentRequest]) (*connect.Response[emptypb.Empty], error) DeleteAttachment(context.Context, *connect.Request[v1.DeleteAttachmentRequest]) (*connect.Response[emptypb.Empty], error)
// BatchDeleteAttachments deletes multiple attachments in one request.
BatchDeleteAttachments(context.Context, *connect.Request[v1.BatchDeleteAttachmentsRequest]) (*connect.Response[emptypb.Empty], error)
} }
// NewAttachmentServiceClient constructs a client for the memos.api.v1.AttachmentService service. By // NewAttachmentServiceClient constructs a client for the memos.api.v1.AttachmentService service. By
...@@ -106,6 +111,12 @@ func NewAttachmentServiceClient(httpClient connect.HTTPClient, baseURL string, o ...@@ -106,6 +111,12 @@ func NewAttachmentServiceClient(httpClient connect.HTTPClient, baseURL string, o
connect.WithSchema(attachmentServiceMethods.ByName("DeleteAttachment")), connect.WithSchema(attachmentServiceMethods.ByName("DeleteAttachment")),
connect.WithClientOptions(opts...), connect.WithClientOptions(opts...),
), ),
batchDeleteAttachments: connect.NewClient[v1.BatchDeleteAttachmentsRequest, emptypb.Empty](
httpClient,
baseURL+AttachmentServiceBatchDeleteAttachmentsProcedure,
connect.WithSchema(attachmentServiceMethods.ByName("BatchDeleteAttachments")),
connect.WithClientOptions(opts...),
),
} }
} }
...@@ -116,6 +127,7 @@ type attachmentServiceClient struct { ...@@ -116,6 +127,7 @@ type attachmentServiceClient struct {
getAttachment *connect.Client[v1.GetAttachmentRequest, v1.Attachment] getAttachment *connect.Client[v1.GetAttachmentRequest, v1.Attachment]
updateAttachment *connect.Client[v1.UpdateAttachmentRequest, v1.Attachment] updateAttachment *connect.Client[v1.UpdateAttachmentRequest, v1.Attachment]
deleteAttachment *connect.Client[v1.DeleteAttachmentRequest, emptypb.Empty] deleteAttachment *connect.Client[v1.DeleteAttachmentRequest, emptypb.Empty]
batchDeleteAttachments *connect.Client[v1.BatchDeleteAttachmentsRequest, emptypb.Empty]
} }
// CreateAttachment calls memos.api.v1.AttachmentService.CreateAttachment. // CreateAttachment calls memos.api.v1.AttachmentService.CreateAttachment.
...@@ -143,6 +155,11 @@ func (c *attachmentServiceClient) DeleteAttachment(ctx context.Context, req *con ...@@ -143,6 +155,11 @@ func (c *attachmentServiceClient) DeleteAttachment(ctx context.Context, req *con
return c.deleteAttachment.CallUnary(ctx, req) return c.deleteAttachment.CallUnary(ctx, req)
} }
// BatchDeleteAttachments calls memos.api.v1.AttachmentService.BatchDeleteAttachments.
func (c *attachmentServiceClient) BatchDeleteAttachments(ctx context.Context, req *connect.Request[v1.BatchDeleteAttachmentsRequest]) (*connect.Response[emptypb.Empty], error) {
return c.batchDeleteAttachments.CallUnary(ctx, req)
}
// AttachmentServiceHandler is an implementation of the memos.api.v1.AttachmentService service. // AttachmentServiceHandler is an implementation of the memos.api.v1.AttachmentService service.
type AttachmentServiceHandler interface { type AttachmentServiceHandler interface {
// CreateAttachment creates a new attachment. // CreateAttachment creates a new attachment.
...@@ -155,6 +172,8 @@ type AttachmentServiceHandler interface { ...@@ -155,6 +172,8 @@ type AttachmentServiceHandler interface {
UpdateAttachment(context.Context, *connect.Request[v1.UpdateAttachmentRequest]) (*connect.Response[v1.Attachment], error) UpdateAttachment(context.Context, *connect.Request[v1.UpdateAttachmentRequest]) (*connect.Response[v1.Attachment], error)
// DeleteAttachment deletes an attachment by name. // DeleteAttachment deletes an attachment by name.
DeleteAttachment(context.Context, *connect.Request[v1.DeleteAttachmentRequest]) (*connect.Response[emptypb.Empty], error) DeleteAttachment(context.Context, *connect.Request[v1.DeleteAttachmentRequest]) (*connect.Response[emptypb.Empty], error)
// BatchDeleteAttachments deletes multiple attachments in one request.
BatchDeleteAttachments(context.Context, *connect.Request[v1.BatchDeleteAttachmentsRequest]) (*connect.Response[emptypb.Empty], error)
} }
// NewAttachmentServiceHandler builds an HTTP handler from the service implementation. It returns // NewAttachmentServiceHandler builds an HTTP handler from the service implementation. It returns
...@@ -194,6 +213,12 @@ func NewAttachmentServiceHandler(svc AttachmentServiceHandler, opts ...connect.H ...@@ -194,6 +213,12 @@ func NewAttachmentServiceHandler(svc AttachmentServiceHandler, opts ...connect.H
connect.WithSchema(attachmentServiceMethods.ByName("DeleteAttachment")), connect.WithSchema(attachmentServiceMethods.ByName("DeleteAttachment")),
connect.WithHandlerOptions(opts...), connect.WithHandlerOptions(opts...),
) )
attachmentServiceBatchDeleteAttachmentsHandler := connect.NewUnaryHandler(
AttachmentServiceBatchDeleteAttachmentsProcedure,
svc.BatchDeleteAttachments,
connect.WithSchema(attachmentServiceMethods.ByName("BatchDeleteAttachments")),
connect.WithHandlerOptions(opts...),
)
return "/memos.api.v1.AttachmentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return "/memos.api.v1.AttachmentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case AttachmentServiceCreateAttachmentProcedure: case AttachmentServiceCreateAttachmentProcedure:
...@@ -206,6 +231,8 @@ func NewAttachmentServiceHandler(svc AttachmentServiceHandler, opts ...connect.H ...@@ -206,6 +231,8 @@ func NewAttachmentServiceHandler(svc AttachmentServiceHandler, opts ...connect.H
attachmentServiceUpdateAttachmentHandler.ServeHTTP(w, r) attachmentServiceUpdateAttachmentHandler.ServeHTTP(w, r)
case AttachmentServiceDeleteAttachmentProcedure: case AttachmentServiceDeleteAttachmentProcedure:
attachmentServiceDeleteAttachmentHandler.ServeHTTP(w, r) attachmentServiceDeleteAttachmentHandler.ServeHTTP(w, r)
case AttachmentServiceBatchDeleteAttachmentsProcedure:
attachmentServiceBatchDeleteAttachmentsHandler.ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
...@@ -234,3 +261,7 @@ func (UnimplementedAttachmentServiceHandler) UpdateAttachment(context.Context, * ...@@ -234,3 +261,7 @@ func (UnimplementedAttachmentServiceHandler) UpdateAttachment(context.Context, *
func (UnimplementedAttachmentServiceHandler) DeleteAttachment(context.Context, *connect.Request[v1.DeleteAttachmentRequest]) (*connect.Response[emptypb.Empty], error) { func (UnimplementedAttachmentServiceHandler) DeleteAttachment(context.Context, *connect.Request[v1.DeleteAttachmentRequest]) (*connect.Response[emptypb.Empty], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("memos.api.v1.AttachmentService.DeleteAttachment is not implemented")) return nil, connect.NewError(connect.CodeUnimplemented, errors.New("memos.api.v1.AttachmentService.DeleteAttachment is not implemented"))
} }
func (UnimplementedAttachmentServiceHandler) BatchDeleteAttachments(context.Context, *connect.Request[v1.BatchDeleteAttachmentsRequest]) (*connect.Response[emptypb.Empty], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("memos.api.v1.AttachmentService.BatchDeleteAttachments is not implemented"))
}
This diff is collapsed.
...@@ -270,6 +270,33 @@ func local_request_AttachmentService_DeleteAttachment_0(ctx context.Context, mar ...@@ -270,6 +270,33 @@ func local_request_AttachmentService_DeleteAttachment_0(ctx context.Context, mar
return msg, metadata, err return msg, metadata, err
} }
func request_AttachmentService_BatchDeleteAttachments_0(ctx context.Context, marshaler runtime.Marshaler, client AttachmentServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq BatchDeleteAttachmentsRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
msg, err := client.BatchDeleteAttachments(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_AttachmentService_BatchDeleteAttachments_0(ctx context.Context, marshaler runtime.Marshaler, server AttachmentServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq BatchDeleteAttachmentsRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.BatchDeleteAttachments(ctx, &protoReq)
return msg, metadata, err
}
// RegisterAttachmentServiceHandlerServer registers the http handlers for service AttachmentService to "mux". // RegisterAttachmentServiceHandlerServer registers the http handlers for service AttachmentService to "mux".
// UnaryRPC :call AttachmentServiceServer directly. // UnaryRPC :call AttachmentServiceServer directly.
// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. // StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906.
...@@ -376,6 +403,26 @@ func RegisterAttachmentServiceHandlerServer(ctx context.Context, mux *runtime.Se ...@@ -376,6 +403,26 @@ func RegisterAttachmentServiceHandlerServer(ctx context.Context, mux *runtime.Se
} }
forward_AttachmentService_DeleteAttachment_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) forward_AttachmentService_DeleteAttachment_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
}) })
mux.Handle(http.MethodPost, pattern_AttachmentService_BatchDeleteAttachments_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/memos.api.v1.AttachmentService/BatchDeleteAttachments", runtime.WithHTTPPathPattern("/api/v1/attachments:batchDelete"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_AttachmentService_BatchDeleteAttachments_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_AttachmentService_BatchDeleteAttachments_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
return nil return nil
} }
...@@ -501,6 +548,23 @@ func RegisterAttachmentServiceHandlerClient(ctx context.Context, mux *runtime.Se ...@@ -501,6 +548,23 @@ func RegisterAttachmentServiceHandlerClient(ctx context.Context, mux *runtime.Se
} }
forward_AttachmentService_DeleteAttachment_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) forward_AttachmentService_DeleteAttachment_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
}) })
mux.Handle(http.MethodPost, pattern_AttachmentService_BatchDeleteAttachments_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/memos.api.v1.AttachmentService/BatchDeleteAttachments", runtime.WithHTTPPathPattern("/api/v1/attachments:batchDelete"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_AttachmentService_BatchDeleteAttachments_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_AttachmentService_BatchDeleteAttachments_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
return nil return nil
} }
...@@ -510,6 +574,7 @@ var ( ...@@ -510,6 +574,7 @@ var (
pattern_AttachmentService_GetAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "name"}, "")) pattern_AttachmentService_GetAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "name"}, ""))
pattern_AttachmentService_UpdateAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "attachment.name"}, "")) pattern_AttachmentService_UpdateAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "attachment.name"}, ""))
pattern_AttachmentService_DeleteAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "name"}, "")) pattern_AttachmentService_DeleteAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "name"}, ""))
pattern_AttachmentService_BatchDeleteAttachments_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "attachments"}, "batchDelete"))
) )
var ( var (
...@@ -518,4 +583,5 @@ var ( ...@@ -518,4 +583,5 @@ var (
forward_AttachmentService_GetAttachment_0 = runtime.ForwardResponseMessage forward_AttachmentService_GetAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_UpdateAttachment_0 = runtime.ForwardResponseMessage forward_AttachmentService_UpdateAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_DeleteAttachment_0 = runtime.ForwardResponseMessage forward_AttachmentService_DeleteAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_BatchDeleteAttachments_0 = runtime.ForwardResponseMessage
) )
...@@ -25,6 +25,7 @@ const ( ...@@ -25,6 +25,7 @@ const (
AttachmentService_GetAttachment_FullMethodName = "/memos.api.v1.AttachmentService/GetAttachment" AttachmentService_GetAttachment_FullMethodName = "/memos.api.v1.AttachmentService/GetAttachment"
AttachmentService_UpdateAttachment_FullMethodName = "/memos.api.v1.AttachmentService/UpdateAttachment" AttachmentService_UpdateAttachment_FullMethodName = "/memos.api.v1.AttachmentService/UpdateAttachment"
AttachmentService_DeleteAttachment_FullMethodName = "/memos.api.v1.AttachmentService/DeleteAttachment" AttachmentService_DeleteAttachment_FullMethodName = "/memos.api.v1.AttachmentService/DeleteAttachment"
AttachmentService_BatchDeleteAttachments_FullMethodName = "/memos.api.v1.AttachmentService/BatchDeleteAttachments"
) )
// AttachmentServiceClient is the client API for AttachmentService service. // AttachmentServiceClient is the client API for AttachmentService service.
...@@ -41,6 +42,8 @@ type AttachmentServiceClient interface { ...@@ -41,6 +42,8 @@ type AttachmentServiceClient interface {
UpdateAttachment(ctx context.Context, in *UpdateAttachmentRequest, opts ...grpc.CallOption) (*Attachment, error) UpdateAttachment(ctx context.Context, in *UpdateAttachmentRequest, opts ...grpc.CallOption) (*Attachment, error)
// DeleteAttachment deletes an attachment by name. // DeleteAttachment deletes an attachment by name.
DeleteAttachment(ctx context.Context, in *DeleteAttachmentRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) DeleteAttachment(ctx context.Context, in *DeleteAttachmentRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
// BatchDeleteAttachments deletes multiple attachments in one request.
BatchDeleteAttachments(ctx context.Context, in *BatchDeleteAttachmentsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
} }
type attachmentServiceClient struct { type attachmentServiceClient struct {
...@@ -101,6 +104,16 @@ func (c *attachmentServiceClient) DeleteAttachment(ctx context.Context, in *Dele ...@@ -101,6 +104,16 @@ func (c *attachmentServiceClient) DeleteAttachment(ctx context.Context, in *Dele
return out, nil return out, nil
} }
func (c *attachmentServiceClient) BatchDeleteAttachments(ctx context.Context, in *BatchDeleteAttachmentsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty)
err := c.cc.Invoke(ctx, AttachmentService_BatchDeleteAttachments_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AttachmentServiceServer is the server API for AttachmentService service. // AttachmentServiceServer is the server API for AttachmentService service.
// All implementations must embed UnimplementedAttachmentServiceServer // All implementations must embed UnimplementedAttachmentServiceServer
// for forward compatibility. // for forward compatibility.
...@@ -115,6 +128,8 @@ type AttachmentServiceServer interface { ...@@ -115,6 +128,8 @@ type AttachmentServiceServer interface {
UpdateAttachment(context.Context, *UpdateAttachmentRequest) (*Attachment, error) UpdateAttachment(context.Context, *UpdateAttachmentRequest) (*Attachment, error)
// DeleteAttachment deletes an attachment by name. // DeleteAttachment deletes an attachment by name.
DeleteAttachment(context.Context, *DeleteAttachmentRequest) (*emptypb.Empty, error) DeleteAttachment(context.Context, *DeleteAttachmentRequest) (*emptypb.Empty, error)
// BatchDeleteAttachments deletes multiple attachments in one request.
BatchDeleteAttachments(context.Context, *BatchDeleteAttachmentsRequest) (*emptypb.Empty, error)
mustEmbedUnimplementedAttachmentServiceServer() mustEmbedUnimplementedAttachmentServiceServer()
} }
...@@ -140,6 +155,9 @@ func (UnimplementedAttachmentServiceServer) UpdateAttachment(context.Context, *U ...@@ -140,6 +155,9 @@ func (UnimplementedAttachmentServiceServer) UpdateAttachment(context.Context, *U
func (UnimplementedAttachmentServiceServer) DeleteAttachment(context.Context, *DeleteAttachmentRequest) (*emptypb.Empty, error) { func (UnimplementedAttachmentServiceServer) DeleteAttachment(context.Context, *DeleteAttachmentRequest) (*emptypb.Empty, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteAttachment not implemented") return nil, status.Error(codes.Unimplemented, "method DeleteAttachment not implemented")
} }
func (UnimplementedAttachmentServiceServer) BatchDeleteAttachments(context.Context, *BatchDeleteAttachmentsRequest) (*emptypb.Empty, error) {
return nil, status.Error(codes.Unimplemented, "method BatchDeleteAttachments not implemented")
}
func (UnimplementedAttachmentServiceServer) mustEmbedUnimplementedAttachmentServiceServer() {} func (UnimplementedAttachmentServiceServer) mustEmbedUnimplementedAttachmentServiceServer() {}
func (UnimplementedAttachmentServiceServer) testEmbeddedByValue() {} func (UnimplementedAttachmentServiceServer) testEmbeddedByValue() {}
...@@ -251,6 +269,24 @@ func _AttachmentService_DeleteAttachment_Handler(srv interface{}, ctx context.Co ...@@ -251,6 +269,24 @@ func _AttachmentService_DeleteAttachment_Handler(srv interface{}, ctx context.Co
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _AttachmentService_BatchDeleteAttachments_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchDeleteAttachmentsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AttachmentServiceServer).BatchDeleteAttachments(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AttachmentService_BatchDeleteAttachments_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AttachmentServiceServer).BatchDeleteAttachments(ctx, req.(*BatchDeleteAttachmentsRequest))
}
return interceptor(ctx, in, info, handler)
}
// AttachmentService_ServiceDesc is the grpc.ServiceDesc for AttachmentService service. // AttachmentService_ServiceDesc is the grpc.ServiceDesc for AttachmentService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
...@@ -278,6 +314,10 @@ var AttachmentService_ServiceDesc = grpc.ServiceDesc{ ...@@ -278,6 +314,10 @@ var AttachmentService_ServiceDesc = grpc.ServiceDesc{
MethodName: "DeleteAttachment", MethodName: "DeleteAttachment",
Handler: _AttachmentService_DeleteAttachment_Handler, Handler: _AttachmentService_DeleteAttachment_Handler,
}, },
{
MethodName: "BatchDeleteAttachments",
Handler: _AttachmentService_BatchDeleteAttachments_Handler,
},
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "api/v1/attachment_service.proto", Metadata: "api/v1/attachment_service.proto",
......
...@@ -176,6 +176,28 @@ paths: ...@@ -176,6 +176,28 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Status' $ref: '#/components/schemas/Status'
/api/v1/attachments:batchDelete:
post:
tags:
- AttachmentService
description: BatchDeleteAttachments deletes multiple attachments in one request.
operationId: AttachmentService_BatchDeleteAttachments
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/BatchDeleteAttachmentsRequest'
required: true
responses:
"200":
description: OK
content: {}
default:
description: Default error response
content:
application/json:
schema:
$ref: '#/components/schemas/Status'
/api/v1/auth/me: /api/v1/auth/me:
get: get:
tags: tags:
...@@ -2015,6 +2037,19 @@ components: ...@@ -2015,6 +2037,19 @@ components:
description: |- description: |-
Optional. The related memo. Refer to `Memo.name`. Optional. The related memo. Refer to `Memo.name`.
Format: memos/{memo} Format: memos/{memo}
motionMedia:
allOf:
- $ref: '#/components/schemas/MotionMedia'
description: Optional. Motion media metadata.
BatchDeleteAttachmentsRequest:
required:
- names
type: object
properties:
names:
type: array
items:
type: string
Color: Color:
type: object type: object
properties: properties:
...@@ -2781,6 +2816,30 @@ components: ...@@ -2781,6 +2816,30 @@ components:
type: string type: string
description: The title extracted from the first H1 heading, if present. description: The title extracted from the first H1 heading, if present.
description: Computed properties of a memo. description: Computed properties of a memo.
MotionMedia:
type: object
properties:
family:
enum:
- MOTION_MEDIA_FAMILY_UNSPECIFIED
- APPLE_LIVE_PHOTO
- ANDROID_MOTION_PHOTO
type: string
format: enum
role:
enum:
- MOTION_MEDIA_ROLE_UNSPECIFIED
- STILL
- VIDEO
- CONTAINER
type: string
format: enum
groupId:
type: string
presentationTimestampUs:
type: string
hasEmbeddedVideo:
type: boolean
NotificationSetting_EmailSetting: NotificationSetting_EmailSetting:
type: object type: object
properties: properties:
......
This diff is collapsed.
...@@ -17,11 +17,34 @@ enum AttachmentStorageType { ...@@ -17,11 +17,34 @@ enum AttachmentStorageType {
EXTERNAL = 3; EXTERNAL = 3;
} }
enum MotionMediaFamily {
MOTION_MEDIA_FAMILY_UNSPECIFIED = 0;
APPLE_LIVE_PHOTO = 1;
ANDROID_MOTION_PHOTO = 2;
}
enum MotionMediaRole {
MOTION_MEDIA_ROLE_UNSPECIFIED = 0;
STILL = 1;
VIDEO = 2;
CONTAINER = 3;
}
message MotionMedia {
MotionMediaFamily family = 1;
MotionMediaRole role = 2;
string group_id = 3;
int64 presentation_timestamp_us = 4;
bool has_embedded_video = 5;
}
message AttachmentPayload { message AttachmentPayload {
oneof payload { oneof payload {
S3Object s3_object = 1; S3Object s3_object = 1;
} }
MotionMedia motion_media = 10;
message S3Object { message S3Object {
StorageS3Config s3_config = 1; StorageS3Config s3_config = 1;
// key is the S3 object key. // key is the S3 object key.
......
package v1
import (
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
func convertMotionMediaFromStore(motion *storepb.MotionMedia) *v1pb.MotionMedia {
if motion == nil {
return nil
}
return &v1pb.MotionMedia{
Family: v1pb.MotionMediaFamily(motion.Family),
Role: v1pb.MotionMediaRole(motion.Role),
GroupId: motion.GroupId,
PresentationTimestampUs: motion.PresentationTimestampUs,
HasEmbeddedVideo: motion.HasEmbeddedVideo,
}
}
func convertMotionMediaToStore(motion *v1pb.MotionMedia) *storepb.MotionMedia {
if motion == nil {
return nil
}
return &storepb.MotionMedia{
Family: storepb.MotionMediaFamily(motion.Family),
Role: storepb.MotionMediaRole(motion.Role),
GroupId: motion.GroupId,
PresentationTimestampUs: motion.PresentationTimestampUs,
HasEmbeddedVideo: motion.HasEmbeddedVideo,
}
}
func getAttachmentMotionMedia(attachment *store.Attachment) *storepb.MotionMedia {
if attachment == nil || attachment.Payload == nil {
return nil
}
return attachment.Payload.MotionMedia
}
func isAndroidMotionContainer(motion *storepb.MotionMedia) bool {
return motion != nil &&
motion.Family == storepb.MotionMediaFamily_ANDROID_MOTION_PHOTO &&
motion.Role == storepb.MotionMediaRole_CONTAINER &&
motion.HasEmbeddedVideo
}
func ensureAttachmentPayload(payload *storepb.AttachmentPayload) *storepb.AttachmentPayload {
if payload != nil {
return payload
}
return &storepb.AttachmentPayload{}
}
func isMultiMemberMotionGroup(attachments []*store.Attachment) bool {
if len(attachments) < 2 {
return false
}
for _, attachment := range attachments {
motion := getAttachmentMotionMedia(attachment)
if motion == nil || motion.GroupId == "" {
return false
}
}
return true
}
...@@ -22,6 +22,7 @@ import ( ...@@ -22,6 +22,7 @@ import (
"google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
"github.com/usememos/memos/internal/motionphoto"
"github.com/usememos/memos/internal/profile" "github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/internal/util" "github.com/usememos/memos/internal/util"
"github.com/usememos/memos/plugin/filter" "github.com/usememos/memos/plugin/filter"
...@@ -43,6 +44,7 @@ const ( ...@@ -43,6 +44,7 @@ const (
// defaultJPEGQuality is the JPEG quality used when re-encoding images for EXIF stripping. // defaultJPEGQuality is the JPEG quality used when re-encoding images for EXIF stripping.
// Quality 95 maintains visual quality while ensuring metadata is removed. // Quality 95 maintains visual quality while ensuring metadata is removed.
defaultJPEGQuality = 95 defaultJPEGQuality = 95
maxBatchDeleteAttachments = 100
) )
var SupportedThumbnailMimeTypes = []string{ var SupportedThumbnailMimeTypes = []string{
...@@ -111,6 +113,15 @@ func (s *APIV1Service) CreateAttachment(ctx context.Context, request *v1pb.Creat ...@@ -111,6 +113,15 @@ func (s *APIV1Service) CreateAttachment(ctx context.Context, request *v1pb.Creat
Type: request.Attachment.Type, Type: request.Attachment.Type,
} }
inputMotionMedia, err := validateClientMotionMedia(request.Attachment.MotionMedia, attachmentUID)
if err != nil {
return nil, err
}
if inputMotionMedia != nil {
create.Payload = ensureAttachmentPayload(create.Payload)
create.Payload.MotionMedia = inputMotionMedia
}
instanceStorageSetting, err := s.Store.GetInstanceStorageSetting(ctx) instanceStorageSetting, err := s.Store.GetInstanceStorageSetting(ctx)
if err != nil { if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get instance storage setting: %v", err) return nil, status.Errorf(codes.Internal, "failed to get instance storage setting: %v", err)
...@@ -126,9 +137,16 @@ func (s *APIV1Service) CreateAttachment(ctx context.Context, request *v1pb.Creat ...@@ -126,9 +137,16 @@ func (s *APIV1Service) CreateAttachment(ctx context.Context, request *v1pb.Creat
create.Size = int64(size) create.Size = int64(size)
create.Blob = request.Attachment.Content create.Blob = request.Attachment.Content
if create.Payload == nil || create.Payload.MotionMedia == nil {
if detectedMotion := detectAndroidMotionMedia(create.Blob, create.Type, attachmentUID); detectedMotion != nil {
create.Payload = ensureAttachmentPayload(create.Payload)
create.Payload.MotionMedia = detectedMotion
}
}
// Strip EXIF metadata from images for privacy protection. // Strip EXIF metadata from images for privacy protection.
// This removes sensitive information like GPS location, device details, etc. // This removes sensitive information like GPS location, device details, etc.
if shouldStripExif(create.Type) { if shouldStripExif(create.Type) && !isAndroidMotionContainer(create.Payload.GetMotionMedia()) {
if strippedBlob, err := stripImageExif(create.Blob, create.Type); err != nil { if strippedBlob, err := stripImageExif(create.Blob, create.Type); err != nil {
// Log warning but continue with original image to ensure uploads don't fail. // Log warning but continue with original image to ensure uploads don't fail.
slog.Warn("failed to strip EXIF metadata from image", slog.Warn("failed to strip EXIF metadata from image",
...@@ -333,6 +351,56 @@ func (s *APIV1Service) DeleteAttachment(ctx context.Context, request *v1pb.Delet ...@@ -333,6 +351,56 @@ func (s *APIV1Service) DeleteAttachment(ctx context.Context, request *v1pb.Delet
return &emptypb.Empty{}, nil return &emptypb.Empty{}, nil
} }
func (s *APIV1Service) BatchDeleteAttachments(ctx context.Context, request *v1pb.BatchDeleteAttachmentsRequest) (*emptypb.Empty, error) {
user, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if len(request.Names) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "attachment names are required")
}
if len(request.Names) > maxBatchDeleteAttachments {
return nil, status.Errorf(codes.InvalidArgument, "too many attachment names; max %d", maxBatchDeleteAttachments)
}
attachments := make([]*store.Attachment, 0, len(request.Names))
seen := make(map[string]bool, len(request.Names))
for _, name := range request.Names {
if name == "" {
return nil, status.Errorf(codes.InvalidArgument, "attachment name is required")
}
if seen[name] {
continue
}
seen[name] = true
attachmentUID, err := ExtractAttachmentUIDFromName(name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid attachment id: %v", err)
}
attachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{UID: &attachmentUID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get attachment: %v", err)
}
if attachment == nil {
return nil, status.Errorf(codes.NotFound, "attachment not found")
}
if attachment.CreatorID != user.ID && !isSuperUser(user) {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
attachments = append(attachments, attachment)
}
if err := s.Store.DeleteAttachments(ctx, attachments); err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete attachments: %v", err)
}
return &emptypb.Empty{}, nil
}
func convertAttachmentFromStore(attachment *store.Attachment) *v1pb.Attachment { func convertAttachmentFromStore(attachment *store.Attachment) *v1pb.Attachment {
attachmentMessage := &v1pb.Attachment{ attachmentMessage := &v1pb.Attachment{
Name: fmt.Sprintf("%s%s", AttachmentNamePrefix, attachment.UID), Name: fmt.Sprintf("%s%s", AttachmentNamePrefix, attachment.UID),
...@@ -340,6 +408,7 @@ func convertAttachmentFromStore(attachment *store.Attachment) *v1pb.Attachment { ...@@ -340,6 +408,7 @@ func convertAttachmentFromStore(attachment *store.Attachment) *v1pb.Attachment {
Filename: attachment.Filename, Filename: attachment.Filename,
Type: attachment.Type, Type: attachment.Type,
Size: attachment.Size, Size: attachment.Size,
MotionMedia: convertMotionMediaFromStore(getAttachmentMotionMedia(attachment)),
} }
if attachment.MemoUID != nil && *attachment.MemoUID != "" { if attachment.MemoUID != nil && *attachment.MemoUID != "" {
memoName := fmt.Sprintf("%s%s", MemoNamePrefix, *attachment.MemoUID) memoName := fmt.Sprintf("%s%s", MemoNamePrefix, *attachment.MemoUID)
...@@ -425,15 +494,15 @@ func SaveAttachmentBlob(ctx context.Context, profile *profile.Profile, stores *s ...@@ -425,15 +494,15 @@ func SaveAttachmentBlob(ctx context.Context, profile *profile.Profile, stores *s
create.Reference = presignURL create.Reference = presignURL
create.Blob = nil create.Blob = nil
create.StorageType = storepb.AttachmentStorageType_S3 create.StorageType = storepb.AttachmentStorageType_S3
create.Payload = &storepb.AttachmentPayload{ payload := ensureAttachmentPayload(create.Payload)
Payload: &storepb.AttachmentPayload_S3Object_{ payload.Payload = &storepb.AttachmentPayload_S3Object_{
S3Object: &storepb.AttachmentPayload_S3Object{ S3Object: &storepb.AttachmentPayload_S3Object{
S3Config: s3Config, S3Config: s3Config,
Key: key, Key: key,
LastPresignedTime: timestamppb.New(time.Now()), LastPresignedTime: timestamppb.New(time.Now()),
}, },
},
} }
create.Payload = payload
} }
return nil return nil
...@@ -624,6 +693,48 @@ func (s *APIV1Service) checkAttachmentAccess(ctx context.Context, attachment *st ...@@ -624,6 +693,48 @@ func (s *APIV1Service) checkAttachmentAccess(ctx context.Context, attachment *st
return nil return nil
} }
func validateClientMotionMedia(motion *v1pb.MotionMedia, attachmentUID string) (*storepb.MotionMedia, error) {
if motion == nil {
return nil, nil
}
if motion.Family != v1pb.MotionMediaFamily_APPLE_LIVE_PHOTO {
return nil, status.Errorf(codes.InvalidArgument, "only Apple Live Photo motion metadata can be provided by clients")
}
if motion.Role != v1pb.MotionMediaRole_STILL && motion.Role != v1pb.MotionMediaRole_VIDEO {
return nil, status.Errorf(codes.InvalidArgument, "invalid Apple Live Photo motion role")
}
storeMotion := convertMotionMediaToStore(motion)
if storeMotion.GroupId == "" {
return nil, status.Errorf(codes.InvalidArgument, "motion media group_id is required")
}
if storeMotion.Family == storepb.MotionMediaFamily_ANDROID_MOTION_PHOTO && storeMotion.GroupId == "" {
storeMotion.GroupId = attachmentUID
}
return storeMotion, nil
}
func detectAndroidMotionMedia(blob []byte, mimeType, attachmentUID string) *storepb.MotionMedia {
if mimeType != "image/jpeg" && mimeType != "image/jpg" {
return nil
}
detection := motionphoto.DetectJPEG(blob)
if detection == nil {
return nil
}
return &storepb.MotionMedia{
Family: storepb.MotionMediaFamily_ANDROID_MOTION_PHOTO,
Role: storepb.MotionMediaRole_CONTAINER,
GroupId: attachmentUID,
PresentationTimestampUs: detection.PresentationTimestampUs,
HasEmbeddedVideo: true,
}
}
// shouldStripExif checks if the MIME type is an image format that may contain EXIF metadata. // shouldStripExif checks if the MIME type is an image format that may contain EXIF metadata.
// Returns true for formats like JPEG, TIFF, WebP, HEIC, and HEIF which commonly contain // Returns true for formats like JPEG, TIFF, WebP, HEIC, and HEIF which commonly contain
// privacy-sensitive metadata such as GPS coordinates, camera settings, and device information. // privacy-sensitive metadata such as GPS coordinates, camera settings, and device information.
......
...@@ -419,6 +419,14 @@ func (s *ConnectServiceHandler) DeleteAttachment(ctx context.Context, req *conne ...@@ -419,6 +419,14 @@ func (s *ConnectServiceHandler) DeleteAttachment(ctx context.Context, req *conne
return connect.NewResponse(resp), nil return connect.NewResponse(resp), nil
} }
func (s *ConnectServiceHandler) BatchDeleteAttachments(ctx context.Context, req *connect.Request[v1pb.BatchDeleteAttachmentsRequest]) (*connect.Response[emptypb.Empty], error) {
resp, err := s.APIV1Service.BatchDeleteAttachments(ctx, req.Msg)
if err != nil {
return nil, convertGRPCError(err)
}
return connect.NewResponse(resp), nil
}
// ShortcutService // ShortcutService
func (s *ConnectServiceHandler) ListShortcuts(ctx context.Context, req *connect.Request[v1pb.ListShortcutsRequest]) (*connect.Response[v1pb.ListShortcutsResponse], error) { func (s *ConnectServiceHandler) ListShortcuts(ctx context.Context, req *connect.Request[v1pb.ListShortcutsRequest]) (*connect.Response[v1pb.ListShortcutsResponse], error) {
......
...@@ -51,27 +51,26 @@ func (s *APIV1Service) SetMemoAttachments(ctx context.Context, request *v1pb.Set ...@@ -51,27 +51,26 @@ func (s *APIV1Service) SetMemoAttachments(ctx context.Context, request *v1pb.Set
} }
func (s *APIV1Service) setMemoAttachmentsInternal(ctx context.Context, memo *store.Memo, requestAttachments []*v1pb.Attachment) error { func (s *APIV1Service) setMemoAttachmentsInternal(ctx context.Context, memo *store.Memo, requestAttachments []*v1pb.Attachment) error {
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{ currentAttachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{
MemoID: &memo.ID, MemoID: &memo.ID,
}) })
if err != nil { if err != nil {
return status.Errorf(codes.Internal, "failed to list attachments") return status.Errorf(codes.Internal, "failed to list attachments")
} }
// Delete attachments that are not in the request. normalizedAttachments, err := s.normalizeMemoAttachmentRequest(ctx, currentAttachments, requestAttachments)
for _, attachment := range attachments {
found := false
for _, requestAttachment := range requestAttachments {
requestAttachmentUID, err := ExtractAttachmentUIDFromName(requestAttachment.Name)
if err != nil { if err != nil {
return status.Errorf(codes.InvalidArgument, "invalid attachment name: %v", err) return err
}
if attachment.UID == requestAttachmentUID {
found = true
break
} }
requestedIDs := make(map[int32]bool, len(normalizedAttachments))
for _, attachment := range normalizedAttachments {
requestedIDs[attachment.ID] = true
} }
if !found {
// Delete attachments that are not in the request.
for _, attachment := range currentAttachments {
if !requestedIDs[attachment.ID] {
if err = s.Store.DeleteAttachment(ctx, &store.DeleteAttachment{ if err = s.Store.DeleteAttachment(ctx, &store.DeleteAttachment{
ID: int32(attachment.ID), ID: int32(attachment.ID),
MemoID: &memo.ID, MemoID: &memo.ID,
...@@ -81,23 +80,12 @@ func (s *APIV1Service) setMemoAttachmentsInternal(ctx context.Context, memo *sto ...@@ -81,23 +80,12 @@ func (s *APIV1Service) setMemoAttachmentsInternal(ctx context.Context, memo *sto
} }
} }
slices.Reverse(requestAttachments) slices.Reverse(normalizedAttachments)
// Update attachments' memo_id in the request. // Update attachments' memo_id in the request.
for index, attachment := range requestAttachments { for index, attachment := range normalizedAttachments {
attachmentUID, err := ExtractAttachmentUIDFromName(attachment.Name)
if err != nil {
return status.Errorf(codes.InvalidArgument, "invalid attachment name: %v", err)
}
tempAttachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{UID: &attachmentUID})
if err != nil {
return status.Errorf(codes.Internal, "failed to get attachment: %v", err)
}
if tempAttachment == nil {
return status.Errorf(codes.NotFound, "attachment not found: %s", attachmentUID)
}
updatedTs := time.Now().Unix() + int64(index) updatedTs := time.Now().Unix() + int64(index)
if err := s.Store.UpdateAttachment(ctx, &store.UpdateAttachment{ if err := s.Store.UpdateAttachment(ctx, &store.UpdateAttachment{
ID: tempAttachment.ID, ID: attachment.ID,
MemoID: &memo.ID, MemoID: &memo.ID,
UpdatedTs: &updatedTs, UpdatedTs: &updatedTs,
}); err != nil { }); err != nil {
...@@ -108,6 +96,100 @@ func (s *APIV1Service) setMemoAttachmentsInternal(ctx context.Context, memo *sto ...@@ -108,6 +96,100 @@ func (s *APIV1Service) setMemoAttachmentsInternal(ctx context.Context, memo *sto
return nil return nil
} }
func (s *APIV1Service) normalizeMemoAttachmentRequest(
ctx context.Context,
currentAttachments []*store.Attachment,
requestAttachments []*v1pb.Attachment,
) ([]*store.Attachment, error) {
requestedAttachments := make([]*store.Attachment, 0, len(requestAttachments))
for _, requestAttachment := range requestAttachments {
attachmentUID, err := ExtractAttachmentUIDFromName(requestAttachment.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid attachment name: %v", err)
}
attachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{UID: &attachmentUID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get attachment: %v", err)
}
if attachment == nil {
return nil, status.Errorf(codes.NotFound, "attachment not found: %s", attachmentUID)
}
requestedAttachments = append(requestedAttachments, attachment)
}
currentGroups := make(map[string][]*store.Attachment)
for _, attachment := range currentAttachments {
motion := getAttachmentMotionMedia(attachment)
if motion == nil || motion.GroupId == "" {
continue
}
currentGroups[motion.GroupId] = append(currentGroups[motion.GroupId], attachment)
}
requestGroups := make(map[string][]*store.Attachment)
requestNamesByGroup := make(map[string]map[string]bool)
for _, attachment := range requestedAttachments {
motion := getAttachmentMotionMedia(attachment)
if motion == nil || motion.GroupId == "" {
continue
}
requestGroups[motion.GroupId] = append(requestGroups[motion.GroupId], attachment)
if requestNamesByGroup[motion.GroupId] == nil {
requestNamesByGroup[motion.GroupId] = make(map[string]bool)
}
requestNamesByGroup[motion.GroupId][attachment.UID] = true
}
normalized := make([]*store.Attachment, 0, len(requestedAttachments))
appendedGroups := make(map[string]bool)
appendedAttachments := make(map[string]bool)
for _, attachment := range requestedAttachments {
motion := getAttachmentMotionMedia(attachment)
if motion == nil || motion.GroupId == "" {
if !appendedAttachments[attachment.UID] {
normalized = append(normalized, attachment)
appendedAttachments[attachment.UID] = true
}
continue
}
groupID := motion.GroupId
if appendedGroups[groupID] {
continue
}
currentGroup := currentGroups[groupID]
if isMultiMemberMotionGroup(currentGroup) && !allGroupMembersRequested(currentGroup, requestNamesByGroup[groupID]) {
appendedGroups[groupID] = true
continue
}
for _, groupAttachment := range requestGroups[groupID] {
if appendedAttachments[groupAttachment.UID] {
continue
}
normalized = append(normalized, groupAttachment)
appendedAttachments[groupAttachment.UID] = true
}
appendedGroups[groupID] = true
}
return normalized, nil
}
func allGroupMembersRequested(group []*store.Attachment, requestedNames map[string]bool) bool {
if len(group) == 0 {
return false
}
for _, attachment := range group {
if !requestedNames[attachment.UID] {
return false
}
}
return true
}
func (s *APIV1Service) ListMemoAttachments(ctx context.Context, request *v1pb.ListMemoAttachmentsRequest) (*v1pb.ListMemoAttachmentsResponse, error) { func (s *APIV1Service) ListMemoAttachments(ctx context.Context, request *v1pb.ListMemoAttachmentsRequest) (*v1pb.ListMemoAttachmentsResponse, error) {
memoUID, err := ExtractMemoUIDFromName(request.Name) memoUID, err := ExtractMemoUIDFromName(request.Name)
if err != nil { if err != nil {
......
...@@ -6,6 +6,7 @@ import ( ...@@ -6,6 +6,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/usememos/memos/internal/testutil"
v1pb "github.com/usememos/memos/proto/gen/api/v1" v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store" storepb "github.com/usememos/memos/proto/gen/store"
apiv1 "github.com/usememos/memos/server/router/api/v1" apiv1 "github.com/usememos/memos/server/router/api/v1"
...@@ -112,3 +113,118 @@ func TestCreateAttachment(t *testing.T) { ...@@ -112,3 +113,118 @@ func TestCreateAttachment(t *testing.T) {
require.Equal(t, []byte("second-image"), secondBlob) require.Equal(t, []byte("second-image"), secondBlob)
}) })
} }
func TestCreateAttachmentMotionMedia(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
ctx := context.Background()
user, err := ts.CreateRegularUser(ctx, "motion_user")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, user.ID)
t.Run("Apple live photo metadata roundtrip", func(t *testing.T) {
attachment, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{
Attachment: &v1pb.Attachment{
Filename: "live.heic",
Type: "image/heic",
Content: []byte("fake-heic-still"),
MotionMedia: &v1pb.MotionMedia{
Family: v1pb.MotionMediaFamily_APPLE_LIVE_PHOTO,
Role: v1pb.MotionMediaRole_STILL,
GroupId: "apple-group-1",
},
},
})
require.NoError(t, err)
require.NotNil(t, attachment.MotionMedia)
require.Equal(t, v1pb.MotionMediaFamily_APPLE_LIVE_PHOTO, attachment.MotionMedia.Family)
require.Equal(t, v1pb.MotionMediaRole_STILL, attachment.MotionMedia.Role)
require.Equal(t, "apple-group-1", attachment.MotionMedia.GroupId)
})
t.Run("Android motion photo detection", func(t *testing.T) {
attachment, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{
Attachment: &v1pb.Attachment{
Filename: "motion.jpg",
Type: "image/jpeg",
Content: testutil.BuildMotionPhotoJPEG(),
},
})
require.NoError(t, err)
require.NotNil(t, attachment.MotionMedia)
require.Equal(t, v1pb.MotionMediaFamily_ANDROID_MOTION_PHOTO, attachment.MotionMedia.Family)
require.Equal(t, v1pb.MotionMediaRole_CONTAINER, attachment.MotionMedia.Role)
require.True(t, attachment.MotionMedia.HasEmbeddedVideo)
})
}
func TestBatchDeleteAttachments(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
ctx := context.Background()
user, err := ts.CreateRegularUser(ctx, "delete_user")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, user.ID)
first, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{
Attachment: &v1pb.Attachment{Filename: "one.txt", Type: "text/plain", Content: []byte("one")},
})
require.NoError(t, err)
second, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{
Attachment: &v1pb.Attachment{Filename: "two.txt", Type: "text/plain", Content: []byte("two")},
})
require.NoError(t, err)
_, err = ts.Service.BatchDeleteAttachments(userCtx, &v1pb.BatchDeleteAttachmentsRequest{
Names: []string{first.Name, second.Name},
})
require.NoError(t, err)
firstUID, err := apiv1.ExtractAttachmentUIDFromName(first.Name)
require.NoError(t, err)
secondUID, err := apiv1.ExtractAttachmentUIDFromName(second.Name)
require.NoError(t, err)
storedFirst, err := ts.Store.GetAttachment(ctx, &store.FindAttachment{UID: &firstUID})
require.NoError(t, err)
storedSecond, err := ts.Store.GetAttachment(ctx, &store.FindAttachment{UID: &secondUID})
require.NoError(t, err)
require.Nil(t, storedFirst)
require.Nil(t, storedSecond)
t.Run("deduplicates duplicate names", func(t *testing.T) {
third, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{
Attachment: &v1pb.Attachment{Filename: "three.txt", Type: "text/plain", Content: []byte("three")},
})
require.NoError(t, err)
_, err = ts.Service.BatchDeleteAttachments(userCtx, &v1pb.BatchDeleteAttachmentsRequest{
Names: []string{third.Name, third.Name},
})
require.NoError(t, err)
thirdUID, err := apiv1.ExtractAttachmentUIDFromName(third.Name)
require.NoError(t, err)
storedThird, err := ts.Store.GetAttachment(ctx, &store.FindAttachment{UID: &thirdUID})
require.NoError(t, err)
require.Nil(t, storedThird)
})
t.Run("rejects unauthorized deletes", func(t *testing.T) {
ownerAttachment, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{
Attachment: &v1pb.Attachment{Filename: "private.txt", Type: "text/plain", Content: []byte("private")},
})
require.NoError(t, err)
otherUser, err := ts.CreateRegularUser(ctx, "other_delete_user")
require.NoError(t, err)
otherCtx := ts.CreateUserContext(ctx, otherUser.ID)
_, err = ts.Service.BatchDeleteAttachments(otherCtx, &v1pb.BatchDeleteAttachmentsRequest{
Names: []string{ownerAttachment.Name},
})
require.Error(t, err)
require.Contains(t, err.Error(), "permission denied")
})
}
...@@ -163,4 +163,64 @@ func TestSetMemoAttachments(t *testing.T) { ...@@ -163,4 +163,64 @@ func TestSetMemoAttachments(t *testing.T) {
require.Error(t, err) require.Error(t, err)
require.Contains(t, err.Error(), "not found") require.Contains(t, err.Error(), "not found")
}) })
t.Run("SetMemoAttachments removes incomplete live photo groups", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
user, err := ts.CreateRegularUser(ctx, "live_group_user")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, user.ID)
still, err := ts.Service.CreateAttachment(userCtx, &apiv1.CreateAttachmentRequest{
Attachment: &apiv1.Attachment{
Filename: "live.heic",
Type: "image/heic",
Content: []byte("still"),
MotionMedia: &apiv1.MotionMedia{
Family: apiv1.MotionMediaFamily_APPLE_LIVE_PHOTO,
Role: apiv1.MotionMediaRole_STILL,
GroupId: "memo-live-group",
},
},
})
require.NoError(t, err)
video, err := ts.Service.CreateAttachment(userCtx, &apiv1.CreateAttachmentRequest{
Attachment: &apiv1.Attachment{
Filename: "live.mov",
Type: "video/quicktime",
Content: []byte("video"),
MotionMedia: &apiv1.MotionMedia{
Family: apiv1.MotionMediaFamily_APPLE_LIVE_PHOTO,
Role: apiv1.MotionMediaRole_VIDEO,
GroupId: "memo-live-group",
},
},
})
require.NoError(t, err)
memo, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
Memo: &apiv1.Memo{
Content: "memo with live photo",
Visibility: apiv1.Visibility_PRIVATE,
Attachments: []*apiv1.Attachment{
{Name: still.Name},
{Name: video.Name},
},
},
})
require.NoError(t, err)
_, err = ts.Service.SetMemoAttachments(userCtx, &apiv1.SetMemoAttachmentsRequest{
Name: memo.Name,
Attachments: []*apiv1.Attachment{
{Name: still.Name},
},
})
require.NoError(t, err)
response, err := ts.Service.ListMemoAttachments(userCtx, &apiv1.ListMemoAttachmentsRequest{Name: memo.Name})
require.NoError(t, err)
require.Len(t, response.Attachments, 0)
})
} }
...@@ -19,6 +19,7 @@ import ( ...@@ -19,6 +19,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"golang.org/x/sync/semaphore" "golang.org/x/sync/semaphore"
"github.com/usememos/memos/internal/motionphoto"
"github.com/usememos/memos/internal/profile" "github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/plugin/storage/s3" "github.com/usememos/memos/plugin/storage/s3"
storepb "github.com/usememos/memos/proto/gen/store" storepb "github.com/usememos/memos/proto/gen/store"
...@@ -31,6 +32,9 @@ const ( ...@@ -31,6 +32,9 @@ const (
// ThumbnailCacheFolder is the folder name where thumbnail images are stored. // ThumbnailCacheFolder is the folder name where thumbnail images are stored.
ThumbnailCacheFolder = ".thumbnail_cache" ThumbnailCacheFolder = ".thumbnail_cache"
// MotionCacheFolder is the folder name where extracted motion clips are stored.
MotionCacheFolder = ".motion_cache"
// thumbnailMaxSize is the maximum dimension (width or height) for thumbnails. // thumbnailMaxSize is the maximum dimension (width or height) for thumbnails.
thumbnailMaxSize = 600 thumbnailMaxSize = 600
...@@ -122,6 +126,7 @@ func (s *FileServerService) serveAttachmentFile(c *echo.Context) error { ...@@ -122,6 +126,7 @@ func (s *FileServerService) serveAttachmentFile(c *echo.Context) error {
ctx := c.Request().Context() ctx := c.Request().Context()
uid := c.Param("uid") uid := c.Param("uid")
wantThumbnail := c.QueryParam("thumbnail") == "true" wantThumbnail := c.QueryParam("thumbnail") == "true"
wantMotion := c.QueryParam("motion") == "true"
attachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{ attachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{
UID: &uid, UID: &uid,
...@@ -138,6 +143,10 @@ func (s *FileServerService) serveAttachmentFile(c *echo.Context) error { ...@@ -138,6 +143,10 @@ func (s *FileServerService) serveAttachmentFile(c *echo.Context) error {
return err return err
} }
if wantMotion {
return s.serveMotionClip(c, attachment)
}
contentType := s.sanitizeContentType(attachment.Type) contentType := s.sanitizeContentType(attachment.Type)
// Stream video/audio to avoid loading entire file into memory. // Stream video/audio to avoid loading entire file into memory.
...@@ -226,6 +235,7 @@ func (s *FileServerService) serveStaticFile(c *echo.Context, attachment *store.A ...@@ -226,6 +235,7 @@ func (s *FileServerService) serveStaticFile(c *echo.Context, attachment *store.A
slog.Warn("failed to get thumbnail", "error", err) slog.Warn("failed to get thumbnail", "error", err)
} else { } else {
blob = thumbnailBlob blob = thumbnailBlob
contentType = "image/jpeg"
} }
} }
...@@ -411,7 +421,7 @@ func (s *FileServerService) getThumbnailPath(attachment *store.Attachment) (stri ...@@ -411,7 +421,7 @@ func (s *FileServerService) getThumbnailPath(attachment *store.Attachment) (stri
if err := os.MkdirAll(cacheFolder, os.ModePerm); err != nil { if err := os.MkdirAll(cacheFolder, os.ModePerm); err != nil {
return "", errors.Wrap(err, "failed to create thumbnail cache folder") return "", errors.Wrap(err, "failed to create thumbnail cache folder")
} }
filename := fmt.Sprintf("%d%s", attachment.ID, filepath.Ext(attachment.Filename)) filename := fmt.Sprintf("%s.jpeg", attachment.UID)
return filepath.Join(cacheFolder, filename), nil return filepath.Join(cacheFolder, filename), nil
} }
...@@ -443,7 +453,13 @@ func (s *FileServerService) generateThumbnail(attachment *store.Attachment, thum ...@@ -443,7 +453,13 @@ func (s *FileServerService) generateThumbnail(attachment *store.Attachment, thum
thumbnailImage := imaging.Resize(img, thumbnailWidth, thumbnailHeight, imaging.Lanczos) thumbnailImage := imaging.Resize(img, thumbnailWidth, thumbnailHeight, imaging.Lanczos)
if err := imaging.Save(thumbnailImage, thumbnailPath); err != nil { output, err := os.Create(thumbnailPath)
if err != nil {
return nil, errors.Wrap(err, "failed to create thumbnail file")
}
defer output.Close()
if err := imaging.Encode(output, thumbnailImage, imaging.JPEG, imaging.JPEGQuality(90)); err != nil {
return nil, errors.Wrap(err, "failed to save thumbnail") return nil, errors.Wrap(err, "failed to save thumbnail")
} }
...@@ -463,6 +479,60 @@ func calculateThumbnailDimensions(width, height int) (int, int) { ...@@ -463,6 +479,60 @@ func calculateThumbnailDimensions(width, height int) (int, int) {
return 0, thumbnailMaxSize // Portrait: constrain height. return 0, thumbnailMaxSize // Portrait: constrain height.
} }
func (s *FileServerService) serveMotionClip(c *echo.Context, attachment *store.Attachment) error {
motionMedia := attachment.Payload.GetMotionMedia()
if motionMedia == nil || motionMedia.Family != storepb.MotionMediaFamily_ANDROID_MOTION_PHOTO || !motionMedia.HasEmbeddedVideo {
return echo.NewHTTPError(http.StatusBadRequest, "attachment does not have motion clip")
}
clipBlob, err := s.getOrExtractMotionClip(c.Request().Context(), attachment)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get motion clip").Wrap(err)
}
setSecurityHeaders(c)
setMediaHeaders(c, "video/mp4", "video/mp4")
modTime := time.Unix(attachment.UpdatedTs, 0)
http.ServeContent(c.Response(), c.Request(), attachment.UID+".mp4", modTime, bytes.NewReader(clipBlob))
return nil
}
func (s *FileServerService) getOrExtractMotionClip(_ context.Context, attachment *store.Attachment) ([]byte, error) {
motionPath, err := s.getMotionPath(attachment)
if err != nil {
return nil, err
}
if blob, err := s.readCachedThumbnail(motionPath); err == nil {
return blob, nil
}
blob, err := s.getAttachmentBlob(attachment)
if err != nil {
return nil, err
}
videoBlob, _ := motionphoto.ExtractVideo(blob)
if len(videoBlob) == 0 {
return nil, errors.New("motion video not found")
}
if err := os.WriteFile(motionPath, videoBlob, 0644); err != nil {
return nil, errors.Wrap(err, "failed to cache motion clip")
}
return videoBlob, nil
}
func (s *FileServerService) getMotionPath(attachment *store.Attachment) (string, error) {
cacheFolder := filepath.Join(s.Profile.Data, MotionCacheFolder)
if err := os.MkdirAll(cacheFolder, os.ModePerm); err != nil {
return "", errors.Wrap(err, "failed to create motion cache folder")
}
return filepath.Join(cacheFolder, attachment.UID+".mp4"), nil
}
// ============================================================================= // =============================================================================
// Authentication & Authorization // Authentication & Authorization
// ============================================================================= // =============================================================================
......
...@@ -12,6 +12,7 @@ import ( ...@@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/usememos/memos/internal/profile" "github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/internal/testutil"
"github.com/usememos/memos/plugin/markdown" "github.com/usememos/memos/plugin/markdown"
apiv1 "github.com/usememos/memos/proto/gen/api/v1" apiv1 "github.com/usememos/memos/proto/gen/api/v1"
"github.com/usememos/memos/server/auth" "github.com/usememos/memos/server/auth"
...@@ -139,6 +140,51 @@ func TestServeAttachmentFile_ShareTokenRejectsCommentAttachment(t *testing.T) { ...@@ -139,6 +140,51 @@ func TestServeAttachmentFile_ShareTokenRejectsCommentAttachment(t *testing.T) {
require.Equal(t, http.StatusUnauthorized, rec.Code) require.Equal(t, http.StatusUnauthorized, rec.Code)
} }
func TestServeAttachmentFile_MotionClip(t *testing.T) {
ctx := context.Background()
svc, fs, _, cleanup := newShareAttachmentTestServices(ctx, t)
defer cleanup()
creator, err := svc.Store.CreateUser(ctx, &store.User{
Username: "motion-owner",
Role: store.RoleUser,
Email: "motion-owner@example.com",
})
require.NoError(t, err)
creatorCtx := context.WithValue(ctx, auth.UserIDContextKey, creator.ID)
attachment, err := svc.CreateAttachment(creatorCtx, &apiv1.CreateAttachmentRequest{
Attachment: &apiv1.Attachment{
Filename: "motion.jpg",
Type: "image/jpeg",
Content: testutil.BuildMotionPhotoJPEG(),
},
})
require.NoError(t, err)
_, err = svc.CreateMemo(creatorCtx, &apiv1.CreateMemoRequest{
Memo: &apiv1.Memo{
Content: "motion memo",
Visibility: apiv1.Visibility_PUBLIC,
Attachments: []*apiv1.Attachment{
{Name: attachment.Name},
},
},
})
require.NoError(t, err)
e := echo.New()
fs.RegisterRoutes(e)
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/file/%s/%s?motion=true", attachment.Name, attachment.Filename), nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "video/mp4", rec.Header().Get("Content-Type"))
require.Contains(t, rec.Body.String(), "ftyp")
}
func newShareAttachmentTestServices(ctx context.Context, t *testing.T) (*apiv1service.APIV1Service, *FileServerService, *store.Store, func()) { func newShareAttachmentTestServices(ctx context.Context, t *testing.T) (*apiv1service.APIV1Service, *FileServerService, *store.Store, func()) {
t.Helper() t.Helper()
......
...@@ -71,6 +71,11 @@ type DeleteAttachment struct { ...@@ -71,6 +71,11 @@ type DeleteAttachment struct {
MemoID *int32 MemoID *int32
} }
const (
thumbnailCacheFolder = ".thumbnail_cache"
motionCacheFolder = ".motion_cache"
)
func (s *Store) CreateAttachment(ctx context.Context, create *Attachment) (*Attachment, error) { func (s *Store) CreateAttachment(ctx context.Context, create *Attachment) (*Attachment, error) {
if !base.UIDMatcher.MatchString(create.UID) { if !base.UIDMatcher.MatchString(create.UID) {
return nil, errors.New("invalid uid") return nil, errors.New("invalid uid")
...@@ -123,6 +128,56 @@ func (s *Store) DeleteAttachment(ctx context.Context, delete *DeleteAttachment) ...@@ -123,6 +128,56 @@ func (s *Store) DeleteAttachment(ctx context.Context, delete *DeleteAttachment)
return errors.New("attachment not found") return errors.New("attachment not found")
} }
if err := s.DeleteAttachmentStorage(ctx, attachment); err != nil {
if attachment.StorageType == storepb.AttachmentStorageType_LOCAL {
return errors.Wrap(err, "failed to delete local file")
}
slog.Warn("Failed to delete attachment storage", slog.Any("err", err))
}
return s.driver.DeleteAttachment(ctx, delete)
}
func (s *Store) DeleteAttachments(ctx context.Context, attachments []*Attachment) error {
if len(attachments) == 0 {
return nil
}
deletes := make([]*DeleteAttachment, 0, len(attachments))
for _, attachment := range attachments {
if attachment == nil {
continue
}
deletes = append(deletes, &DeleteAttachment{ID: attachment.ID, MemoID: attachment.MemoID})
}
if len(deletes) == 0 {
return nil
}
if err := s.driver.DeleteAttachments(ctx, deletes); err != nil {
return err
}
for _, attachment := range attachments {
if attachment == nil {
continue
}
if err := s.DeleteAttachmentStorage(ctx, attachment); err != nil {
if attachment.StorageType == storepb.AttachmentStorageType_LOCAL {
return errors.Wrap(err, "failed to delete local file")
}
slog.Warn("Failed to delete attachment storage", slog.Any("err", err))
}
}
return nil
}
func (s *Store) DeleteAttachmentStorage(ctx context.Context, attachment *Attachment) error {
if attachment == nil {
return nil
}
if attachment.StorageType == storepb.AttachmentStorageType_LOCAL { if attachment.StorageType == storepb.AttachmentStorageType_LOCAL {
if err := func() error { if err := func() error {
p := filepath.FromSlash(attachment.Reference) p := filepath.FromSlash(attachment.Reference)
...@@ -135,7 +190,7 @@ func (s *Store) DeleteAttachment(ctx context.Context, delete *DeleteAttachment) ...@@ -135,7 +190,7 @@ func (s *Store) DeleteAttachment(ctx context.Context, delete *DeleteAttachment)
} }
return nil return nil
}(); err != nil { }(); err != nil {
return errors.Wrap(err, "failed to delete local file") return err
} }
} else if attachment.StorageType == storepb.AttachmentStorageType_S3 { } else if attachment.StorageType == storepb.AttachmentStorageType_S3 {
if err := func() error { if err := func() error {
...@@ -164,9 +219,21 @@ func (s *Store) DeleteAttachment(ctx context.Context, delete *DeleteAttachment) ...@@ -164,9 +219,21 @@ func (s *Store) DeleteAttachment(ctx context.Context, delete *DeleteAttachment)
} }
return nil return nil
}(); err != nil { }(); err != nil {
slog.Warn("Failed to delete s3 object", slog.Any("err", err)) return err
} }
} }
return s.driver.DeleteAttachment(ctx, delete) s.deleteAttachmentDerivedCaches(attachment)
return nil
}
func (s *Store) deleteAttachmentDerivedCaches(attachment *Attachment) {
for _, cachePath := range []string{
filepath.Join(s.profile.Data, thumbnailCacheFolder, attachment.UID+".jpeg"),
filepath.Join(s.profile.Data, motionCacheFolder, attachment.UID+".mp4"),
} {
if err := os.Remove(cachePath); err != nil && !os.IsNotExist(err) {
slog.Warn("Failed to delete derived attachment cache", slog.String("path", cachePath), slog.Any("err", err))
}
}
} }
...@@ -239,3 +239,36 @@ func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachmen ...@@ -239,3 +239,36 @@ func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachmen
return nil return nil
} }
func (d *DB) DeleteAttachments(ctx context.Context, deletes []*store.DeleteAttachment) error {
if len(deletes) == 0 {
return nil
}
tx, err := d.db.BeginTx(ctx, nil)
if err != nil {
return errors.Wrap(err, "failed to start attachment delete transaction")
}
defer func() {
if tx != nil {
_ = tx.Rollback()
}
}()
stmt := "DELETE FROM `attachment` WHERE `id` = ?"
for _, delete := range deletes {
result, err := tx.ExecContext(ctx, stmt, delete.ID)
if err != nil {
return err
}
if _, err := result.RowsAffected(); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
tx = nil
return nil
}
...@@ -219,3 +219,36 @@ func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachmen ...@@ -219,3 +219,36 @@ func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachmen
} }
return nil return nil
} }
func (d *DB) DeleteAttachments(ctx context.Context, deletes []*store.DeleteAttachment) error {
if len(deletes) == 0 {
return nil
}
tx, err := d.db.BeginTx(ctx, nil)
if err != nil {
return errors.Wrap(err, "failed to start attachment delete transaction")
}
defer func() {
if tx != nil {
_ = tx.Rollback()
}
}()
stmt := `DELETE FROM attachment WHERE id = $1`
for _, delete := range deletes {
result, err := tx.ExecContext(ctx, stmt, delete.ID)
if err != nil {
return err
}
if _, err := result.RowsAffected(); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
tx = nil
return nil
}
...@@ -219,3 +219,36 @@ func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachmen ...@@ -219,3 +219,36 @@ func (d *DB) DeleteAttachment(ctx context.Context, delete *store.DeleteAttachmen
} }
return nil return nil
} }
func (d *DB) DeleteAttachments(ctx context.Context, deletes []*store.DeleteAttachment) error {
if len(deletes) == 0 {
return nil
}
tx, err := d.db.BeginTx(ctx, nil)
if err != nil {
return errors.Wrap(err, "failed to start attachment delete transaction")
}
defer func() {
if tx != nil {
_ = tx.Rollback()
}
}()
stmt := "DELETE FROM `attachment` WHERE `id` = ?"
for _, delete := range deletes {
result, err := tx.ExecContext(ctx, stmt, delete.ID)
if err != nil {
return err
}
if _, err := result.RowsAffected(); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
tx = nil
return nil
}
...@@ -18,6 +18,7 @@ type Driver interface { ...@@ -18,6 +18,7 @@ type Driver interface {
ListAttachments(ctx context.Context, find *FindAttachment) ([]*Attachment, error) ListAttachments(ctx context.Context, find *FindAttachment) ([]*Attachment, error)
UpdateAttachment(ctx context.Context, update *UpdateAttachment) error UpdateAttachment(ctx context.Context, update *UpdateAttachment) error
DeleteAttachment(ctx context.Context, delete *DeleteAttachment) error DeleteAttachment(ctx context.Context, delete *DeleteAttachment) error
DeleteAttachments(ctx context.Context, deletes []*DeleteAttachment) error
// Memo model related methods. // Memo model related methods.
CreateMemo(ctx context.Context, create *Memo) (*Memo, error) CreateMemo(ctx context.Context, create *Memo) (*Memo, error)
......
...@@ -12,6 +12,7 @@ export const EditorMetadata: FC<EditorMetadataProps> = ({ memoName }) => { ...@@ -12,6 +12,7 @@ export const EditorMetadata: FC<EditorMetadataProps> = ({ memoName }) => {
attachments={state.metadata.attachments} attachments={state.metadata.attachments}
localFiles={state.localFiles} localFiles={state.localFiles}
onAttachmentsChange={(attachments) => dispatch(actions.setMetadata({ attachments }))} onAttachmentsChange={(attachments) => dispatch(actions.setMetadata({ attachments }))}
onLocalFilesChange={(localFiles) => dispatch(actions.setLocalFiles(localFiles))}
onRemoveLocalFile={(previewUrl) => dispatch(actions.removeLocalFile(previewUrl))} onRemoveLocalFile={(previewUrl) => dispatch(actions.removeLocalFile(previewUrl))}
/> />
......
import { create } from "@bufbuild/protobuf";
import { useRef } from "react"; import { useRef } from "react";
import { type MotionMedia, MotionMediaFamily, MotionMediaRole, MotionMediaSchema } from "@/types/proto/api/v1/attachment_service_pb";
import type { LocalFile } from "../types/attachment"; import type { LocalFile } from "../types/attachment";
export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void) => { export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void) => {
...@@ -11,10 +13,12 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void ...@@ -11,10 +13,12 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void
return; return;
} }
selectingFlagRef.current = true; selectingFlagRef.current = true;
const localFiles: LocalFile[] = files.map((file) => ({ const localFiles: LocalFile[] = pairAppleLivePhotoFiles(
files.map((file) => ({
file, file,
previewUrl: URL.createObjectURL(file), previewUrl: URL.createObjectURL(file),
})); })),
);
onFilesSelected(localFiles); onFilesSelected(localFiles);
selectingFlagRef.current = false; selectingFlagRef.current = false;
// Optionally clear input value to allow re-selecting the same file // Optionally clear input value to allow re-selecting the same file
...@@ -32,3 +36,53 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void ...@@ -32,3 +36,53 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void
handleUploadClick, handleUploadClick,
}; };
}; };
const pairAppleLivePhotoFiles = (localFiles: LocalFile[]): LocalFile[] => {
const stemMap = new Map<string, LocalFile[]>();
for (const localFile of localFiles) {
const stem = normalizeFilenameStem(localFile.file.name);
const group = stemMap.get(stem) ?? [];
group.push(localFile);
stemMap.set(stem, group);
}
const groupIds = new Map<string, string>();
return localFiles.map((localFile) => {
const stem = normalizeFilenameStem(localFile.file.name);
const group = stemMap.get(stem) ?? [];
const images = group.filter((item) => item.file.type.startsWith("image/"));
const videos = group.filter((item) => item.file.type.startsWith("video/"));
if (images.length !== 1 || videos.length !== 1) {
return localFile;
}
const image = images[0];
const video = videos[0];
const groupId = groupIds.get(stem) ?? `${stem}-${crypto.randomUUID()}`;
groupIds.set(stem, groupId);
if (localFile.previewUrl === image.previewUrl) {
return { ...localFile, motionMedia: buildLocalMotionMedia(groupId, MotionMediaRole.STILL) };
}
if (localFile.previewUrl === video.previewUrl) {
return { ...localFile, motionMedia: buildLocalMotionMedia(groupId, MotionMediaRole.VIDEO) };
}
return localFile;
});
};
const buildLocalMotionMedia = (groupId: string, role: MotionMediaRole): MotionMedia =>
create(MotionMediaSchema, {
family: MotionMediaFamily.APPLE_LIVE_PHOTO,
role,
groupId,
presentationTimestampUs: 0n,
hasEmbeddedVideo: false,
});
const normalizeFilenameStem = (filename: string): string => {
const parts = filename.split(".");
if (parts.length <= 1) {
return filename.toLowerCase();
}
return parts.slice(0, -1).join(".").toLowerCase();
};
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { attachmentServiceClient } from "@/connect"; import { attachmentServiceClient } from "@/connect";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb"; import { AttachmentSchema, MotionMediaSchema } from "@/types/proto/api/v1/attachment_service_pb";
import type { LocalFile } from "../types/attachment"; import type { LocalFile } from "../types/attachment";
export const uploadService = { export const uploadService = {
...@@ -10,7 +10,8 @@ export const uploadService = { ...@@ -10,7 +10,8 @@ export const uploadService = {
const attachments: Attachment[] = []; const attachments: Attachment[] = [];
for (const { file } of localFiles) { for (const localFile of localFiles) {
const { file, motionMedia } = localFile;
const buffer = new Uint8Array(await file.arrayBuffer()); const buffer = new Uint8Array(await file.arrayBuffer());
const attachment = await attachmentServiceClient.createAttachment({ const attachment = await attachmentServiceClient.createAttachment({
attachment: create(AttachmentSchema, { attachment: create(AttachmentSchema, {
...@@ -18,6 +19,7 @@ export const uploadService = { ...@@ -18,6 +19,7 @@ export const uploadService = {
size: BigInt(file.size), size: BigInt(file.size),
type: file.type, type: file.type,
content: buffer, content: buffer,
motionMedia: motionMedia ? create(MotionMediaSchema, motionMedia) : undefined,
}), }),
}); });
attachments.push(attachment); attachments.push(attachment);
......
...@@ -49,6 +49,11 @@ export const editorActions = { ...@@ -49,6 +49,11 @@ export const editorActions = {
payload: previewUrl, payload: previewUrl,
}), }),
setLocalFiles: (files: LocalFile[]): EditorAction => ({
type: "SET_LOCAL_FILES",
payload: files,
}),
clearLocalFiles: (): EditorAction => ({ clearLocalFiles: (): EditorAction => ({
type: "CLEAR_LOCAL_FILES", type: "CLEAR_LOCAL_FILES",
}), }),
......
...@@ -74,6 +74,12 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS ...@@ -74,6 +74,12 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
localFiles: state.localFiles.filter((f) => f.previewUrl !== action.payload), localFiles: state.localFiles.filter((f) => f.previewUrl !== action.payload),
}; };
case "SET_LOCAL_FILES":
return {
...state,
localFiles: action.payload,
};
case "CLEAR_LOCAL_FILES": case "CLEAR_LOCAL_FILES":
return { return {
...state, ...state,
......
...@@ -55,6 +55,7 @@ export type EditorAction = ...@@ -55,6 +55,7 @@ export type EditorAction =
| { type: "REMOVE_RELATION"; payload: string } | { type: "REMOVE_RELATION"; payload: string }
| { type: "ADD_LOCAL_FILE"; payload: LocalFile } | { type: "ADD_LOCAL_FILE"; payload: LocalFile }
| { type: "REMOVE_LOCAL_FILE"; payload: string } | { type: "REMOVE_LOCAL_FILE"; payload: string }
| { type: "SET_LOCAL_FILES"; payload: LocalFile[] }
| { type: "CLEAR_LOCAL_FILES" } | { type: "CLEAR_LOCAL_FILES" }
| { type: "TOGGLE_FOCUS_MODE" } | { type: "TOGGLE_FOCUS_MODE" }
| { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } } | { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } }
......
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import type { Attachment, MotionMedia } from "@/types/proto/api/v1/attachment_service_pb";
import { MotionMediaFamily, MotionMediaRole } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import { buildAttachmentVisualItems } from "@/utils/media-item";
export type FileCategory = "image" | "video" | "audio" | "document"; export type FileCategory = "image" | "video" | "motion" | "audio" | "document";
// Unified view model for rendering attachments and local files
export interface AttachmentItem { export interface AttachmentItem {
readonly id: string; readonly id: string;
readonly memberIds: string[];
readonly filename: string; readonly filename: string;
readonly category: FileCategory; readonly category: FileCategory;
readonly mimeType: string; readonly mimeType: string;
...@@ -15,25 +17,27 @@ export interface AttachmentItem { ...@@ -15,25 +17,27 @@ export interface AttachmentItem {
readonly isLocal: boolean; readonly isLocal: boolean;
} }
// For MemoEditor: local files being uploaded
export interface LocalFile { export interface LocalFile {
readonly file: File; readonly file: File;
readonly previewUrl: string; readonly previewUrl: string;
readonly motionMedia?: MotionMedia;
} }
function categorizeFile(mimeType: string): FileCategory { function categorizeFile(mimeType: string, motionMedia?: MotionMedia): FileCategory {
if (motionMedia) return "motion";
if (mimeType.startsWith("image/")) return "image"; if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("video/")) return "video"; if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("audio/")) return "audio"; if (mimeType.startsWith("audio/")) return "audio";
return "document"; return "document";
} }
export function attachmentToItem(attachment: Attachment): AttachmentItem { function attachmentGroupToItem(attachment: Attachment): AttachmentItem {
const attachmentType = getAttachmentType(attachment); const attachmentType = getAttachmentType(attachment);
const sourceUrl = getAttachmentUrl(attachment); const sourceUrl = getAttachmentUrl(attachment);
return { return {
id: attachment.name, id: attachment.name,
memberIds: [attachment.name],
filename: attachment.filename, filename: attachment.filename,
category: categorizeFile(attachment.type), category: categorizeFile(attachment.type),
mimeType: attachment.type, mimeType: attachment.type,
...@@ -44,21 +48,96 @@ export function attachmentToItem(attachment: Attachment): AttachmentItem { ...@@ -44,21 +48,96 @@ export function attachmentToItem(attachment: Attachment): AttachmentItem {
}; };
} }
export function fileToItem(file: File, blobUrl: string): AttachmentItem { function visualItemToAttachmentItem(item: ReturnType<typeof buildAttachmentVisualItems>[number]): AttachmentItem {
return { return {
id: blobUrl, id: item.id,
filename: file.name, memberIds: item.attachmentNames,
category: categorizeFile(file.type), filename: item.filename,
mimeType: file.type, category: item.kind === "motion" ? "motion" : item.kind,
thumbnailUrl: blobUrl, mimeType: item.mimeType,
sourceUrl: blobUrl, thumbnailUrl: item.posterUrl,
size: file.size, sourceUrl: item.sourceUrl,
size: item.attachments.reduce((total, attachment) => total + Number(attachment.size), 0),
isLocal: false,
};
}
function fileToItem(file: LocalFile): AttachmentItem {
return {
id: file.motionMedia?.groupId || file.previewUrl,
memberIds: [file.previewUrl],
filename: file.file.name,
category: categorizeFile(file.file.type, file.motionMedia),
mimeType: file.file.type,
thumbnailUrl: file.previewUrl,
sourceUrl: file.previewUrl,
size: file.file.size,
isLocal: true, isLocal: true,
}; };
} }
function toLocalMotionItems(localFiles: LocalFile[]): AttachmentItem[] {
const grouped = new Map<string, LocalFile[]>();
const singles: AttachmentItem[] = [];
for (const localFile of localFiles) {
const groupId = localFile.motionMedia?.groupId;
if (!groupId) {
singles.push(fileToItem(localFile));
continue;
}
const group = grouped.get(groupId) ?? [];
group.push(localFile);
grouped.set(groupId, group);
}
const groupedItems = Array.from(grouped.entries()).flatMap(([groupId, files]) => {
const still = files.find(
(file) => file.motionMedia?.family === MotionMediaFamily.APPLE_LIVE_PHOTO && file.motionMedia.role === MotionMediaRole.STILL,
);
const video = files.find(
(file) => file.motionMedia?.family === MotionMediaFamily.APPLE_LIVE_PHOTO && file.motionMedia.role === MotionMediaRole.VIDEO,
);
if (still && video && files.length === 2) {
return [
{
id: groupId,
memberIds: [still.previewUrl, video.previewUrl],
filename: still.file.name,
category: "motion" as const,
mimeType: still.file.type,
thumbnailUrl: still.previewUrl,
sourceUrl: video.previewUrl,
size: still.file.size + video.file.size,
isLocal: true,
},
];
}
return files.map(fileToItem);
});
return [...groupedItems, ...singles];
}
export function toAttachmentItems(attachments: Attachment[], localFiles: LocalFile[] = []): AttachmentItem[] { export function toAttachmentItems(attachments: Attachment[], localFiles: LocalFile[] = []): AttachmentItem[] {
return [...attachments.map(attachmentToItem), ...localFiles.map(({ file, previewUrl }) => fileToItem(file, previewUrl))]; const visualAttachments = attachments.filter((attachment) => {
const attachmentType = getAttachmentType(attachment);
return attachmentType === "image/*" || attachmentType === "video/*" || attachment.motionMedia !== undefined;
});
const attachmentVisualIds = new Set<string>();
const attachmentVisualItems = buildAttachmentVisualItems(visualAttachments).map((item) => {
item.attachmentNames.forEach((name) => attachmentVisualIds.add(name));
return visualItemToAttachmentItem(item);
});
const nonVisualAttachmentItems = attachments
.filter((attachment) => !attachmentVisualIds.has(attachment.name))
.map(attachmentGroupToItem)
.filter((item) => item.category === "audio" || item.category === "document");
return [...attachmentVisualItems, ...nonVisualAttachmentItems, ...toLocalMotionItems(localFiles)];
} }
export function filterByCategory(items: AttachmentItem[], categories: FileCategory[]): AttachmentItem[] { export function filterByCategory(items: AttachmentItem[], categories: FileCategory[]): AttachmentItem[] {
...@@ -71,7 +150,7 @@ export function separateMediaAndDocs(items: AttachmentItem[]): { media: Attachme ...@@ -71,7 +150,7 @@ export function separateMediaAndDocs(items: AttachmentItem[]): { media: Attachme
const docs: AttachmentItem[] = []; const docs: AttachmentItem[] = [];
for (const item of items) { for (const item of items) {
if (item.category === "image" || item.category === "video") { if (item.category === "image" || item.category === "video" || item.category === "motion") {
media.push(item); media.push(item);
} else { } else {
docs.push(item); docs.push(item);
......
...@@ -11,6 +11,7 @@ interface AttachmentListEditorProps { ...@@ -11,6 +11,7 @@ interface AttachmentListEditorProps {
attachments: Attachment[]; attachments: Attachment[];
localFiles?: LocalFile[]; localFiles?: LocalFile[];
onAttachmentsChange?: (attachments: Attachment[]) => void; onAttachmentsChange?: (attachments: Attachment[]) => void;
onLocalFilesChange?: (localFiles: LocalFile[]) => void;
onRemoveLocalFile?: (previewUrl: string) => void; onRemoveLocalFile?: (previewUrl: string) => void;
} }
...@@ -23,19 +24,24 @@ const AttachmentItemCard: FC<{ ...@@ -23,19 +24,24 @@ const AttachmentItemCard: FC<{
canMoveDown?: boolean; canMoveDown?: boolean;
}> = ({ item, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => { }> = ({ item, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => {
const { category, filename, thumbnailUrl, mimeType, size } = item; const { category, filename, thumbnailUrl, mimeType, size } = item;
const fileTypeLabel = getFileTypeLabel(mimeType); const fileTypeLabel = item.category === "motion" ? "Live Photo" : getFileTypeLabel(mimeType);
const fileSizeLabel = size ? formatFileSize(size) : undefined; const fileSizeLabel = size ? formatFileSize(size) : undefined;
const displayName = category === "audio" && /^voice-(recording|note)-/i.test(filename) ? "Voice note" : filename; const displayName = category === "audio" && /^voice-(recording|note)-/i.test(filename) ? "Voice note" : filename;
return ( return (
<div className="relative rounded border border-transparent px-1.5 py-1 transition-all hover:border-border hover:bg-accent/20"> <div className="relative rounded border border-transparent px-1.5 py-1 transition-all hover:border-border hover:bg-accent/20">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-muted/40"> <div className="relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-muted/40">
{category === "image" && thumbnailUrl ? ( {(category === "image" || category === "motion") && thumbnailUrl ? (
<img src={thumbnailUrl} alt="" className="h-full w-full object-cover" /> <img src={thumbnailUrl} alt="" className="h-full w-full object-cover" />
) : ( ) : (
<FileIcon className="h-3.5 w-3.5 text-muted-foreground" /> <FileIcon className="h-3.5 w-3.5 text-muted-foreground" />
)} )}
{category === "motion" && (
<span className="absolute inset-x-0 bottom-0 bg-black/70 text-center text-[7px] font-semibold uppercase tracking-wide text-white">
Live
</span>
)}
</div> </div>
<div className="min-w-0 flex-1 flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:gap-1.5"> <div className="min-w-0 flex-1 flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:gap-1.5">
...@@ -104,58 +110,87 @@ const AttachmentItemCard: FC<{ ...@@ -104,58 +110,87 @@ const AttachmentItemCard: FC<{
); );
}; };
const AttachmentListEditor: FC<AttachmentListEditorProps> = ({ attachments, localFiles = [], onAttachmentsChange, onRemoveLocalFile }) => { const AttachmentListEditor: FC<AttachmentListEditorProps> = ({
attachments,
localFiles = [],
onAttachmentsChange,
onLocalFilesChange,
onRemoveLocalFile,
}) => {
if (attachments.length === 0 && localFiles.length === 0) { if (attachments.length === 0 && localFiles.length === 0) {
return null; return null;
} }
const items = toAttachmentItems(attachments, localFiles); const items = toAttachmentItems(attachments, localFiles);
const attachmentItems = items.filter((item) => !item.isLocal);
const localItems = items.filter((item) => item.isLocal);
const handleMoveUp = (index: number) => { const handleMoveAttachments = (itemId: string, direction: -1 | 1) => {
if (index === 0 || !onAttachmentsChange) return; if (!onAttachmentsChange) return;
const newAttachments = [...attachments]; const itemIndex = attachmentItems.findIndex((item) => item.id === itemId);
[newAttachments[index - 1], newAttachments[index]] = [newAttachments[index], newAttachments[index - 1]]; const targetIndex = itemIndex + direction;
onAttachmentsChange(newAttachments); if (itemIndex < 0 || targetIndex < 0 || targetIndex >= attachmentItems.length) {
}; return;
}
const handleMoveDown = (index: number) => { const reorderedItems = [...attachmentItems];
if (index === attachments.length - 1 || !onAttachmentsChange) return; [reorderedItems[itemIndex], reorderedItems[targetIndex]] = [reorderedItems[targetIndex], reorderedItems[itemIndex]];
const newAttachments = [...attachments]; const attachmentMap = new Map(attachments.map((attachment) => [attachment.name, attachment]));
[newAttachments[index], newAttachments[index + 1]] = [newAttachments[index + 1], newAttachments[index]]; onAttachmentsChange(
onAttachmentsChange(newAttachments); reorderedItems.flatMap((item) => item.memberIds.map((memberId) => attachmentMap.get(memberId)).filter(Boolean) as Attachment[]),
);
}; };
const handleRemoveAttachment = (name: string) => { const handleMoveLocalFiles = (itemId: string, direction: -1 | 1) => {
if (onAttachmentsChange) { if (!onLocalFilesChange) return;
onAttachmentsChange(attachments.filter((attachment) => attachment.name !== name));
const itemIndex = localItems.findIndex((item) => item.id === itemId);
const targetIndex = itemIndex + direction;
if (itemIndex < 0 || targetIndex < 0 || targetIndex >= localItems.length) {
return;
} }
const reorderedItems = [...localItems];
[reorderedItems[itemIndex], reorderedItems[targetIndex]] = [reorderedItems[targetIndex], reorderedItems[itemIndex]];
const localFileMap = new Map(localFiles.map((localFile) => [localFile.previewUrl, localFile]));
onLocalFilesChange(
reorderedItems.flatMap((item) => item.memberIds.map((memberId) => localFileMap.get(memberId)).filter(Boolean) as LocalFile[]),
);
}; };
const handleRemoveItem = (item: (typeof items)[0]) => { const handleRemoveItem = (item: AttachmentItem) => {
if (item.isLocal) { if (item.isLocal) {
onRemoveLocalFile?.(item.id); const nextLocalFiles = localFiles.filter((file) => !item.memberIds.includes(file.previewUrl));
} else { onLocalFilesChange?.(nextLocalFiles);
handleRemoveAttachment(item.id); if (!onLocalFilesChange) {
item.memberIds.forEach((previewUrl) => onRemoveLocalFile?.(previewUrl));
}
return;
}
if (onAttachmentsChange) {
onAttachmentsChange(attachments.filter((attachment) => !item.memberIds.includes(attachment.name)));
} }
}; };
return ( return (
<MetadataSection icon={PaperclipIcon} title="Attachments" count={items.length} contentClassName="flex flex-col gap-0.5 p-1 sm:p-1.5"> <MetadataSection icon={PaperclipIcon} title="Attachments" count={items.length} contentClassName="flex flex-col gap-0.5 p-1 sm:p-1.5">
{items.map((item) => { {items.map((item) => {
const isLocalFile = item.isLocal; const itemList = item.isLocal ? localItems : attachmentItems;
const attachmentIndex = isLocalFile ? -1 : attachments.findIndex((a) => a.name === item.id); const itemIndex = itemList.findIndex((entry) => entry.id === item.id);
return ( return (
<AttachmentItemCard <AttachmentItemCard
key={item.id} key={item.id}
item={item} item={item}
onRemove={() => handleRemoveItem(item)} onRemove={() => handleRemoveItem(item)}
onMoveUp={!isLocalFile ? () => handleMoveUp(attachmentIndex) : undefined} onMoveUp={item.isLocal ? () => handleMoveLocalFiles(item.id, -1) : () => handleMoveAttachments(item.id, -1)}
onMoveDown={!isLocalFile ? () => handleMoveDown(attachmentIndex) : undefined} onMoveDown={item.isLocal ? () => handleMoveLocalFiles(item.id, 1) : () => handleMoveAttachments(item.id, 1)}
canMoveUp={!isLocalFile && attachmentIndex > 0} canMoveUp={itemIndex > 0}
canMoveDown={!isLocalFile && attachmentIndex < attachments.length - 1} canMoveDown={itemIndex >= 0 && itemIndex < itemList.length - 1}
/> />
); );
})} })}
......
import { DownloadIcon, FileIcon, Maximize2Icon, PaperclipIcon, PlayIcon } from "lucide-react"; import { DownloadIcon, FileIcon, PaperclipIcon, PlayIcon } from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
import MetadataSection from "@/components/MemoMetadata/MetadataSection"; import MetadataSection from "@/components/MemoMetadata/MetadataSection";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentUrl } from "@/utils/attachment"; import { getAttachmentUrl } from "@/utils/attachment";
import AttachmentCard from "./AttachmentCard"; import type { PreviewMediaItem } from "@/utils/media-item";
import { buildAttachmentVisualItems } from "@/utils/media-item";
import AudioAttachmentItem from "./AudioAttachmentItem"; import AudioAttachmentItem from "./AudioAttachmentItem";
import { getAttachmentMetadata, isImageAttachment, isVideoAttachment, separateAttachments } from "./attachmentHelpers"; import { getAttachmentMetadata, isAudioAttachment, separateAttachments } from "./attachmentHelpers";
interface AttachmentListViewProps { interface AttachmentListViewProps {
attachments: Attachment[]; attachments: Attachment[];
onImagePreview?: (urls: string[], index: number) => void; onImagePreview?: (items: PreviewMediaItem[], index: number) => void;
} }
const AttachmentMeta = ({ attachment }: { attachment: Attachment }) => { const AttachmentMeta = ({ attachment }: { attachment: Attachment }) => {
...@@ -48,21 +49,26 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => { ...@@ -48,21 +49,26 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => {
); );
}; };
interface VisualItemProps { const MotionBadge = () => (
attachment: Attachment; <span className="pointer-events-none absolute left-2 top-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white backdrop-blur-sm">
featured?: boolean; LIVE
} </span>
);
const ImageItem = ({ attachment, onImageClick, featured = false }: VisualItemProps & { onImageClick?: (url: string) => void }) => {
const handleClick = () => {
onImageClick?.(getAttachmentUrl(attachment));
};
const MotionItem = ({
item,
featured = false,
onPreview,
}: {
item: ReturnType<typeof buildAttachmentVisualItems>[number];
featured?: boolean;
onPreview?: () => void;
}) => {
return ( return (
<button <button
type="button" type="button"
className={cn("group block w-full text-left", featured ? "max-w-[18rem] sm:max-w-[20rem]" : "")} className={cn("group block w-full text-left", featured ? "max-w-[18rem] sm:max-w-[20rem]" : "")}
onClick={handleClick} onClick={onPreview}
> >
<div <div
className={cn( className={cn(
...@@ -70,77 +76,54 @@ const ImageItem = ({ attachment, onImageClick, featured = false }: VisualItemPro ...@@ -70,77 +76,54 @@ const ImageItem = ({ attachment, onImageClick, featured = false }: VisualItemPro
featured ? "aspect-[4/3]" : "aspect-square", featured ? "aspect-[4/3]" : "aspect-square",
)} )}
> >
<AttachmentCard {item.kind === "video" ? (
attachment={attachment} <video
className="h-full w-full rounded-none transition-transform duration-300 group-hover:scale-[1.02]" src={item.sourceUrl}
className="h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"
preload="metadata"
/> />
) : (
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/15 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100" /> <img
<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"> src={item.posterUrl}
<Maximize2Icon className="h-3.5 w-3.5" /> 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> </span>
)}
</div> </div>
</button> </button>
); );
}; };
const ImageGallery = ({ attachments, onImageClick }: { attachments: Attachment[]; onImageClick?: (url: string) => void }) => { const VisualGallery = ({
if (attachments.length === 1) { items,
onPreview,
}: {
items: ReturnType<typeof buildAttachmentVisualItems>;
onPreview?: (itemId: string) => void;
}) => {
if (items.length === 1) {
return ( return (
<div className="flex"> <div className="flex">
<ImageItem attachment={attachments[0]} featured onImageClick={onImageClick} /> <MotionItem item={items[0]} featured onPreview={() => onPreview?.(items[0].id)} />
</div> </div>
); );
} }
return ( return (
<div className="grid max-w-[22rem] grid-cols-2 gap-1.5 sm:max-w-[24rem]"> <div className="grid max-w-[22rem] grid-cols-2 gap-1.5 sm:max-w-[24rem]">
{attachments.map((attachment) => ( {items.map((item) => (
<ImageItem key={attachment.name} attachment={attachment} onImageClick={onImageClick} /> <MotionItem key={item.id} item={item} onPreview={() => onPreview?.(item.id)} />
))}
</div>
);
};
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> </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>
); );
}; };
...@@ -172,9 +155,9 @@ const Divider = () => <div className="border-t border-border/70 opacity-80" />; ...@@ -172,9 +155,9 @@ const Divider = () => <div className="border-t border-border/70 opacity-80" />;
const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewProps) => { const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewProps) => {
const { visual, audio, docs } = useMemo(() => separateAttachments(attachments), [attachments]); const { visual, audio, docs } = useMemo(() => separateAttachments(attachments), [attachments]);
const imageAttachments = useMemo(() => visual.filter(isImageAttachment), [visual]); const visualItems = useMemo(() => buildAttachmentVisualItems(visual), [visual]);
const imageUrls = useMemo(() => imageAttachments.map(getAttachmentUrl), [imageAttachments]); const previewItems = useMemo(() => visualItems.map((item) => item.previewItem), [visualItems]);
const hasVisual = visual.length > 0; const hasVisual = visualItems.length > 0;
const hasAudio = audio.length > 0; const hasAudio = audio.length > 0;
const hasDocs = docs.length > 0; const hasDocs = docs.length > 0;
const sectionCount = [hasVisual, hasAudio, hasDocs].filter(Boolean).length; const sectionCount = [hasVisual, hasAudio, hasDocs].filter(Boolean).length;
...@@ -183,16 +166,21 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP ...@@ -183,16 +166,21 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP
return null; return null;
} }
const handleImageClick = (imgUrl: string) => { const handlePreview = (itemId: string) => {
const index = imageUrls.findIndex((url) => url === imgUrl); const index = previewItems.findIndex((item) => item.id === itemId);
onImagePreview?.(imageUrls, index >= 0 ? index : 0); onImagePreview?.(previewItems, index >= 0 ? index : 0);
}; };
return ( return (
<MetadataSection icon={PaperclipIcon} title="Attachments" count={attachments.length} contentClassName="flex flex-col gap-2 p-2"> <MetadataSection
{hasVisual && <VisualSection attachments={visual} onImageClick={handleImageClick} />} icon={PaperclipIcon}
title="Attachments"
count={visualItems.length + audio.length + docs.length}
contentClassName="flex flex-col gap-2 p-2"
>
{hasVisual && <VisualGallery items={visualItems} onPreview={handlePreview} />}
{hasVisual && sectionCount > 1 && <Divider />} {hasVisual && sectionCount > 1 && <Divider />}
{hasAudio && <AudioList attachments={audio} />} {hasAudio && <AudioList attachments={audio.filter(isAudioAttachment)} />}
{hasAudio && hasDocs && <Divider />} {hasAudio && hasDocs && <Divider />}
{hasDocs && <DocsList attachments={docs} />} {hasDocs && <DocsList attachments={docs} />}
</MetadataSection> </MetadataSection>
......
...@@ -5,7 +5,8 @@ import { cn } from "@/lib/utils"; ...@@ -5,7 +5,8 @@ import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb"; import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb"; import type { User } from "@/types/proto/api/v1/user_service_pb";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; import { getAttachmentType, isMotionAttachment } from "@/utils/attachment";
import { buildAttachmentVisualItems, countLogicalAttachmentItems } from "@/utils/media-item";
import MemoContent from "../MemoContent"; import MemoContent from "../MemoContent";
import { MemoViewContext, type MemoViewContextValue } from "../MemoView/MemoViewContext"; import { MemoViewContext, type MemoViewContextValue } from "../MemoView/MemoViewContext";
...@@ -36,28 +37,35 @@ const STUB_CONTEXT: MemoViewContextValue = { ...@@ -36,28 +37,35 @@ const STUB_CONTEXT: MemoViewContextValue = {
}; };
const AttachmentThumbnails = ({ attachments }: { attachments: Attachment[] }) => { const AttachmentThumbnails = ({ attachments }: { attachments: Attachment[] }) => {
const images: Attachment[] = []; const visualAttachments = attachments.filter(
const others: Attachment[] = []; (attachment) =>
for (const a of attachments) { getAttachmentType(attachment) === "image/*" || getAttachmentType(attachment) === "video/*" || isMotionAttachment(attachment),
if (getAttachmentType(a) === "image/*") images.push(a); );
else others.push(a); const items = buildAttachmentVisualItems(visualAttachments);
} const images = items.filter((item) => item.kind === "image" || item.kind === "motion");
const others = items.filter((item) => item.kind === "video");
return ( return (
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
{images.map((a) => ( {images.map((item) => (
<div key={item.id} className="relative">
<img <img
key={a.name} src={item.posterUrl}
src={getAttachmentUrl(a)} alt={item.filename}
alt={a.filename}
className="w-10 h-10 rounded border border-border object-cover bg-muted/40" className="w-10 h-10 rounded border border-border object-cover bg-muted/40"
loading="lazy" loading="lazy"
/> />
{item.kind === "motion" && (
<span className="absolute left-1 top-1 rounded bg-black/70 px-1 py-0.5 text-[8px] font-semibold leading-none text-white">
LIVE
</span>
)}
</div>
))} ))}
{others.map((a) => ( {others.map((item) => (
<div key={a.name} className="flex items-center gap-1 text-[10px] text-muted-foreground"> <div key={item.id} className="flex items-center gap-1 text-[10px] text-muted-foreground">
<FileIcon className="w-3 h-3 shrink-0" /> <FileIcon className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[80px]">{a.filename}</span> <span className="truncate max-w-[80px]">{item.filename}</span>
</div> </div>
))} ))}
</div> </div>
...@@ -138,7 +146,7 @@ const MemoPreview = ({ ...@@ -138,7 +146,7 @@ const MemoPreview = ({
(truncate ? ( (truncate ? (
<div className="shrink-0 text-muted-foreground/70 inline-flex justify-center items-center gap-0.5"> <div className="shrink-0 text-muted-foreground/70 inline-flex justify-center items-center gap-0.5">
<FileIcon className="w-3 h-3 inline-block" /> <FileIcon className="w-3 h-3 inline-block" />
<span className="text-xs">{attachments.length}</span> <span className="text-xs">{countLogicalAttachmentItems(attachments)}</span>
</div> </div>
) : ( ) : (
<AttachmentThumbnails attachments={attachments} /> <AttachmentThumbnails attachments={attachments} />
......
...@@ -97,7 +97,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => { ...@@ -97,7 +97,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
<PreviewImageDialog <PreviewImageDialog
open={previewState.open} open={previewState.open}
onOpenChange={setPreviewOpen} onOpenChange={setPreviewOpen}
imgUrls={previewState.urls} items={previewState.items}
initialIndex={previewState.index} initialIndex={previewState.index}
/> />
</article> </article>
......
...@@ -4,6 +4,7 @@ import { useLocation } from "react-router-dom"; ...@@ -4,6 +4,7 @@ import { useLocation } from "react-router-dom";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb"; import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb"; import type { User } from "@/types/proto/api/v1/user_service_pb";
import type { PreviewMediaItem } from "@/utils/media-item";
import { RELATIVE_TIME_THRESHOLD_MS } from "./constants"; import { RELATIVE_TIME_THRESHOLD_MS } from "./constants";
export interface MemoViewContextValue { export interface MemoViewContextValue {
...@@ -17,7 +18,7 @@ export interface MemoViewContextValue { ...@@ -17,7 +18,7 @@ export interface MemoViewContextValue {
blurred: boolean; blurred: boolean;
openEditor: () => void; openEditor: () => void;
toggleBlurVisibility: () => void; toggleBlurVisibility: () => void;
openPreview: (urls: string | string[], index?: number) => void; openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void;
} }
export const MemoViewContext = createContext<MemoViewContextValue | null>(null); export const MemoViewContext = createContext<MemoViewContextValue | null>(null);
......
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import type { PreviewMediaItem } from "@/utils/media-item";
export interface ImagePreviewState { export interface ImagePreviewState {
open: boolean; open: boolean;
urls: string[]; items: PreviewMediaItem[];
index: number; index: number;
} }
export interface UseImagePreviewReturn { export interface UseImagePreviewReturn {
previewState: ImagePreviewState; previewState: ImagePreviewState;
openPreview: (urls: string | string[], index?: number) => void; openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void;
setPreviewOpen: (open: boolean) => void; setPreviewOpen: (open: boolean) => void;
} }
export const useImagePreview = (): UseImagePreviewReturn => { export const useImagePreview = (): UseImagePreviewReturn => {
const [previewState, setPreviewState] = useState<ImagePreviewState>({ open: false, urls: [], index: 0 }); const [previewState, setPreviewState] = useState<ImagePreviewState>({ open: false, items: [], index: 0 });
const openPreview = useCallback((urls: string | string[], index = 0) => { const openPreview = useCallback((items: string | string[] | PreviewMediaItem[], index = 0) => {
setPreviewState({ open: true, urls: Array.isArray(urls) ? urls : [urls], index }); const normalizedItems = normalizePreviewItems(items);
setPreviewState({ open: true, items: normalizedItems, index });
}, []); }, []);
const setPreviewOpen = useCallback((open: boolean) => { const setPreviewOpen = useCallback((open: boolean) => {
...@@ -25,3 +27,31 @@ export const useImagePreview = (): UseImagePreviewReturn => { ...@@ -25,3 +27,31 @@ export const useImagePreview = (): UseImagePreviewReturn => {
return { previewState, openPreview, setPreviewOpen }; return { previewState, openPreview, setPreviewOpen };
}; };
function normalizePreviewItems(items: string | string[] | PreviewMediaItem[]): PreviewMediaItem[] {
if (typeof items === "string") {
return [
{
id: items,
kind: "image",
sourceUrl: items,
posterUrl: items,
filename: "Image",
isMotion: false,
},
];
}
if (Array.isArray(items) && (items.length === 0 || typeof items[0] === "string")) {
return (items as string[]).map((url) => ({
id: url,
kind: "image",
sourceUrl: url,
posterUrl: url,
filename: "Image",
isMotion: false,
}));
}
return items as PreviewMediaItem[];
}
import { useCallback } from "react"; import { useCallback } from "react";
import { useInstance } from "@/contexts/InstanceContext"; import { useInstance } from "@/contexts/InstanceContext";
import type { PreviewMediaItem } from "@/utils/media-item";
interface UseMemoHandlersOptions { interface UseMemoHandlersOptions {
readonly: boolean; readonly: boolean;
openEditor: () => void; openEditor: () => void;
openPreview: (urls: string | string[], index?: number) => void; openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void;
} }
export const useMemoHandlers = (options: UseMemoHandlersOptions) => { export const useMemoHandlers = (options: UseMemoHandlersOptions) => {
......
...@@ -2,16 +2,21 @@ import { X } from "lucide-react"; ...@@ -2,16 +2,21 @@ import { X } from "lucide-react";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import type { PreviewMediaItem } from "@/utils/media-item";
interface Props { interface Props {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
imgUrls: string[]; imgUrls?: string[];
items?: PreviewMediaItem[];
initialIndex?: number; initialIndex?: number;
} }
function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: Props) { function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIndex = 0 }: Props) {
const [currentIndex, setCurrentIndex] = useState(initialIndex); const [currentIndex, setCurrentIndex] = useState(initialIndex);
const previewItems =
items ??
imgUrls.map((url) => ({ id: url, kind: "image" as const, sourceUrl: url, posterUrl: url, filename: "Image", isMotion: false }));
// Update current index when initialIndex prop changes // Update current index when initialIndex prop changes
useEffect(() => { useEffect(() => {
...@@ -28,7 +33,7 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P ...@@ -28,7 +33,7 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P
onOpenChange(false); onOpenChange(false);
break; break;
case "ArrowRight": case "ArrowRight":
setCurrentIndex((prev) => Math.min(prev + 1, imgUrls.length - 1)); setCurrentIndex((prev) => Math.min(prev + 1, previewItems.length - 1));
break; break;
case "ArrowLeft": case "ArrowLeft":
setCurrentIndex((prev) => Math.max(prev - 1, 0)); setCurrentIndex((prev) => Math.max(prev - 1, 0));
...@@ -53,10 +58,11 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P ...@@ -53,10 +58,11 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P
}; };
// Return early if no images provided // Return early if no images provided
if (!imgUrls.length) return null; if (!previewItems.length) return null;
// Ensure currentIndex is within bounds // Ensure currentIndex is within bounds
const safeIndex = Math.max(0, Math.min(currentIndex, imgUrls.length - 1)); const safeIndex = Math.max(0, Math.min(currentIndex, previewItems.length - 1));
const currentItem = previewItems[safeIndex];
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
...@@ -79,14 +85,30 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P ...@@ -79,14 +85,30 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P
{/* Image container */} {/* Image container */}
<div className="w-full h-full flex items-center justify-center p-4 sm:p-8 overflow-auto" onClick={handleBackdropClick}> <div className="w-full h-full flex items-center justify-center p-4 sm:p-8 overflow-auto" onClick={handleBackdropClick}>
{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 <img
src={imgUrls[safeIndex]} src={currentItem.sourceUrl}
alt={`Preview image ${safeIndex + 1} of ${imgUrls.length}`} alt={`Preview image ${safeIndex + 1} of ${previewItems.length}`}
className="max-w-full max-h-full object-contain select-none" className="max-w-full max-h-full object-contain select-none"
draggable={false} draggable={false}
loading="eager" loading="eager"
decoding="async" decoding="async"
/> />
)}
</div> </div>
{/* Screen reader description */} {/* Screen reader description */}
......
import { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import { Attachment, MotionMediaFamily, MotionMediaRole } from "@/types/proto/api/v1/attachment_service_pb";
export const getAttachmentUrl = (attachment: Attachment) => { export const getAttachmentUrl = (attachment: Attachment) => {
if (attachment.externalLink) { if (attachment.externalLink) {
...@@ -12,6 +12,10 @@ export const getAttachmentThumbnailUrl = (attachment: Attachment) => { ...@@ -12,6 +12,10 @@ export const getAttachmentThumbnailUrl = (attachment: Attachment) => {
return `${window.location.origin}/file/${attachment.name}/${attachment.filename}?thumbnail=true`; return `${window.location.origin}/file/${attachment.name}/${attachment.filename}?thumbnail=true`;
}; };
export const getAttachmentMotionClipUrl = (attachment: Attachment) => {
return `${window.location.origin}/file/${attachment.name}/${attachment.filename}?motion=true`;
};
export const getAttachmentType = (attachment: Attachment) => { export const getAttachmentType = (attachment: Attachment) => {
if (isImage(attachment.type)) { if (isImage(attachment.type)) {
return "image/*"; return "image/*";
...@@ -52,3 +56,21 @@ export const isMidiFile = (mimeType: string): boolean => { ...@@ -52,3 +56,21 @@ export const isMidiFile = (mimeType: string): boolean => {
const isPSD = (t: string) => { const isPSD = (t: string) => {
return t === "image/vnd.adobe.photoshop" || t === "image/x-photoshop" || t === "image/photoshop"; return t === "image/vnd.adobe.photoshop" || t === "image/x-photoshop" || t === "image/photoshop";
}; };
export const getAttachmentMotionGroupId = (attachment: Attachment): string | undefined => {
return attachment.motionMedia?.groupId || undefined;
};
export const isAppleLivePhotoStill = (attachment: Attachment): boolean =>
attachment.motionMedia?.family === MotionMediaFamily.APPLE_LIVE_PHOTO && attachment.motionMedia.role === MotionMediaRole.STILL;
export const isAppleLivePhotoVideo = (attachment: Attachment): boolean =>
attachment.motionMedia?.family === MotionMediaFamily.APPLE_LIVE_PHOTO && attachment.motionMedia.role === MotionMediaRole.VIDEO;
export const isAndroidMotionContainer = (attachment: Attachment): boolean =>
attachment.motionMedia?.family === MotionMediaFamily.ANDROID_MOTION_PHOTO &&
attachment.motionMedia.role === MotionMediaRole.CONTAINER &&
attachment.motionMedia.hasEmbeddedVideo;
export const isMotionAttachment = (attachment: Attachment): boolean =>
isAppleLivePhotoStill(attachment) || isAppleLivePhotoVideo(attachment) || isAndroidMotionContainer(attachment);
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import {
getAttachmentMotionClipUrl,
getAttachmentMotionGroupId,
getAttachmentThumbnailUrl,
getAttachmentType,
getAttachmentUrl,
isAndroidMotionContainer,
isAppleLivePhotoStill,
isAppleLivePhotoVideo,
isMotionAttachment,
} from "./attachment";
export interface PreviewMediaItem {
id: string;
kind: "image" | "video";
sourceUrl: string;
posterUrl?: string;
filename: string;
isMotion: boolean;
presentationTimestampUs?: bigint;
}
export interface AttachmentVisualItem {
id: string;
kind: "image" | "video" | "motion";
filename: string;
posterUrl: string;
sourceUrl: string;
attachmentNames: string[];
attachments: Attachment[];
previewItem: PreviewMediaItem;
mimeType: string;
}
export function buildAttachmentVisualItems(attachments: Attachment[]): AttachmentVisualItem[] {
const attachmentsByGroup = new Map<string, Attachment[]>();
for (const attachment of attachments) {
const groupId = getAttachmentMotionGroupId(attachment);
if (!groupId) {
continue;
}
const group = attachmentsByGroup.get(groupId) ?? [];
group.push(attachment);
attachmentsByGroup.set(groupId, group);
}
const consumedGroups = new Set<string>();
const items: AttachmentVisualItem[] = [];
for (const attachment of attachments) {
if (isAndroidMotionContainer(attachment)) {
items.push(buildAndroidMotionItem(attachment));
continue;
}
const groupId = getAttachmentMotionGroupId(attachment);
if (!groupId || consumedGroups.has(groupId)) {
if (!groupId) {
items.push(buildSingleAttachmentItem(attachment));
}
continue;
}
const group = attachmentsByGroup.get(groupId) ?? [];
const still = group.find(isAppleLivePhotoStill);
const video = group.find(isAppleLivePhotoVideo);
if (still && video && group.length === 2) {
items.push(buildAppleMotionItem(still, video));
consumedGroups.add(groupId);
continue;
}
items.push(buildSingleAttachmentItem(attachment));
consumedGroups.add(groupId);
for (const member of group) {
if (member.name === attachment.name) {
continue;
}
items.push(buildSingleAttachmentItem(member));
}
}
return dedupeVisualItems(items);
}
export function countLogicalAttachmentItems(attachments: Attachment[]): number {
const visualAttachments = attachments.filter(
(attachment) =>
getAttachmentType(attachment) === "image/*" || getAttachmentType(attachment) === "video/*" || isMotionAttachment(attachment),
);
const visualNames = new Set(visualAttachments.map((attachment) => attachment.name));
const visualCount = buildAttachmentVisualItems(visualAttachments).length;
const nonVisualCount = attachments.filter((attachment) => !visualNames.has(attachment.name)).length;
return visualCount + nonVisualCount;
}
function buildSingleAttachmentItem(attachment: Attachment): AttachmentVisualItem {
const attachmentType = getAttachmentType(attachment);
const sourceUrl = getAttachmentUrl(attachment);
const posterUrl = attachmentType === "image/*" ? getAttachmentThumbnailUrl(attachment) : sourceUrl;
const previewKind = attachmentType === "video/*" ? "video" : "image";
return {
id: attachment.name,
kind: attachmentType === "video/*" ? "video" : "image",
filename: attachment.filename,
posterUrl,
sourceUrl,
attachmentNames: [attachment.name],
attachments: [attachment],
previewItem: {
id: attachment.name,
kind: previewKind,
sourceUrl,
posterUrl,
filename: attachment.filename,
isMotion: false,
},
mimeType: attachment.type,
};
}
function buildAppleMotionItem(still: Attachment, video: Attachment): AttachmentVisualItem {
const sourceUrl = getAttachmentUrl(video);
const posterUrl = getAttachmentThumbnailUrl(still);
return {
id: getAttachmentMotionGroupId(still) ?? still.name,
kind: "motion",
filename: still.filename,
posterUrl,
sourceUrl,
attachmentNames: [still.name, video.name],
attachments: [still, video],
previewItem: {
id: getAttachmentMotionGroupId(still) ?? still.name,
kind: "video",
sourceUrl,
posterUrl,
filename: still.filename,
isMotion: true,
},
mimeType: still.type,
};
}
function buildAndroidMotionItem(attachment: Attachment): AttachmentVisualItem {
return {
id: attachment.name,
kind: "motion",
filename: attachment.filename,
posterUrl: getAttachmentThumbnailUrl(attachment),
sourceUrl: getAttachmentMotionClipUrl(attachment),
attachmentNames: [attachment.name],
attachments: [attachment],
previewItem: {
id: attachment.name,
kind: "video",
sourceUrl: getAttachmentMotionClipUrl(attachment),
posterUrl: getAttachmentThumbnailUrl(attachment),
filename: attachment.filename,
isMotion: true,
presentationTimestampUs: attachment.motionMedia?.presentationTimestampUs,
},
mimeType: attachment.type,
};
}
function dedupeVisualItems(items: AttachmentVisualItem[]): AttachmentVisualItem[] {
const seen = new Set<string>();
return items.filter((item) => {
if (seen.has(item.id)) {
return false;
}
seen.add(item.id);
return true;
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment