Unverified Commit 1921b576 authored by memoclaw's avatar memoclaw Committed by GitHub

fix(tags): allow blur-only tag metadata (#5800)

Co-authored-by: 's avatarmemoclaw <265580040+memoclaw@users.noreply.github.com>
parent 201c8a8e
...@@ -167,7 +167,8 @@ message InstanceSetting { ...@@ -167,7 +167,8 @@ message InstanceSetting {
// Metadata for a tag. // Metadata for a tag.
message TagMetadata { 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; google.type.Color background_color = 1;
// Whether memos with this tag should have their content blurred. // Whether memos with this tag should have their content blurred.
bool blur_content = 2; bool blur_content = 2;
......
...@@ -759,7 +759,8 @@ func (x *InstanceSetting_MemoRelatedSetting) GetReactions() []string { ...@@ -759,7 +759,8 @@ func (x *InstanceSetting_MemoRelatedSetting) GetReactions() []string {
// Metadata for a tag. // Metadata for a tag.
type InstanceSetting_TagMetadata struct { type InstanceSetting_TagMetadata struct {
state protoimpl.MessageState `protogen:"open.v1"` 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"` 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. // 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"` BlurContent bool `protobuf:"varint,2,opt,name=blur_content,json=blurContent,proto3" json:"blur_content,omitempty"`
......
...@@ -2396,7 +2396,9 @@ components: ...@@ -2396,7 +2396,9 @@ components:
backgroundColor: backgroundColor:
allOf: allOf:
- $ref: '#/components/schemas/Color' - $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: blurContent:
type: boolean type: boolean
description: Whether memos with this tag should have their content blurred. description: Whether memos with this tag should have their content blurred.
......
...@@ -754,7 +754,8 @@ func (x *InstanceMemoRelatedSetting) GetReactions() []string { ...@@ -754,7 +754,8 @@ func (x *InstanceMemoRelatedSetting) GetReactions() []string {
type InstanceTagMetadata struct { type InstanceTagMetadata struct {
state protoimpl.MessageState `protogen:"open.v1"` 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"` 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. // 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"` BlurContent bool `protobuf:"varint,2,opt,name=blur_content,json=blurContent,proto3" json:"blur_content,omitempty"`
......
...@@ -111,7 +111,8 @@ message InstanceMemoRelatedSetting { ...@@ -111,7 +111,8 @@ message InstanceMemoRelatedSetting {
} }
message InstanceTagMetadata { 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; google.type.Color background_color = 1;
// Whether memos with this tag should have their content blurred. // Whether memos with this tag should have their content blurred.
bool blur_content = 2; bool blur_content = 2;
......
...@@ -423,11 +423,10 @@ func validateInstanceTagsSetting(setting *v1pb.InstanceSetting_TagsSetting) erro ...@@ -423,11 +423,10 @@ func validateInstanceTagsSetting(setting *v1pb.InstanceSetting_TagsSetting) erro
if metadata == nil { if metadata == nil {
return errors.Errorf("tag metadata is required for %q", tag) return errors.Errorf("tag metadata is required for %q", tag)
} }
if metadata.GetBackgroundColor() == nil { 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 err := validateInstanceColor(metadata.GetBackgroundColor()); err != nil { }
return errors.Wrapf(err, "background_color for %q", tag)
} }
} }
return nil return nil
......
...@@ -318,6 +318,34 @@ func TestUpdateInstanceSetting(t *testing.T) { ...@@ -318,6 +318,34 @@ func TestUpdateInstanceSetting(t *testing.T) {
require.Contains(t, err.Error(), "invalid instance setting") 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) { t.Run("UpdateInstanceSetting - notification setting password is write-only", func(t *testing.T) {
ts := NewTestService(t) ts := NewTestService(t)
defer ts.Cleanup() defer ts.Cleanup()
......
...@@ -257,6 +257,34 @@ func TestInstanceSettingTagsSetting(t *testing.T) { ...@@ -257,6 +257,34 @@ func TestInstanceSettingTagsSetting(t *testing.T) {
ts.Close() 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) { func TestInstanceSettingNotificationSetting(t *testing.T) {
t.Parallel() t.Parallel()
ctx := context.Background() ctx := context.Background()
......
...@@ -22,8 +22,7 @@ import SettingGroup from "./SettingGroup"; ...@@ -22,8 +22,7 @@ import SettingGroup from "./SettingGroup";
import SettingSection from "./SettingSection"; import SettingSection from "./SettingSection";
import SettingTable from "./SettingTable"; import SettingTable from "./SettingTable";
// Fallback to white when no color is stored. const DEFAULT_TAG_COLOR = "#ffffff";
const tagColorToHex = (color?: { red?: number; green?: number; blue?: number }): string => colorToHex(color) ?? "#ffffff";
// Converts a CSS hex string to a google.type.Color message. // Converts a CSS hex string to a google.type.Color message.
const hexToColor = (hex: string) => const hexToColor = (hex: string) =>
...@@ -34,10 +33,18 @@ const hexToColor = (hex: string) => ...@@ -34,10 +33,18 @@ const hexToColor = (hex: string) =>
}); });
interface LocalTagMeta { interface LocalTagMeta {
color: string; color?: string;
blur: boolean; 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 TagsSection = () => {
const t = useTranslate(); const t = useTranslate();
const { tagsSetting: originalSetting, updateSetting, fetchSetting } = useInstance(); const { tagsSetting: originalSetting, updateSetting, fetchSetting } = useInstance();
...@@ -45,28 +52,16 @@ const TagsSection = () => { ...@@ -45,28 +52,16 @@ const TagsSection = () => {
// Local state: map of tagName → { color, blur } for editing. // Local state: map of tagName → { color, blur } for editing.
const [localTags, setLocalTags] = useState<Record<string, LocalTagMeta>>(() => const [localTags, setLocalTags] = useState<Record<string, LocalTagMeta>>(() =>
Object.fromEntries( Object.fromEntries(Object.entries(originalSetting.tags).map(([name, meta]) => [name, toLocalTagMeta(meta)])),
Object.entries(originalSetting.tags).map(([name, meta]) => [
name,
{ color: tagColorToHex(meta.backgroundColor), blur: meta.blurContent },
]),
),
); );
const [newTagName, setNewTagName] = useState(""); const [newTagName, setNewTagName] = useState("");
const [newTagColor, setNewTagColor] = useState("#ffffff"); const [newTagColor, setNewTagColor] = useState<string | undefined>(undefined);
const [newTagBlur, setNewTagBlur] = useState(false); const [newTagBlur, setNewTagBlur] = useState(false);
// Sync local state when the fetched setting arrives (the fetch is async and // Sync local state when the fetched setting arrives (the fetch is async and
// completes after mount, so localTags would be empty without this sync). // completes after mount, so localTags would be empty without this sync).
useEffect(() => { useEffect(() => {
setLocalTags( setLocalTags(Object.fromEntries(Object.entries(originalSetting.tags).map(([name, meta]) => [name, toLocalTagMeta(meta)])));
Object.fromEntries(
Object.entries(originalSetting.tags).map(([name, meta]) => [
name,
{ color: tagColorToHex(meta.backgroundColor), blur: meta.blurContent },
]),
),
);
}, [originalSetting.tags]); }, [originalSetting.tags]);
// All known tag names: union of saved entries and tags used in memos. // All known tag names: union of saved entries and tags used in memos.
...@@ -85,13 +80,7 @@ const TagsSection = () => { ...@@ -85,13 +80,7 @@ const TagsSection = () => {
); );
const originalMetaMap = useMemo( const originalMetaMap = useMemo(
() => () => Object.fromEntries(Object.entries(originalSetting.tags).map(([name, meta]) => [name, toLocalTagMeta(meta)])),
Object.fromEntries(
Object.entries(originalSetting.tags).map(([name, meta]) => [
name,
{ color: tagColorToHex(meta.backgroundColor), blur: meta.blurContent },
]),
),
[originalSetting.tags], [originalSetting.tags],
); );
const hasChanges = !isEqual(localTags, originalMetaMap); const hasChanges = !isEqual(localTags, originalMetaMap);
...@@ -104,6 +93,10 @@ const TagsSection = () => { ...@@ -104,6 +93,10 @@ const TagsSection = () => {
setLocalTags((prev) => ({ ...prev, [tagName]: { ...prev[tagName], blur } })); setLocalTags((prev) => ({ ...prev, [tagName]: { ...prev[tagName], blur } }));
}; };
const handleClearColor = (tagName: string) => {
setLocalTags((prev) => ({ ...prev, [tagName]: { ...prev[tagName], color: undefined } }));
};
const handleRemoveTag = (tagName: string) => { const handleRemoveTag = (tagName: string) => {
setLocalTags((prev) => { setLocalTags((prev) => {
const next = { ...prev }; const next = { ...prev };
...@@ -125,7 +118,7 @@ const TagsSection = () => { ...@@ -125,7 +118,7 @@ const TagsSection = () => {
} }
setLocalTags((prev) => ({ ...prev, [name]: { color: newTagColor, blur: newTagBlur } })); setLocalTags((prev) => ({ ...prev, [name]: { color: newTagColor, blur: newTagBlur } }));
setNewTagName(""); setNewTagName("");
setNewTagColor("#ffffff"); setNewTagColor(undefined);
setNewTagBlur(false); setNewTagBlur(false);
}; };
...@@ -134,7 +127,10 @@ const TagsSection = () => { ...@@ -134,7 +127,10 @@ const TagsSection = () => {
const tags = Object.fromEntries( const tags = Object.fromEntries(
Object.entries(localTags).map(([name, meta]) => [ Object.entries(localTags).map(([name, meta]) => [
name, 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( await updateSetting(
...@@ -171,9 +167,15 @@ const TagsSection = () => { ...@@ -171,9 +167,15 @@ const TagsSection = () => {
<input <input
type="color" type="color"
className="w-8 h-8 cursor-pointer rounded border border-border bg-transparent p-0.5" 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)} 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> </div>
), ),
}, },
...@@ -224,9 +226,12 @@ const TagsSection = () => { ...@@ -224,9 +226,12 @@ const TagsSection = () => {
<input <input
type="color" type="color"
className="w-8 h-8 cursor-pointer rounded border border-border bg-transparent p-0.5" 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)} 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"> <label className="flex items-center gap-1.5 text-sm text-muted-foreground">
<input <input
type="checkbox" type="checkbox"
...@@ -242,6 +247,7 @@ const TagsSection = () => { ...@@ -242,6 +247,7 @@ const TagsSection = () => {
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground mt-1">{t("setting.tags.tag-pattern-hint")}</p> <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> </SettingGroup>
<div className="w-full flex justify-end"> <div className="w-full flex justify-end">
......
...@@ -475,7 +475,7 @@ ...@@ -475,7 +475,7 @@
"tags": { "tags": {
"label": "Tags", "label": "Tags",
"title": "Tag metadata", "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", "background-color": "Background color",
"blur-content": "Blur content", "blur-content": "Blur content",
"no-tags-configured": "No tag metadata configured.", "no-tags-configured": "No tag metadata configured.",
...@@ -483,7 +483,8 @@ ...@@ -483,7 +483,8 @@
"tag-name-placeholder": "e.g. work or project/.*", "tag-name-placeholder": "e.g. work or project/.*",
"tag-already-exists": "Tag already exists.", "tag-already-exists": "Tag already exists.",
"tag-pattern-hint": "Tag name or regex pattern (e.g. project/.* matches all project/ tags)", "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": { "tag": {
......
...@@ -414,7 +414,8 @@ export const InstanceSetting_MemoRelatedSettingSchema: GenMessage<InstanceSettin ...@@ -414,7 +414,8 @@ export const InstanceSetting_MemoRelatedSettingSchema: GenMessage<InstanceSettin
*/ */
export type InstanceSetting_TagMetadata = Message<"memos.api.v1.InstanceSetting.TagMetadata"> & { 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; * @generated from field: google.type.Color background_color = 1;
*/ */
......
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