feat: user tag management page and owner-scoped tag API

Upstream #2962 asked for tags as a managed, many-to-many relation
instead of only per-file metadata strings. Tags stay stored as tag:*
file metadata, but gain a central management surface:

- inventory.FileClient: ListMetadataStats (per-owner prefix aggregation),
  RenameMetadataName (merge-on-collision rename + optional recolor),
  DeleteMetadataByNameForOwner — all strictly owner-scoped.
- service/explorer: GET/PATCH/DELETE /file/tag with tag-name and
  iscolor validation, update_metadata activity events.
- frontend: Settings -> Tags tab listing tags with file counts, edit
  dialog (rename + CircleColorSelector), delete confirm, FileTag chips;
  en-US + zh-CN locales.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3587/head
Tomas Dvorak 2 weeks ago
parent 8060eee426
commit 189176b7ec

@ -206,6 +206,7 @@ Order = user-visible value first; each ships with backend + UI + tests.
- Fix upstream bug backlog by impact: ~~#3574 OOM~~ (done — paged tree walk + batched delete), ~~#3118/#3005 WebDAV large-file~~ (done — Content-Range assembly into one session; non-local policies get honest 501; single-PUT giant-file 500s are proxy/client timeouts, not fixable server-side), ~~#3375 SMTP auth discovery~~ (done — `smtp_auth` setting) - Fix upstream bug backlog by impact: ~~#3574 OOM~~ (done — paged tree walk + batched delete), ~~#3118/#3005 WebDAV large-file~~ (done — Content-Range assembly into one session; non-local policies get honest 501; single-PUT giant-file 500s are proxy/client timeouts, not fixable server-side), ~~#3375 SMTP auth discovery~~ (done — `smtp_auth` setting)
- #3454 (PG FK on upload) is **Pro-only** — `audit_logs` doesn't exist in this codebase. When B.5 adds our own audit log: insert the audit row in the same tx *after* the file row, never before. - #3454 (PG FK on upload) is **Pro-only** — `audit_logs` doesn't exist in this codebase. When B.5 adds our own audit log: insert the audit row in the same tx *after* the file row, never before.
- [x] `desloppify` pass — 73 review items dispositioned (46 fixed, 27 honestly skipped), strict score 77.1 (was 18.9); scorecard lives in README. `security-reviewer` pass done incrementally per batch (OAuth secrets, SSRF, process exec, path safety) - [x] `desloppify` pass — 73 review items dispositioned (46 fixed, 27 honestly skipped), strict score 77.1 (was 18.9); scorecard lives in README. `security-reviewer` pass done incrementally per batch (OAuth secrets, SSRF, process exec, path safety)
- [x] Tag management page (upstream #2962) — owner-scoped `tag:` metadata stats/rename/recolor/delete in `inventory.FileClient`, `GET/PATCH/DELETE /file/tag` routes, Settings → Tags tab with merge-on-rename semantics
## 6. Phase D — desktop, all platforms ## 6. Phase D — desktop, all platforms

@ -1113,7 +1113,19 @@
"gift": "Gift code", "gift": "Gift code",
"share_income": "Share link purchased", "share_income": "Share link purchased",
"adjust": "Manual adjustment" "adjust": "Manual adjustment"
} },
"tags": "Tags",
"tagsDes": "Rename, recolor or delete a tag across all your files. Renaming onto an existing tag merges them.",
"tagName": "Tag",
"tagFiles": "Files",
"tagActions": "Actions",
"noTags": "No tags yet",
"editTag": "Edit tag",
"deleteTag": "Delete tag",
"deleteTagDes": "Remove tag \"{{name}}\" from {{count}} file(s)? The files themselves are kept.",
"tagUpdated": "Tag updated",
"tagDeleted": "Tag deleted",
"tagNameInvalid": "Tag name cannot be empty or contain \":\""
}, },
"vas": { "vas": {
"points": "Points", "points": "Points",

@ -1113,7 +1113,19 @@
"gift": "礼品码", "gift": "礼品码",
"share_income": "分享售出", "share_income": "分享售出",
"adjust": "手动调整" "adjust": "手动调整"
} },
"tags": "标签",
"tagsDes": "在所有文件中重命名、换色或删除标签。重命名为已有标签时将合并两者。",
"tagName": "标签",
"tagFiles": "文件数",
"tagActions": "操作",
"noTags": "暂无标签",
"editTag": "编辑标签",
"deleteTag": "删除标签",
"deleteTagDes": "将标签「{{name}}」从 {{count}} 个文件中移除?文件本身不会被删除。",
"tagUpdated": "标签已更新",
"tagDeleted": "标签已删除",
"tagNameInvalid": "标签名不能为空或包含「:」"
}, },
"vas": { "vas": {
"points": "积分", "points": "积分",

@ -76,6 +76,7 @@ import {
MoveFileService, MoveFileService,
MultipleUriService, MultipleUriService,
PatchMetadataService, PatchMetadataService,
PatchTagService,
PatchViewSyncService, PatchViewSyncService,
PinFileService, PinFileService,
PreferredPolicyService, PreferredPolicyService,
@ -86,6 +87,7 @@ import {
UnlockFileService, UnlockFileService,
UploadCredential, UploadCredential,
UploadSessionRequest, UploadSessionRequest,
UserTag,
VersionControlService, VersionControlService,
ViewerGroup, ViewerGroup,
ViewerSessionResponse, ViewerSessionResponse,
@ -555,6 +557,56 @@ export function sendMetadataPatch(req: PatchMetadataService): ThunkResponse<void
}; };
} }
export function getUserTags(): ThunkResponse<UserTag[]> {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/file/tag",
{
method: "GET",
},
{
...defaultOpts,
},
),
);
};
}
export function sendPatchTag(req: PatchTagService): ThunkResponse<void> {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/file/tag",
{
data: req,
method: "PATCH",
},
{
...defaultOpts,
},
),
);
};
}
export function sendDeleteTag(name: string): ThunkResponse<void> {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/file/tag",
{
data: { name },
method: "DELETE",
},
{
...defaultOpts,
},
),
);
};
}
export function getAllowedPolicies(): ThunkResponse<StoragePolicyBrief[]> { export function getAllowedPolicies(): ThunkResponse<StoragePolicyBrief[]> {
return async (dispatch, _getState) => { return async (dispatch, _getState) => {
return await dispatch( return await dispatch(

@ -338,6 +338,18 @@ export interface PatchMetadataService extends MultipleUriService {
patches: MetadataPatch[]; patches: MetadataPatch[];
} }
export interface UserTag {
name: string;
color: string;
file_count: number;
}
export interface PatchTagService {
name: string;
new_name?: string;
color?: string;
}
export interface ShareCreateService { export interface ShareCreateService {
uri?: string; uri?: string;
uris?: string[]; uris?: string[];

@ -18,12 +18,15 @@ import FinanceSetting from "./FinanceSetting.tsx";
import PreferenceSetting from "./PreferenceSetting.tsx"; import PreferenceSetting from "./PreferenceSetting.tsx";
import ProfileSetting from "./ProfileSetting.tsx"; import ProfileSetting from "./ProfileSetting.tsx";
import SecuritySetting from "./Security/SecuritySetting.tsx"; import SecuritySetting from "./Security/SecuritySetting.tsx";
import Tag from "../../Icons/Tag.tsx";
import TagSetting from "./TagSetting.tsx";
export enum SettingPageTab { export enum SettingPageTab {
Profile = "profile", Profile = "profile",
Preference = "preference", Preference = "preference",
Security = "security", Security = "security",
Finance = "finance", Finance = "finance",
Tags = "tags",
} }
const Setting = () => { const Setting = () => {
@ -69,6 +72,11 @@ const Setting = () => {
value: SettingPageTab.Finance, value: SettingPageTab.Finance,
icon: <AccountBalanceWalletOutlined />, icon: <AccountBalanceWalletOutlined />,
}, },
{
label: t("application:setting.tags"),
value: SettingPageTab.Tags,
icon: <Tag />,
},
], ],
); );
return res; return res;
@ -115,6 +123,7 @@ const Setting = () => {
{tab == SettingPageTab.Preference && <PreferenceSetting setting={setting} setSetting={setSetting} />} {tab == SettingPageTab.Preference && <PreferenceSetting setting={setting} setSetting={setSetting} />}
{tab == SettingPageTab.Security && <SecuritySetting setting={setting} setSetting={setSetting} />} {tab == SettingPageTab.Security && <SecuritySetting setting={setting} setSetting={setSetting} />}
{tab == SettingPageTab.Finance && <FinanceSetting />} {tab == SettingPageTab.Finance && <FinanceSetting />}
{tab == SettingPageTab.Tags && <TagSetting />}
</Box> </Box>
)} )}
</Box> </Box>

@ -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;

@ -3,6 +3,7 @@ package inventory
import ( import (
"context" "context"
"fmt" "fmt"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -162,6 +163,13 @@ type (
PrimaryEntityParentFiles []int PrimaryEntityParentFiles []int
} }
// MetadataNameStat aggregates usage of one metadata key for an owner.
MetadataNameStat struct {
Name string `json:"name"`
Value string `json:"value"`
Count int `json:"count"`
}
CopyParameter struct { CopyParameter struct {
Files []*ent.File Files []*ent.File
DstMap map[int][]*ent.File DstMap map[int][]*ent.File
@ -268,6 +276,17 @@ type FileClient interface {
UpdateModifiedAt(ctx context.Context, file *ent.File, modifiedAt time.Time) error UpdateModifiedAt(ctx context.Context, file *ent.File, modifiedAt time.Time) error
// DeleteAllMetadataByName deletes all metadata by a given name // DeleteAllMetadataByName deletes all metadata by a given name
DeleteAllMetadataByName(ctx context.Context, name string) error DeleteAllMetadataByName(ctx context.Context, name string) error
// ListMetadataStats aggregates distinct metadata keys under namePrefix
// across all files owned by ownerID, with per-key usage counts.
ListMetadataStats(ctx context.Context, ownerID int, namePrefix string) ([]*MetadataNameStat, error)
// RenameMetadataName renames a metadata key across all files owned by
// ownerID. Where a file already carries newName its oldName row is
// dropped (merge). setValue, when non-nil, rewrites the stored value on
// all of the owner's rows under the final name.
RenameMetadataName(ctx context.Context, ownerID int, oldName, newName string, setValue *string) error
// DeleteMetadataByNameForOwner deletes a metadata key across all files
// owned by ownerID.
DeleteMetadataByNameForOwner(ctx context.Context, ownerID int, name string) error
} }
func NewFileClient(client *ent.Client, dbType conf.DBType, hasher hashid.Encoder) FileClient { func NewFileClient(client *ent.Client, dbType conf.DBType, hasher hashid.Encoder) FileClient {
@ -362,6 +381,84 @@ func (f *fileClient) DeleteAllMetadataByName(ctx context.Context, name string) e
return nil return nil
} }
func (f *fileClient) ListMetadataStats(ctx context.Context, ownerID int, namePrefix string) ([]*MetadataNameStat, error) {
rows, err := f.client.Metadata.Query().
Where(metadata.NameHasPrefix(namePrefix), metadata.HasFileWith(file.OwnerID(ownerID))).
All(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list metadata stats: %w", err)
}
stats := make([]*MetadataNameStat, 0)
byName := make(map[string]*MetadataNameStat)
for _, row := range rows {
s, ok := byName[row.Name]
if !ok {
s = &MetadataNameStat{Name: row.Name, Value: row.Value}
byName[row.Name] = s
stats = append(stats, s)
}
s.Count++
}
slices.SortFunc(stats, func(a, b *MetadataNameStat) int {
return strings.Compare(a.Name, b.Name)
})
return stats, nil
}
func (f *fileClient) RenameMetadataName(ctx context.Context, ownerID int, oldName, newName string, setValue *string) error {
if oldName == newName && setValue == nil {
return nil
}
if oldName != newName {
// Files already carrying newName keep it — their oldName rows merge away.
collisionIDs, err := f.client.Metadata.Query().
Where(metadata.Name(newName), metadata.HasFileWith(file.OwnerID(ownerID))).
Select(metadata.FieldFileID).
Ints(ctx)
if err != nil {
return fmt.Errorf("failed to query metadata collisions: %w", err)
}
for _, chunk := range lo.Chunk(collisionIDs, capPageSize(f.maxSQlParam, math.MaxInt, 10)) {
if _, err := f.client.Metadata.Delete().
Where(metadata.Name(oldName), metadata.FileIDIn(chunk...)).
Exec(schema.SkipSoftDelete(ctx)); err != nil {
return fmt.Errorf("failed to merge metadata rows: %w", err)
}
}
if _, err := f.client.Metadata.Update().
Where(metadata.Name(oldName), metadata.HasFileWith(file.OwnerID(ownerID))).
SetName(newName).
Save(ctx); err != nil {
return fmt.Errorf("failed to rename metadata: %w", err)
}
}
if setValue != nil {
if _, err := f.client.Metadata.Update().
Where(metadata.Name(newName), metadata.HasFileWith(file.OwnerID(ownerID))).
SetValue(*setValue).
Save(ctx); err != nil {
return fmt.Errorf("failed to update metadata value: %w", err)
}
}
return nil
}
func (f *fileClient) DeleteMetadataByNameForOwner(ctx context.Context, ownerID int, name string) error {
_, err := f.client.Metadata.Delete().
Where(metadata.Name(name), metadata.HasFileWith(file.OwnerID(ownerID))).
Exec(schema.SkipSoftDelete(ctx))
if err != nil {
return fmt.Errorf("failed to delete metadata: %w", err)
}
return nil
}
func (f *fileClient) CountIndexableFiles(ctx context.Context) (int, error) { func (f *fileClient) CountIndexableFiles(ctx context.Context) (int, error) {
return f.indexableFilesQuery().Count(ctx) return f.indexableFilesQuery().Count(ctx)
} }

@ -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)
}

@ -27,6 +27,7 @@ const (
wildcardMetadataKey = "*" wildcardMetadataKey = "*"
customizeMetadataSuffix = "customize" customizeMetadataSuffix = "customize"
tagMetadataSuffix = "tag" tagMetadataSuffix = "tag"
TagMetadataPrefix = tagMetadataSuffix + ":"
customPropsMetadataSuffix = "props" customPropsMetadataSuffix = "props"
iconColorMetadataKey = customizeMetadataSuffix + ":icon_color" iconColorMetadataKey = customizeMetadataSuffix + ":icon_color"
emojiIconMetadataKey = customizeMetadataSuffix + ":emoji" emojiIconMetadataKey = customizeMetadataSuffix + ":emoji"

@ -353,6 +353,41 @@ func PatchMetadata(c *gin.Context) {
c.JSON(200, serializer.Response{}) c.JSON(200, serializer.Response{})
} }
// ListTags lists all tags used by the current user's files
func ListTags(c *gin.Context) {
service := ParametersFromContext[*explorer.ListTagsService](c, explorer.ListTagsParameterCtx{})
resp, err := service.Get(c)
if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{
Data: resp,
})
}
// PatchTag renames and/or recolors a tag across the current user's files
func PatchTag(c *gin.Context) {
service := ParametersFromContext[*explorer.PatchTagService](c, explorer.PatchTagParameterCtx{})
err := service.Patch(c)
if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
}
// DeleteTag removes a tag from all of the current user's files
func DeleteTag(c *gin.Context) {
service := ParametersFromContext[*explorer.DeleteTagService](c, explorer.DeleteTagParameterCtx{})
err := service.Delete(c)
if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
}
// ListAcl lists ACL entries of a file. // ListAcl lists ACL entries of a file.
func ListAcl(c *gin.Context) { func ListAcl(c *gin.Context) {
service := ParametersFromContext[*explorer.AclListService](c, explorer.AclListParamCtx{}) service := ParametersFromContext[*explorer.AclListService](c, explorer.AclListParamCtx{})

@ -764,6 +764,21 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
middleware.ValidateBatchFileCount(dep, explorer.PatchMetadataParameterCtx{}), middleware.ValidateBatchFileCount(dep, explorer.PatchMetadataParameterCtx{}),
controllers.PatchMetadata, controllers.PatchMetadata,
) )
// Tag management across all of the user's files
file.GET("tag",
controllers.FromQuery[explorer.ListTagsService](explorer.ListTagsParameterCtx{}),
controllers.ListTags,
)
file.PATCH("tag",
middleware.RequiredScopes(types.ScopeFilesWrite),
controllers.FromJSON[explorer.PatchTagService](explorer.PatchTagParameterCtx{}),
controllers.PatchTag,
)
file.DELETE("tag",
middleware.RequiredScopes(types.ScopeFilesWrite),
controllers.FromJSON[explorer.DeleteTagService](explorer.DeleteTagParameterCtx{}),
controllers.DeleteTag,
)
// List storage policies available to the current group // List storage policies available to the current group
file.GET("policy", file.GET("policy",
controllers.FromQuery[explorer.AllowedPolicyService](explorer.AllowedPolicyParamCtx{}), controllers.FromQuery[explorer.AllowedPolicyService](explorer.AllowedPolicyParamCtx{}),

@ -51,6 +51,9 @@ func TestMasterRouteWiring(t *testing.T) {
"GET /api/v4/user/info/:id", "GET /api/v4/user/info/:id",
"PUT /api/v4/file/upload", "PUT /api/v4/file/upload",
"POST /api/v4/file/upload/:sessionId/:index", "POST /api/v4/file/upload/:sessionId/:index",
"GET /api/v4/file/tag",
"PATCH /api/v4/file/tag",
"DELETE /api/v4/file/tag",
"POST /api/v4/share/purchase/:id", "POST /api/v4/share/purchase/:id",
"GET /api/v4/session/qq/login", "GET /api/v4/session/qq/login",
"GET /api/v4/session/qq/callback", "GET /api/v4/session/qq/callback",

@ -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…
Cancel
Save