Unverified Commit 65d14fbb authored by boojack's avatar boojack Committed by GitHub

feat(instance): add canonical tag metadata setting (#5736)

parent 330291d4
......@@ -8,6 +8,7 @@ import "google/api/client.proto";
import "google/api/field_behavior.proto";
import "google/api/resource.proto";
import "google/protobuf/field_mask.proto";
import "google/type/color.proto";
option go_package = "gen/api/v1";
......@@ -69,6 +70,7 @@ message InstanceSetting {
GeneralSetting general_setting = 2;
StorageSetting storage_setting = 3;
MemoRelatedSetting memo_related_setting = 4;
TagsSetting tags_setting = 5;
}
// Enumeration of instance setting keys.
......@@ -80,6 +82,8 @@ message InstanceSetting {
STORAGE = 2;
// MEMO_RELATED is the key for memo related settings.
MEMO_RELATED = 3;
// TAGS is the key for tag metadata.
TAGS = 4;
}
// General instance settings configuration.
......@@ -159,6 +163,17 @@ message InstanceSetting {
// reactions is the list of reactions.
repeated string reactions = 7;
}
// Metadata for a tag.
message TagMetadata {
// Background color for the tag label.
google.type.Color background_color = 1;
}
// Tag metadata configuration.
message TagsSetting {
map<string, TagMetadata> tags = 1;
}
}
// Request message for GetInstanceSetting method.
......
......@@ -10,7 +10,6 @@ import "google/api/resource.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/field_mask.proto";
import "google/protobuf/timestamp.proto";
import "google/type/color.proto";
option go_package = "gen/api/v1";
......@@ -376,7 +375,6 @@ message UserSetting {
oneof value {
GeneralSetting general_setting = 2;
WebhooksSetting webhooks_setting = 5;
TagsSetting tags_setting = 6;
}
// Enumeration of user setting keys.
......@@ -386,8 +384,6 @@ message UserSetting {
GENERAL = 1;
// WEBHOOKS is the key for user webhooks.
WEBHOOKS = 4;
// TAGS is the key for user tag metadata.
TAGS = 5;
}
// General user settings configuration.
......@@ -407,17 +403,6 @@ message UserSetting {
// List of user webhooks.
repeated UserWebhook webhooks = 1;
}
// Metadata for a tag.
message TagMetadata {
// Background color for the tag label.
google.type.Color background_color = 1;
}
// User tag metadata configuration.
message TagsSetting {
map<string, TagMetadata> tags = 1;
}
}
message GetUserSettingRequest {
......
This diff is collapsed.
This diff is collapsed.
......@@ -2190,6 +2190,8 @@ components:
$ref: '#/components/schemas/InstanceSetting_StorageSetting'
memoRelatedSetting:
$ref: '#/components/schemas/InstanceSetting_MemoRelatedSetting'
tagsSetting:
$ref: '#/components/schemas/InstanceSetting_TagsSetting'
description: An instance setting resource.
InstanceSetting_GeneralSetting:
type: object
......@@ -2271,6 +2273,22 @@ components:
- $ref: '#/components/schemas/StorageSetting_S3Config'
description: The S3 config.
description: Storage configuration settings for instance attachments.
InstanceSetting_TagMetadata:
type: object
properties:
backgroundColor:
allOf:
- $ref: '#/components/schemas/Color'
description: Background color for the tag label.
description: Metadata for a tag.
InstanceSetting_TagsSetting:
type: object
properties:
tags:
type: object
additionalProperties:
$ref: '#/components/schemas/InstanceSetting_TagMetadata'
description: Tag metadata configuration.
ListAllUserStatsResponse:
type: object
properties:
......@@ -2986,8 +3004,6 @@ components:
$ref: '#/components/schemas/UserSetting_GeneralSetting'
webhooksSetting:
$ref: '#/components/schemas/UserSetting_WebhooksSetting'
tagsSetting:
$ref: '#/components/schemas/UserSetting_TagsSetting'
description: User settings message
UserSetting_GeneralSetting:
type: object
......@@ -3005,22 +3021,6 @@ components:
This references a CSS file in the web/public/themes/ directory.
If not set, the default theme will be used.
description: General user settings configuration.
UserSetting_TagMetadata:
type: object
properties:
backgroundColor:
allOf:
- $ref: '#/components/schemas/Color'
description: Background color for the tag label.
description: Metadata for a tag.
UserSetting_TagsSetting:
type: object
properties:
tags:
type: object
additionalProperties:
$ref: '#/components/schemas/UserSetting_TagMetadata'
description: User tag metadata configuration.
UserSetting_WebhooksSetting:
type: object
properties:
......
This diff is collapsed.
This diff is collapsed.
......@@ -2,6 +2,8 @@ syntax = "proto3";
package memos.store;
import "google/type/color.proto";
option go_package = "gen/store";
enum InstanceSettingKey {
......@@ -14,6 +16,8 @@ enum InstanceSettingKey {
STORAGE = 3;
// MEMO_RELATED is the key for memo related settings.
MEMO_RELATED = 4;
// TAGS is the key for tag metadata.
TAGS = 5;
}
message InstanceSetting {
......@@ -23,6 +27,7 @@ message InstanceSetting {
InstanceGeneralSetting general_setting = 3;
InstanceStorageSetting storage_setting = 4;
InstanceMemoRelatedSetting memo_related_setting = 5;
InstanceTagsSetting tags_setting = 6;
}
}
......@@ -103,3 +108,12 @@ message InstanceMemoRelatedSetting {
// reactions is the list of reactions.
repeated string reactions = 7;
}
message InstanceTagMetadata {
// Background color for the tag label.
google.type.Color background_color = 1;
}
message InstanceTagsSetting {
map<string, InstanceTagMetadata> tags = 1;
}
......@@ -3,7 +3,6 @@ syntax = "proto3";
package memos.store;
import "google/protobuf/timestamp.proto";
import "google/type/color.proto";
option go_package = "gen/store";
......@@ -20,8 +19,6 @@ message UserSetting {
REFRESH_TOKENS = 6;
// Personal access tokens for the user.
PERSONAL_ACCESS_TOKENS = 7;
// Tag metadata for the user.
TAGS = 8;
}
int32 user_id = 1;
......@@ -33,7 +30,6 @@ message UserSetting {
WebhooksUserSetting webhooks = 7;
RefreshTokensUserSetting refresh_tokens = 8;
PersonalAccessTokensUserSetting personal_access_tokens = 9;
TagsUserSetting tags = 10;
}
}
......@@ -115,12 +111,3 @@ message WebhooksUserSetting {
}
repeated Webhook webhooks = 1;
}
message TagMetadata {
// Background color for the tag label.
google.type.Color background_color = 1;
}
message TagsUserSetting {
map<string, TagMetadata> tags = 1;
}
......@@ -3,8 +3,11 @@ package v1
import (
"context"
"fmt"
"math"
"strings"
"github.com/pkg/errors"
colorpb "google.golang.org/genproto/googleapis/type/color"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
......@@ -46,6 +49,8 @@ func (s *APIV1Service) GetInstanceSetting(ctx context.Context, request *v1pb.Get
_, err = s.Store.GetInstanceMemoRelatedSetting(ctx)
case storepb.InstanceSettingKey_STORAGE:
_, err = s.Store.GetInstanceStorageSetting(ctx)
case storepb.InstanceSettingKey_TAGS:
_, err = s.Store.GetInstanceTagsSetting(ctx)
default:
return nil, status.Errorf(codes.InvalidArgument, "unsupported instance setting key: %v", instanceSettingKey)
}
......@@ -95,6 +100,10 @@ func (s *APIV1Service) UpdateInstanceSetting(ctx context.Context, request *v1pb.
// TODO: Apply update_mask if specified
_ = request.UpdateMask
if err := validateInstanceSetting(request.Setting); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid instance setting: %v", err)
}
updateSetting := convertInstanceSettingToStore(request.Setting)
instanceSetting, err := s.Store.UpsertInstanceSetting(ctx, updateSetting)
if err != nil {
......@@ -121,6 +130,10 @@ func convertInstanceSettingFromStore(setting *storepb.InstanceSetting) *v1pb.Ins
instanceSetting.Value = &v1pb.InstanceSetting_MemoRelatedSetting_{
MemoRelatedSetting: convertInstanceMemoRelatedSettingFromStore(setting.GetMemoRelatedSetting()),
}
case *storepb.InstanceSetting_TagsSetting:
instanceSetting.Value = &v1pb.InstanceSetting_TagsSetting_{
TagsSetting: convertInstanceTagsSettingFromStore(setting.GetTagsSetting()),
}
default:
// Leave Value unset for unsupported setting variants.
}
......@@ -148,6 +161,10 @@ func convertInstanceSettingToStore(setting *v1pb.InstanceSetting) *storepb.Insta
instanceSetting.Value = &storepb.InstanceSetting_MemoRelatedSetting{
MemoRelatedSetting: convertInstanceMemoRelatedSettingToStore(setting.GetMemoRelatedSetting()),
}
case storepb.InstanceSettingKey_TAGS:
instanceSetting.Value = &storepb.InstanceSetting_TagsSetting{
TagsSetting: convertInstanceTagsSettingToStore(setting.GetTagsSetting()),
}
default:
// Keep the default GeneralSetting value
}
......@@ -271,6 +288,96 @@ func convertInstanceMemoRelatedSettingToStore(setting *v1pb.InstanceSetting_Memo
}
}
func convertInstanceTagsSettingFromStore(setting *storepb.InstanceTagsSetting) *v1pb.InstanceSetting_TagsSetting {
if setting == nil {
return nil
}
tags := make(map[string]*v1pb.InstanceSetting_TagMetadata, len(setting.Tags))
for tag, metadata := range setting.Tags {
tags[tag] = &v1pb.InstanceSetting_TagMetadata{
BackgroundColor: metadata.GetBackgroundColor(),
}
}
return &v1pb.InstanceSetting_TagsSetting{
Tags: tags,
}
}
func convertInstanceTagsSettingToStore(setting *v1pb.InstanceSetting_TagsSetting) *storepb.InstanceTagsSetting {
if setting == nil {
return nil
}
tags := make(map[string]*storepb.InstanceTagMetadata, len(setting.Tags))
for tag, metadata := range setting.Tags {
tags[tag] = &storepb.InstanceTagMetadata{
BackgroundColor: metadata.GetBackgroundColor(),
}
}
return &storepb.InstanceTagsSetting{
Tags: tags,
}
}
func validateInstanceSetting(setting *v1pb.InstanceSetting) error {
key, err := ExtractInstanceSettingKeyFromName(setting.Name)
if err != nil {
return err
}
if key != storepb.InstanceSettingKey_TAGS.String() {
return nil
}
return validateInstanceTagsSetting(setting.GetTagsSetting())
}
func validateInstanceTagsSetting(setting *v1pb.InstanceSetting_TagsSetting) error {
if setting == nil {
return errors.New("tags setting is required")
}
for tag, metadata := range setting.Tags {
if strings.TrimSpace(tag) == "" {
return errors.New("tag key cannot be empty")
}
if metadata == nil {
return errors.Errorf("tag metadata is required for %q", tag)
}
if metadata.GetBackgroundColor() == nil {
return errors.Errorf("background_color is required for %q", tag)
}
if err := validateInstanceColor(metadata.GetBackgroundColor()); err != nil {
return errors.Wrapf(err, "background_color for %q", tag)
}
}
return nil
}
func validateInstanceColor(color *colorpb.Color) error {
if err := validateInstanceColorComponent("red", color.GetRed()); err != nil {
return err
}
if err := validateInstanceColorComponent("green", color.GetGreen()); err != nil {
return err
}
if err := validateInstanceColorComponent("blue", color.GetBlue()); err != nil {
return err
}
if alpha := color.GetAlpha(); alpha != nil {
if err := validateInstanceColorComponent("alpha", alpha.GetValue()); err != nil {
return err
}
}
return nil
}
func validateInstanceColorComponent(name string, value float32) error {
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
return errors.Errorf("%s must be a finite number", name)
}
if value < 0 || value > 1 {
return errors.Errorf("%s must be between 0 and 1", name)
}
return nil
}
func (s *APIV1Service) GetInstanceAdmin(ctx context.Context) (*v1pb.User, error) {
adminUserType := store.RoleAdmin
user, err := s.Store.GetUser(ctx, &store.FindUser{
......
......@@ -5,6 +5,8 @@ import (
"testing"
"github.com/stretchr/testify/require"
colorpb "google.golang.org/genproto/googleapis/type/color"
"google.golang.org/protobuf/types/known/fieldmaskpb"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
)
......@@ -186,6 +188,22 @@ func TestGetInstanceSetting(t *testing.T) {
require.NotNil(t, memoRelatedSetting)
})
t.Run("GetInstanceSetting - tags setting", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
req := &v1pb.GetInstanceSettingRequest{
Name: "instance/settings/TAGS",
}
resp, err := ts.Service.GetInstanceSetting(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, "instance/settings/TAGS", resp.Name)
require.NotNil(t, resp.GetTagsSetting())
require.Empty(t, resp.GetTagsSetting().GetTags())
})
t.Run("GetInstanceSetting - invalid setting name", func(t *testing.T) {
// Create test service for this specific test
ts := NewTestService(t)
......@@ -202,3 +220,67 @@ func TestGetInstanceSetting(t *testing.T) {
require.Contains(t, err.Error(), "invalid instance setting name")
})
}
func TestUpdateInstanceSetting(t *testing.T) {
ctx := context.Background()
t.Run("UpdateInstanceSetting - tags setting", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
hostUser, err := ts.CreateHostUser(ctx, "admin")
require.NoError(t, err)
resp, err := ts.Service.UpdateInstanceSetting(ts.CreateUserContext(ctx, hostUser.ID), &v1pb.UpdateInstanceSettingRequest{
Setting: &v1pb.InstanceSetting{
Name: "instance/settings/TAGS",
Value: &v1pb.InstanceSetting_TagsSetting_{
TagsSetting: &v1pb.InstanceSetting_TagsSetting{
Tags: map[string]*v1pb.InstanceSetting_TagMetadata{
"bug": {
BackgroundColor: &colorpb.Color{
Red: 0.9,
Green: 0.1,
Blue: 0.1,
},
},
},
},
},
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"tags"}},
})
require.NoError(t, err)
require.NotNil(t, resp.GetTagsSetting())
require.Contains(t, resp.GetTagsSetting().GetTags(), "bug")
})
t.Run("UpdateInstanceSetting - invalid tags color", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
hostUser, err := ts.CreateHostUser(ctx, "admin")
require.NoError(t, err)
_, err = ts.Service.UpdateInstanceSetting(ts.CreateUserContext(ctx, hostUser.ID), &v1pb.UpdateInstanceSettingRequest{
Setting: &v1pb.InstanceSetting{
Name: "instance/settings/TAGS",
Value: &v1pb.InstanceSetting_TagsSetting_{
TagsSetting: &v1pb.InstanceSetting_TagsSetting{
Tags: map[string]*v1pb.InstanceSetting_TagMetadata{
"bug": {
BackgroundColor: &colorpb.Color{
Red: 1.2,
Green: 0.1,
Blue: 0.1,
},
},
},
},
},
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "invalid instance setting")
})
}
package test
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/require"
colorpb "google.golang.org/genproto/googleapis/type/color"
"google.golang.org/protobuf/types/known/fieldmaskpb"
apiv1 "github.com/usememos/memos/proto/gen/api/v1"
)
func TestUserSettingTags(t *testing.T) {
t.Parallel()
ctx := context.Background()
t.Run("GetUserSetting returns empty tags setting by default", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
user, err := ts.CreateHostUser(ctx, "tags-default")
require.NoError(t, err)
response, err := ts.Service.GetUserSetting(ts.CreateUserContext(ctx, user.ID), &apiv1.GetUserSettingRequest{
Name: fmt.Sprintf("users/%d/settings/TAGS", user.ID),
})
require.NoError(t, err)
require.NotNil(t, response)
require.NotNil(t, response.GetTagsSetting())
require.Empty(t, response.GetTagsSetting().GetTags())
})
t.Run("UpdateUserSetting replaces tag metadata", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
user, err := ts.CreateHostUser(ctx, "tags-update")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, user.ID)
settingName := fmt.Sprintf("users/%d/settings/TAGS", user.ID)
updateRequest := &apiv1.UpdateUserSettingRequest{
Setting: &apiv1.UserSetting{
Name: settingName,
Value: &apiv1.UserSetting_TagsSetting_{
TagsSetting: &apiv1.UserSetting_TagsSetting{
Tags: map[string]*apiv1.UserSetting_TagMetadata{
"bug": {
BackgroundColor: &colorpb.Color{
Red: 0.9,
Green: 0.1,
Blue: 0.1,
},
},
},
},
},
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"tags"}},
}
response, err := ts.Service.UpdateUserSetting(userCtx, updateRequest)
require.NoError(t, err)
require.NotNil(t, response.GetTagsSetting())
require.Contains(t, response.GetTagsSetting().GetTags(), "bug")
require.InDelta(t, 0.9, response.GetTagsSetting().GetTags()["bug"].GetBackgroundColor().GetRed(), 0.0001)
getResponse, err := ts.Service.GetUserSetting(userCtx, &apiv1.GetUserSettingRequest{Name: settingName})
require.NoError(t, err)
require.Len(t, getResponse.GetTagsSetting().GetTags(), 1)
require.Contains(t, getResponse.GetTagsSetting().GetTags(), "bug")
})
t.Run("UpdateUserSetting rejects invalid color", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
user, err := ts.CreateHostUser(ctx, "tags-invalid")
require.NoError(t, err)
_, err = ts.Service.UpdateUserSetting(ts.CreateUserContext(ctx, user.ID), &apiv1.UpdateUserSettingRequest{
Setting: &apiv1.UserSetting{
Name: fmt.Sprintf("users/%d/settings/TAGS", user.ID),
Value: &apiv1.UserSetting_TagsSetting_{
TagsSetting: &apiv1.UserSetting_TagsSetting{
Tags: map[string]*apiv1.UserSetting_TagMetadata{
"bug": {
BackgroundColor: &colorpb.Color{
Red: 1.2,
Green: 0.1,
Blue: 0.1,
},
},
},
},
},
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"tags"}},
})
require.Error(t, err)
require.Contains(t, err.Error(), "invalid tags setting")
})
t.Run("Other users cannot read or update tag metadata", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
user, err := ts.CreateHostUser(ctx, "tags-owner")
require.NoError(t, err)
otherUser, err := ts.CreateHostUser(ctx, "tags-other")
require.NoError(t, err)
settingName := fmt.Sprintf("users/%d/settings/TAGS", user.ID)
_, err = ts.Service.GetUserSetting(ts.CreateUserContext(ctx, otherUser.ID), &apiv1.GetUserSettingRequest{
Name: settingName,
})
require.Error(t, err)
require.Contains(t, err.Error(), "permission denied")
_, err = ts.Service.UpdateUserSetting(ts.CreateUserContext(ctx, otherUser.ID), &apiv1.UpdateUserSettingRequest{
Setting: &apiv1.UserSetting{
Name: settingName,
Value: &apiv1.UserSetting_TagsSetting_{
TagsSetting: &apiv1.UserSetting_TagsSetting{
Tags: map[string]*apiv1.UserSetting_TagMetadata{
"bug": {
BackgroundColor: &colorpb.Color{
Red: 0.1,
Green: 0.2,
Blue: 0.3,
},
},
},
},
},
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"tags"}},
})
require.Error(t, err)
require.Contains(t, err.Error(), "permission denied")
})
}
......@@ -5,7 +5,6 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
"math"
"regexp"
"strconv"
"strings"
......@@ -15,7 +14,6 @@ import (
"github.com/google/cel-go/common/ast"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
colorpb "google.golang.org/genproto/googleapis/type/color"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
......@@ -334,12 +332,6 @@ func getDefaultUserGeneralSetting() *v1pb.UserSetting_GeneralSetting {
}
}
func getDefaultUserTagsSetting() *v1pb.UserSetting_TagsSetting {
return &v1pb.UserSetting_TagsSetting{
Tags: map[string]*v1pb.UserSetting_TagMetadata{},
}
}
func (s *APIV1Service) GetUserSetting(ctx context.Context, request *v1pb.GetUserSettingRequest) (*v1pb.UserSetting, error) {
// Parse resource name: users/{user}/settings/{setting}
userID, settingKey, err := ExtractUserIDAndSettingKeyFromName(request.Name)
......@@ -450,24 +442,6 @@ func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *v1pb.Upda
GeneralSetting: updatedGeneral,
},
}
case storepb.UserSetting_TAGS:
if len(request.UpdateMask.Paths) != 1 || request.UpdateMask.Paths[0] != "tags" {
return nil, status.Errorf(codes.InvalidArgument, "tags setting only supports update_mask [\"tags\"]")
}
incomingTags := request.Setting.GetTagsSetting()
if incomingTags == nil {
return nil, status.Errorf(codes.InvalidArgument, "tags setting is required")
}
normalizedTags, err := validateAndNormalizeUserTagsSetting(incomingTags)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid tags setting: %v", err)
}
updatedSetting = &v1pb.UserSetting{
Name: request.Setting.Name,
Value: &v1pb.UserSetting_TagsSetting_{
TagsSetting: normalizedTags,
},
}
default:
return nil, status.Errorf(codes.InvalidArgument, "setting type %s should not be updated via UpdateUserSetting", storeKey.String())
}
......@@ -521,14 +495,10 @@ func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListU
}
hasGeneral := false
hasTags := false
for _, setting := range settings {
if setting.GetGeneralSetting() != nil {
hasGeneral = true
}
if setting.GetTagsSetting() != nil {
hasTags = true
}
}
if !hasGeneral {
defaultGeneral := &v1pb.UserSetting{
......@@ -539,16 +509,6 @@ func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListU
}
settings = append([]*v1pb.UserSetting{defaultGeneral}, settings...)
}
if !hasTags {
defaultTags := &v1pb.UserSetting{
Name: fmt.Sprintf("users/%d/settings/%s", userID, convertSettingKeyFromStore(storepb.UserSetting_TAGS)),
Value: &v1pb.UserSetting_TagsSetting_{
TagsSetting: getDefaultUserTagsSetting(),
},
}
settings = append(settings, defaultTags)
}
response := &v1pb.ListUserSettingsResponse{
Settings: settings,
TotalSize: int32(len(settings)),
......@@ -1037,8 +997,6 @@ func convertSettingKeyToStore(key string) (storepb.UserSetting_Key, error) {
return storepb.UserSetting_GENERAL, nil
case v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_WEBHOOKS)]:
return storepb.UserSetting_WEBHOOKS, nil
case v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_TAGS)]:
return storepb.UserSetting_TAGS, nil
default:
return storepb.UserSetting_KEY_UNSPECIFIED, errors.Errorf("unknown setting key: %s", key)
}
......@@ -1053,8 +1011,6 @@ func convertSettingKeyFromStore(key storepb.UserSetting_Key) string {
return "SHORTCUTS" // Not defined in API proto
case storepb.UserSetting_WEBHOOKS:
return v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_WEBHOOKS)]
case storepb.UserSetting_TAGS:
return v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_TAGS)]
default:
return "unknown"
}
......@@ -1076,10 +1032,6 @@ func convertUserSettingFromStore(storeSetting *storepb.UserSetting, userID int32
Webhooks: []*v1pb.UserWebhook{},
},
}
case storepb.UserSetting_TAGS:
setting.Value = &v1pb.UserSetting_TagsSetting_{
TagsSetting: getDefaultUserTagsSetting(),
}
default:
// Default to general setting
setting.Value = &v1pb.UserSetting_GeneralSetting_{
......@@ -1125,19 +1077,6 @@ func convertUserSettingFromStore(storeSetting *storepb.UserSetting, userID int32
Webhooks: apiWebhooks,
},
}
case storepb.UserSetting_TAGS:
tags := storeSetting.GetTags()
apiTags := make(map[string]*v1pb.UserSetting_TagMetadata, len(tags.GetTags()))
for tag, metadata := range tags.GetTags() {
apiTags[tag] = &v1pb.UserSetting_TagMetadata{
BackgroundColor: metadata.GetBackgroundColor(),
}
}
setting.Value = &v1pb.UserSetting_TagsSetting_{
TagsSetting: &v1pb.UserSetting_TagsSetting{
Tags: apiTags,
},
}
default:
// Default to general setting if unknown key
setting.Value = &v1pb.UserSetting_GeneralSetting_{
......@@ -1187,22 +1126,6 @@ func convertUserSettingToStore(apiSetting *v1pb.UserSetting, userID int32, key s
} else {
return nil, errors.Errorf("webhooks setting is required")
}
case storepb.UserSetting_TAGS:
if tags := apiSetting.GetTagsSetting(); tags != nil {
storeTags := make(map[string]*storepb.TagMetadata, len(tags.GetTags()))
for tag, metadata := range tags.GetTags() {
storeTags[tag] = &storepb.TagMetadata{
BackgroundColor: metadata.GetBackgroundColor(),
}
}
storeSetting.Value = &storepb.UserSetting_Tags{
Tags: &storepb.TagsUserSetting{
Tags: storeTags,
},
}
} else {
return nil, errors.Errorf("tags setting is required")
}
default:
return nil, errors.Errorf("unsupported setting key: %v", key)
}
......@@ -1220,59 +1143,6 @@ func extractWebhookIDFromName(name string) string {
return ""
}
func validateAndNormalizeUserTagsSetting(tagsSetting *v1pb.UserSetting_TagsSetting) (*v1pb.UserSetting_TagsSetting, error) {
normalized := &v1pb.UserSetting_TagsSetting{
Tags: make(map[string]*v1pb.UserSetting_TagMetadata, len(tagsSetting.GetTags())),
}
for tag, metadata := range tagsSetting.GetTags() {
if strings.TrimSpace(tag) == "" {
return nil, errors.New("tag key cannot be empty")
}
if metadata == nil {
return nil, errors.Errorf("tag metadata is required for %q", tag)
}
backgroundColor := metadata.GetBackgroundColor()
if backgroundColor == nil {
return nil, errors.Errorf("background_color is required for %q", tag)
}
if err := validateColor(backgroundColor); err != nil {
return nil, errors.Wrapf(err, "background_color for %q", tag)
}
normalized.Tags[tag] = &v1pb.UserSetting_TagMetadata{
BackgroundColor: backgroundColor,
}
}
return normalized, nil
}
func validateColor(color *colorpb.Color) error {
if err := validateColorComponent("red", color.GetRed()); err != nil {
return err
}
if err := validateColorComponent("green", color.GetGreen()); err != nil {
return err
}
if err := validateColorComponent("blue", color.GetBlue()); err != nil {
return err
}
if alpha := color.GetAlpha(); alpha != nil {
if err := validateColorComponent("alpha", alpha.GetValue()); err != nil {
return err
}
}
return nil
}
func validateColorComponent(name string, value float32) error {
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
return errors.Errorf("%s must be a finite number", name)
}
if value < 0 || value > 1 {
return errors.Errorf("%s must be between 0 and 1", name)
}
return nil
}
// extractUsernameFromFilter extracts username from the filter string using CEL.
// Supported filter format: "username == 'steven'"
// Returns the username value and an error if the filter format is invalid.
......
......@@ -37,6 +37,8 @@ func (s *Store) UpsertInstanceSetting(ctx context.Context, upsert *storepb.Insta
valueBytes, err = protojson.Marshal(upsert.GetStorageSetting())
} else if upsert.Key == storepb.InstanceSettingKey_MEMO_RELATED {
valueBytes, err = protojson.Marshal(upsert.GetMemoRelatedSetting())
} else if upsert.Key == storepb.InstanceSettingKey_TAGS {
valueBytes, err = protojson.Marshal(upsert.GetTagsSetting())
} else {
return nil, errors.Errorf("unsupported instance setting key: %v", upsert.Key)
}
......@@ -168,6 +170,28 @@ func (s *Store) GetInstanceMemoRelatedSetting(ctx context.Context) (*storepb.Ins
return instanceMemoRelatedSetting, nil
}
func (s *Store) GetInstanceTagsSetting(ctx context.Context) (*storepb.InstanceTagsSetting, error) {
instanceSetting, err := s.GetInstanceSetting(ctx, &FindInstanceSetting{
Name: storepb.InstanceSettingKey_TAGS.String(),
})
if err != nil {
return nil, errors.Wrap(err, "failed to get instance tags setting")
}
instanceTagsSetting := &storepb.InstanceTagsSetting{}
if instanceSetting != nil {
instanceTagsSetting = instanceSetting.GetTagsSetting()
}
if instanceTagsSetting.Tags == nil {
instanceTagsSetting.Tags = map[string]*storepb.InstanceTagMetadata{}
}
s.instanceSettingCache.Set(ctx, storepb.InstanceSettingKey_TAGS.String(), &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_TAGS,
Value: &storepb.InstanceSetting_TagsSetting{TagsSetting: instanceTagsSetting},
})
return instanceTagsSetting, nil
}
const (
defaultInstanceStorageType = storepb.InstanceStorageSetting_LOCAL
defaultInstanceUploadSizeLimitMb = 30
......@@ -231,6 +255,12 @@ func convertInstanceSettingFromRaw(instanceSettingRaw *InstanceSetting) (*storep
return nil, err
}
instanceSetting.Value = &storepb.InstanceSetting_MemoRelatedSetting{MemoRelatedSetting: memoRelatedSetting}
case storepb.InstanceSettingKey_TAGS.String():
tagsSetting := &storepb.InstanceTagsSetting{}
if err := protojsonUnmarshaler.Unmarshal([]byte(instanceSettingRaw.Value), tagsSetting); err != nil {
return nil, err
}
instanceSetting.Value = &storepb.InstanceSetting_TagsSetting{TagsSetting: tagsSetting}
default:
// Skip unsupported instance setting key.
return nil, nil
......
......@@ -5,6 +5,7 @@ import (
"testing"
"github.com/stretchr/testify/require"
colorpb "google.golang.org/genproto/googleapis/type/color"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
......@@ -220,6 +221,42 @@ func TestInstanceSettingStorageSetting(t *testing.T) {
ts.Close()
}
func TestInstanceSettingTagsSetting(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
tagsSetting, err := ts.GetInstanceTagsSetting(ctx)
require.NoError(t, err)
require.NotNil(t, tagsSetting)
require.Empty(t, tagsSetting.Tags)
_, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_TAGS,
Value: &storepb.InstanceSetting_TagsSetting{
TagsSetting: &storepb.InstanceTagsSetting{
Tags: map[string]*storepb.InstanceTagMetadata{
"bug": {
BackgroundColor: &colorpb.Color{
Red: 0.9,
Green: 0.1,
Blue: 0.1,
},
},
},
},
},
})
require.NoError(t, err)
tagsSetting, err = ts.GetInstanceTagsSetting(ctx)
require.NoError(t, err)
require.Contains(t, tagsSetting.Tags, "bug")
require.InDelta(t, 0.9, tagsSetting.Tags["bug"].GetBackgroundColor().GetRed(), 0.0001)
ts.Close()
}
func TestInstanceSettingListAll(t *testing.T) {
t.Parallel()
ctx := context.Background()
......
......@@ -6,7 +6,6 @@ import (
"testing"
"github.com/stretchr/testify/require"
colorpb "google.golang.org/genproto/googleapis/type/color"
"google.golang.org/protobuf/types/known/timestamppb"
storepb "github.com/usememos/memos/proto/gen/store"
......@@ -105,48 +104,6 @@ func TestUserSettingUpsertUpdate(t *testing.T) {
ts.Close()
}
func TestUserSettingTags(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
_, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: user.ID,
Key: storepb.UserSetting_TAGS,
Value: &storepb.UserSetting_Tags{
Tags: &storepb.TagsUserSetting{
Tags: map[string]*storepb.TagMetadata{
"bug": {
BackgroundColor: &colorpb.Color{
Red: 0.1,
Green: 0.2,
Blue: 0.3,
},
},
},
},
},
})
require.NoError(t, err)
setting, err := ts.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &user.ID,
Key: storepb.UserSetting_TAGS,
})
require.NoError(t, err)
require.NotNil(t, setting)
require.Contains(t, setting.GetTags().Tags, "bug")
require.InDelta(t, 0.1, setting.GetTags().Tags["bug"].GetBackgroundColor().GetRed(), 0.0001)
list, err := ts.ListUserSettings(ctx, &store.FindUserSetting{UserID: &user.ID})
require.NoError(t, err)
require.Len(t, list, 1)
ts.Close()
}
func TestUserSettingRefreshTokens(t *testing.T) {
t.Parallel()
ctx := context.Background()
......
......@@ -431,12 +431,6 @@ func convertUserSettingFromRaw(raw *UserSetting) (*storepb.UserSetting, error) {
return nil, err
}
userSetting.Value = &storepb.UserSetting_Webhooks{Webhooks: webhooksUserSetting}
case storepb.UserSetting_TAGS:
tagsUserSetting := &storepb.TagsUserSetting{}
if err := protojsonUnmarshaler.Unmarshal([]byte(raw.Value), tagsUserSetting); err != nil {
return nil, err
}
userSetting.Value = &storepb.UserSetting_Tags{Tags: tagsUserSetting}
default:
return nil, nil
}
......@@ -485,13 +479,6 @@ func convertUserSettingToRaw(userSetting *storepb.UserSetting) (*UserSetting, er
return nil, err
}
raw.Value = string(value)
case storepb.UserSetting_TAGS:
tagsUserSetting := userSetting.GetTags()
value, err := protojson.Marshal(tagsUserSetting)
if err != nil {
return nil, err
}
raw.Value = string(value)
default:
return nil, errors.Errorf("unsupported user setting key: %v", userSetting.Key)
}
......
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