feat(fs): custom properties for files (#2407)

pull/2693/head
Aaron Liu 2025-07-12 11:15:33 +08:00
parent b13490357b
commit 3cda4d1ef7
9 changed files with 252 additions and 39 deletions

2
assets

@ -1 +1 @@
Subproject commit e9b91c4e03654d5968f8a676a13fc4badf530b5d Subproject commit ada49fd21d159563b21d29d7d3499a45c5ab1503

View File

@ -59,11 +59,17 @@ type (
StoragePolicyID int StoragePolicyID int
} }
MetadataFilter struct {
Key string
Value string
Exact bool
}
SearchFileParameters struct { SearchFileParameters struct {
Name []string Name []string
// NameOperatorOr is true if the name should match any of the given names, false if all of them // NameOperatorOr is true if the name should match any of the given names, false if all of them
NameOperatorOr bool NameOperatorOr bool
Metadata map[string]string Metadata []MetadataFilter
Type *types.FileType Type *types.FileType
UseFullText bool UseFullText bool
CaseFolding bool CaseFolding bool

View File

@ -16,6 +16,10 @@ import (
"github.com/samber/lo" "github.com/samber/lo"
) )
const (
metadataExactMatchPrefix = "!exact:"
)
func (f *fileClient) searchQuery(q *ent.FileQuery, args *SearchFileParameters, parents []*ent.File, ownerId int) *ent.FileQuery { func (f *fileClient) searchQuery(q *ent.FileQuery, args *SearchFileParameters, parents []*ent.File, ownerId int) *ent.FileQuery {
if len(parents) == 1 && parents[0] == nil { if len(parents) == 1 && parents[0] == nil {
q = q.Where(file.OwnerID(ownerId)) 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 { if len(args.Metadata) > 0 {
metaPredicates := lo.MapToSlice(args.Metadata, func(name string, value string) predicate.Metadata { metaPredicates := lo.Map(args.Metadata, func(item MetadataFilter, index int) predicate.Metadata {
nameEq := metadata.NameEQ(value) if item.Exact {
if name == "" { return metadata.And(metadata.NameEQ(item.Key), metadata.ValueEQ(item.Value))
}
nameEq := metadata.NameEQ(item.Key)
if item.Value == "" {
return nameEq return nameEq
} else { } else {
valueContain := metadata.ValueContainsFold(value) valueContain := metadata.ValueContainsFold(item.Value)
return metadata.And(metadata.NameEQ(name), valueContain) return metadata.And(nameEq, valueContain)
} }
}) })
metaPredicates = append(metaPredicates, metadata.IsPublic(true)) metaPredicates = append(metaPredicates, metadata.IsPublic(true))

View File

@ -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{ var DefaultSettings = map[string]string{
@ -516,4 +532,10 @@ func init() {
} }
DefaultSettings["file_viewers"] = string(viewers) DefaultSettings["file_viewers"] = string(viewers)
customProps, err := json.Marshal(defaultFileProps)
if err != nil {
panic(err)
}
DefaultSettings["custom_props"] = string(customProps)
} }

View File

@ -173,7 +173,8 @@ type (
} }
ColumTypeProps struct { 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 { ShareProps struct {
@ -278,26 +279,51 @@ const (
ViewerTypeCustom = "custom" ViewerTypeCustom = "custom"
) )
type Viewer struct { type (
ID string `json:"id"` Viewer struct {
Type ViewerType `json:"type"` ID string `json:"id"`
DisplayName string `json:"display_name"` Type ViewerType `json:"type"`
Exts []string `json:"exts"` DisplayName string `json:"display_name"`
Url string `json:"url,omitempty"` Exts []string `json:"exts"`
Icon string `json:"icon,omitempty"` Url string `json:"url,omitempty"`
WopiActions map[string]map[ViewerAction]string `json:"wopi_actions,omitempty"` Icon string `json:"icon,omitempty"`
Props map[string]string `json:"props,omitempty"` WopiActions map[string]map[ViewerAction]string `json:"wopi_actions,omitempty"`
MaxSize int64 `json:"max_size,omitempty"` Props map[string]string `json:"props,omitempty"`
Disabled bool `json:"disabled,omitempty"` MaxSize int64 `json:"max_size,omitempty"`
Templates []NewFileTemplate `json:"templates,omitempty"` Disabled bool `json:"disabled,omitempty"`
Platform string `json:"platform,omitempty"` Templates []NewFileTemplate `json:"templates,omitempty"`
} Platform string `json:"platform,omitempty"`
}
ViewerGroup struct {
Viewers []Viewer `json:"viewers"`
}
type ViewerGroup struct { NewFileTemplate struct {
Viewers []Viewer `json:"viewers"` Ext string `json:"ext"`
} DisplayName string `json:"display_name"`
}
)
type NewFileTemplate struct { type (
Ext string `json:"ext"` CustomPropsType string
DisplayName string `json:"display_name"` 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"
)

View File

@ -25,6 +25,7 @@ const (
QuerySearchNameOpOr = "name_op_or" QuerySearchNameOpOr = "name_op_or"
QuerySearchUseOr = "use_or" QuerySearchUseOr = "use_or"
QuerySearchMetadataPrefix = "meta_" QuerySearchMetadataPrefix = "meta_"
QuerySearchMetadataExact = "exact_meta_"
QuerySearchCaseFolding = "case_folding" QuerySearchCaseFolding = "case_folding"
QuerySearchType = "type" QuerySearchType = "type"
QuerySearchTypeCategory = "category" QuerySearchTypeCategory = "category"
@ -218,7 +219,7 @@ func (u *URI) FileSystem() constants.FileSystemType {
func (u *URI) SearchParameters() *inventory.SearchFileParameters { func (u *URI) SearchParameters() *inventory.SearchFileParameters {
q := u.U.Query() q := u.U.Query()
res := &inventory.SearchFileParameters{ res := &inventory.SearchFileParameters{
Metadata: make(map[string]string), Metadata: make([]inventory.MetadataFilter, 0),
} }
withSearch := false withSearch := false
@ -252,7 +253,18 @@ func (u *URI) SearchParameters() *inventory.SearchFileParameters {
for k, v := range q { for k, v := range q {
if strings.HasPrefix(k, QuerySearchMetadataPrefix) { 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 withSearch = true
} }
} }

View File

@ -5,14 +5,18 @@ import (
"crypto/sha1" "crypto/sha1"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"strings"
"github.com/cloudreve/Cloudreve/v4/application/constants" "github.com/cloudreve/Cloudreve/v4/application/constants"
"github.com/cloudreve/Cloudreve/v4/application/dependency" "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"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid" "github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"strings" "github.com/samber/lo"
) )
type ( type (
@ -20,13 +24,14 @@ type (
) )
const ( const (
wildcardMetadataKey = "*" wildcardMetadataKey = "*"
customizeMetadataSuffix = "customize" customizeMetadataSuffix = "customize"
tagMetadataSuffix = "tag" tagMetadataSuffix = "tag"
iconColorMetadataKey = customizeMetadataSuffix + ":icon_color" customPropsMetadataSuffix = "props"
emojiIconMetadataKey = customizeMetadataSuffix + ":emoji" iconColorMetadataKey = customizeMetadataSuffix + ":icon_color"
shareOwnerMetadataKey = dbfs.MetadataSysPrefix + "shared_owner" emojiIconMetadataKey = customizeMetadataSuffix + ":emoji"
shareRedirectMetadataKey = dbfs.MetadataSysPrefix + "shared_redirect" shareOwnerMetadataKey = dbfs.MetadataSysPrefix + "shared_owner"
shareRedirectMetadataKey = dbfs.MetadataSysPrefix + "shared_redirect"
) )
var ( var (
@ -131,6 +136,126 @@ var (
return nil 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")
},
},
} }
) )

View File

@ -196,6 +196,8 @@ type (
LibRawThumbExts(ctx context.Context) []string LibRawThumbExts(ctx context.Context) []string
// LibRawThumbPath returns the path of libraw executable. // LibRawThumbPath returns the path of libraw executable.
LibRawThumbPath(ctx context.Context) string LibRawThumbPath(ctx context.Context) string
// CustomProps returns the custom props settings.
CustomProps(ctx context.Context) []types.CustomProps
} }
UseFirstSiteUrlCtxKey = struct{} 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 { func (s *settingProvider) License(ctx context.Context) string {
return s.getString(ctx, "license", "") return s.getString(ctx, "license", "")
} }

View File

@ -45,6 +45,7 @@ type SiteConfig struct {
MaxBatchSize int `json:"max_batch_size,omitempty"` MaxBatchSize int `json:"max_batch_size,omitempty"`
ThumbnailWidth int `json:"thumbnail_width,omitempty"` ThumbnailWidth int `json:"thumbnail_width,omitempty"`
ThumbnailHeight int `json:"thumbnail_height,omitempty"` ThumbnailHeight int `json:"thumbnail_height,omitempty"`
CustomProps []types.CustomProps `json:"custom_props,omitempty"`
// App settings // App settings
AppPromotion bool `json:"app_promotion,omitempty"` AppPromotion bool `json:"app_promotion,omitempty"`
@ -87,6 +88,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
explorerSettings := settings.ExplorerFrontendSettings(c) explorerSettings := settings.ExplorerFrontendSettings(c)
mapSettings := settings.MapSetting(c) mapSettings := settings.MapSetting(c)
fileViewers := settings.FileViewers(c) fileViewers := settings.FileViewers(c)
customProps := settings.CustomProps(c)
maxBatchSize := settings.MaxBatchedFile(c) maxBatchSize := settings.MaxBatchedFile(c)
w, h := settings.ThumbSize(c) w, h := settings.ThumbSize(c)
for i := range fileViewers { for i := range fileViewers {
@ -102,6 +104,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
GoogleMapTileType: mapSettings.GoogleTileType, GoogleMapTileType: mapSettings.GoogleTileType,
ThumbnailWidth: w, ThumbnailWidth: w,
ThumbnailHeight: h, ThumbnailHeight: h,
CustomProps: customProps,
}, nil }, nil
case "emojis": case "emojis":
emojis := settings.EmojiPresets(c) emojis := settings.EmojiPresets(c)