Merge pull request #194 from Dvorinka/feat/tag-management
feat: tag management page and owner-scoped tag APIpull/3587/head
commit
831f40a0dc
@ -0,0 +1,231 @@
|
||||
import {
|
||||
Box,
|
||||
DialogContent,
|
||||
IconButton,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { enqueueSnackbar } from "notistack";
|
||||
import { getUserTags, sendDeleteTag, sendPatchTag } from "../../../api/api.ts";
|
||||
import { UserTag } from "../../../api/explorer.ts";
|
||||
import { defaultColors } from "../../../constants";
|
||||
import { useAppDispatch } from "../../../redux/hooks.ts";
|
||||
import SessionManager, { UserSettings } from "../../../session";
|
||||
import { addRecentUsedColor } from "../../../session/utils.ts";
|
||||
import FacebookCircularProgress from "../../Common/CircularProgress.tsx";
|
||||
import { FilledTextField } from "../../Common/StyledComponents.tsx";
|
||||
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
|
||||
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../../Admin/Settings/Settings.tsx";
|
||||
import CircleColorSelector, { customizeMagicColor } from "../../FileManager/FileInfo/ColorCircle/CircleColorSelector.tsx";
|
||||
import FileTag from "../../FileManager/Explorer/FileTag.tsx";
|
||||
import Delete from "../../Icons/Delete.tsx";
|
||||
import Edit from "../../Icons/Edit.tsx";
|
||||
|
||||
const TagSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
|
||||
const [tags, setTags] = useState<UserTag[] | undefined>(undefined);
|
||||
const [editTarget, setEditTarget] = useState<UserTag | undefined>(undefined);
|
||||
const [deleteTarget, setDeleteTarget] = useState<UserTag | undefined>(undefined);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [hex, setHex] = useState<string | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
dispatch(getUserTags()).then((res) => setTags(res ?? []));
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const presetColors = useMemo(() => {
|
||||
const colors = new Set(defaultColors);
|
||||
const recentColors = SessionManager.get(UserSettings.UsedCustomizedTagColors) as string[] | undefined;
|
||||
recentColors?.forEach((color) => colors.add(color));
|
||||
return [...colors];
|
||||
}, [hex]);
|
||||
|
||||
const openEdit = useCallback((tag: UserTag) => {
|
||||
setEditTarget(tag);
|
||||
setNewName(tag.name);
|
||||
setHex(tag.color || undefined);
|
||||
}, []);
|
||||
|
||||
const onColorChange = useCallback(
|
||||
(color: string | undefined) => {
|
||||
color = color == theme.palette.action.selected ? undefined : color;
|
||||
addRecentUsedColor(color, UserSettings.UsedCustomizedTagColors);
|
||||
setHex(color);
|
||||
},
|
||||
[theme],
|
||||
);
|
||||
|
||||
const submitEdit = useCallback(() => {
|
||||
if (!editTarget || loading) {
|
||||
return;
|
||||
}
|
||||
const trimmed = newName.trim();
|
||||
if (!trimmed || trimmed.includes(":")) {
|
||||
enqueueSnackbar(t("application:setting.tagNameInvalid"), { variant: "error" });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
dispatch(
|
||||
sendPatchTag({
|
||||
name: editTarget.name,
|
||||
new_name: trimmed == editTarget.name ? undefined : trimmed,
|
||||
color: hex,
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
enqueueSnackbar(t("application:setting.tagUpdated"), { variant: "success" });
|
||||
setEditTarget(undefined);
|
||||
load();
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [dispatch, editTarget, newName, hex, loading, load, t]);
|
||||
|
||||
const submitDelete = useCallback(() => {
|
||||
if (!deleteTarget || loading) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
dispatch(sendDeleteTag(deleteTarget.name))
|
||||
.then(() => {
|
||||
enqueueSnackbar(t("application:setting.tagDeleted"), { variant: "success" });
|
||||
setDeleteTarget(undefined);
|
||||
load();
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [dispatch, deleteTarget, loading, load, t]);
|
||||
|
||||
if (!tags) {
|
||||
return (
|
||||
<Box sx={{ pt: 20, display: "flex", justifyContent: "center" }}>
|
||||
<FacebookCircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={5}>
|
||||
<SettingSection>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("application:setting.tags")}
|
||||
</Typography>
|
||||
<SettingSectionContent>
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t("application:setting.tagName")}</TableCell>
|
||||
<TableCell>{t("application:setting.tagFiles")}</TableCell>
|
||||
<TableCell align="right">{t("application:setting.tagActions")}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tags.map((tag) => (
|
||||
<TableRow key={tag.name}>
|
||||
<TableCell>
|
||||
<FileTag
|
||||
defaultStyle
|
||||
label={tag.name}
|
||||
tagColor={tag.color || theme.palette.action.selected}
|
||||
disableClick
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{tag.file_count}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => openEdit(tag)}>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => setDeleteTarget(tag)}>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{tags.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} align="center">
|
||||
{t("application:setting.noTags")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<NoMarginHelperText>{t("application:setting.tagsDes")}</NoMarginHelperText>
|
||||
</SettingSectionContent>
|
||||
</SettingSection>
|
||||
|
||||
<DraggableDialog
|
||||
title={t("application:setting.editTag")}
|
||||
loading={loading}
|
||||
disabled={!newName.trim()}
|
||||
showActions
|
||||
showCancel
|
||||
onAccept={submitEdit}
|
||||
dialogProps={{
|
||||
open: editTarget != undefined,
|
||||
onClose: () => !loading && setEditTarget(undefined),
|
||||
fullWidth: true,
|
||||
maxWidth: "xs",
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<FilledTextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label={t("application:setting.tagName")}
|
||||
type="text"
|
||||
fullWidth
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<CircleColorSelector
|
||||
colors={[theme.palette.action.selected, ...presetColors, customizeMagicColor]}
|
||||
selectedColor={hex ?? theme.palette.action.selected}
|
||||
onChange={onColorChange}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
|
||||
<DraggableDialog
|
||||
title={t("application:setting.deleteTag")}
|
||||
loading={loading}
|
||||
showActions
|
||||
showCancel
|
||||
onAccept={submitDelete}
|
||||
dialogProps={{
|
||||
open: deleteTarget != undefined,
|
||||
onClose: () => !loading && setDeleteTarget(undefined),
|
||||
maxWidth: "xs",
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
{t("application:setting.deleteTagDes", { name: deleteTarget?.name, count: deleteTarget?.file_count })}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagSetting;
|
||||
@ -0,0 +1,77 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func mkTagFile(t *testing.T, client *ent.Client, u *ent.User, name string, tags map[string]string) *ent.File {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
f := client.File.Create().SetName(name).SetType(int(types.FileTypeFile)).SetOwner(u).SaveX(ctx)
|
||||
for k, v := range tags {
|
||||
client.Metadata.Create().SetName(k).SetValue(v).SetFileID(f.ID).SaveX(ctx)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func TestTagMetadataManagement(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
fc := NewFileClient(client, conf.SQLite3DB, nil)
|
||||
|
||||
u, _, _ := dedupFixture(t, client, "a")
|
||||
other, _, _ := dedupFixture(t, client, "b")
|
||||
|
||||
mkTagFile(t, client, u, "a.txt", map[string]string{"tag:work": "#ff0000", "tag:proj": "#00ff00"})
|
||||
mkTagFile(t, client, u, "b.txt", map[string]string{"tag:work": "#ff0000"})
|
||||
mkTagFile(t, client, other, "c.txt", map[string]string{"tag:work": "#0000ff"})
|
||||
|
||||
// List: owner-scoped, prefix-scoped, sorted by name.
|
||||
stats, err := fc.ListMetadataStats(ctx, u.ID, "tag:")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stats, 2)
|
||||
require.Equal(t, "tag:proj", stats[0].Name)
|
||||
require.Equal(t, 1, stats[0].Count)
|
||||
require.Equal(t, "tag:work", stats[1].Name)
|
||||
require.Equal(t, 2, stats[1].Count)
|
||||
require.Equal(t, "#ff0000", stats[1].Value)
|
||||
|
||||
// Rename onto an existing tag merges: a.txt already carries tag:work,
|
||||
// so its tag:proj row is dropped rather than duplicated.
|
||||
require.NoError(t, fc.RenameMetadataName(ctx, u.ID, "tag:proj", "tag:work", nil))
|
||||
stats, err = fc.ListMetadataStats(ctx, u.ID, "tag:")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stats, 1)
|
||||
require.Equal(t, "tag:work", stats[0].Name)
|
||||
require.Equal(t, 2, stats[0].Count)
|
||||
|
||||
// Recolor across all of the owner's files.
|
||||
red := "#123456"
|
||||
require.NoError(t, fc.RenameMetadataName(ctx, u.ID, "tag:work", "tag:work", &red))
|
||||
stats, err = fc.ListMetadataStats(ctx, u.ID, "tag:")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "#123456", stats[0].Value)
|
||||
|
||||
// Other owner's tag untouched by all of the above.
|
||||
ostats, err := fc.ListMetadataStats(ctx, other.ID, "tag:")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ostats, 1)
|
||||
require.Equal(t, "#0000ff", ostats[0].Value)
|
||||
|
||||
// Delete scoped to owner.
|
||||
require.NoError(t, fc.DeleteMetadataByNameForOwner(ctx, u.ID, "tag:work"))
|
||||
stats, err = fc.ListMetadataStats(ctx, u.ID, "tag:")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, stats)
|
||||
ostats, err = fc.ListMetadataStats(ctx, other.ID, "tag:")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ostats, 1)
|
||||
}
|
||||
@ -0,0 +1,106 @@
|
||||
package explorer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/activity"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type (
|
||||
// ListTagsService lists all tag metadata keys used by the current user's files.
|
||||
ListTagsService struct{}
|
||||
|
||||
ListTagsParameterCtx struct{}
|
||||
|
||||
// TagResponse is one managed tag entry, name without the "tag:" prefix.
|
||||
TagResponse struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Count int `json:"file_count"`
|
||||
}
|
||||
|
||||
// PatchTagService renames and/or recolors a tag across all of the
|
||||
// current user's files. Renaming onto an existing tag merges them.
|
||||
PatchTagService struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=64"`
|
||||
NewName string `json:"new_name" binding:"omitempty,min=1,max=64"`
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
|
||||
PatchTagParameterCtx struct{}
|
||||
|
||||
// DeleteTagService removes a tag from all of the current user's files.
|
||||
DeleteTagService struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=64"`
|
||||
}
|
||||
|
||||
DeleteTagParameterCtx struct{}
|
||||
)
|
||||
|
||||
func (s *ListTagsService) Get(c *gin.Context) (any, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
|
||||
stats, err := dep.FileClient().ListMetadataStats(c, user.ID, manager.TagMetadataPrefix)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list tags", err)
|
||||
}
|
||||
|
||||
return lo.Map(stats, func(item *inventory.MetadataNameStat, _ int) *TagResponse {
|
||||
return &TagResponse{
|
||||
Name: strings.TrimPrefix(item.Name, manager.TagMetadataPrefix),
|
||||
Color: item.Value,
|
||||
Count: item.Count,
|
||||
}
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s *PatchTagService) Patch(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
|
||||
newName := s.Name
|
||||
if s.NewName != "" {
|
||||
newName = s.NewName
|
||||
}
|
||||
if strings.ContainsAny(s.Name, ":") || strings.ContainsAny(newName, ":") {
|
||||
return serializer.NewError(serializer.CodeParamErr, "tag name cannot contain ':'", nil)
|
||||
}
|
||||
if s.Color != nil {
|
||||
if err := validator.New().Var(*s.Color, "iscolor"); err != nil {
|
||||
return serializer.NewError(serializer.CodeParamErr, "invalid color", err)
|
||||
}
|
||||
}
|
||||
if err := dep.FileClient().RenameMetadataName(c, user.ID,
|
||||
manager.TagMetadataPrefix+s.Name, manager.TagMetadataPrefix+newName, s.Color); err != nil {
|
||||
return serializer.NewError(serializer.CodeDBError, "Failed to update tag", err)
|
||||
}
|
||||
|
||||
activity.Record(c, dep.SettingProvider(), dep.ActivityClient(), types.EventUpdateMetadata,
|
||||
activity.Extra(map[string]any{"tag": s.Name, "new_tag": newName}))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DeleteTagService) Delete(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
|
||||
if strings.Contains(s.Name, ":") {
|
||||
return serializer.NewError(serializer.CodeParamErr, "tag name cannot contain ':'", nil)
|
||||
}
|
||||
if err := dep.FileClient().DeleteMetadataByNameForOwner(c, user.ID, manager.TagMetadataPrefix+s.Name); err != nil {
|
||||
return serializer.NewError(serializer.CodeDBError, "Failed to delete tag", err)
|
||||
}
|
||||
|
||||
activity.Record(c, dep.SettingProvider(), dep.ActivityClient(), types.EventUpdateMetadata,
|
||||
activity.Extra(map[string]any{"tag": s.Name, "removed": true}))
|
||||
return nil
|
||||
}
|
||||
Loading…
Reference in new issue