diff --git a/api/v2/memo_relation_service.go b/api/v2/memo_relation_service.go
new file mode 100644
index 00000000..0f3fa6c2
--- /dev/null
+++ b/api/v2/memo_relation_service.go
@@ -0,0 +1,91 @@
+package v2
+
+import (
+ "context"
+
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+
+ apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
+ "github.com/usememos/memos/store"
+)
+
+func (s *APIV2Service) SetMemoRelations(ctx context.Context, request *apiv2pb.SetMemoRelationsRequest) (*apiv2pb.SetMemoRelationsResponse, error) {
+ referenceType := store.MemoRelationReference
+ // Delete all reference relations first.
+ if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{
+ MemoID: &request.Id,
+ Type: &referenceType,
+ }); err != nil {
+ return nil, status.Errorf(codes.Internal, "failed to delete memo relation")
+ }
+
+ for _, relation := range request.Relations {
+ if _, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{
+ MemoID: request.Id,
+ RelatedMemoID: relation.RelatedMemoId,
+ Type: convertMemoRelationTypeToStore(relation.Type),
+ }); err != nil {
+ return nil, status.Errorf(codes.Internal, "failed to upsert memo relation")
+ }
+ }
+
+ return &apiv2pb.SetMemoRelationsResponse{}, nil
+}
+
+func (s *APIV2Service) ListMemoRelations(ctx context.Context, request *apiv2pb.ListMemoRelationsRequest) (*apiv2pb.ListMemoRelationsResponse, error) {
+ relationList := []*apiv2pb.MemoRelation{}
+ tempList, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
+ MemoID: &request.Id,
+ })
+ if err != nil {
+ return nil, err
+ }
+ for _, relation := range tempList {
+ relationList = append(relationList, convertMemoRelationFromStore(relation))
+ }
+ tempList, err = s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
+ RelatedMemoID: &request.Id,
+ })
+ if err != nil {
+ return nil, err
+ }
+ for _, relation := range tempList {
+ relationList = append(relationList, convertMemoRelationFromStore(relation))
+ }
+
+ response := &apiv2pb.ListMemoRelationsResponse{
+ Relations: relationList,
+ }
+ return response, nil
+}
+
+func convertMemoRelationFromStore(memoRelation *store.MemoRelation) *apiv2pb.MemoRelation {
+ return &apiv2pb.MemoRelation{
+ MemoId: memoRelation.MemoID,
+ RelatedMemoId: memoRelation.RelatedMemoID,
+ Type: convertMemoRelationTypeFromStore(memoRelation.Type),
+ }
+}
+
+func convertMemoRelationTypeFromStore(relationType store.MemoRelationType) apiv2pb.MemoRelation_Type {
+ switch relationType {
+ case store.MemoRelationReference:
+ return apiv2pb.MemoRelation_REFERENCE
+ case store.MemoRelationComment:
+ return apiv2pb.MemoRelation_COMMENT
+ default:
+ return apiv2pb.MemoRelation_TYPE_UNSPECIFIED
+ }
+}
+
+func convertMemoRelationTypeToStore(relationType apiv2pb.MemoRelation_Type) store.MemoRelationType {
+ switch relationType {
+ case apiv2pb.MemoRelation_REFERENCE:
+ return store.MemoRelationReference
+ case apiv2pb.MemoRelation_COMMENT:
+ return store.MemoRelationComment
+ default:
+ return store.MemoRelationReference
+ }
+}
diff --git a/api/v2/memo_resource_service.go b/api/v2/memo_resource_service.go
new file mode 100644
index 00000000..5c64efc0
--- /dev/null
+++ b/api/v2/memo_resource_service.go
@@ -0,0 +1,66 @@
+package v2
+
+import (
+ "context"
+
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+
+ apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
+ "github.com/usememos/memos/store"
+)
+
+func (s *APIV2Service) SetMemoResources(ctx context.Context, request *apiv2pb.SetMemoResourcesRequest) (*apiv2pb.SetMemoResourcesResponse, error) {
+ resources, err := s.Store.ListResources(ctx, &store.FindResource{MemoID: &request.Id})
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "failed to list resources")
+ }
+
+ // Delete resources that are not in the request.
+ for _, resource := range resources {
+ found := false
+ for _, requestResource := range request.Resources {
+ if resource.ID == int32(requestResource.Id) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ if err = s.Store.DeleteResource(ctx, &store.DeleteResource{
+ ID: int32(resource.ID),
+ MemoID: &request.Id,
+ }); err != nil {
+ return nil, status.Errorf(codes.Internal, "failed to delete resource")
+ }
+ }
+ }
+
+ // Update resources' memo_id in the request.
+ for _, resource := range request.Resources {
+ if _, err := s.Store.UpdateResource(ctx, &store.UpdateResource{
+ ID: resource.Id,
+ MemoID: &request.Id,
+ }); err != nil {
+ return nil, status.Errorf(codes.Internal, "failed to update resource")
+ }
+ }
+
+ return &apiv2pb.SetMemoResourcesResponse{}, nil
+}
+
+func (s *APIV2Service) ListMemoResources(ctx context.Context, request *apiv2pb.ListMemoResourcesRequest) (*apiv2pb.ListMemoResourcesResponse, error) {
+ resources, err := s.Store.ListResources(ctx, &store.FindResource{
+ MemoID: &request.Id,
+ })
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "failed to list resources")
+ }
+
+ response := &apiv2pb.ListMemoResourcesResponse{
+ Resources: []*apiv2pb.Resource{},
+ }
+ for _, resource := range resources {
+ response.Resources = append(response.Resources, s.convertResourceFromStore(ctx, resource))
+ }
+ return response, nil
+}
diff --git a/api/v2/memo_service.go b/api/v2/memo_service.go
index 36e7ebf5..f8b1f702 100644
--- a/api/v2/memo_service.go
+++ b/api/v2/memo_service.go
@@ -8,15 +8,20 @@ import (
"github.com/google/cel-go/cel"
"github.com/pkg/errors"
+ "go.uber.org/zap"
expr "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
apiv1 "github.com/usememos/memos/api/v1"
+ "github.com/usememos/memos/internal/log"
"github.com/usememos/memos/plugin/gomark/parser"
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
+ "github.com/usememos/memos/plugin/webhook"
apiv2pb "github.com/usememos/memos/proto/gen/api/v2"
+ storepb "github.com/usememos/memos/proto/gen/store"
+ "github.com/usememos/memos/server/service/metric"
"github.com/usememos/memos/store"
)
@@ -38,11 +43,17 @@ func (s *APIV2Service) CreateMemo(ctx context.Context, request *apiv2pb.CreateMe
if err != nil {
return nil, err
}
+ metric.Enqueue("memo create")
memoMessage, err := s.convertMemoFromStore(ctx, memo)
if err != nil {
return nil, errors.Wrap(err, "failed to convert memo")
}
+ // Try to dispatch webhook when memo is created.
+ if err := s.DispatchMemoCreatedWebhook(ctx, memoMessage); err != nil {
+ log.Warn("Failed to dispatch memo created webhook", zap.Error(err))
+ }
+
response := &apiv2pb.CreateMemoResponse{
Memo: memoMessage,
}
@@ -222,6 +233,11 @@ func (s *APIV2Service) UpdateMemo(ctx context.Context, request *apiv2pb.UpdateMe
if err != nil {
return nil, errors.Wrap(err, "failed to convert memo")
}
+ // Try to dispatch webhook when memo is updated.
+ if err := s.DispatchMemoUpdatedWebhook(ctx, memoMessage); err != nil {
+ log.Warn("Failed to dispatch memo updated webhook", zap.Error(err))
+ }
+
return &apiv2pb.UpdateMemoResponse{
Memo: memoMessage,
}, nil
@@ -252,112 +268,12 @@ func (s *APIV2Service) DeleteMemo(ctx context.Context, request *apiv2pb.DeleteMe
return &apiv2pb.DeleteMemoResponse{}, nil
}
-func (s *APIV2Service) SetMemoResources(ctx context.Context, request *apiv2pb.SetMemoResourcesRequest) (*apiv2pb.SetMemoResourcesResponse, error) {
- resources, err := s.Store.ListResources(ctx, &store.FindResource{MemoID: &request.Id})
- if err != nil {
- return nil, status.Errorf(codes.Internal, "failed to list resources")
- }
-
- // Delete resources that are not in the request.
- for _, resource := range resources {
- found := false
- for _, requestResource := range request.Resources {
- if resource.ID == int32(requestResource.Id) {
- found = true
- break
- }
- }
- if !found {
- if err = s.Store.DeleteResource(ctx, &store.DeleteResource{
- ID: int32(resource.ID),
- MemoID: &request.Id,
- }); err != nil {
- return nil, status.Errorf(codes.Internal, "failed to delete resource")
- }
- }
- }
-
- // Update resources' memo_id in the request.
- for _, resource := range request.Resources {
- if _, err := s.Store.UpdateResource(ctx, &store.UpdateResource{
- ID: resource.Id,
- MemoID: &request.Id,
- }); err != nil {
- return nil, status.Errorf(codes.Internal, "failed to update resource")
- }
- }
-
- return &apiv2pb.SetMemoResourcesResponse{}, nil
-}
-
-func (s *APIV2Service) ListMemoResources(ctx context.Context, request *apiv2pb.ListMemoResourcesRequest) (*apiv2pb.ListMemoResourcesResponse, error) {
- resources, err := s.Store.ListResources(ctx, &store.FindResource{
- MemoID: &request.Id,
- })
- if err != nil {
- return nil, status.Errorf(codes.Internal, "failed to list resources")
- }
-
- response := &apiv2pb.ListMemoResourcesResponse{
- Resources: []*apiv2pb.Resource{},
- }
- for _, resource := range resources {
- response.Resources = append(response.Resources, s.convertResourceFromStore(ctx, resource))
- }
- return response, nil
-}
-
-func (s *APIV2Service) SetMemoRelations(ctx context.Context, request *apiv2pb.SetMemoRelationsRequest) (*apiv2pb.SetMemoRelationsResponse, error) {
- referenceType := store.MemoRelationReference
- // Delete all reference relations first.
- if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{
- MemoID: &request.Id,
- Type: &referenceType,
- }); err != nil {
- return nil, status.Errorf(codes.Internal, "failed to delete memo relation")
- }
-
- for _, relation := range request.Relations {
- if _, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{
- MemoID: request.Id,
- RelatedMemoID: relation.RelatedMemoId,
- Type: convertMemoRelationTypeToStore(relation.Type),
- }); err != nil {
- return nil, status.Errorf(codes.Internal, "failed to upsert memo relation")
- }
- }
-
- return &apiv2pb.SetMemoRelationsResponse{}, nil
-}
-
-func (s *APIV2Service) ListMemoRelations(ctx context.Context, request *apiv2pb.ListMemoRelationsRequest) (*apiv2pb.ListMemoRelationsResponse, error) {
- relationList := []*apiv2pb.MemoRelation{}
- tempList, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
- MemoID: &request.Id,
- })
- if err != nil {
- return nil, err
- }
- for _, relation := range tempList {
- relationList = append(relationList, convertMemoRelationFromStore(relation))
- }
- tempList, err = s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
- RelatedMemoID: &request.Id,
- })
- if err != nil {
- return nil, err
- }
- for _, relation := range tempList {
- relationList = append(relationList, convertMemoRelationFromStore(relation))
- }
-
- response := &apiv2pb.ListMemoRelationsResponse{
- Relations: relationList,
- }
- return response, nil
-}
-
func (s *APIV2Service) CreateMemoComment(ctx context.Context, request *apiv2pb.CreateMemoCommentRequest) (*apiv2pb.CreateMemoCommentResponse, error) {
+ relatedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{ID: &request.Id})
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "failed to get memo")
+ }
+
// Create the comment memo first.
createMemoResponse, err := s.CreateMemo(ctx, request.Create)
if err != nil {
@@ -374,6 +290,34 @@ func (s *APIV2Service) CreateMemoComment(ctx context.Context, request *apiv2pb.C
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create memo relation")
}
+ if memo.Visibility != apiv2pb.Visibility_PRIVATE {
+ activity, err := s.Store.CreateActivity(ctx, &store.Activity{
+ CreatorID: memo.CreatorId,
+ Type: store.ActivityTypeMemoComment,
+ Level: store.ActivityLevelInfo,
+ Payload: &storepb.ActivityPayload{
+ MemoComment: &storepb.ActivityMemoCommentPayload{
+ MemoId: memo.Id,
+ RelatedMemoId: request.Id,
+ },
+ },
+ })
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "failed to create activity")
+ }
+ if _, err := s.Store.CreateInbox(ctx, &store.Inbox{
+ SenderID: memo.CreatorId,
+ ReceiverID: relatedMemo.CreatorID,
+ Status: store.UNREAD,
+ Message: &storepb.InboxMessage{
+ Type: storepb.InboxMessage_TYPE_MEMO_COMMENT,
+ ActivityId: &activity.ID,
+ },
+ }); err != nil {
+ return nil, status.Errorf(codes.Internal, "failed to create inbox")
+ }
+ }
+ metric.Enqueue("memo comment create")
response := &apiv2pb.CreateMemoCommentResponse{
Memo: memo,
@@ -473,36 +417,6 @@ func (s *APIV2Service) getMemoDisplayWithUpdatedTsSettingValue(ctx context.Conte
return memoDisplayWithUpdatedTs, nil
}
-func convertMemoRelationFromStore(memoRelation *store.MemoRelation) *apiv2pb.MemoRelation {
- return &apiv2pb.MemoRelation{
- MemoId: memoRelation.MemoID,
- RelatedMemoId: memoRelation.RelatedMemoID,
- Type: convertMemoRelationTypeFromStore(memoRelation.Type),
- }
-}
-
-func convertMemoRelationTypeFromStore(relationType store.MemoRelationType) apiv2pb.MemoRelation_Type {
- switch relationType {
- case store.MemoRelationReference:
- return apiv2pb.MemoRelation_REFERENCE
- case store.MemoRelationComment:
- return apiv2pb.MemoRelation_COMMENT
- default:
- return apiv2pb.MemoRelation_TYPE_UNSPECIFIED
- }
-}
-
-func convertMemoRelationTypeToStore(relationType apiv2pb.MemoRelation_Type) store.MemoRelationType {
- switch relationType {
- case apiv2pb.MemoRelation_REFERENCE:
- return store.MemoRelationReference
- case apiv2pb.MemoRelation_COMMENT:
- return store.MemoRelationComment
- default:
- return store.MemoRelationReference
- }
-}
-
func convertVisibilityFromStore(visibility store.Visibility) apiv2pb.Visibility {
switch visibility {
case store.Private:
@@ -613,3 +527,73 @@ func findField(callExpr *expr.Expr_Call, filter *ListMemosFilter) {
}
}
}
+
+// DispatchMemoCreatedWebhook dispatches webhook when memo is created.
+func (s *APIV2Service) DispatchMemoCreatedWebhook(ctx context.Context, memo *apiv2pb.Memo) error {
+ return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.created")
+}
+
+// DispatchMemoUpdatedWebhook dispatches webhook when memo is updated.
+func (s *APIV2Service) DispatchMemoUpdatedWebhook(ctx context.Context, memo *apiv2pb.Memo) error {
+ return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.updated")
+}
+
+func (s *APIV2Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *apiv2pb.Memo, activityType string) error {
+ webhooks, err := s.Store.ListWebhooks(ctx, &store.FindWebhook{
+ CreatorID: &memo.CreatorId,
+ })
+ if err != nil {
+ return err
+ }
+ metric.Enqueue("webhook dispatch")
+ for _, hook := range webhooks {
+ payload := convertMemoToWebhookPayload(memo)
+ payload.ActivityType = activityType
+ payload.URL = hook.Url
+ err := webhook.Post(*payload)
+ if err != nil {
+ return errors.Wrap(err, "failed to post webhook")
+ }
+ }
+ return nil
+}
+
+func convertMemoToWebhookPayload(memo *apiv2pb.Memo) *webhook.WebhookPayload {
+ return &webhook.WebhookPayload{
+ CreatorID: memo.CreatorId,
+ CreatedTs: time.Now().Unix(),
+ Memo: &webhook.Memo{
+ ID: memo.Id,
+ CreatorID: memo.CreatorId,
+ CreatedTs: memo.CreateTime.Seconds,
+ UpdatedTs: memo.UpdateTime.Seconds,
+ Content: memo.Content,
+ Visibility: memo.Visibility.String(),
+ Pinned: memo.Pinned,
+ ResourceList: func() []*webhook.Resource {
+ resources := []*webhook.Resource{}
+ for _, resource := range memo.Resources {
+ resources = append(resources, &webhook.Resource{
+ ID: resource.Id,
+ Filename: resource.Filename,
+ ExternalLink: resource.ExternalLink,
+ Type: resource.Type,
+ Size: resource.Size,
+ })
+ }
+ return resources
+ }(),
+ RelationList: func() []*webhook.MemoRelation {
+ relations := []*webhook.MemoRelation{}
+ for _, relation := range memo.Relations {
+ relations = append(relations, &webhook.MemoRelation{
+ MemoID: relation.MemoId,
+ RelatedMemoID: relation.RelatedMemoId,
+ Type: relation.Type.String(),
+ })
+ }
+ return relations
+ }(),
+ },
+ }
+}
diff --git a/server/version/version.go b/server/version/version.go
index d7365959..9dcba4b3 100644
--- a/server/version/version.go
+++ b/server/version/version.go
@@ -12,7 +12,7 @@ import (
var Version = "0.18.1"
// DevVersion is the service current development version.
-var DevVersion = "0.18.1"
+var DevVersion = "0.18.2"
func GetCurrentVersion(mode string) string {
if mode == "dev" || mode == "demo" {
diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx
index f444f064..b26c61b3 100644
--- a/web/src/components/MemoEditor/index.tsx
+++ b/web/src/components/MemoEditor/index.tsx
@@ -9,7 +9,7 @@ import { TAB_SPACE_WIDTH, UNKNOWN_ID } from "@/helpers/consts";
import { useGlobalStore, useResourceStore } from "@/store/module";
import { useMemoStore, useUserStore } from "@/store/v1";
import { MemoRelation, MemoRelation_Type } from "@/types/proto/api/v2/memo_relation_service";
-import { Visibility } from "@/types/proto/api/v2/memo_service";
+import { Memo, Visibility } from "@/types/proto/api/v2/memo_service";
import { Resource } from "@/types/proto/api/v2/resource_service";
import { UserSetting } from "@/types/proto/api/v2/user_service";
import { useTranslate } from "@/utils/i18n";
@@ -28,6 +28,7 @@ interface Props {
editorClassName?: string;
cacheKey?: string;
memoId?: number;
+ parentMemoId?: number;
relationList?: MemoRelation[];
onConfirm?: (memoId: number) => void;
}
@@ -41,7 +42,7 @@ interface State {
}
const MemoEditor = (props: Props) => {
- const { className, editorClassName, cacheKey, memoId, onConfirm } = props;
+ const { className, editorClassName, cacheKey, memoId, parentMemoId, onConfirm } = props;
const { i18n } = useTranslation();
const t = useTranslate();
const contentCacheKey = `memo-editor-${cacheKey}`;
@@ -260,6 +261,7 @@ const MemoEditor = (props: Props) => {
});
const content = editorRef.current?.getContent() ?? "";
try {
+ // Update memo.
if (memoId && memoId !== UNKNOWN_ID) {
const prevMemo = await memoStore.getOrFetchMemoById(memoId ?? UNKNOWN_ID);
if (prevMemo) {
@@ -284,10 +286,22 @@ const MemoEditor = (props: Props) => {
}
}
} else {
- const memo = await memoStore.createMemo({
- content,
- visibility: state.memoVisibility,
- });
+ // Create memo or memo comment.
+ const request = !parentMemoId
+ ? memoStore.createMemo({
+ content,
+ visibility: state.memoVisibility,
+ })
+ : memoServiceClient
+ .createMemoComment({
+ id: parentMemoId,
+ create: {
+ content,
+ visibility: state.memoVisibility,
+ },
+ })
+ .then(({ memo }) => memo as Memo);
+ const memo = await request;
await memoServiceClient.setMemoResources({
id: memo.id,
resources: state.resourceList,
diff --git a/web/src/pages/MemoDetail.tsx b/web/src/pages/MemoDetail.tsx
index c06c294f..3b455249 100644
--- a/web/src/pages/MemoDetail.tsx
+++ b/web/src/pages/MemoDetail.tsx
@@ -15,7 +15,6 @@ import MobileHeader from "@/components/MobileHeader";
import showShareMemoDialog from "@/components/ShareMemoDialog";
import UserAvatar from "@/components/UserAvatar";
import VisibilityIcon from "@/components/VisibilityIcon";
-import { UNKNOWN_ID } from "@/helpers/consts";
import { getDateTimeString } from "@/helpers/datetime";
import useCurrentUser from "@/hooks/useCurrentUser";
import useNavigateTo from "@/hooks/useNavigateTo";
@@ -214,12 +213,7 @@ const MemoDetail = () => {
{/* Only show comment editor when user login */}
{currentUser && (
-
+
)}