fix(tags): allow blur-only tag metadata (#5800)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
This commit is contained in:
parent
201c8a8ea9
commit
1921b57662
11 changed files with 111 additions and 42 deletions
|
|
@ -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 {
|
||||||
// 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:
|
||||||
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 {
|
||||||
|
|
||||||
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 {
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
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) {
|
||||||
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) {
|
||||||
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";
|
||||||
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) =>
|
||||||
});
|
});
|
||||||
|
|
||||||
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 = () => {
|
||||||
|
|
||||||
// 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 = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
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 = () => {
|
||||||
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 = () => {
|
||||||
}
|
}
|
||||||
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 = () => {
|
||||||
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 = () => {
|
||||||
<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 = () => {
|
||||||
<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 = () => {
|
||||||
</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 @@
|
||||||
"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 @@
|
||||||
"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
|
||||||
*/
|
*/
|
||||||
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;
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue