mirror of https://github.com/cloudreve/Cloudreve
feat(fs): custom properties for files (#2407)
parent
b13490357b
commit
3cda4d1ef7
2
assets
2
assets
|
@ -1 +1 @@
|
||||||
Subproject commit e9b91c4e03654d5968f8a676a13fc4badf530b5d
|
Subproject commit ada49fd21d159563b21d29d7d3499a45c5ab1503
|
|
@ -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
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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", "")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue