fix: allow tags metadata without background color

This commit is contained in:
memoclaw 2026-03-31 21:15:30 +08:00
parent 201c8a8ea9
commit cc71d7f113
11 changed files with 106 additions and 42 deletions

View File

@ -167,7 +167,8 @@ message InstanceSetting {
// Metadata for a tag.
message TagMetadata {
// Background color for the tag label.
// Optional background color for the tag label.
// When unset, the default tag color is used.
google.type.Color background_color = 1;
// Whether memos with this tag should have their content blurred.
bool blur_content = 2;

View File

@ -759,7 +759,8 @@ func (x *InstanceSetting_MemoRelatedSetting) GetReactions() []string {
// Metadata for a tag.
type InstanceSetting_TagMetadata struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Background color for the tag label.
// Optional background color for the tag label.
// When unset, the default tag color is used.
BackgroundColor *color.Color `protobuf:"bytes,1,opt,name=background_color,json=backgroundColor,proto3" json:"background_color,omitempty"`
// Whether memos with this tag should have their content blurred.
BlurContent bool `protobuf:"varint,2,opt,name=blur_content,json=blurContent,proto3" json:"blur_content,omitempty"`

View File

@ -2396,7 +2396,9 @@ components:
backgroundColor:
allOf:
- $ref: '#/components/schemas/Color'
description: Background color for the tag label.
description: |-
Optional background color for the tag label.
When unset, the default tag color is used.
blurContent:
type: boolean
description: Whether memos with this tag should have their content blurred.

View File

@ -754,7 +754,8 @@ func (x *InstanceMemoRelatedSetting) GetReactions() []string {
type InstanceTagMetadata struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Background color for the tag label.
// Optional background color for the tag label.
// When unset, the default tag color is used.
BackgroundColor *color.Color `protobuf:"bytes,1,opt,name=background_color,json=backgroundColor,proto3" json:"background_color,omitempty"`
// Whether memos with this tag should have their content blurred.
BlurContent bool `protobuf:"varint,2,opt,name=blur_content,json=blurContent,proto3" json:"blur_content,omitempty"`

View File

@ -111,7 +111,8 @@ message InstanceMemoRelatedSetting {
}
message InstanceTagMetadata {
// Background color for the tag label.
// Optional background color for the tag label.
// When unset, the default tag color is used.
google.type.Color background_color = 1;
// Whether memos with this tag should have their content blurred.
bool blur_content = 2;

View File

@ -423,11 +423,10 @@ func validateInstanceTagsSetting(setting *v1pb.InstanceSetting_TagsSetting) erro
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)
if metadata.GetBackgroundColor() != nil {
if err := validateInstanceColor(metadata.GetBackgroundColor()); err != nil {
return errors.Wrapf(err, "background_color for %q", tag)
}
}
}
return nil

View File

@ -318,6 +318,34 @@ func TestUpdateInstanceSetting(t *testing.T) {
require.Contains(t, err.Error(), "invalid instance setting")
})
t.Run("UpdateInstanceSetting - tags setting without color", 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{
"spoiler": {
BlurContent: true,
},
},
},
},
},
})
require.NoError(t, err)
require.NotNil(t, resp.GetTagsSetting())
require.Contains(t, resp.GetTagsSetting().GetTags(), "spoiler")
require.Nil(t, resp.GetTagsSetting().GetTags()["spoiler"].GetBackgroundColor())
require.True(t, resp.GetTagsSetting().GetTags()["spoiler"].GetBlurContent())
})
t.Run("UpdateInstanceSetting - notification setting password is write-only", func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()

View File

@ -257,6 +257,34 @@ func TestInstanceSettingTagsSetting(t *testing.T) {
ts.Close()
}
func TestInstanceSettingTagsSettingWithoutColor(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
_, err := ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_TAGS,
Value: &storepb.InstanceSetting_TagsSetting{
TagsSetting: &storepb.InstanceTagsSetting{
Tags: map[string]*storepb.InstanceTagMetadata{
"spoiler": {
BlurContent: true,
},
},
},
},
})
require.NoError(t, err)
tagsSetting, err := ts.GetInstanceTagsSetting(ctx)
require.NoError(t, err)
require.Contains(t, tagsSetting.Tags, "spoiler")
require.Nil(t, tagsSetting.Tags["spoiler"].GetBackgroundColor())
require.True(t, tagsSetting.Tags["spoiler"].GetBlurContent())
ts.Close()
}
func TestInstanceSettingNotificationSetting(t *testing.T) {
t.Parallel()
ctx := context.Background()

View File

@ -22,8 +22,7 @@ import SettingGroup from "./SettingGroup";
import SettingSection from "./SettingSection";
import SettingTable from "./SettingTable";
// Fallback to white when no color is stored.
const tagColorToHex = (color?: { red?: number; green?: number; blue?: number }): string => colorToHex(color) ?? "#ffffff";
const DEFAULT_TAG_COLOR = "#ffffff";
// Converts a CSS hex string to a google.type.Color message.
const hexToColor = (hex: string) =>
@ -34,10 +33,15 @@ const hexToColor = (hex: string) =>
});
interface LocalTagMeta {
color: string;
color?: string;
blur: boolean;
}
const toLocalTagMeta = (meta: { backgroundColor?: { red?: number; green?: number; blue?: number }; blurContent: boolean }): LocalTagMeta => ({
color: colorToHex(meta.backgroundColor),
blur: meta.blurContent,
});
const TagsSection = () => {
const t = useTranslate();
const { tagsSetting: originalSetting, updateSetting, fetchSetting } = useInstance();
@ -45,28 +49,16 @@ const TagsSection = () => {
// Local state: map of tagName → { color, blur } for editing.
const [localTags, setLocalTags] = useState<Record<string, LocalTagMeta>>(() =>
Object.fromEntries(
Object.entries(originalSetting.tags).map(([name, meta]) => [
name,
{ color: tagColorToHex(meta.backgroundColor), blur: meta.blurContent },
]),
),
Object.fromEntries(Object.entries(originalSetting.tags).map(([name, meta]) => [name, toLocalTagMeta(meta)])),
);
const [newTagName, setNewTagName] = useState("");
const [newTagColor, setNewTagColor] = useState("#ffffff");
const [newTagColor, setNewTagColor] = useState<string | undefined>(undefined);
const [newTagBlur, setNewTagBlur] = useState(false);
// Sync local state when the fetched setting arrives (the fetch is async and
// completes after mount, so localTags would be empty without this sync).
useEffect(() => {
setLocalTags(
Object.fromEntries(
Object.entries(originalSetting.tags).map(([name, meta]) => [
name,
{ color: tagColorToHex(meta.backgroundColor), blur: meta.blurContent },
]),
),
);
setLocalTags(Object.fromEntries(Object.entries(originalSetting.tags).map(([name, meta]) => [name, toLocalTagMeta(meta)])));
}, [originalSetting.tags]);
// All known tag names: union of saved entries and tags used in memos.
@ -85,13 +77,7 @@ const TagsSection = () => {
);
const originalMetaMap = useMemo(
() =>
Object.fromEntries(
Object.entries(originalSetting.tags).map(([name, meta]) => [
name,
{ color: tagColorToHex(meta.backgroundColor), blur: meta.blurContent },
]),
),
() => Object.fromEntries(Object.entries(originalSetting.tags).map(([name, meta]) => [name, toLocalTagMeta(meta)])),
[originalSetting.tags],
);
const hasChanges = !isEqual(localTags, originalMetaMap);
@ -104,6 +90,10 @@ const TagsSection = () => {
setLocalTags((prev) => ({ ...prev, [tagName]: { ...prev[tagName], blur } }));
};
const handleClearColor = (tagName: string) => {
setLocalTags((prev) => ({ ...prev, [tagName]: { ...prev[tagName], color: undefined } }));
};
const handleRemoveTag = (tagName: string) => {
setLocalTags((prev) => {
const next = { ...prev };
@ -125,7 +115,7 @@ const TagsSection = () => {
}
setLocalTags((prev) => ({ ...prev, [name]: { color: newTagColor, blur: newTagBlur } }));
setNewTagName("");
setNewTagColor("#ffffff");
setNewTagColor(undefined);
setNewTagBlur(false);
};
@ -134,7 +124,10 @@ const TagsSection = () => {
const tags = Object.fromEntries(
Object.entries(localTags).map(([name, meta]) => [
name,
create(InstanceSetting_TagMetadataSchema, { backgroundColor: hexToColor(meta.color), blurContent: meta.blur }),
create(InstanceSetting_TagMetadataSchema, {
blurContent: meta.blur,
...(meta.color ? { backgroundColor: hexToColor(meta.color) } : {}),
}),
]),
);
await updateSetting(
@ -171,9 +164,13 @@ const TagsSection = () => {
<input
type="color"
className="w-8 h-8 cursor-pointer rounded border border-border bg-transparent p-0.5"
value={localTags[row.name].color}
value={localTags[row.name].color ?? DEFAULT_TAG_COLOR}
onChange={(e) => handleColorChange(row.name, e.target.value)}
/>
<Button variant="ghost" size="sm" onClick={() => handleClearColor(row.name)} disabled={!localTags[row.name].color}>
{t("common.clear")}
</Button>
{!localTags[row.name].color && <span className="text-xs text-muted-foreground">{t("setting.tags.using-default-color")}</span>}
</div>
),
},
@ -224,9 +221,12 @@ const TagsSection = () => {
<input
type="color"
className="w-8 h-8 cursor-pointer rounded border border-border bg-transparent p-0.5"
value={newTagColor}
value={newTagColor ?? DEFAULT_TAG_COLOR}
onChange={(e) => setNewTagColor(e.target.value)}
/>
<Button variant="ghost" size="sm" onClick={() => setNewTagColor(undefined)} disabled={!newTagColor}>
{t("common.clear")}
</Button>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
<input
type="checkbox"
@ -242,6 +242,7 @@ const TagsSection = () => {
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1">{t("setting.tags.tag-pattern-hint")}</p>
{!newTagColor && <p className="text-xs text-muted-foreground">{t("setting.tags.using-default-color")}</p>}
</SettingGroup>
<div className="w-full flex justify-end">

View File

@ -475,7 +475,7 @@
"tags": {
"label": "Tags",
"title": "Tag metadata",
"description": "Assign display colors to tags instance-wide. Tag names are treated as anchored regex patterns.",
"description": "Assign optional display colors to tags instance-wide, or blur matching memo content. Tag names are treated as anchored regex patterns.",
"background-color": "Background color",
"blur-content": "Blur content",
"no-tags-configured": "No tag metadata configured.",
@ -483,7 +483,8 @@
"tag-name-placeholder": "e.g. work or project/.*",
"tag-already-exists": "Tag already exists.",
"tag-pattern-hint": "Tag name or regex pattern (e.g. project/.* matches all project/ tags)",
"invalid-regex": "Invalid or unsafe regex pattern."
"invalid-regex": "Invalid or unsafe regex pattern.",
"using-default-color": "Using default color."
}
},
"tag": {

View File

@ -414,7 +414,8 @@ export const InstanceSetting_MemoRelatedSettingSchema: GenMessage<InstanceSettin
*/
export type InstanceSetting_TagMetadata = Message<"memos.api.v1.InstanceSetting.TagMetadata"> & {
/**
* Background color for the tag label.
* Optional background color for the tag label.
* When unset, the default tag color is used.
*
* @generated from field: google.type.Color background_color = 1;
*/