From 3cda4d1ef74645511234b6ac29cd61bc5f6b83cd Mon Sep 17 00:00:00 2001 From: Aaron Liu Date: Sat, 12 Jul 2025 11:15:33 +0800 Subject: [PATCH] feat(fs): custom properties for files (#2407) --- assets | 2 +- inventory/file.go | 8 +- inventory/file_utils.go | 18 +++- inventory/setting.go | 22 +++++ inventory/types/types.go | 70 +++++++++----- pkg/filemanager/fs/uri.go | 16 +++- pkg/filemanager/manager/metadata.go | 141 ++++++++++++++++++++++++++-- pkg/setting/provider.go | 11 +++ service/basic/site.go | 3 + 9 files changed, 252 insertions(+), 39 deletions(-) diff --git a/assets b/assets index e9b91c4..ada49fd 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit e9b91c4e03654d5968f8a676a13fc4badf530b5d +Subproject commit ada49fd21d159563b21d29d7d3499a45c5ab1503 diff --git a/inventory/file.go b/inventory/file.go index b94d57f..ac88eac 100644 --- a/inventory/file.go +++ b/inventory/file.go @@ -59,11 +59,17 @@ type ( StoragePolicyID int } + MetadataFilter struct { + Key string + Value string + Exact bool + } + SearchFileParameters struct { Name []string // NameOperatorOr is true if the name should match any of the given names, false if all of them NameOperatorOr bool - Metadata map[string]string + Metadata []MetadataFilter Type *types.FileType UseFullText bool CaseFolding bool diff --git a/inventory/file_utils.go b/inventory/file_utils.go index f141c97..890d419 100644 --- a/inventory/file_utils.go +++ b/inventory/file_utils.go @@ -16,6 +16,10 @@ import ( "github.com/samber/lo" ) +const ( + metadataExactMatchPrefix = "!exact:" +) + func (f *fileClient) searchQuery(q *ent.FileQuery, args *SearchFileParameters, parents []*ent.File, ownerId int) *ent.FileQuery { if len(parents) == 1 && parents[0] == nil { q = q.Where(file.OwnerID(ownerId)) @@ -69,13 +73,17 @@ func (f *fileClient) searchQuery(q *ent.FileQuery, args *SearchFileParameters, p } if len(args.Metadata) > 0 { - metaPredicates := lo.MapToSlice(args.Metadata, func(name string, value string) predicate.Metadata { - nameEq := metadata.NameEQ(value) - if name == "" { + metaPredicates := lo.Map(args.Metadata, func(item MetadataFilter, index int) predicate.Metadata { + if item.Exact { + return metadata.And(metadata.NameEQ(item.Key), metadata.ValueEQ(item.Value)) + } + + nameEq := metadata.NameEQ(item.Key) + if item.Value == "" { return nameEq } else { - valueContain := metadata.ValueContainsFold(value) - return metadata.And(metadata.NameEQ(name), valueContain) + valueContain := metadata.ValueContainsFold(item.Value) + return metadata.And(nameEq, valueContain) } }) metaPredicates = append(metaPredicates, metadata.IsPublic(true)) diff --git a/inventory/setting.go b/inventory/setting.go index d8dd0b5..cf5c2dd 100644 --- a/inventory/setting.go +++ b/inventory/setting.go @@ -324,6 +324,22 @@ var ( }, }, } + + defaultFileProps = []types.CustomProps{ + { + ID: "description", + Type: types.CustomPropsTypeText, + Name: "fileManager.description", + Icon: "fluent:slide-text-24-filled", + }, + { + ID: "rating", + Type: types.CustomPropsTypeRating, + Name: "fileManager.rating", + Icon: "fluent:data-bar-vertical-star-24-filled", + Max: 5, + }, + } ) var DefaultSettings = map[string]string{ @@ -516,4 +532,10 @@ func init() { } DefaultSettings["file_viewers"] = string(viewers) + + customProps, err := json.Marshal(defaultFileProps) + if err != nil { + panic(err) + } + DefaultSettings["custom_props"] = string(customProps) } diff --git a/inventory/types/types.go b/inventory/types/types.go index 8bc383a..2aaf51d 100644 --- a/inventory/types/types.go +++ b/inventory/types/types.go @@ -173,7 +173,8 @@ type ( } ColumTypeProps struct { - MetadataKey string `json:"metadata_key,omitempty" binding:"max=255"` + MetadataKey string `json:"metadata_key,omitempty" binding:"max=255"` + CustomPropsID string `json:"custom_props_id,omitempty" binding:"max=255"` } ShareProps struct { @@ -278,26 +279,51 @@ const ( ViewerTypeCustom = "custom" ) -type Viewer struct { - ID string `json:"id"` - Type ViewerType `json:"type"` - DisplayName string `json:"display_name"` - Exts []string `json:"exts"` - Url string `json:"url,omitempty"` - Icon string `json:"icon,omitempty"` - WopiActions map[string]map[ViewerAction]string `json:"wopi_actions,omitempty"` - Props map[string]string `json:"props,omitempty"` - MaxSize int64 `json:"max_size,omitempty"` - Disabled bool `json:"disabled,omitempty"` - Templates []NewFileTemplate `json:"templates,omitempty"` - Platform string `json:"platform,omitempty"` -} +type ( + Viewer struct { + ID string `json:"id"` + Type ViewerType `json:"type"` + DisplayName string `json:"display_name"` + Exts []string `json:"exts"` + Url string `json:"url,omitempty"` + Icon string `json:"icon,omitempty"` + WopiActions map[string]map[ViewerAction]string `json:"wopi_actions,omitempty"` + Props map[string]string `json:"props,omitempty"` + MaxSize int64 `json:"max_size,omitempty"` + Disabled bool `json:"disabled,omitempty"` + Templates []NewFileTemplate `json:"templates,omitempty"` + Platform string `json:"platform,omitempty"` + } + ViewerGroup struct { + Viewers []Viewer `json:"viewers"` + } -type ViewerGroup struct { - Viewers []Viewer `json:"viewers"` -} + NewFileTemplate struct { + Ext string `json:"ext"` + DisplayName string `json:"display_name"` + } +) -type NewFileTemplate struct { - Ext string `json:"ext"` - DisplayName string `json:"display_name"` -} +type ( + CustomPropsType string + CustomProps struct { + ID string `json:"id"` + Name string `json:"name"` + Type CustomPropsType `json:"type"` + Max int `json:"max,omitempty"` + Min int `json:"min,omitempty"` + Default string `json:"default,omitempty"` + Options []string `json:"options,omitempty"` + Icon string `json:"icon,omitempty"` + } +) + +const ( + CustomPropsTypeText = "text" + CustomPropsTypeNumber = "number" + CustomPropsTypeBoolean = "boolean" + CustomPropsTypeSelect = "select" + CustomPropsTypeMultiSelect = "multi_select" + CustomPropsTypeLink = "link" + CustomPropsTypeRating = "rating" +) diff --git a/pkg/filemanager/fs/uri.go b/pkg/filemanager/fs/uri.go index 9e1e3a4..3f5bb14 100644 --- a/pkg/filemanager/fs/uri.go +++ b/pkg/filemanager/fs/uri.go @@ -25,6 +25,7 @@ const ( QuerySearchNameOpOr = "name_op_or" QuerySearchUseOr = "use_or" QuerySearchMetadataPrefix = "meta_" + QuerySearchMetadataExact = "exact_meta_" QuerySearchCaseFolding = "case_folding" QuerySearchType = "type" QuerySearchTypeCategory = "category" @@ -218,7 +219,7 @@ func (u *URI) FileSystem() constants.FileSystemType { func (u *URI) SearchParameters() *inventory.SearchFileParameters { q := u.U.Query() res := &inventory.SearchFileParameters{ - Metadata: make(map[string]string), + Metadata: make([]inventory.MetadataFilter, 0), } withSearch := false @@ -252,7 +253,18 @@ func (u *URI) SearchParameters() *inventory.SearchFileParameters { for k, v := range q { if strings.HasPrefix(k, QuerySearchMetadataPrefix) { - res.Metadata[strings.TrimPrefix(k, QuerySearchMetadataPrefix)] = v[0] + res.Metadata = append(res.Metadata, inventory.MetadataFilter{ + Key: strings.TrimPrefix(k, QuerySearchMetadataPrefix), + Value: v[0], + Exact: false, + }) + withSearch = true + } else if strings.HasPrefix(k, QuerySearchMetadataExact) { + res.Metadata = append(res.Metadata, inventory.MetadataFilter{ + Key: strings.TrimPrefix(k, QuerySearchMetadataExact), + Value: v[0], + Exact: true, + }) withSearch = true } } diff --git a/pkg/filemanager/manager/metadata.go b/pkg/filemanager/manager/metadata.go index b6ae460..9f6cb39 100644 --- a/pkg/filemanager/manager/metadata.go +++ b/pkg/filemanager/manager/metadata.go @@ -5,14 +5,18 @@ import ( "crypto/sha1" "encoding/json" "fmt" + "strconv" + "strings" + "github.com/cloudreve/Cloudreve/v4/application/constants" "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/inventory/types" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs" "github.com/cloudreve/Cloudreve/v4/pkg/hashid" "github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/go-playground/validator/v10" - "strings" + "github.com/samber/lo" ) type ( @@ -20,13 +24,14 @@ type ( ) const ( - wildcardMetadataKey = "*" - customizeMetadataSuffix = "customize" - tagMetadataSuffix = "tag" - iconColorMetadataKey = customizeMetadataSuffix + ":icon_color" - emojiIconMetadataKey = customizeMetadataSuffix + ":emoji" - shareOwnerMetadataKey = dbfs.MetadataSysPrefix + "shared_owner" - shareRedirectMetadataKey = dbfs.MetadataSysPrefix + "shared_redirect" + wildcardMetadataKey = "*" + customizeMetadataSuffix = "customize" + tagMetadataSuffix = "tag" + customPropsMetadataSuffix = "props" + iconColorMetadataKey = customizeMetadataSuffix + ":icon_color" + emojiIconMetadataKey = customizeMetadataSuffix + ":emoji" + shareOwnerMetadataKey = dbfs.MetadataSysPrefix + "shared_owner" + shareRedirectMetadataKey = dbfs.MetadataSysPrefix + "shared_redirect" ) var ( @@ -131,6 +136,126 @@ var ( return nil }, }, + customPropsMetadataSuffix: { + wildcardMetadataKey: func(ctx context.Context, m *manager, patch *fs.MetadataPatch) error { + if patch.Remove { + return nil + } + + customProps := m.settings.CustomProps(ctx) + propId := strings.TrimPrefix(patch.Key, customPropsMetadataSuffix+":") + for _, prop := range customProps { + if prop.ID == propId { + switch prop.Type { + case types.CustomPropsTypeText: + if prop.Min > 0 && prop.Min > len(patch.Value) { + return fmt.Errorf("value is too short") + } + if prop.Max > 0 && prop.Max < len(patch.Value) { + return fmt.Errorf("value is too long") + } + + return nil + case types.CustomPropsTypeRating: + if patch.Value == "" { + return nil + } + + // validate the value is a number + rating, err := strconv.Atoi(patch.Value) + if err != nil { + return fmt.Errorf("value is not a number") + } + + if prop.Max < rating { + return fmt.Errorf("value is too large") + } + + return nil + + case types.CustomPropsTypeNumber: + if patch.Value == "" { + return nil + } + + value, err := strconv.Atoi(patch.Value) + if err != nil { + return fmt.Errorf("value is not a number") + } + + if prop.Min > value { + return fmt.Errorf("value is too small") + } + if prop.Max > 0 && prop.Max < value { + return fmt.Errorf("value is too large") + } + + return nil + + case types.CustomPropsTypeBoolean: + if patch.Value == "" { + return nil + } + + if patch.Value != "true" && patch.Value != "false" { + return fmt.Errorf("value is not a boolean") + } + + return nil + case types.CustomPropsTypeSelect: + if patch.Value == "" { + return nil + } + + for _, option := range prop.Options { + if option == patch.Value { + return nil + } + } + + return fmt.Errorf("invalid option") + case types.CustomPropsTypeMultiSelect: + if patch.Value == "" { + return nil + } + + var values []string + if err := json.Unmarshal([]byte(patch.Value), &values); err != nil { + return fmt.Errorf("invalid multi select value: %w", err) + } + + // make sure all values are in the options + for _, value := range values { + if !lo.Contains(prop.Options, value) { + return fmt.Errorf("invalid option") + } + } + + return nil + + case types.CustomPropsTypeLink: + if patch.Value == "" { + return nil + } + + if prop.Min > 0 && len(patch.Value) < prop.Min { + return fmt.Errorf("value is too small") + } + + if prop.Max > 0 && len(patch.Value) > prop.Max { + return fmt.Errorf("value is too large") + } + + return nil + default: + return nil + } + } + } + + return fmt.Errorf("unkown custom props") + }, + }, } ) diff --git a/pkg/setting/provider.go b/pkg/setting/provider.go index 09aacbe..b234c3d 100644 --- a/pkg/setting/provider.go +++ b/pkg/setting/provider.go @@ -196,6 +196,8 @@ type ( LibRawThumbExts(ctx context.Context) []string // LibRawThumbPath returns the path of libraw executable. LibRawThumbPath(ctx context.Context) string + // CustomProps returns the custom props settings. + CustomProps(ctx context.Context) []types.CustomProps } UseFirstSiteUrlCtxKey = struct{} ) @@ -223,6 +225,15 @@ type ( } ) +func (s *settingProvider) CustomProps(ctx context.Context) []types.CustomProps { + raw := s.getString(ctx, "custom_props", "[]") + var props []types.CustomProps + if err := json.Unmarshal([]byte(raw), &props); err != nil { + return []types.CustomProps{} + } + return props +} + func (s *settingProvider) License(ctx context.Context) string { return s.getString(ctx, "license", "") } diff --git a/service/basic/site.go b/service/basic/site.go index aface24..5af8fae 100644 --- a/service/basic/site.go +++ b/service/basic/site.go @@ -45,6 +45,7 @@ type SiteConfig struct { MaxBatchSize int `json:"max_batch_size,omitempty"` ThumbnailWidth int `json:"thumbnail_width,omitempty"` ThumbnailHeight int `json:"thumbnail_height,omitempty"` + CustomProps []types.CustomProps `json:"custom_props,omitempty"` // App settings AppPromotion bool `json:"app_promotion,omitempty"` @@ -87,6 +88,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) { explorerSettings := settings.ExplorerFrontendSettings(c) mapSettings := settings.MapSetting(c) fileViewers := settings.FileViewers(c) + customProps := settings.CustomProps(c) maxBatchSize := settings.MaxBatchedFile(c) w, h := settings.ThumbSize(c) for i := range fileViewers { @@ -102,6 +104,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) { GoogleMapTileType: mapSettings.GoogleTileType, ThumbnailWidth: w, ThumbnailHeight: h, + CustomProps: customProps, }, nil case "emojis": emojis := settings.EmojiPresets(c)