fix: batch user lookups and harden username resource handling

This commit is contained in:
memoclaw 2026-03-25 09:03:40 +08:00
parent beb0232a25
commit 4b6f80596a
18 changed files with 269 additions and 151 deletions

View File

@ -52,7 +52,7 @@ service ShortcutService {
message Shortcut {
option (google.api.resource) = {
type: "memos.api.v1/Shortcut"
pattern: "users/{user}/shortcuts/{shortcut}"
pattern: "users/{username}/shortcuts/{shortcut}"
singular: "shortcut"
plural: "shortcuts"
};

View File

@ -357,7 +357,7 @@ message ListAllUserStatsResponse {
message UserSetting {
option (google.api.resource) = {
type: "memos.api.v1/UserSetting"
pattern: "users/{user}/settings/{setting}"
pattern: "users/{username}/settings/{setting}"
singular: "userSetting"
plural: "userSettings"
};

View File

@ -95,10 +95,8 @@ const (
type UserServiceClient interface {
// ListUsers returns a list of users.
ListUsers(context.Context, *connect.Request[v1.ListUsersRequest]) (*connect.Response[v1.ListUsersResponse], error)
// GetUser gets a user by ID or username.
// Supports both numeric IDs and username strings:
// - users/{id} (e.g., users/101)
// - users/{username} (e.g., users/steven)
// GetUser gets a user by username.
// Format: users/{username} (e.g., users/steven)
GetUser(context.Context, *connect.Request[v1.GetUserRequest]) (*connect.Response[v1.User], error)
// CreateUser creates a new user.
CreateUser(context.Context, *connect.Request[v1.CreateUserRequest]) (*connect.Response[v1.User], error)
@ -402,10 +400,8 @@ func (c *userServiceClient) DeleteUserNotification(ctx context.Context, req *con
type UserServiceHandler interface {
// ListUsers returns a list of users.
ListUsers(context.Context, *connect.Request[v1.ListUsersRequest]) (*connect.Response[v1.ListUsersResponse], error)
// GetUser gets a user by ID or username.
// Supports both numeric IDs and username strings:
// - users/{id} (e.g., users/101)
// - users/{username} (e.g., users/steven)
// GetUser gets a user by username.
// Format: users/{username} (e.g., users/steven)
GetUser(context.Context, *connect.Request[v1.GetUserRequest]) (*connect.Response[v1.User], error)
// CreateUser creates a new user.
CreateUser(context.Context, *connect.Request[v1.CreateUserRequest]) (*connect.Response[v1.User], error)

View File

@ -27,7 +27,7 @@ const (
type Shortcut struct {
state protoimpl.MessageState `protogen:"open.v1"`
// The resource name of the shortcut.
// Format: users/{user}/shortcuts/{shortcut}
// Format: users/{username}/shortcuts/{shortcut}
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// The title of the shortcut.
Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
@ -91,7 +91,7 @@ func (x *Shortcut) GetFilter() string {
type ListShortcutsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Required. The parent resource where shortcuts are listed.
// Format: users/{user}
// Format: users/{username}
Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@ -182,7 +182,7 @@ func (x *ListShortcutsResponse) GetShortcuts() []*Shortcut {
type GetShortcutRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Required. The resource name of the shortcut to retrieve.
// Format: users/{user}/shortcuts/{shortcut}
// Format: users/{username}/shortcuts/{shortcut}
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@ -228,7 +228,7 @@ func (x *GetShortcutRequest) GetName() string {
type CreateShortcutRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Required. The parent resource where this shortcut will be created.
// Format: users/{user}
// Format: users/{username}
Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
// Required. The shortcut to create.
Shortcut *Shortcut `protobuf:"bytes,2,opt,name=shortcut,proto3" json:"shortcut,omitempty"`
@ -346,7 +346,7 @@ func (x *UpdateShortcutRequest) GetUpdateMask() *fieldmaskpb.FieldMask {
type DeleteShortcutRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Required. The resource name of the shortcut to delete.
// Format: users/{user}/shortcuts/{shortcut}
// Format: users/{username}/shortcuts/{shortcut}
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@ -393,12 +393,12 @@ var File_api_v1_shortcut_service_proto protoreflect.FileDescriptor
const file_api_v1_shortcut_service_proto_rawDesc = "" +
"\n" +
"\x1dapi/v1/shortcut_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\"\xaf\x01\n" +
"\x1dapi/v1/shortcut_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\"\xb3\x01\n" +
"\bShortcut\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12\x19\n" +
"\x05title\x18\x02 \x01(\tB\x03\xe0A\x02R\x05title\x12\x1b\n" +
"\x06filter\x18\x03 \x01(\tB\x03\xe0A\x01R\x06filter:R\xeaAO\n" +
"\x15memos.api.v1/Shortcut\x12!users/{user}/shortcuts/{shortcut}*\tshortcuts2\bshortcut\"M\n" +
"\x06filter\x18\x03 \x01(\tB\x03\xe0A\x01R\x06filter:V\xeaAS\n" +
"\x15memos.api.v1/Shortcut\x12%users/{username}/shortcuts/{shortcut}*\tshortcuts2\bshortcut\"M\n" +
"\x14ListShortcutsRequest\x125\n" +
"\x06parent\x18\x01 \x01(\tB\x1d\xe0A\x02\xfaA\x17\x12\x15memos.api.v1/ShortcutR\x06parent\"M\n" +
"\x15ListShortcutsResponse\x124\n" +

View File

@ -506,11 +506,7 @@ func (x *ListUsersResponse) GetTotalSize() int32 {
type GetUserRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Required. The resource name of the user.
// Supports both numeric IDs and username strings:
// - users/{id} (e.g., users/101)
// - users/{username} (e.g., users/steven)
//
// Format: users/{id_or_username}
// Format: users/{username}
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// Optional. The fields to return in the response.
// If not specified, all fields are returned.
@ -979,8 +975,8 @@ func (x *ListAllUserStatsResponse) GetStats() []*UserStats {
type UserSetting struct {
state protoimpl.MessageState `protogen:"open.v1"`
// The name of the user setting.
// Format: users/{user}/settings/{setting}, {setting} is the key for the setting.
// For example, "users/123/settings/GENERAL" for general settings.
// Format: users/{username}/settings/{setting}, {setting} is the key for the setting.
// For example, "users/steven/settings/GENERAL" for general settings.
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// Types that are valid to be assigned to Value:
//
@ -2658,7 +2654,7 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\x11memos.api.v1/UserR\x04name\"\x19\n" +
"\x17ListAllUserStatsRequest\"I\n" +
"\x18ListAllUserStatsResponse\x12-\n" +
"\x05stats\x18\x01 \x03(\v2\x17.memos.api.v1.UserStatsR\x05stats\"\xb0\x04\n" +
"\x05stats\x18\x01 \x03(\v2\x17.memos.api.v1.UserStatsR\x05stats\"\xb4\x04\n" +
"\vUserSetting\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12S\n" +
"\x0fgeneral_setting\x18\x02 \x01(\v2(.memos.api.v1.UserSetting.GeneralSettingH\x00R\x0egeneralSetting\x12V\n" +
@ -2672,8 +2668,8 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\x03Key\x12\x13\n" +
"\x0fKEY_UNSPECIFIED\x10\x00\x12\v\n" +
"\aGENERAL\x10\x01\x12\f\n" +
"\bWEBHOOKS\x10\x04:Y\xeaAV\n" +
"\x18memos.api.v1/UserSetting\x12\x1fusers/{user}/settings/{setting}*\fuserSettings2\vuserSettingB\a\n" +
"\bWEBHOOKS\x10\x04:]\xeaAZ\n" +
"\x18memos.api.v1/UserSetting\x12#users/{username}/settings/{setting}*\fuserSettings2\vuserSettingB\a\n" +
"\x05value\"M\n" +
"\x15GetUserSettingRequest\x124\n" +
"\x04name\x18\x01 \x01(\tB \xe0A\x02\xfaA\x1a\n" +

View File

@ -48,10 +48,8 @@ const (
type UserServiceClient interface {
// ListUsers returns a list of users.
ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error)
// GetUser gets a user by ID or username.
// Supports both numeric IDs and username strings:
// - users/{id} (e.g., users/101)
// - users/{username} (e.g., users/steven)
// GetUser gets a user by username.
// Format: users/{username} (e.g., users/steven)
GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*User, error)
// CreateUser creates a new user.
CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*User, error)
@ -307,10 +305,8 @@ func (c *userServiceClient) DeleteUserNotification(ctx context.Context, in *Dele
type UserServiceServer interface {
// ListUsers returns a list of users.
ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error)
// GetUser gets a user by ID or username.
// Supports both numeric IDs and username strings:
// - users/{id} (e.g., users/101)
// - users/{username} (e.g., users/steven)
// GetUser gets a user by username.
// Format: users/{username} (e.g., users/steven)
GetUser(context.Context, *GetUserRequest) (*User, error)
// CreateUser creates a new user.
CreateUser(context.Context, *CreateUserRequest) (*User, error)

View File

@ -1206,10 +1206,8 @@ paths:
tags:
- UserService
description: |-
GetUser gets a user by ID or username.
Supports both numeric IDs and username strings:
- users/{id} (e.g., users/101)
- users/{username} (e.g., users/steven)
GetUser gets a user by username.
Format: users/{username} (e.g., users/steven)
operationId: UserService_GetUser
parameters:
- name: user
@ -2939,7 +2937,7 @@ components:
type: string
description: |-
The resource name of the shortcut.
Format: users/{user}/shortcuts/{shortcut}
Format: users/{username}/shortcuts/{shortcut}
title:
type: string
description: The title of the shortcut.
@ -3178,8 +3176,8 @@ components:
type: string
description: |-
The name of the user setting.
Format: users/{user}/settings/{setting}, {setting} is the key for the setting.
For example, "users/123/settings/GENERAL" for general settings.
Format: users/{username}/settings/{setting}, {setting} is the key for the setting.
For example, "users/steven/settings/GENERAL" for general settings.
generalSetting:
$ref: '#/components/schemas/UserSetting_GeneralSetting'
webhooksSetting:

View File

@ -278,6 +278,14 @@ func (s *APIV1Service) ListMemos(ctx context.Context, request *v1pb.ListMemosReq
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to batch load memo relations")
}
creatorIDs := make([]int32, 0, len(memos))
for _, memo := range memos {
creatorIDs = append(creatorIDs, memo.CreatorID)
}
creatorMap, err := s.listUsersByID(ctx, creatorIDs)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memo creators: %v", err)
}
for _, memo := range memos {
memoName := fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID)
@ -285,7 +293,7 @@ func (s *APIV1Service) ListMemos(ctx context.Context, request *v1pb.ListMemosReq
attachments := attachmentMap[memo.ID]
relations := relationMap[memo.ID]
memoMessage, err := s.convertMemoFromStore(ctx, memo, reactions, attachments, relations)
memoMessage, err := s.convertMemoFromStoreWithCreators(ctx, memo, reactions, attachments, relations, creatorMap)
if err != nil {
return nil, errors.Wrap(err, "failed to convert memo")
}
@ -753,6 +761,14 @@ func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListM
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to batch load memo relations")
}
creatorIDs := make([]int32, 0, len(memos))
for _, memo := range memos {
creatorIDs = append(creatorIDs, memo.CreatorID)
}
creatorMap, err := s.listUsersByID(ctx, creatorIDs)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memo creators: %v", err)
}
var memosResponse []*v1pb.Memo
for _, m := range memos {
@ -761,7 +777,7 @@ func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListM
attachments := attachmentMap[m.ID]
relations := relationMap[m.ID]
memoMessage, err := s.convertMemoFromStore(ctx, m, reactions, attachments, relations)
memoMessage, err := s.convertMemoFromStoreWithCreators(ctx, m, reactions, attachments, relations, creatorMap)
if err != nil {
return nil, errors.Wrap(err, "failed to convert memo")
}

View File

@ -14,6 +14,14 @@ import (
)
func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Memo, reactions []*store.Reaction, attachments []*store.Attachment, relations []*v1pb.MemoRelation) (*v1pb.Memo, error) {
creatorMap, err := s.listUsersByID(ctx, []int32{memo.CreatorID})
if err != nil {
return nil, errors.Wrap(err, "failed to list memo creators")
}
return s.convertMemoFromStoreWithCreators(ctx, memo, reactions, attachments, relations, creatorMap)
}
func (s *APIV1Service) convertMemoFromStoreWithCreators(ctx context.Context, memo *store.Memo, reactions []*store.Reaction, attachments []*store.Attachment, relations []*v1pb.MemoRelation, creatorMap map[int32]*store.User) (*v1pb.Memo, error) {
displayTs := memo.CreatedTs
instanceMemoRelatedSetting, err := s.Store.GetInstanceMemoRelatedSetting(ctx)
if err != nil {
@ -24,10 +32,7 @@ func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Mem
}
name := fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID)
creator, err := s.Store.GetUser(ctx, &store.FindUser{ID: &memo.CreatorID})
if err != nil {
return nil, errors.Wrap(err, "failed to get memo creator")
}
creator := creatorMap[memo.CreatorID]
if creator == nil {
return nil, errors.New("memo creator not found")
}

View File

@ -41,5 +41,9 @@ func ResolveUserByName(ctx context.Context, stores *store.Store, name string) (*
if err != nil {
return nil, err
}
return stores.GetUser(ctx, &store.FindUser{Username: &username})
user, err := stores.GetUser(ctx, &store.FindUser{Username: &username})
if err != nil {
return nil, errors.Wrap(err, "resolve user by name: GetUser failed")
}
return user, nil
}

View File

@ -165,6 +165,12 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
}
if user == nil {
if request.AllowMissing {
return nil, status.Errorf(codes.NotFound, "user not found")
}
return nil, status.Errorf(codes.NotFound, "user not found")
}
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx)
if err != nil {
@ -179,15 +185,6 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
if user == nil {
// Handle allow_missing field
if request.AllowMissing {
// Could create user if missing, but for now return not found
return nil, status.Errorf(codes.NotFound, "user not found")
}
return nil, status.Errorf(codes.NotFound, "user not found")
}
currentTs := time.Now().Unix()
update := &store.UpdateUser{
ID: user.ID,
@ -271,6 +268,9 @@ func (s *APIV1Service) DeleteUser(ctx context.Context, request *v1pb.DeleteUserR
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.NotFound, "user not found")
}
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx)
if err != nil {
@ -283,10 +283,6 @@ func (s *APIV1Service) DeleteUser(ctx context.Context, request *v1pb.DeleteUserR
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
if user == nil {
return nil, status.Errorf(codes.NotFound, "user not found")
}
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
ID: user.ID,
}); err != nil {
@ -991,26 +987,6 @@ func extractImageInfo(dataURI string) (string, string, error) {
return imageType, base64Data, nil
}
// Helper functions for user settings
// ExtractUserIDAndSettingKeyFromName extracts user ID and setting key from a legacy numeric resource name.
// e.g., "users/123/settings/general" -> 123, "general".
func ExtractUserIDAndSettingKeyFromName(name string) (int32, string, error) {
// Expected format: users/{user}/settings/{setting}
parts := strings.Split(name, "/")
if len(parts) != 4 || parts[0] != "users" || parts[2] != "settings" {
return 0, "", errors.Errorf("invalid resource name format: %s", name)
}
userID, err := util.ConvertStringToInt32(parts[1])
if err != nil {
return 0, "", errors.Errorf("invalid user ID: %s", parts[1])
}
settingKey := parts[3]
return userID, settingKey, nil
}
// convertSettingKeyToStore converts API setting key to store enum.
func convertSettingKeyToStore(key string) (storepb.UserSetting_Key, error) {
switch key {
@ -1084,14 +1060,17 @@ func convertUserSettingFromStore(storeSetting *storepb.UserSetting, user *store.
}
case storepb.UserSetting_WEBHOOKS:
webhooks := storeSetting.GetWebhooks()
apiWebhooks := make([]*v1pb.UserWebhook, 0, len(webhooks.Webhooks))
for _, webhook := range webhooks.Webhooks {
apiWebhook := &v1pb.UserWebhook{
Name: fmt.Sprintf("%s/webhooks/%s", BuildUserName(user.Username), webhook.Id),
Url: webhook.Url,
DisplayName: webhook.Title,
apiWebhooks := make([]*v1pb.UserWebhook, 0)
if webhooks != nil {
apiWebhooks = make([]*v1pb.UserWebhook, 0, len(webhooks.Webhooks))
for _, webhook := range webhooks.Webhooks {
apiWebhook := &v1pb.UserWebhook{
Name: fmt.Sprintf("%s/webhooks/%s", BuildUserName(user.Username), webhook.Id),
Url: webhook.Url,
DisplayName: webhook.Title,
}
apiWebhooks = append(apiWebhooks, apiWebhook)
}
apiWebhooks = append(apiWebhooks, apiWebhook)
}
setting.Value = &v1pb.UserSetting_WebhooksSetting_{
WebhooksSetting: &v1pb.UserSetting_WebhooksSetting{
@ -1290,10 +1269,19 @@ func (s *APIV1Service) ListUserNotifications(ctx context.Context, request *v1pb.
return nil, status.Errorf(codes.Internal, "failed to list inboxes: %v", err)
}
// Convert storage layer inboxes to API notifications
// Convert storage layer inboxes to API notifications.
userIDs := make([]int32, 0, len(inboxes)*2)
for _, inbox := range inboxes {
userIDs = append(userIDs, inbox.ReceiverID, inbox.SenderID)
}
usersByID, err := s.listUsersByID(ctx, userIDs)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list notification users: %v", err)
}
notifications := []*v1pb.UserNotification{}
for _, inbox := range inboxes {
notification, err := s.convertInboxToUserNotification(ctx, inbox)
notification, err := s.convertInboxToUserNotificationWithUsers(ctx, inbox, usersByID)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert inbox: %v", err)
}
@ -1426,17 +1414,19 @@ func (s *APIV1Service) DeleteUserNotification(ctx context.Context, request *v1pb
// convertInboxToUserNotification converts a storage-layer inbox to an API notification.
// This handles the mapping between the internal inbox representation and the public API.
func (s *APIV1Service) convertInboxToUserNotification(ctx context.Context, inbox *store.Inbox) (*v1pb.UserNotification, error) {
receiver, err := s.Store.GetUser(ctx, &store.FindUser{ID: &inbox.ReceiverID})
usersByID, err := s.listUsersByID(ctx, []int32{inbox.ReceiverID, inbox.SenderID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get notification receiver: %v", err)
return nil, status.Errorf(codes.Internal, "failed to list notification users: %v", err)
}
return s.convertInboxToUserNotificationWithUsers(ctx, inbox, usersByID)
}
func (s *APIV1Service) convertInboxToUserNotificationWithUsers(ctx context.Context, inbox *store.Inbox, usersByID map[int32]*store.User) (*v1pb.UserNotification, error) {
receiver := usersByID[inbox.ReceiverID]
if receiver == nil {
return nil, status.Errorf(codes.NotFound, "notification receiver not found")
}
sender, err := s.Store.GetUser(ctx, &store.FindUser{ID: &inbox.SenderID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get notification sender: %v", err)
}
sender := usersByID[inbox.SenderID]
if sender == nil {
return nil, status.Errorf(codes.NotFound, "notification sender not found")
}
@ -1513,20 +1503,3 @@ func (s *APIV1Service) convertUserNotificationPayload(ctx context.Context, messa
RelatedMemo: fmt.Sprintf("%s%s", MemoNamePrefix, relatedMemo.UID),
}, nil
}
// ExtractNotificationIDFromName extracts the notification ID from a resource name.
// Expected format: users/{user_id}/notifications/{notification_id}.
func ExtractNotificationIDFromName(name string) (int32, error) {
pattern := regexp.MustCompile(`^users/(\d+)/notifications/(\d+)$`)
matches := pattern.FindStringSubmatch(name)
if len(matches) != 3 {
return 0, errors.Errorf("invalid notification name: %s", name)
}
id, err := strconv.Atoi(matches[2])
if err != nil {
return 0, errors.Errorf("invalid notification id: %s", matches[2])
}
return int32(id), nil
}

View File

@ -3,7 +3,6 @@ package v1
import (
"context"
"fmt"
"strings"
"time"
"github.com/pkg/errors"
@ -15,18 +14,41 @@ import (
"github.com/usememos/memos/store"
)
func (s *APIV1Service) listUsernamesByID(ctx context.Context, userIDs []int32) (map[int32]string, error) {
func (s *APIV1Service) listUsersByID(ctx context.Context, userIDs []int32) (map[int32]*store.User, error) {
if len(userIDs) == 0 {
return map[int32]string{}, nil
return map[int32]*store.User{}, nil
}
users, err := s.Store.ListUsers(ctx, &store.FindUser{IDList: userIDs})
uniqueUserIDs := make([]int32, 0, len(userIDs))
seenUserIDs := make(map[int32]struct{}, len(userIDs))
for _, userID := range userIDs {
if _, seen := seenUserIDs[userID]; seen {
continue
}
seenUserIDs[userID] = struct{}{}
uniqueUserIDs = append(uniqueUserIDs, userID)
}
users, err := s.Store.ListUsers(ctx, &store.FindUser{IDList: uniqueUserIDs})
if err != nil {
return nil, err
}
usernamesByID := make(map[int32]string, len(users))
usersByID := make(map[int32]*store.User, len(users))
for _, user := range users {
usersByID[user.ID] = user
}
return usersByID, nil
}
func (s *APIV1Service) listUsernamesByID(ctx context.Context, userIDs []int32) (map[int32]string, error) {
usersByID, err := s.listUsersByID(ctx, userIDs)
if err != nil {
return nil, err
}
usernamesByID := make(map[int32]string, len(usersByID))
for _, user := range usersByID {
usernamesByID[user.ID] = user.Username
}
return usernamesByID, nil
@ -62,6 +84,7 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUser
}
userMemoStatMap := make(map[int32]*v1pb.UserStats)
pinnedMemoIDsByUserID := make(map[int32][]int32)
limit := 1000
offset := 0
memoFind.Limit = &limit
@ -128,7 +151,7 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUser
// Track pinned memos
if memo.Pinned {
stats.PinnedMemos = append(stats.PinnedMemos, fmt.Sprintf("users/%d/memos/%d", memo.CreatorID, memo.ID))
pinnedMemoIDsByUserID[memo.CreatorID] = append(pinnedMemoIDsByUserID[memo.CreatorID], memo.ID)
}
}
@ -150,12 +173,8 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUser
return nil, status.Errorf(codes.Internal, "failed to resolve user stats name")
}
userMemoStat.Name = fmt.Sprintf("%s/stats", BuildUserName(username))
for i, pinnedMemo := range userMemoStat.PinnedMemos {
parts := strings.Split(pinnedMemo, "/")
if len(parts) != 4 {
return nil, status.Errorf(codes.Internal, "failed to resolve pinned memo name")
}
userMemoStat.PinnedMemos[i] = fmt.Sprintf("%s/memos/%s", BuildUserName(username), parts[3])
for _, memoID := range pinnedMemoIDsByUserID[userID] {
userMemoStat.PinnedMemos = append(userMemoStat.PinnedMemos, fmt.Sprintf("%s/memos/%d", BuildUserName(username), memoID))
}
userMemoStats = append(userMemoStats, userMemoStat)
}

View File

@ -57,6 +57,37 @@ func storeAttachmentToJSON(ctx context.Context, stores *store.Store, a *store.At
return j, nil
}
func storeAttachmentToJSONWithUsernames(a *store.Attachment, usernamesByID map[int32]string) (attachmentJSON, error) {
creator, err := lookupUsernameFromCache(usernamesByID, a.CreatorID)
if err != nil {
return attachmentJSON{}, err
}
j := attachmentJSON{
Name: "attachments/" + a.UID,
Creator: creator,
CreateTime: a.CreatedTs,
Filename: a.Filename,
Type: a.Type,
Size: a.Size,
}
switch a.StorageType {
case storepb.AttachmentStorageType_LOCAL:
j.StorageType = "LOCAL"
case storepb.AttachmentStorageType_S3:
j.StorageType = "S3"
j.ExternalLink = a.Reference
case storepb.AttachmentStorageType_EXTERNAL:
j.StorageType = "EXTERNAL"
j.ExternalLink = a.Reference
default:
j.StorageType = "DATABASE"
}
if a.MemoUID != nil && *a.MemoUID != "" {
j.Memo = "memos/" + *a.MemoUID
}
return j, nil
}
func parseAttachmentUID(name string) (string, error) {
uid, ok := strings.CutPrefix(name, "attachments/")
if !ok || uid == "" {
@ -140,10 +171,18 @@ func (s *MCPService) handleListAttachments(ctx context.Context, req mcp.CallTool
if hasMore {
attachments = attachments[:pageSize]
}
creatorIDs := make([]int32, 0, len(attachments))
for _, attachment := range attachments {
creatorIDs = append(creatorIDs, attachment.CreatorID)
}
usernamesByID, err := preloadUsernames(ctx, s.store, creatorIDs)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to preload attachment creators: %v", err)), nil
}
results := make([]attachmentJSON, len(attachments))
for i, a := range attachments {
result, err := storeAttachmentToJSON(ctx, s.store, a)
result, err := storeAttachmentToJSONWithUsernames(a, usernamesByID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve attachment creator: %v", err)), nil
}

View File

@ -113,6 +113,41 @@ func lookupUsername(ctx context.Context, stores *store.Store, userID int32) (str
return "users/" + user.Username, nil
}
func preloadUsernames(ctx context.Context, stores *store.Store, userIDs []int32) (map[int32]string, error) {
if len(userIDs) == 0 {
return map[int32]string{}, nil
}
uniqueUserIDs := make([]int32, 0, len(userIDs))
seenUserIDs := make(map[int32]struct{}, len(userIDs))
for _, userID := range userIDs {
if _, seen := seenUserIDs[userID]; seen {
continue
}
seenUserIDs[userID] = struct{}{}
uniqueUserIDs = append(uniqueUserIDs, userID)
}
users, err := stores.ListUsers(ctx, &store.FindUser{IDList: uniqueUserIDs})
if err != nil {
return nil, errors.Wrap(err, "failed to list creator users")
}
usernamesByID := make(map[int32]string, len(users))
for _, user := range users {
usernamesByID[user.ID] = "users/" + user.Username
}
return usernamesByID, nil
}
func lookupUsernameFromCache(usernamesByID map[int32]string, userID int32) (string, error) {
username, ok := usernamesByID[userID]
if !ok {
return "", errors.Errorf("creator user %d not found", userID)
}
return username, nil
}
func storeMemoToJSONWithStore(ctx context.Context, stores *store.Store, m *store.Memo) (memoJSON, error) {
j := storeMemoToJSON(m)
creator, err := lookupUsername(ctx, stores, m.CreatorID)
@ -123,6 +158,16 @@ func storeMemoToJSONWithStore(ctx context.Context, stores *store.Store, m *store
return j, nil
}
func storeMemoToJSONWithUsernames(m *store.Memo, usernamesByID map[int32]string) (memoJSON, error) {
j := storeMemoToJSON(m)
creator, err := lookupUsernameFromCache(usernamesByID, m.CreatorID)
if err != nil {
return memoJSON{}, err
}
j.Creator = creator
return j, nil
}
// checkMemoAccess returns an error if the caller cannot read memo.
// userID == 0 means anonymous.
func checkMemoAccess(memo *store.Memo, userID int32) error {
@ -306,10 +351,18 @@ func (s *MCPService) handleListMemos(ctx context.Context, req mcp.CallToolReques
if hasMore {
memos = memos[:pageSize]
}
creatorIDs := make([]int32, 0, len(memos))
for _, memo := range memos {
creatorIDs = append(creatorIDs, memo.CreatorID)
}
usernamesByID, err := preloadUsernames(ctx, s.store, creatorIDs)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to preload memo creators: %v", err)), nil
}
results := make([]memoJSON, len(memos))
for i, m := range memos {
result, err := storeMemoToJSONWithStore(ctx, s.store, m)
result, err := storeMemoToJSONWithUsernames(m, usernamesByID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve memo creator: %v", err)), nil
}
@ -514,10 +567,18 @@ func (s *MCPService) handleSearchMemos(ctx context.Context, req mcp.CallToolRequ
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to search memos: %v", err)), nil
}
creatorIDs := make([]int32, 0, len(memos))
for _, memo := range memos {
creatorIDs = append(creatorIDs, memo.CreatorID)
}
usernamesByID, err := preloadUsernames(ctx, s.store, creatorIDs)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to preload memo creators: %v", err)), nil
}
results := make([]memoJSON, len(memos))
for i, m := range memos {
result, err := storeMemoToJSONWithStore(ctx, s.store, m)
result, err := storeMemoToJSONWithUsernames(m, usernamesByID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve memo creator: %v", err)), nil
}
@ -571,11 +632,21 @@ func (s *MCPService) handleListMemoComments(ctx context.Context, req mcp.CallToo
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to list comments: %v", err)), nil
}
creatorIDs := make([]int32, 0, len(memos))
for _, memo := range memos {
if checkMemoAccess(memo, userID) == nil {
creatorIDs = append(creatorIDs, memo.CreatorID)
}
}
usernamesByID, err := preloadUsernames(ctx, s.store, creatorIDs)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to preload memo creators: %v", err)), nil
}
results := make([]memoJSON, 0, len(memos))
for _, m := range memos {
if checkMemoAccess(m, userID) == nil {
result, err := storeMemoToJSONWithStore(ctx, s.store, m)
result, err := storeMemoToJSONWithUsernames(m, usernamesByID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve memo creator: %v", err)), nil
}

View File

@ -60,10 +60,18 @@ func (s *MCPService) handleListReactions(ctx context.Context, req mcp.CallToolRe
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to list reactions: %v", err)), nil
}
creatorIDs := make([]int32, 0, len(reactions))
for _, reaction := range reactions {
creatorIDs = append(creatorIDs, reaction.CreatorID)
}
usernamesByID, err := preloadUsernames(ctx, s.store, creatorIDs)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to preload reaction creators: %v", err)), nil
}
results := make([]reactionJSON, len(reactions))
for i, r := range reactions {
creator, err := lookupUsername(ctx, s.store, r.CreatorID)
creator, err := lookupUsernameFromCache(usernamesByID, r.CreatorID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to resolve reaction creator: %v", err)), nil
}

View File

@ -23,6 +23,8 @@ const getShortcutId = (name: string): string => {
return parts.length === 4 ? parts[3] : "";
};
const escapeFilterValue = (value: string): string => JSON.stringify(value);
export interface UseMemoFiltersOptions {
creatorName?: string;
includeShortcuts?: boolean;
@ -63,9 +65,9 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
// Add active filters from context
for (const filter of filters) {
if (filter.factor === "contentSearch") {
conditions.push(`content.contains("${filter.value}")`);
conditions.push(`content.contains(${escapeFilterValue(filter.value)})`);
} else if (filter.factor === "tagSearch") {
conditions.push(`tag in ["${filter.value}"]`);
conditions.push(`tag in [${escapeFilterValue(filter.value)}]`);
} else if (filter.factor === "pinned") {
if (includePinned) {
conditions.push(`pinned`);

View File

@ -16,7 +16,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file api/v1/shortcut_service.proto.
*/
export const file_api_v1_shortcut_service: GenFile = /*@__PURE__*/
fileDesc("Ch1hcGkvdjEvc2hvcnRjdXRfc2VydmljZS5wcm90bxIMbWVtb3MuYXBpLnYxIpoBCghTaG9ydGN1dBIRCgRuYW1lGAEgASgJQgPgQQgSEgoFdGl0bGUYAiABKAlCA+BBAhITCgZmaWx0ZXIYAyABKAlCA+BBATpS6kFPChVtZW1vcy5hcGkudjEvU2hvcnRjdXQSIXVzZXJzL3t1c2VyfS9zaG9ydGN1dHMve3Nob3J0Y3V0fSoJc2hvcnRjdXRzMghzaG9ydGN1dCJFChRMaXN0U2hvcnRjdXRzUmVxdWVzdBItCgZwYXJlbnQYASABKAlCHeBBAvpBFxIVbWVtb3MuYXBpLnYxL1Nob3J0Y3V0IkIKFUxpc3RTaG9ydGN1dHNSZXNwb25zZRIpCglzaG9ydGN1dHMYASADKAsyFi5tZW1vcy5hcGkudjEuU2hvcnRjdXQiQQoSR2V0U2hvcnRjdXRSZXF1ZXN0EisKBG5hbWUYASABKAlCHeBBAvpBFwoVbWVtb3MuYXBpLnYxL1Nob3J0Y3V0IpEBChVDcmVhdGVTaG9ydGN1dFJlcXVlc3QSLQoGcGFyZW50GAEgASgJQh3gQQL6QRcSFW1lbW9zLmFwaS52MS9TaG9ydGN1dBItCghzaG9ydGN1dBgCIAEoCzIWLm1lbW9zLmFwaS52MS5TaG9ydGN1dEID4EECEhoKDXZhbGlkYXRlX29ubHkYAyABKAhCA+BBASJ8ChVVcGRhdGVTaG9ydGN1dFJlcXVlc3QSLQoIc2hvcnRjdXQYASABKAsyFi5tZW1vcy5hcGkudjEuU2hvcnRjdXRCA+BBAhI0Cgt1cGRhdGVfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tCA+BBASJEChVEZWxldGVTaG9ydGN1dFJlcXVlc3QSKwoEbmFtZRgBIAEoCUId4EEC+kEXChVtZW1vcy5hcGkudjEvU2hvcnRjdXQy3gUKD1Nob3J0Y3V0U2VydmljZRKNAQoNTGlzdFNob3J0Y3V0cxIiLm1lbW9zLmFwaS52MS5MaXN0U2hvcnRjdXRzUmVxdWVzdBojLm1lbW9zLmFwaS52MS5MaXN0U2hvcnRjdXRzUmVzcG9uc2UiM9pBBnBhcmVudILT5JMCJBIiL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L3Nob3J0Y3V0cxJ6CgtHZXRTaG9ydGN1dBIgLm1lbW9zLmFwaS52MS5HZXRTaG9ydGN1dFJlcXVlc3QaFi5tZW1vcy5hcGkudjEuU2hvcnRjdXQiMdpBBG5hbWWC0+STAiQSIi9hcGkvdjEve25hbWU9dXNlcnMvKi9zaG9ydGN1dHMvKn0SlQEKDkNyZWF0ZVNob3J0Y3V0EiMubWVtb3MuYXBpLnYxLkNyZWF0ZVNob3J0Y3V0UmVxdWVzdBoWLm1lbW9zLmFwaS52MS5TaG9ydGN1dCJG2kEPcGFyZW50LHNob3J0Y3V0gtPkkwIuOghzaG9ydGN1dCIiL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L3Nob3J0Y3V0cxKjAQoOVXBkYXRlU2hvcnRjdXQSIy5tZW1vcy5hcGkudjEuVXBkYXRlU2hvcnRjdXRSZXF1ZXN0GhYubWVtb3MuYXBpLnYxLlNob3J0Y3V0IlTaQRRzaG9ydGN1dCx1cGRhdGVfbWFza4LT5JMCNzoIc2hvcnRjdXQyKy9hcGkvdjEve3Nob3J0Y3V0Lm5hbWU9dXNlcnMvKi9zaG9ydGN1dHMvKn0SgAEKDkRlbGV0ZVNob3J0Y3V0EiMubWVtb3MuYXBpLnYxLkRlbGV0ZVNob3J0Y3V0UmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIx2kEEbmFtZYLT5JMCJCoiL2FwaS92MS97bmFtZT11c2Vycy8qL3Nob3J0Y3V0cy8qfUKsAQoQY29tLm1lbW9zLmFwaS52MUIUU2hvcnRjdXRTZXJ2aWNlUHJvdG9QAVowZ2l0aHViLmNvbS91c2VtZW1vcy9tZW1vcy9wcm90by9nZW4vYXBpL3YxO2FwaXYxogIDTUFYqgIMTWVtb3MuQXBpLlYxygIMTWVtb3NcQXBpXFYx4gIYTWVtb3NcQXBpXFYxXEdQQk1ldGFkYXRh6gIOTWVtb3M6OkFwaTo6VjFiBnByb3RvMw", [file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask]);
fileDesc("Ch1hcGkvdjEvc2hvcnRjdXRfc2VydmljZS5wcm90bxIMbWVtb3MuYXBpLnYxIp4BCghTaG9ydGN1dBIRCgRuYW1lGAEgASgJQgPgQQgSEgoFdGl0bGUYAiABKAlCA+BBAhITCgZmaWx0ZXIYAyABKAlCA+BBATpW6kFTChVtZW1vcy5hcGkudjEvU2hvcnRjdXQSJXVzZXJzL3t1c2VybmFtZX0vc2hvcnRjdXRzL3tzaG9ydGN1dH0qCXNob3J0Y3V0czIIc2hvcnRjdXQiRQoUTGlzdFNob3J0Y3V0c1JlcXVlc3QSLQoGcGFyZW50GAEgASgJQh3gQQL6QRcSFW1lbW9zLmFwaS52MS9TaG9ydGN1dCJCChVMaXN0U2hvcnRjdXRzUmVzcG9uc2USKQoJc2hvcnRjdXRzGAEgAygLMhYubWVtb3MuYXBpLnYxLlNob3J0Y3V0IkEKEkdldFNob3J0Y3V0UmVxdWVzdBIrCgRuYW1lGAEgASgJQh3gQQL6QRcKFW1lbW9zLmFwaS52MS9TaG9ydGN1dCKRAQoVQ3JlYXRlU2hvcnRjdXRSZXF1ZXN0Ei0KBnBhcmVudBgBIAEoCUId4EEC+kEXEhVtZW1vcy5hcGkudjEvU2hvcnRjdXQSLQoIc2hvcnRjdXQYAiABKAsyFi5tZW1vcy5hcGkudjEuU2hvcnRjdXRCA+BBAhIaCg12YWxpZGF0ZV9vbmx5GAMgASgIQgPgQQEifAoVVXBkYXRlU2hvcnRjdXRSZXF1ZXN0Ei0KCHNob3J0Y3V0GAEgASgLMhYubWVtb3MuYXBpLnYxLlNob3J0Y3V0QgPgQQISNAoLdXBkYXRlX21hc2sYAiABKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrQgPgQQEiRAoVRGVsZXRlU2hvcnRjdXRSZXF1ZXN0EisKBG5hbWUYASABKAlCHeBBAvpBFwoVbWVtb3MuYXBpLnYxL1Nob3J0Y3V0Mt4FCg9TaG9ydGN1dFNlcnZpY2USjQEKDUxpc3RTaG9ydGN1dHMSIi5tZW1vcy5hcGkudjEuTGlzdFNob3J0Y3V0c1JlcXVlc3QaIy5tZW1vcy5hcGkudjEuTGlzdFNob3J0Y3V0c1Jlc3BvbnNlIjPaQQZwYXJlbnSC0+STAiQSIi9hcGkvdjEve3BhcmVudD11c2Vycy8qfS9zaG9ydGN1dHMSegoLR2V0U2hvcnRjdXQSIC5tZW1vcy5hcGkudjEuR2V0U2hvcnRjdXRSZXF1ZXN0GhYubWVtb3MuYXBpLnYxLlNob3J0Y3V0IjHaQQRuYW1lgtPkkwIkEiIvYXBpL3YxL3tuYW1lPXVzZXJzLyovc2hvcnRjdXRzLyp9EpUBCg5DcmVhdGVTaG9ydGN1dBIjLm1lbW9zLmFwaS52MS5DcmVhdGVTaG9ydGN1dFJlcXVlc3QaFi5tZW1vcy5hcGkudjEuU2hvcnRjdXQiRtpBD3BhcmVudCxzaG9ydGN1dILT5JMCLjoIc2hvcnRjdXQiIi9hcGkvdjEve3BhcmVudD11c2Vycy8qfS9zaG9ydGN1dHMSowEKDlVwZGF0ZVNob3J0Y3V0EiMubWVtb3MuYXBpLnYxLlVwZGF0ZVNob3J0Y3V0UmVxdWVzdBoWLm1lbW9zLmFwaS52MS5TaG9ydGN1dCJU2kEUc2hvcnRjdXQsdXBkYXRlX21hc2uC0+STAjc6CHNob3J0Y3V0MisvYXBpL3YxL3tzaG9ydGN1dC5uYW1lPXVzZXJzLyovc2hvcnRjdXRzLyp9EoABCg5EZWxldGVTaG9ydGN1dBIjLm1lbW9zLmFwaS52MS5EZWxldGVTaG9ydGN1dFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiMdpBBG5hbWWC0+STAiQqIi9hcGkvdjEve25hbWU9dXNlcnMvKi9zaG9ydGN1dHMvKn1CrAEKEGNvbS5tZW1vcy5hcGkudjFCFFNob3J0Y3V0U2VydmljZVByb3RvUAFaMGdpdGh1Yi5jb20vdXNlbWVtb3MvbWVtb3MvcHJvdG8vZ2VuL2FwaS92MTthcGl2MaICA01BWKoCDE1lbW9zLkFwaS5WMcoCDE1lbW9zXEFwaVxWMeICGE1lbW9zXEFwaVxWMVxHUEJNZXRhZGF0YeoCDk1lbW9zOjpBcGk6OlYxYgZwcm90bzM", [file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask]);
/**
* @generated from message memos.api.v1.Shortcut
@ -24,7 +24,7 @@ export const file_api_v1_shortcut_service: GenFile = /*@__PURE__*/
export type Shortcut = Message<"memos.api.v1.Shortcut"> & {
/**
* The resource name of the shortcut.
* Format: users/{user}/shortcuts/{shortcut}
* Format: users/{username}/shortcuts/{shortcut}
*
* @generated from field: string name = 1;
*/
@ -58,7 +58,7 @@ export const ShortcutSchema: GenMessage<Shortcut> = /*@__PURE__*/
export type ListShortcutsRequest = Message<"memos.api.v1.ListShortcutsRequest"> & {
/**
* Required. The parent resource where shortcuts are listed.
* Format: users/{user}
* Format: users/{username}
*
* @generated from field: string parent = 1;
*/
@ -97,7 +97,7 @@ export const ListShortcutsResponseSchema: GenMessage<ListShortcutsResponse> = /*
export type GetShortcutRequest = Message<"memos.api.v1.GetShortcutRequest"> & {
/**
* Required. The resource name of the shortcut to retrieve.
* Format: users/{user}/shortcuts/{shortcut}
* Format: users/{username}/shortcuts/{shortcut}
*
* @generated from field: string name = 1;
*/
@ -117,7 +117,7 @@ export const GetShortcutRequestSchema: GenMessage<GetShortcutRequest> = /*@__PUR
export type CreateShortcutRequest = Message<"memos.api.v1.CreateShortcutRequest"> & {
/**
* Required. The parent resource where this shortcut will be created.
* Format: users/{user}
* Format: users/{username}
*
* @generated from field: string parent = 1;
*/
@ -177,7 +177,7 @@ export const UpdateShortcutRequestSchema: GenMessage<UpdateShortcutRequest> = /*
export type DeleteShortcutRequest = Message<"memos.api.v1.DeleteShortcutRequest"> & {
/**
* Required. The resource name of the shortcut to delete.
* Format: users/{user}/shortcuts/{shortcut}
* Format: users/{username}/shortcuts/{shortcut}
*
* @generated from field: string name = 1;
*/

File diff suppressed because one or more lines are too long