package v1 import ( "context" "encoding/json" "fmt" "net/http" "strconv" "time" "github.com/labstack/echo/v4" "github.com/pkg/errors" "github.com/usememos/memos/store" ) // Visibility is the type of a visibility. type Visibility string const ( // Public is the PUBLIC visibility. Public Visibility = "PUBLIC" // Protected is the PROTECTED visibility. Protected Visibility = "PROTECTED" // Private is the PRIVATE visibility. Private Visibility = "PRIVATE" ) func (v Visibility) String() string { switch v { case Public: return "PUBLIC" case Protected: return "PROTECTED" case Private: return "PRIVATE" } return "PRIVATE" } type Memo struct { ID int `json:"id"` // Standard fields RowStatus RowStatus `json:"rowStatus"` CreatorID int `json:"creatorId"` CreatedTs int64 `json:"createdTs"` UpdatedTs int64 `json:"updatedTs"` // Domain specific fields DisplayTs int64 `json:"displayTs"` Content string `json:"content"` Visibility Visibility `json:"visibility"` Pinned bool `json:"pinned"` // Related fields CreatorName string `json:"creatorName"` CreatorUsername string `json:"creatorUsername"` ResourceList []*Resource `json:"resourceList"` RelationList []*MemoRelation `json:"relationList"` } type CreateMemoRequest struct { // Standard fields CreatorID int `json:"-"` CreatedTs *int64 `json:"createdTs"` // Domain specific fields Visibility Visibility `json:"visibility"` Content string `json:"content"` // Related fields ResourceIDList []int `json:"resourceIdList"` RelationList []*UpsertMemoRelationRequest `json:"relationList"` } type PatchMemoRequest struct { ID int `json:"-"` // Standard fields CreatedTs *int64 `json:"createdTs"` UpdatedTs *int64 RowStatus *RowStatus `json:"rowStatus"` // Domain specific fields Content *string `json:"content"` Visibility *Visibility `json:"visibility"` // Related fields ResourceIDList []int `json:"resourceIdList"` RelationList []*UpsertMemoRelationRequest `json:"relationList"` } type FindMemoRequest struct { ID *int // Standard fields RowStatus *RowStatus CreatorID *int // Domain specific fields Pinned *bool ContentSearch []string VisibilityList []Visibility // Pagination Limit *int Offset *int } // maxContentLength means the max memo content bytes is 1MB. const maxContentLength = 1 << 30 func (s *APIV1Service) registerMemoRoutes(g *echo.Group) { g.POST("/memo", func(c echo.Context) error { ctx := c.Request().Context() userID, ok := c.Get(getUserIDContextKey()).(int) if !ok { return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") } createMemoRequest := &CreateMemoRequest{} if err := json.NewDecoder(c.Request().Body).Decode(createMemoRequest); err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request").SetInternal(err) } if len(createMemoRequest.Content) > maxContentLength { return echo.NewHTTPError(http.StatusBadRequest, "Content size overflow, up to 1MB") } if createMemoRequest.Visibility == "" { userMemoVisibilitySetting, err := s.Store.GetUserSetting(ctx, &store.FindUserSetting{ UserID: &userID, Key: UserSettingMemoVisibilityKey.String(), }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user setting").SetInternal(err) } if userMemoVisibilitySetting != nil { memoVisibility := Private err := json.Unmarshal([]byte(userMemoVisibilitySetting.Value), &memoVisibility) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal user setting value").SetInternal(err) } createMemoRequest.Visibility = memoVisibility } else { // Private is the default memo visibility. createMemoRequest.Visibility = Private } } // Find disable public memos system setting. disablePublicMemosSystemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{ Name: SystemSettingDisablePublicMemosName.String(), }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err) } if disablePublicMemosSystemSetting != nil { disablePublicMemos := false err = json.Unmarshal([]byte(disablePublicMemosSystemSetting.Value), &disablePublicMemos) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err) } if disablePublicMemos { user, err := s.Store.GetUser(ctx, &store.FindUser{ ID: &userID, }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) } if user == nil { return echo.NewHTTPError(http.StatusNotFound, "User not found") } // Enforce normal user to create private memo if public memos are disabled. if user.Role == store.RoleUser { createMemoRequest.Visibility = Private } } } createMemoRequest.CreatorID = userID memo, err := s.Store.CreateMemo(ctx, convertCreateMemoRequestToMemoMessage(createMemoRequest)) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").SetInternal(err) } if err := s.createMemoCreateActivity(ctx, memo); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) } for _, resourceID := range createMemoRequest.ResourceIDList { if _, err := s.Store.UpsertMemoResource(ctx, &store.UpsertMemoResource{ MemoID: memo.ID, ResourceID: resourceID, }); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err) } } for _, memoRelationUpsert := range createMemoRequest.RelationList { if _, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{ MemoID: memo.ID, RelatedMemoID: memoRelationUpsert.RelatedMemoID, Type: store.MemoRelationType(memoRelationUpsert.Type), }); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err) } } memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ ID: &memo.ID, }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo").SetInternal(err) } if memo == nil { return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memo.ID)) } memoResponse, err := s.convertMemoFromStore(ctx, memo) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) } return c.JSON(http.StatusOK, memoResponse) }) g.PATCH("/memo/:memoId", func(c echo.Context) error { ctx := c.Request().Context() userID, ok := c.Get(getUserIDContextKey()).(int) if !ok { return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") } memoID, err := strconv.Atoi(c.Param("memoId")) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) } memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ ID: &memoID, }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) } if memo == nil { return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) } if memo.CreatorID != userID { return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") } currentTs := time.Now().Unix() patchMemoRequest := &PatchMemoRequest{ ID: memoID, UpdatedTs: ¤tTs, } if err := json.NewDecoder(c.Request().Body).Decode(patchMemoRequest); err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch memo request").SetInternal(err) } if patchMemoRequest.Content != nil && len(*patchMemoRequest.Content) > maxContentLength { return echo.NewHTTPError(http.StatusBadRequest, "Content size overflow, up to 1MB").SetInternal(err) } updateMemoMessage := &store.UpdateMemo{ ID: memoID, CreatedTs: patchMemoRequest.CreatedTs, UpdatedTs: patchMemoRequest.UpdatedTs, Content: patchMemoRequest.Content, } if patchMemoRequest.RowStatus != nil { rowStatus := store.RowStatus(patchMemoRequest.RowStatus.String()) updateMemoMessage.RowStatus = &rowStatus } if patchMemoRequest.Visibility != nil { visibility := store.Visibility(patchMemoRequest.Visibility.String()) updateMemoMessage.Visibility = &visibility } err = s.Store.UpdateMemo(ctx, updateMemoMessage) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch memo").SetInternal(err) } memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ID: &memoID}) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) } if memo == nil { return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) } if patchMemoRequest.ResourceIDList != nil { addedResourceIDList, removedResourceIDList := getIDListDiff(memo.ResourceIDList, patchMemoRequest.ResourceIDList) for _, resourceID := range addedResourceIDList { if _, err := s.Store.UpsertMemoResource(ctx, &store.UpsertMemoResource{ MemoID: memo.ID, ResourceID: resourceID, }); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err) } } for _, resourceID := range removedResourceIDList { if err := s.Store.DeleteMemoResource(ctx, &store.DeleteMemoResource{ MemoID: &memo.ID, ResourceID: &resourceID, }); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo resource").SetInternal(err) } } } if patchMemoRequest.RelationList != nil { patchMemoRelationList := make([]*store.MemoRelation, 0) for _, memoRelation := range patchMemoRequest.RelationList { patchMemoRelationList = append(patchMemoRelationList, &store.MemoRelation{ MemoID: memo.ID, RelatedMemoID: memoRelation.RelatedMemoID, Type: store.MemoRelationType(memoRelation.Type), }) } addedMemoRelationList, removedMemoRelationList := getMemoRelationListDiff(memo.RelationList, patchMemoRelationList) for _, memoRelation := range addedMemoRelationList { if _, err := s.Store.UpsertMemoRelation(ctx, memoRelation); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err) } } for _, memoRelation := range removedMemoRelationList { if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{ MemoID: &memo.ID, RelatedMemoID: &memoRelation.RelatedMemoID, Type: &memoRelation.Type, }); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo relation").SetInternal(err) } } } memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ID: &memoID}) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) } if memo == nil { return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) } memoResponse, err := s.convertMemoFromStore(ctx, memo) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) } return c.JSON(http.StatusOK, memoResponse) }) g.GET("/memo", func(c echo.Context) error { ctx := c.Request().Context() findMemoMessage := &store.FindMemo{} if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil { findMemoMessage.CreatorID = &userID } if username := c.QueryParam("creatorUsername"); username != "" { user, _ := s.Store.GetUser(ctx, &store.FindUser{Username: &username}) if user != nil { findMemoMessage.CreatorID = &user.ID } } currentUserID, ok := c.Get(getUserIDContextKey()).(int) if !ok { // Anonymous use should only fetch PUBLIC memos with specified user if findMemoMessage.CreatorID == nil { return echo.NewHTTPError(http.StatusBadRequest, "Missing user to find memo") } findMemoMessage.VisibilityList = []store.Visibility{store.Public} } else { // Authorized user can fetch all PUBLIC/PROTECTED memo visibilityList := []store.Visibility{store.Public, store.Protected} // If Creator is authorized user (as default), PRIVATE memo is OK if findMemoMessage.CreatorID == nil || *findMemoMessage.CreatorID == currentUserID { findMemoMessage.CreatorID = ¤tUserID visibilityList = append(visibilityList, store.Private) } findMemoMessage.VisibilityList = visibilityList } rowStatus := store.RowStatus(c.QueryParam("rowStatus")) if rowStatus != "" { findMemoMessage.RowStatus = &rowStatus } pinnedStr := c.QueryParam("pinned") if pinnedStr != "" { pinned := pinnedStr == "true" findMemoMessage.Pinned = &pinned } contentSearch := []string{} tag := c.QueryParam("tag") if tag != "" { contentSearch = append(contentSearch, "#"+tag) } contentSlice := c.QueryParams()["content"] if len(contentSlice) > 0 { contentSearch = append(contentSearch, contentSlice...) } findMemoMessage.ContentSearch = contentSearch if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil { findMemoMessage.Limit = &limit } if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil { findMemoMessage.Offset = &offset } memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err) } if memoDisplayWithUpdatedTs { findMemoMessage.OrderByUpdatedTs = true } list, err := s.Store.ListMemos(ctx, findMemoMessage) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch memo list").SetInternal(err) } memoResponseList := []*Memo{} for _, memo := range list { memoResponse, err := s.convertMemoFromStore(ctx, memo) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) } memoResponseList = append(memoResponseList, memoResponse) } return c.JSON(http.StatusOK, memoResponseList) }) g.GET("/memo/:memoId", func(c echo.Context) error { ctx := c.Request().Context() memoID, err := strconv.Atoi(c.Param("memoId")) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) } memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ ID: &memoID, }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err) } if memo == nil { return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) } userID, ok := c.Get(getUserIDContextKey()).(int) if memo.Visibility == store.Private { if !ok || memo.CreatorID != userID { return echo.NewHTTPError(http.StatusForbidden, "this memo is private only") } } else if memo.Visibility == store.Protected { if !ok { return echo.NewHTTPError(http.StatusForbidden, "this memo is protected, missing user in session") } } memoResponse, err := s.convertMemoFromStore(ctx, memo) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) } return c.JSON(http.StatusOK, memoResponse) }) g.GET("/memo/stats", func(c echo.Context) error { ctx := c.Request().Context() normalStatus := store.Normal findMemoMessage := &store.FindMemo{ RowStatus: &normalStatus, } if creatorID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil { findMemoMessage.CreatorID = &creatorID } if username := c.QueryParam("creatorUsername"); username != "" { user, _ := s.Store.GetUser(ctx, &store.FindUser{Username: &username}) if user != nil { findMemoMessage.CreatorID = &user.ID } } if findMemoMessage.CreatorID == nil { return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo") } currentUserID, ok := c.Get(getUserIDContextKey()).(int) if !ok { findMemoMessage.VisibilityList = []store.Visibility{store.Public} } else { if *findMemoMessage.CreatorID != currentUserID { findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected} } else { findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected, store.Private} } } memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err) } if memoDisplayWithUpdatedTs { findMemoMessage.OrderByUpdatedTs = true } list, err := s.Store.ListMemos(ctx, findMemoMessage) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err) } memoResponseList := []*Memo{} for _, memo := range list { memoResponse, err := s.convertMemoFromStore(ctx, memo) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) } memoResponseList = append(memoResponseList, memoResponse) } displayTsList := []int64{} for _, memo := range memoResponseList { displayTsList = append(displayTsList, memo.DisplayTs) } return c.JSON(http.StatusOK, displayTsList) }) g.GET("/memo/all", func(c echo.Context) error { ctx := c.Request().Context() findMemoMessage := &store.FindMemo{} _, ok := c.Get(getUserIDContextKey()).(int) if !ok { findMemoMessage.VisibilityList = []store.Visibility{store.Public} } else { findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected} } pinnedStr := c.QueryParam("pinned") if pinnedStr != "" { pinned := pinnedStr == "true" findMemoMessage.Pinned = &pinned } contentSearch := []string{} tag := c.QueryParam("tag") if tag != "" { contentSearch = append(contentSearch, "#"+tag+" ") } contentSlice := c.QueryParams()["content"] if len(contentSlice) > 0 { contentSearch = append(contentSearch, contentSlice...) } findMemoMessage.ContentSearch = contentSearch if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil { findMemoMessage.Limit = &limit } if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil { findMemoMessage.Offset = &offset } // Only fetch normal status memos. normalStatus := store.Normal findMemoMessage.RowStatus = &normalStatus memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err) } if memoDisplayWithUpdatedTs { findMemoMessage.OrderByUpdatedTs = true } list, err := s.Store.ListMemos(ctx, findMemoMessage) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch all memo list").SetInternal(err) } memoResponseList := []*Memo{} for _, memo := range list { memoResponse, err := s.convertMemoFromStore(ctx, memo) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err) } memoResponseList = append(memoResponseList, memoResponse) } return c.JSON(http.StatusOK, memoResponseList) }) g.DELETE("/memo/:memoId", func(c echo.Context) error { ctx := c.Request().Context() userID, ok := c.Get(getUserIDContextKey()).(int) if !ok { return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session") } memoID, err := strconv.Atoi(c.Param("memoId")) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err) } memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ ID: &memoID, }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err) } if memo == nil { return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID)) } if memo.CreatorID != userID { return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") } if err := s.Store.DeleteMemo(ctx, &store.DeleteMemo{ ID: memoID, }); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete memo ID: %v", memoID)).SetInternal(err) } return c.JSON(http.StatusOK, true) }) } func (s *APIV1Service) createMemoCreateActivity(ctx context.Context, memo *store.Memo) error { payload := ActivityMemoCreatePayload{ Content: memo.Content, Visibility: memo.Visibility.String(), } payloadBytes, err := json.Marshal(payload) if err != nil { return errors.Wrap(err, "failed to marshal activity payload") } activity, err := s.Store.CreateActivity(ctx, &store.Activity{ CreatorID: memo.CreatorID, Type: ActivityMemoCreate.String(), Level: ActivityInfo.String(), Payload: string(payloadBytes), }) if err != nil || activity == nil { return errors.Wrap(err, "failed to create activity") } return err } func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Memo) (*Memo, error) { memoResponse := &Memo{ ID: memo.ID, RowStatus: RowStatus(memo.RowStatus.String()), CreatorID: memo.CreatorID, CreatedTs: memo.CreatedTs, UpdatedTs: memo.UpdatedTs, Content: memo.Content, Visibility: Visibility(memo.Visibility.String()), Pinned: memo.Pinned, } // Compose creator name. user, err := s.Store.GetUser(ctx, &store.FindUser{ ID: &memoResponse.CreatorID, }) if err != nil { return nil, err } if user.Nickname != "" { memoResponse.CreatorName = user.Nickname } else { memoResponse.CreatorName = user.Username } memoResponse.CreatorUsername = user.Username // Compose display ts. memoResponse.DisplayTs = memoResponse.CreatedTs // Find memo display with updated ts setting. memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx) if err != nil { return nil, err } if memoDisplayWithUpdatedTs { memoResponse.DisplayTs = memoResponse.UpdatedTs } relationList := []*MemoRelation{} for _, relation := range memo.RelationList { relationList = append(relationList, convertMemoRelationFromStore(relation)) } memoResponse.RelationList = relationList resourceList := []*Resource{} for _, resourceID := range memo.ResourceIDList { resource, err := s.Store.GetResource(ctx, &store.FindResource{ ID: &resourceID, }) if err != nil { return nil, err } if resource != nil { resourceList = append(resourceList, convertResourceFromStore(resource)) } } memoResponse.ResourceList = resourceList return memoResponse, nil } func (s *APIV1Service) getMemoDisplayWithUpdatedTsSettingValue(ctx context.Context) (bool, error) { memoDisplayWithUpdatedTsSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{ Name: SystemSettingMemoDisplayWithUpdatedTsName.String(), }) if err != nil { return false, errors.Wrap(err, "failed to find system setting") } memoDisplayWithUpdatedTs := false if memoDisplayWithUpdatedTsSetting != nil { err = json.Unmarshal([]byte(memoDisplayWithUpdatedTsSetting.Value), &memoDisplayWithUpdatedTs) if err != nil { return false, errors.Wrap(err, "failed to unmarshal system setting value") } } return memoDisplayWithUpdatedTs, nil } func convertCreateMemoRequestToMemoMessage(memoCreate *CreateMemoRequest) *store.Memo { createdTs := time.Now().Unix() if memoCreate.CreatedTs != nil { createdTs = *memoCreate.CreatedTs } return &store.Memo{ CreatorID: memoCreate.CreatorID, CreatedTs: createdTs, Content: memoCreate.Content, Visibility: store.Visibility(memoCreate.Visibility), } } func getMemoRelationListDiff(oldList, newList []*store.MemoRelation) (addedList, removedList []*store.MemoRelation) { oldMap := map[string]bool{} for _, relation := range oldList { oldMap[fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)] = true } newMap := map[string]bool{} for _, relation := range newList { newMap[fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)] = true } for _, relation := range oldList { key := fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type) if !newMap[key] { removedList = append(removedList, relation) } } for _, relation := range newList { key := fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type) if !oldMap[key] { addedList = append(addedList, relation) } } return addedList, removedList } func getIDListDiff(oldList, newList []int) (addedList, removedList []int) { oldMap := map[int]bool{} for _, id := range oldList { oldMap[id] = true } newMap := map[int]bool{} for _, id := range newList { newMap[id] = true } for id := range oldMap { if !newMap[id] { removedList = append(removedList, id) } } for id := range newMap { if !oldMap[id] { addedList = append(addedList, id) } } return addedList, removedList }