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