feat(explorer): save user's view setting to server / optionally share view setting via share link (#2232)

This commit is contained in:
Aaron Liu
2025-06-05 10:00:37 +08:00
parent c13b7365b0
commit 522fcca6af
31 changed files with 704 additions and 158 deletions

View File

@@ -35,6 +35,7 @@ const (
ContextHintTTL = 5 * 60 // 5 minutes
folderSummaryCachePrefix = "folder_summary_"
defaultPageSize = 100
)
type (
@@ -119,17 +120,46 @@ func (f *DBFS) List(ctx context.Context, path *fs.URI, opts ...fs.Option) (fs.Fi
searchParams := path.SearchParameters()
isSearching := searchParams != nil
// Validate pagination args
props := navigator.Capabilities(isSearching)
if o.PageSize > props.MaxPageSize {
o.PageSize = props.MaxPageSize
}
parent, err := f.getFileByPath(ctx, navigator, path)
if err != nil {
return nil, nil, fmt.Errorf("Parent not exist: %w", err)
}
pageSize := 0
orderDirection := ""
orderBy := ""
view := navigator.GetView(ctx, parent)
if view != nil {
pageSize = view.PageSize
orderDirection = view.OrderDirection
orderBy = view.Order
}
if o.PageSize > 0 {
pageSize = o.PageSize
}
if o.OrderDirection != "" {
orderDirection = o.OrderDirection
}
if o.OrderBy != "" {
orderBy = o.OrderBy
}
// Validate pagination args
props := navigator.Capabilities(isSearching)
if pageSize > props.MaxPageSize {
pageSize = props.MaxPageSize
} else if pageSize == 0 {
pageSize = defaultPageSize
}
if view != nil {
view.PageSize = pageSize
view.OrderDirection = orderDirection
view.Order = orderBy
}
var hintId *uuid.UUID
if o.generateContextHint {
newHintId := uuid.Must(uuid.NewV4())
@@ -155,9 +185,9 @@ func (f *DBFS) List(ctx context.Context, path *fs.URI, opts ...fs.Option) (fs.Fi
children, err := navigator.Children(ctx, parent, &ListArgs{
Page: &inventory.PaginationArgs{
Page: o.FsOption.Page,
PageSize: o.PageSize,
OrderBy: o.OrderBy,
Order: inventory.OrderDirection(o.OrderDirection),
PageSize: pageSize,
OrderBy: orderBy,
Order: inventory.OrderDirection(orderDirection),
UseCursorPagination: o.useCursorPagination,
PageToken: o.pageToken,
},
@@ -188,6 +218,7 @@ func (f *DBFS) List(ctx context.Context, path *fs.URI, opts ...fs.Option) (fs.Fi
SingleFileView: children.SingleFileView,
Parent: parent,
StoragePolicy: storagePolicy,
View: view,
}, nil
}
@@ -270,89 +301,6 @@ func (f *DBFS) CreateEntity(ctx context.Context, file fs.File, policy *ent.Stora
return fs.NewEntity(entity), nil
}
func (f *DBFS) PatchMetadata(ctx context.Context, path []*fs.URI, metas ...fs.MetadataPatch) error {
ae := serializer.NewAggregateError()
targets := make([]*File, 0, len(path))
for _, p := range path {
navigator, err := f.getNavigator(ctx, p, NavigatorCapabilityUpdateMetadata, NavigatorCapabilityLockFile)
if err != nil {
ae.Add(p.String(), err)
continue
}
target, err := f.getFileByPath(ctx, navigator, p)
if err != nil {
ae.Add(p.String(), fmt.Errorf("failed to get target file: %w", err))
continue
}
// Require Update permission
if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && target.OwnerID() != f.user.ID {
return fs.ErrOwnerOnly.WithError(fmt.Errorf("permission denied"))
}
if target.IsRootFolder() {
ae.Add(p.String(), fs.ErrNotSupportedAction.WithError(fmt.Errorf("cannot move root folder")))
continue
}
targets = append(targets, target)
}
if len(targets) == 0 {
return ae.Aggregate()
}
// Lock all targets
lockTargets := lo.Map(targets, func(value *File, key int) *LockByPath {
return &LockByPath{value.Uri(true), value, value.Type(), ""}
})
ls, err := f.acquireByPath(ctx, -1, f.user, true, fs.LockApp(fs.ApplicationUpdateMetadata), lockTargets...)
defer func() { _ = f.Release(ctx, ls) }()
if err != nil {
return err
}
metadataMap := make(map[string]string)
privateMap := make(map[string]bool)
deleted := make([]string, 0)
for _, meta := range metas {
if meta.Remove {
deleted = append(deleted, meta.Key)
continue
}
metadataMap[meta.Key] = meta.Value
if meta.Private {
privateMap[meta.Key] = meta.Private
}
}
fc, tx, ctx, err := inventory.WithTx(ctx, f.fileClient)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to start transaction", err)
}
for _, target := range targets {
if err := fc.UpsertMetadata(ctx, target.Model, metadataMap, privateMap); err != nil {
_ = inventory.Rollback(tx)
return fmt.Errorf("failed to upsert metadata: %w", err)
}
if len(deleted) > 0 {
if err := fc.RemoveMetadata(ctx, target.Model, deleted...); err != nil {
_ = inventory.Rollback(tx)
return fmt.Errorf("failed to remove metadata: %w", err)
}
}
}
if err := inventory.Commit(tx); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to commit metadata change", err)
}
return ae.Aggregate()
}
func (f *DBFS) SharedAddressTranslation(ctx context.Context, path *fs.URI, opts ...fs.Option) (fs.File, *fs.URI, error) {
o := newDbfsOption()
for _, opt := range opts {
@@ -470,6 +418,9 @@ func (f *DBFS) Get(ctx context.Context, path *fs.URI, opts ...fs.Option) (fs.Fil
target.FileExtendedInfo = extendedInfo
if target.OwnerID() == f.user.ID || f.user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) {
target.FileExtendedInfo.Shares = target.Model.Edges.Shares
if target.Model.Props != nil {
target.FileExtendedInfo.View = target.Model.Props.View
}
}
entities := target.Entities()

View File

@@ -22,13 +22,20 @@ func init() {
gob.Register(map[int]*File{})
}
var filePool = &sync.Pool{
New: func() any {
return &File{
Children: make(map[string]*File),
}
},
}
var (
filePool = &sync.Pool{
New: func() any {
return &File{
Children: make(map[string]*File),
}
},
}
defaultView = &types.ExplorerView{
PageSize: defaultPageSize,
View: "grid",
Thumbnail: true,
}
)
type (
File struct {
@@ -42,7 +49,8 @@ type (
FileExtendedInfo *fs.FileExtendedInfo
FileFolderSummary *fs.FolderSummary
mu *sync.Mutex
disableView bool
mu *sync.Mutex
}
)
@@ -181,6 +189,31 @@ func (f *File) Uri(isRoot bool) *fs.URI {
return parent.Path[index].Join(elements...)
}
// View returns the view setting of the file, can be inherited from parent.
func (f *File) View() *types.ExplorerView {
// If owner has disabled view sync, return nil
owner := f.Owner()
if owner != nil && owner.Settings != nil && owner.Settings.DisableViewSync {
return nil
}
// If navigator has disabled view sync, return nil
userRoot := f.UserRoot()
if userRoot == nil || userRoot.disableView {
return nil
}
current := f
for current != nil {
if current.Model.Props != nil && current.Model.Props.View != nil {
return current.Model.Props.View
}
current = current.Parent
}
return defaultView
}
// UserRoot return the root file from user's view.
func (f *File) UserRoot() *File {
root := f

View File

@@ -106,6 +106,7 @@ func (n *myNavigator) To(ctx context.Context, path *fs.URI) (*File, error) {
rootPath := path.Root()
n.root.Path[pathIndexRoot], n.root.Path[pathIndexUser] = rootPath, rootPath
n.root.OwnerModel = targetUser
n.root.disableView = fsUid != n.user.ID
n.root.IsUserRoot = true
n.root.CapabilitiesBs = n.Capabilities(false).Capability
}
@@ -178,3 +179,7 @@ func (n *myNavigator) FollowTx(ctx context.Context) (func(), error) {
func (n *myNavigator) ExecuteHook(ctx context.Context, hookType fs.HookType, file *File) error {
return nil
}
func (n *myNavigator) GetView(ctx context.Context, file *File) *types.ExplorerView {
return file.View()
}

View File

@@ -53,6 +53,8 @@ type (
FollowTx(ctx context.Context) (func(), error)
// ExecuteHook performs custom operations before or after certain actions.
ExecuteHook(ctx context.Context, hookType fs.HookType, file *File) error
// GetView returns the view setting of the given file.
GetView(ctx context.Context, file *File) *types.ExplorerView
}
State interface{}
@@ -100,6 +102,7 @@ const (
NavigatorCapability_CommunityPlacehodler8
NavigatorCapability_CommunityPlacehodler9
NavigatorCapabilityEnterFolder
NavigatorCapabilityModifyProps
searchTokenSeparator = "|"
)
@@ -120,6 +123,7 @@ func init() {
NavigatorCapabilityInfo: true,
NavigatorCapabilityVersionControl: true,
NavigatorCapabilityEnterFolder: true,
NavigatorCapabilityModifyProps: true,
}, myNavigatorCapability)
boolset.Sets(map[NavigatorCapability]bool{
NavigatorCapabilityDownloadFile: true,
@@ -129,6 +133,7 @@ func init() {
NavigatorCapabilityInfo: true,
NavigatorCapabilityVersionControl: true,
NavigatorCapabilityEnterFolder: true,
NavigatorCapabilityModifyProps: true,
}, shareNavigatorCapability)
boolset.Sets(map[NavigatorCapability]bool{
NavigatorCapabilityListChildren: true,

View File

@@ -0,0 +1,138 @@
package dbfs
import (
"context"
"fmt"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/samber/lo"
)
func (f *DBFS) PatchProps(ctx context.Context, uri *fs.URI, props *types.FileProps, delete bool) error {
navigator, err := f.getNavigator(ctx, uri, NavigatorCapabilityModifyProps, NavigatorCapabilityLockFile)
if err != nil {
return err
}
target, err := f.getFileByPath(ctx, navigator, uri)
if err != nil {
return fmt.Errorf("failed to get target file: %w", err)
}
if target.OwnerID() != f.user.ID && !f.user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) {
return fs.ErrOwnerOnly.WithError(fmt.Errorf("only file owner can modify file props"))
}
// Lock target
lr := &LockByPath{target.Uri(true), target, target.Type(), ""}
ls, err := f.acquireByPath(ctx, -1, f.user, false, fs.LockApp(fs.ApplicationUpdateMetadata), lr)
defer func() { _ = f.Release(ctx, ls) }()
if err != nil {
return err
}
currentProps := target.Model.Props
if currentProps == nil {
currentProps = &types.FileProps{}
}
if props.View != nil {
if delete {
currentProps.View = nil
} else {
currentProps.View = props.View
}
}
if _, err := f.fileClient.UpdateProps(ctx, target.Model, currentProps); err != nil {
return serializer.NewError(serializer.CodeDBError, "failed to update file props", err)
}
return nil
}
func (f *DBFS) PatchMetadata(ctx context.Context, path []*fs.URI, metas ...fs.MetadataPatch) error {
ae := serializer.NewAggregateError()
targets := make([]*File, 0, len(path))
for _, p := range path {
navigator, err := f.getNavigator(ctx, p, NavigatorCapabilityUpdateMetadata, NavigatorCapabilityLockFile)
if err != nil {
ae.Add(p.String(), err)
continue
}
target, err := f.getFileByPath(ctx, navigator, p)
if err != nil {
ae.Add(p.String(), fmt.Errorf("failed to get target file: %w", err))
continue
}
// Require Update permission
if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && target.OwnerID() != f.user.ID {
return fs.ErrOwnerOnly.WithError(fmt.Errorf("permission denied"))
}
if target.IsRootFolder() {
ae.Add(p.String(), fs.ErrNotSupportedAction.WithError(fmt.Errorf("cannot move root folder")))
continue
}
targets = append(targets, target)
}
if len(targets) == 0 {
return ae.Aggregate()
}
// Lock all targets
lockTargets := lo.Map(targets, func(value *File, key int) *LockByPath {
return &LockByPath{value.Uri(true), value, value.Type(), ""}
})
ls, err := f.acquireByPath(ctx, -1, f.user, true, fs.LockApp(fs.ApplicationUpdateMetadata), lockTargets...)
defer func() { _ = f.Release(ctx, ls) }()
if err != nil {
return err
}
metadataMap := make(map[string]string)
privateMap := make(map[string]bool)
deleted := make([]string, 0)
for _, meta := range metas {
if meta.Remove {
deleted = append(deleted, meta.Key)
continue
}
metadataMap[meta.Key] = meta.Value
if meta.Private {
privateMap[meta.Key] = meta.Private
}
}
fc, tx, ctx, err := inventory.WithTx(ctx, f.fileClient)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to start transaction", err)
}
for _, target := range targets {
if err := fc.UpsertMetadata(ctx, target.Model, metadataMap, privateMap); err != nil {
_ = inventory.Rollback(tx)
return fmt.Errorf("failed to upsert metadata: %w", err)
}
if len(deleted) > 0 {
if err := fc.RemoveMetadata(ctx, target.Model, deleted...); err != nil {
_ = inventory.Rollback(tx)
return fmt.Errorf("failed to remove metadata: %w", err)
}
}
}
if err := inventory.Commit(tx); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to commit metadata change", err)
}
return ae.Aggregate()
}

View File

@@ -148,6 +148,7 @@ func (n *shareNavigator) Root(ctx context.Context, path *fs.URI) (*File, error)
n.shareRoot.Path[pathIndexUser] = path.Root()
n.shareRoot.OwnerModel = n.owner
n.shareRoot.IsUserRoot = true
n.shareRoot.disableView = (share.Props == nil || !share.Props.ShareView) && n.user.ID != n.owner.ID
n.shareRoot.CapabilitiesBs = n.Capabilities(false).Capability
// Check if any ancestors is deleted
@@ -303,3 +304,7 @@ func (n *shareNavigator) ExecuteHook(ctx context.Context, hookType fs.HookType,
func (n *shareNavigator) Walk(ctx context.Context, levelFiles []*File, limit, depth int, f WalkFunc) error {
return n.baseNavigator.walk(ctx, levelFiles, limit, depth, f)
}
func (n *shareNavigator) GetView(ctx context.Context, file *File) *types.ExplorerView {
return file.View()
}

View File

@@ -4,8 +4,11 @@ import (
"context"
"errors"
"fmt"
"github.com/cloudreve/Cloudreve/v4/application/constants"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/cache"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
@@ -139,3 +142,10 @@ func (n *sharedWithMeNavigator) FollowTx(ctx context.Context) (func(), error) {
func (n *sharedWithMeNavigator) ExecuteHook(ctx context.Context, hookType fs.HookType, file *File) error {
return nil
}
func (n *sharedWithMeNavigator) GetView(ctx context.Context, file *File) *types.ExplorerView {
if view, ok := n.user.Settings.FsViewMap[string(constants.FileSystemSharedWithMe)]; ok {
return &view
}
return defaultView
}

View File

@@ -3,8 +3,11 @@ package dbfs
import (
"context"
"fmt"
"github.com/cloudreve/Cloudreve/v4/application/constants"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/cache"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
@@ -13,7 +16,26 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
)
var trashNavigatorCapability = &boolset.BooleanSet{}
var (
trashNavigatorCapability = &boolset.BooleanSet{}
defaultTrashView = &types.ExplorerView{
View: "list",
Columns: []types.ListViewColumn{
{
Type: 0,
},
{
Type: 2,
},
{
Type: 8,
},
{
Type: 7,
},
},
}
)
// NewTrashNavigator creates a navigator for user's "trash" file system.
func NewTrashNavigator(u *ent.User, fileClient inventory.FileClient, l logging.Logger, config *setting.DBFS,
@@ -135,3 +157,10 @@ func (n *trashNavigator) FollowTx(ctx context.Context) (func(), error) {
func (n *trashNavigator) ExecuteHook(ctx context.Context, hookType fs.HookType, file *File) error {
return nil
}
func (n *trashNavigator) GetView(ctx context.Context, file *File) *types.ExplorerView {
if view, ok := n.user.Settings.FsViewMap[string(constants.FileSystemTrash)]; ok {
return &view
}
return defaultTrashView
}

View File

@@ -97,6 +97,8 @@ type (
GetFileFromDirectLink(ctx context.Context, dl *ent.DirectLink) (File, error)
// TraverseFile traverses a file to its root file, return the file with linked root.
TraverseFile(ctx context.Context, fileID int) (File, error)
// PatchProps patches the props of a file.
PatchProps(ctx context.Context, uri *URI, props *types.FileProps, delete bool) error
}
UploadManager interface {
@@ -165,6 +167,7 @@ type (
FolderSummary() *FolderSummary
Capabilities() *boolset.BooleanSet
IsRootFolder() bool
View() *types.ExplorerView
}
Entities []Entity
@@ -187,6 +190,7 @@ type (
StorageUsed int64
Shares []*ent.Share
EntityStoragePolicies map[int]*ent.StoragePolicy
View *types.ExplorerView
}
FolderSummary struct {
@@ -215,6 +219,7 @@ type (
MixedType bool
SingleFileView bool
StoragePolicy *ent.StoragePolicy
View *types.ExplorerView
}
// NavigatorProps is the properties of current filesystem.

View File

@@ -75,6 +75,8 @@ type (
CastStoragePolicyOnSlave(ctx context.Context, policy *ent.StoragePolicy) *ent.StoragePolicy
// GetStorageDriver gets storage driver for given policy
GetStorageDriver(ctx context.Context, policy *ent.StoragePolicy) (driver.Handler, error)
// PatchView patches the view setting of a file
PatchView(ctx context.Context, uri *fs.URI, view *types.ExplorerView) error
}
ShareManagement interface {
@@ -111,6 +113,7 @@ type (
IsPrivate bool
RemainDownloads int
Expire *time.Time
ShareView bool
}
)

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"time"
"github.com/cloudreve/Cloudreve/v4/application/constants"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
@@ -261,6 +262,10 @@ func (l *manager) CreateOrUpdateShare(ctx context.Context, path *fs.URI, args *C
password = util.RandString(8, util.RandomLowerCases)
}
props := &types.ShareProps{
ShareView: args.ShareView,
}
share, err := shareClient.Upsert(ctx, &inventory.CreateShareParams{
OwnerID: file.OwnerID(),
FileID: file.ID(),
@@ -268,6 +273,7 @@ func (l *manager) CreateOrUpdateShare(ctx context.Context, path *fs.URI, args *C
Expires: args.Expire,
RemainDownloads: args.RemainDownloads,
Existed: existed,
Props: props,
})
if err != nil {
@@ -281,6 +287,39 @@ func (m *manager) TraverseFile(ctx context.Context, fileID int) (fs.File, error)
return m.fs.TraverseFile(ctx, fileID)
}
func (m *manager) PatchView(ctx context.Context, uri *fs.URI, view *types.ExplorerView) error {
if uri.PathTrimmed() == "" && uri.FileSystem() != constants.FileSystemMy && uri.FileSystem() != constants.FileSystemShare {
if m.user.Settings.FsViewMap == nil {
m.user.Settings.FsViewMap = make(map[string]types.ExplorerView)
}
if view == nil {
delete(m.user.Settings.FsViewMap, string(uri.FileSystem()))
} else {
m.user.Settings.FsViewMap[string(uri.FileSystem())] = *view
}
if err := m.dep.UserClient().SaveSettings(ctx, m.user); err != nil {
return serializer.NewError(serializer.CodeDBError, "failed to save user settings", err)
}
return nil
}
patch := &types.FileProps{
View: view,
}
isDelete := view == nil
if isDelete {
patch.View = &types.ExplorerView{}
}
if err := m.fs.PatchProps(ctx, uri, patch, isDelete); err != nil {
return err
}
return nil
}
func getEntityDisplayName(f fs.File, e fs.Entity) string {
switch e.Type() {
case types.EntityTypeThumbnail: