mirror of https://github.com/usememos/memos
feat(user): add per-user tag metadata settings (#5735)
parent
04f239a2fc
commit
330291d4d9
@ -0,0 +1,144 @@
|
|||||||
|
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")
|
||||||
|
})
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,204 @@
|
|||||||
|
// Copyright 2025 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
// @generated by protoc-gen-es v2.11.0 with parameter "target=ts"
|
||||||
|
// @generated from file google/type/color.proto (package google.type, syntax proto3)
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
|
||||||
|
import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
|
||||||
|
import { file_google_protobuf_wrappers } from "@bufbuild/protobuf/wkt";
|
||||||
|
import type { Message } from "@bufbuild/protobuf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the file google/type/color.proto.
|
||||||
|
*/
|
||||||
|
export const file_google_type_color: GenFile = /*@__PURE__*/
|
||||||
|
fileDesc("Chdnb29nbGUvdHlwZS9jb2xvci5wcm90bxILZ29vZ2xlLnR5cGUiXQoFQ29sb3ISCwoDcmVkGAEgASgCEg0KBWdyZWVuGAIgASgCEgwKBGJsdWUYAyABKAISKgoFYWxwaGEYBCABKAsyGy5nb29nbGUucHJvdG9idWYuRmxvYXRWYWx1ZUKlAQoPY29tLmdvb2dsZS50eXBlQgpDb2xvclByb3RvUAFaNmdvb2dsZS5nb2xhbmcub3JnL2dlbnByb3RvL2dvb2dsZWFwaXMvdHlwZS9jb2xvcjtjb2xvcvgBAaICA0dUWKoCC0dvb2dsZS5UeXBlygILR29vZ2xlXFR5cGXiAhdHb29nbGVcVHlwZVxHUEJNZXRhZGF0YeoCDEdvb2dsZTo6VHlwZWIGcHJvdG8z", [file_google_protobuf_wrappers]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a color in the RGBA color space. This representation is designed
|
||||||
|
* for simplicity of conversion to/from color representations in various
|
||||||
|
* languages over compactness. For example, the fields of this representation
|
||||||
|
* can be trivially provided to the constructor of `java.awt.Color` in Java; it
|
||||||
|
* can also be trivially provided to UIColor's `+colorWithRed:green:blue:alpha`
|
||||||
|
* method in iOS; and, with just a little work, it can be easily formatted into
|
||||||
|
* a CSS `rgba()` string in JavaScript.
|
||||||
|
*
|
||||||
|
* This reference page doesn't carry information about the absolute color
|
||||||
|
* space
|
||||||
|
* that should be used to interpret the RGB value (e.g. sRGB, Adobe RGB,
|
||||||
|
* DCI-P3, BT.2020, etc.). By default, applications should assume the sRGB color
|
||||||
|
* space.
|
||||||
|
*
|
||||||
|
* When color equality needs to be decided, implementations, unless
|
||||||
|
* documented otherwise, treat two colors as equal if all their red,
|
||||||
|
* green, blue, and alpha values each differ by at most 1e-5.
|
||||||
|
*
|
||||||
|
* Example (Java):
|
||||||
|
*
|
||||||
|
* import com.google.type.Color;
|
||||||
|
*
|
||||||
|
* // ...
|
||||||
|
* public static java.awt.Color fromProto(Color protocolor) {
|
||||||
|
* float alpha = protocolor.hasAlpha()
|
||||||
|
* ? protocolor.getAlpha().getValue()
|
||||||
|
* : 1.0;
|
||||||
|
*
|
||||||
|
* return new java.awt.Color(
|
||||||
|
* protocolor.getRed(),
|
||||||
|
* protocolor.getGreen(),
|
||||||
|
* protocolor.getBlue(),
|
||||||
|
* alpha);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* public static Color toProto(java.awt.Color color) {
|
||||||
|
* float red = (float) color.getRed();
|
||||||
|
* float green = (float) color.getGreen();
|
||||||
|
* float blue = (float) color.getBlue();
|
||||||
|
* float denominator = 255.0;
|
||||||
|
* Color.Builder resultBuilder =
|
||||||
|
* Color
|
||||||
|
* .newBuilder()
|
||||||
|
* .setRed(red / denominator)
|
||||||
|
* .setGreen(green / denominator)
|
||||||
|
* .setBlue(blue / denominator);
|
||||||
|
* int alpha = color.getAlpha();
|
||||||
|
* if (alpha != 255) {
|
||||||
|
* result.setAlpha(
|
||||||
|
* FloatValue
|
||||||
|
* .newBuilder()
|
||||||
|
* .setValue(((float) alpha) / denominator)
|
||||||
|
* .build());
|
||||||
|
* }
|
||||||
|
* return resultBuilder.build();
|
||||||
|
* }
|
||||||
|
* // ...
|
||||||
|
*
|
||||||
|
* Example (iOS / Obj-C):
|
||||||
|
*
|
||||||
|
* // ...
|
||||||
|
* static UIColor* fromProto(Color* protocolor) {
|
||||||
|
* float red = [protocolor red];
|
||||||
|
* float green = [protocolor green];
|
||||||
|
* float blue = [protocolor blue];
|
||||||
|
* FloatValue* alpha_wrapper = [protocolor alpha];
|
||||||
|
* float alpha = 1.0;
|
||||||
|
* if (alpha_wrapper != nil) {
|
||||||
|
* alpha = [alpha_wrapper value];
|
||||||
|
* }
|
||||||
|
* return [UIColor colorWithRed:red green:green blue:blue alpha:alpha];
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* static Color* toProto(UIColor* color) {
|
||||||
|
* CGFloat red, green, blue, alpha;
|
||||||
|
* if (![color getRed:&red green:&green blue:&blue alpha:&alpha]) {
|
||||||
|
* return nil;
|
||||||
|
* }
|
||||||
|
* Color* result = [[Color alloc] init];
|
||||||
|
* [result setRed:red];
|
||||||
|
* [result setGreen:green];
|
||||||
|
* [result setBlue:blue];
|
||||||
|
* if (alpha <= 0.9999) {
|
||||||
|
* [result setAlpha:floatWrapperWithValue(alpha)];
|
||||||
|
* }
|
||||||
|
* [result autorelease];
|
||||||
|
* return result;
|
||||||
|
* }
|
||||||
|
* // ...
|
||||||
|
*
|
||||||
|
* Example (JavaScript):
|
||||||
|
*
|
||||||
|
* // ...
|
||||||
|
*
|
||||||
|
* var protoToCssColor = function(rgb_color) {
|
||||||
|
* var redFrac = rgb_color.red || 0.0;
|
||||||
|
* var greenFrac = rgb_color.green || 0.0;
|
||||||
|
* var blueFrac = rgb_color.blue || 0.0;
|
||||||
|
* var red = Math.floor(redFrac * 255);
|
||||||
|
* var green = Math.floor(greenFrac * 255);
|
||||||
|
* var blue = Math.floor(blueFrac * 255);
|
||||||
|
*
|
||||||
|
* if (!('alpha' in rgb_color)) {
|
||||||
|
* return rgbToCssColor(red, green, blue);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* var alphaFrac = rgb_color.alpha.value || 0.0;
|
||||||
|
* var rgbParams = [red, green, blue].join(',');
|
||||||
|
* return ['rgba(', rgbParams, ',', alphaFrac, ')'].join('');
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* var rgbToCssColor = function(red, green, blue) {
|
||||||
|
* var rgbNumber = new Number((red << 16) | (green << 8) | blue);
|
||||||
|
* var hexString = rgbNumber.toString(16);
|
||||||
|
* var missingZeros = 6 - hexString.length;
|
||||||
|
* var resultBuilder = ['#'];
|
||||||
|
* for (var i = 0; i < missingZeros; i++) {
|
||||||
|
* resultBuilder.push('0');
|
||||||
|
* }
|
||||||
|
* resultBuilder.push(hexString);
|
||||||
|
* return resultBuilder.join('');
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* // ...
|
||||||
|
*
|
||||||
|
* @generated from message google.type.Color
|
||||||
|
*/
|
||||||
|
export type Color = Message<"google.type.Color"> & {
|
||||||
|
/**
|
||||||
|
* The amount of red in the color as a value in the interval [0, 1].
|
||||||
|
*
|
||||||
|
* @generated from field: float red = 1;
|
||||||
|
*/
|
||||||
|
red: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of green in the color as a value in the interval [0, 1].
|
||||||
|
*
|
||||||
|
* @generated from field: float green = 2;
|
||||||
|
*/
|
||||||
|
green: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of blue in the color as a value in the interval [0, 1].
|
||||||
|
*
|
||||||
|
* @generated from field: float blue = 3;
|
||||||
|
*/
|
||||||
|
blue: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fraction of this color that should be applied to the pixel. That is,
|
||||||
|
* the final pixel color is defined by the equation:
|
||||||
|
*
|
||||||
|
* `pixel color = alpha * (this color) + (1.0 - alpha) * (background color)`
|
||||||
|
*
|
||||||
|
* This means that a value of 1.0 corresponds to a solid color, whereas
|
||||||
|
* a value of 0.0 corresponds to a completely transparent color. This
|
||||||
|
* uses a wrapper message rather than a simple float scalar so that it is
|
||||||
|
* possible to distinguish between a default value and the value being unset.
|
||||||
|
* If omitted, this color object is rendered as a solid color
|
||||||
|
* (as if the alpha value had been explicitly given a value of 1.0).
|
||||||
|
*
|
||||||
|
* @generated from field: google.protobuf.FloatValue alpha = 4;
|
||||||
|
*/
|
||||||
|
alpha?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message google.type.Color.
|
||||||
|
* Use `create(ColorSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const ColorSchema: GenMessage<Color> = /*@__PURE__*/
|
||||||
|
messageDesc(file_google_type_color, 0);
|
||||||
|
|
||||||
Loading…
Reference in New Issue