From 189176b7ec31053341bd0bb1b5aff8931d257341 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sun, 20 Sep 2026 05:49:08 +0200 Subject: [PATCH] feat: user tag management page and owner-scoped tag API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- ROADMAP.md | 1 + .../public/locales/en-US/application.json | 14 +- .../public/locales/zh-CN/application.json | 14 +- frontend/src/api/api.ts | 52 ++++ frontend/src/api/explorer.ts | 12 + .../src/component/Pages/Setting/Setting.tsx | 9 + .../component/Pages/Setting/TagSetting.tsx | 231 ++++++++++++++++++ inventory/file.go | 97 ++++++++ inventory/metadata_tag_test.go | 77 ++++++ pkg/filemanager/manager/metadata.go | 1 + routers/controllers/file.go | 35 +++ routers/router.go | 15 ++ routers/router_test.go | 3 + service/explorer/tag.go | 106 ++++++++ 14 files changed, 665 insertions(+), 2 deletions(-) create mode 100644 frontend/src/component/Pages/Setting/TagSetting.tsx create mode 100644 inventory/metadata_tag_test.go create mode 100644 service/explorer/tag.go diff --git a/ROADMAP.md b/ROADMAP.md index bfa76cd0..2d38be23 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) - #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] 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 diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json index 2f4cf07d..78afa414 100644 --- a/frontend/public/locales/en-US/application.json +++ b/frontend/public/locales/en-US/application.json @@ -1113,7 +1113,19 @@ "gift": "Gift code", "share_income": "Share link purchased", "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": { "points": "Points", diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json index cffc4f8b..dcd88715 100644 --- a/frontend/public/locales/zh-CN/application.json +++ b/frontend/public/locales/zh-CN/application.json @@ -1113,7 +1113,19 @@ "gift": "礼品码", "share_income": "分享售出", "adjust": "手动调整" - } + }, + "tags": "标签", + "tagsDes": "在所有文件中重命名、换色或删除标签。重命名为已有标签时将合并两者。", + "tagName": "标签", + "tagFiles": "文件数", + "tagActions": "操作", + "noTags": "暂无标签", + "editTag": "编辑标签", + "deleteTag": "删除标签", + "deleteTagDes": "将标签「{{name}}」从 {{count}} 个文件中移除?文件本身不会被删除。", + "tagUpdated": "标签已更新", + "tagDeleted": "标签已删除", + "tagNameInvalid": "标签名不能为空或包含「:」" }, "vas": { "points": "积分", diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 4ec39493..5186705a 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -76,6 +76,7 @@ import { MoveFileService, MultipleUriService, PatchMetadataService, + PatchTagService, PatchViewSyncService, PinFileService, PreferredPolicyService, @@ -86,6 +87,7 @@ import { UnlockFileService, UploadCredential, UploadSessionRequest, + UserTag, VersionControlService, ViewerGroup, ViewerSessionResponse, @@ -555,6 +557,56 @@ export function sendMetadataPatch(req: PatchMetadataService): ThunkResponse { + return async (dispatch, _getState) => { + return await dispatch( + send( + "/file/tag", + { + method: "GET", + }, + { + ...defaultOpts, + }, + ), + ); + }; +} + +export function sendPatchTag(req: PatchTagService): ThunkResponse { + return async (dispatch, _getState) => { + return await dispatch( + send( + "/file/tag", + { + data: req, + method: "PATCH", + }, + { + ...defaultOpts, + }, + ), + ); + }; +} + +export function sendDeleteTag(name: string): ThunkResponse { + return async (dispatch, _getState) => { + return await dispatch( + send( + "/file/tag", + { + data: { name }, + method: "DELETE", + }, + { + ...defaultOpts, + }, + ), + ); + }; +} + export function getAllowedPolicies(): ThunkResponse { return async (dispatch, _getState) => { return await dispatch( diff --git a/frontend/src/api/explorer.ts b/frontend/src/api/explorer.ts index 56a91050..0a07c109 100644 --- a/frontend/src/api/explorer.ts +++ b/frontend/src/api/explorer.ts @@ -338,6 +338,18 @@ export interface PatchMetadataService extends MultipleUriService { 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 { uri?: string; uris?: string[]; diff --git a/frontend/src/component/Pages/Setting/Setting.tsx b/frontend/src/component/Pages/Setting/Setting.tsx index 5ba49077..b6f5b8d5 100644 --- a/frontend/src/component/Pages/Setting/Setting.tsx +++ b/frontend/src/component/Pages/Setting/Setting.tsx @@ -18,12 +18,15 @@ import FinanceSetting from "./FinanceSetting.tsx"; import PreferenceSetting from "./PreferenceSetting.tsx"; import ProfileSetting from "./ProfileSetting.tsx"; import SecuritySetting from "./Security/SecuritySetting.tsx"; +import Tag from "../../Icons/Tag.tsx"; +import TagSetting from "./TagSetting.tsx"; export enum SettingPageTab { Profile = "profile", Preference = "preference", Security = "security", Finance = "finance", + Tags = "tags", } const Setting = () => { @@ -69,6 +72,11 @@ const Setting = () => { value: SettingPageTab.Finance, icon: , }, + { + label: t("application:setting.tags"), + value: SettingPageTab.Tags, + icon: , + }, ], ); return res; @@ -115,6 +123,7 @@ const Setting = () => { {tab == SettingPageTab.Preference && } {tab == SettingPageTab.Security && } {tab == SettingPageTab.Finance && } + {tab == SettingPageTab.Tags && } )} diff --git a/frontend/src/component/Pages/Setting/TagSetting.tsx b/frontend/src/component/Pages/Setting/TagSetting.tsx new file mode 100644 index 00000000..68eccfa2 --- /dev/null +++ b/frontend/src/component/Pages/Setting/TagSetting.tsx @@ -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(undefined); + const [editTarget, setEditTarget] = useState(undefined); + const [deleteTarget, setDeleteTarget] = useState(undefined); + const [newName, setNewName] = useState(""); + const [hex, setHex] = useState(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 ( + + + + ); + } + + return ( + + + + {t("application:setting.tags")} + + + + + + + {t("application:setting.tagName")} + {t("application:setting.tagFiles")} + {t("application:setting.tagActions")} + + + + {tags.map((tag) => ( + + + + + {tag.file_count} + + openEdit(tag)}> + + + setDeleteTarget(tag)}> + + + + + ))} + {tags.length === 0 && ( + + + {t("application:setting.noTags")} + + + )} + +
+
+ {t("application:setting.tagsDes")} +
+
+ + !loading && setEditTarget(undefined), + fullWidth: true, + maxWidth: "xs", + }} + > + + setNewName(e.target.value)} + /> + + + + + + + !loading && setDeleteTarget(undefined), + maxWidth: "xs", + }} + > + + + {t("application:setting.deleteTagDes", { name: deleteTarget?.name, count: deleteTarget?.file_count })} + + + +
+ ); +}; + +export default TagSetting; diff --git a/inventory/file.go b/inventory/file.go index 6ea970a0..81da392d 100644 --- a/inventory/file.go +++ b/inventory/file.go @@ -3,6 +3,7 @@ package inventory import ( "context" "fmt" + "slices" "strconv" "strings" "time" @@ -162,6 +163,13 @@ type ( 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 { Files []*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 // DeleteAllMetadataByName deletes all metadata by a given name 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 { @@ -362,6 +381,84 @@ func (f *fileClient) DeleteAllMetadataByName(ctx context.Context, name string) e 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) { return f.indexableFilesQuery().Count(ctx) } diff --git a/inventory/metadata_tag_test.go b/inventory/metadata_tag_test.go new file mode 100644 index 00000000..0f198dcc --- /dev/null +++ b/inventory/metadata_tag_test.go @@ -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) +} diff --git a/pkg/filemanager/manager/metadata.go b/pkg/filemanager/manager/metadata.go index a5293c18..adaeb9a7 100644 --- a/pkg/filemanager/manager/metadata.go +++ b/pkg/filemanager/manager/metadata.go @@ -27,6 +27,7 @@ const ( wildcardMetadataKey = "*" customizeMetadataSuffix = "customize" tagMetadataSuffix = "tag" + TagMetadataPrefix = tagMetadataSuffix + ":" customPropsMetadataSuffix = "props" iconColorMetadataKey = customizeMetadataSuffix + ":icon_color" emojiIconMetadataKey = customizeMetadataSuffix + ":emoji" diff --git a/routers/controllers/file.go b/routers/controllers/file.go index ccaa5883..59bd3344 100644 --- a/routers/controllers/file.go +++ b/routers/controllers/file.go @@ -353,6 +353,41 @@ func PatchMetadata(c *gin.Context) { 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. func ListAcl(c *gin.Context) { service := ParametersFromContext[*explorer.AclListService](c, explorer.AclListParamCtx{}) diff --git a/routers/router.go b/routers/router.go index 695dad86..02181cb7 100644 --- a/routers/router.go +++ b/routers/router.go @@ -764,6 +764,21 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { middleware.ValidateBatchFileCount(dep, explorer.PatchMetadataParameterCtx{}), 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 file.GET("policy", controllers.FromQuery[explorer.AllowedPolicyService](explorer.AllowedPolicyParamCtx{}), diff --git a/routers/router_test.go b/routers/router_test.go index fbcff16e..b5a52403 100644 --- a/routers/router_test.go +++ b/routers/router_test.go @@ -51,6 +51,9 @@ func TestMasterRouteWiring(t *testing.T) { "GET /api/v4/user/info/:id", "PUT /api/v4/file/upload", "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", "GET /api/v4/session/qq/login", "GET /api/v4/session/qq/callback", diff --git a/service/explorer/tag.go b/service/explorer/tag.go new file mode 100644 index 00000000..1614eec3 --- /dev/null +++ b/service/explorer/tag.go @@ -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 +}