mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 16:50:08 -05:00
Compare commits
6
Commits
main
...
v1.3.8-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fddc85c9f | ||
|
|
94b28eedeb | ||
|
|
8e25922167 | ||
|
|
10ebd4d337 | ||
|
|
83ddc17b4e | ||
|
|
656c18e72e |
No files matched your search
@@ -2,6 +2,17 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
|
||||
|
||||
## v1.3.8
|
||||
|
||||
**BugFixes**:
|
||||
- Quick download icon style after icon change.
|
||||
- Missing error popup for resource creatoin actions (upload/create)
|
||||
- EnforcedOtp login failure until restart (#2330)
|
||||
- Thumbnails for Folders only display sporadically (#2353)
|
||||
- Unwanted user scope change for users with non-default scopes (#2347)
|
||||
- Fix sidebar source info totals (#2321) (#2322) (#982)
|
||||
- Error uploading a large number of photos -- only 100 items get uploaded (#2348)
|
||||
|
||||
## v1.3.7
|
||||
|
||||
**Notes**:
|
||||
|
||||
@@ -146,14 +146,14 @@ func decryptSecret(b64Ciphertext, b64Nonce string) (string, error) {
|
||||
}
|
||||
|
||||
func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) error {
|
||||
// get data from cache
|
||||
cachedSecret, found := TotpCache.Get(user.Username)
|
||||
if !found && user.TOTPSecret == "" {
|
||||
return fmt.Errorf("OTP token not found in cache, please generate a new one")
|
||||
}
|
||||
useCache := found && user.TOTPSecret == ""
|
||||
totpSecret := user.TOTPSecret // The encrypted or plaintext secret
|
||||
totpNonce := user.TOTPNonce // The nonce if encrypted, or empty if plaintext
|
||||
if found {
|
||||
if useCache {
|
||||
splitSecret := strings.Split(cachedSecret, "||")
|
||||
if len(splitSecret) < 2 {
|
||||
return fmt.Errorf("invalid cached OTP token format")
|
||||
@@ -173,9 +173,7 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
|
||||
}
|
||||
secretToValidate = decryptedSecret
|
||||
}
|
||||
// --- END: ADD THIS DECRYPTION LOGIC ---
|
||||
|
||||
// Validate the token using the (now plaintext) secret.
|
||||
valid, err := totp.ValidateCustom(code, secretToValidate, time.Now().UTC(), totp.ValidateOpts{
|
||||
Period: TOTPPeriod,
|
||||
Skew: TOTPSkew,
|
||||
@@ -198,6 +196,7 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
|
||||
logger.Error("error updating user with OTP token:", err)
|
||||
return fmt.Errorf("error updating user with OTP token: %w", err)
|
||||
}
|
||||
TotpCache.Delete(user.Username)
|
||||
} else {
|
||||
return fmt.Errorf("opt secret is empty, cannot enable TOTP")
|
||||
}
|
||||
|
||||
+6
-4
@@ -46,8 +46,7 @@ func validateUserInfo(newDB bool) {
|
||||
}
|
||||
adminUser := settings.Config.Auth.AdminUsername
|
||||
adminPass := settings.Config.Auth.AdminPassword
|
||||
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
|
||||
if user.Username == adminUser && adminPass != "" && passwordEnabled {
|
||||
if user.Username == adminUser && adminPass != "" && user.LoginMethod == users.LoginMethodPassword {
|
||||
logger.Info("Resetting admin user to default username and password.")
|
||||
user.Permissions.Admin = true
|
||||
user.Password = settings.Config.Auth.AdminPassword
|
||||
@@ -63,7 +62,11 @@ func validateUserInfo(newDB bool) {
|
||||
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
|
||||
}
|
||||
}
|
||||
err := store.Users.Save(user, changePass, true)
|
||||
fields := []string{"Scopes", "SidebarLinks", "Tokens", "Permissions", "Preview", "ShowFirstLogin", "LoginMethod", "Version"}
|
||||
if changePass {
|
||||
fields = append(fields, "Password")
|
||||
}
|
||||
err := store.Users.Update(user, true, fields...)
|
||||
if err != nil {
|
||||
logger.Errorf("could not update user: %v", err)
|
||||
}
|
||||
@@ -94,7 +97,6 @@ func updateUserScopes(user *users.User) bool {
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: src.Path,
|
||||
Scope: existingScope.Scope,
|
||||
|
||||
@@ -210,7 +210,7 @@ type SourceConfig struct {
|
||||
Rules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
|
||||
DefaultUserScope string `json:"defaultUserScope"` // defaults to root of index "/" should match folders under path
|
||||
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
|
||||
CreateUserDir bool `json:"createUserDir"` // deprecated: create a user directory for each user under defaultUserScope + username (always true now)
|
||||
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user under defaultUserScope + username
|
||||
UseLogicalSize bool `json:"useLogicalSize"` // calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
|
||||
// hidden but used internally - optimized map lookups for conditional rules
|
||||
ResolvedRules ResolvedRulesConfig `json:"-"`
|
||||
|
||||
@@ -17,6 +17,11 @@ type IndexInfo struct {
|
||||
NumDirs uint64 `json:"numDirs"`
|
||||
NumFiles uint64 `json:"numFiles"`
|
||||
|
||||
// Disk usage (cached from periodic GetIndexInfo / partition probes; aligns with ReducedIndex Stats)
|
||||
UsedAsIndexed uint64 `json:"used"`
|
||||
UsedDisk uint64 `json:"usedAlt"`
|
||||
DiskTotal uint64 `json:"total"`
|
||||
|
||||
// Scanner information - map of scanner path to scanner stats
|
||||
Scanners map[string]*PersistedScannerInfo `json:"scanners"`
|
||||
}
|
||||
|
||||
@@ -298,28 +298,70 @@ func (db *IndexDB) InsertItem(source, path string, info *iteminfo.FileInfo) erro
|
||||
return err
|
||||
}
|
||||
|
||||
// dbTransientOpAttempts is how many times to run a bulk DB op that may hit SQLITE_BUSY before giving up.
|
||||
const dbTransientOpAttempts = 2
|
||||
|
||||
const dbTransientRetryBackoff = 50 * time.Millisecond
|
||||
|
||||
// BulkInsertItems inserts multiple items in a single transaction for a specific source.
|
||||
// Database errors (busy/locked) are treated as soft failures - the filesystem is the source of truth.
|
||||
// Returns nil on success or soft failure (busy/locked), error only on hard failures.
|
||||
// Transient busy/locked errors retry once before giving up (see dbTransientOpAttempts).
|
||||
func (db *IndexDB) BulkInsertItems(source string, items []*iteminfo.FileInfo) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
for attempt := 1; attempt <= dbTransientOpAttempts; attempt++ {
|
||||
ok, transient, err := db.attemptBulkInsert(source, items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
if attempt > 1 {
|
||||
logger.Infof("[DB_TX] BulkInsertItems: succeeded on retry (%d items, attempt %d, %v)",
|
||||
len(items), attempt, time.Since(startTime))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if transient && attempt < dbTransientOpAttempts {
|
||||
logger.Warningf("[DB_TX] BulkInsertItems: transient DB error (attempt %d/%d, %d items), retrying after %v",
|
||||
attempt, dbTransientOpAttempts, len(items), dbTransientRetryBackoff)
|
||||
time.Sleep(dbTransientRetryBackoff)
|
||||
continue
|
||||
}
|
||||
if transient {
|
||||
logger.Warningf("[DB_TX] BulkInsertItems: still failing after %d attempts (%d items, %v) — batch dropped",
|
||||
dbTransientOpAttempts, len(items), time.Since(startTime))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil // defensive if dbTransientOpAttempts is ever 0 and loop never runs
|
||||
}
|
||||
|
||||
// attemptBulkInsert runs one transactional bulk insert.
|
||||
// Returns success=true when committed; transient=true when SQLITE_BUSY-like errors allow a retry (mutex released).
|
||||
func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo) (success bool, transient bool, err error) {
|
||||
tx, err := db.BeginTransaction()
|
||||
if err != nil {
|
||||
// Soft failure: DB is busy or locked, skip this update
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
db.mu.Unlock() // Release mutex on error
|
||||
logger.Debugf("[DB_TX] BulkInsertItems: BeginTransaction failed (DB busy/locked), skipping - took %v", time.Since(startTime))
|
||||
return nil // Non-fatal: filesystem will be used as fallback
|
||||
db.mu.Unlock()
|
||||
logger.Debugf("[DB_TX] BulkInsertItems: BeginTransaction failed (DB busy/locked): %v", err)
|
||||
return false, true, nil
|
||||
}
|
||||
db.mu.Unlock() // Release mutex on error
|
||||
return err // Hard failure: something is wrong with the DB
|
||||
db.mu.Unlock()
|
||||
return false, false, err
|
||||
}
|
||||
defer db.mu.Unlock() // Ensure mutex is always released
|
||||
|
||||
// Always update all fields including last_updated, even if nothing changed
|
||||
// This ensures last_updated stays current for stale entry detection
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
db.mu.Unlock()
|
||||
}()
|
||||
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO index_items (source, path, parent_path, name, size, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -336,14 +378,12 @@ func (db *IndexDB) BulkInsertItems(source string, items []*iteminfo.FileInfo) er
|
||||
`)
|
||||
if err != nil {
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
// With EXCLUSIVE locking and mutex, this shouldn't happen.
|
||||
// Log as warning to surface potential issues (another process accessing DB, bug, etc.)
|
||||
logger.Errorf("[DB] BulkInsertItems: Unexpected busy/lock error during Prepare: %v", err)
|
||||
return nil
|
||||
logger.Debugf("[DB_TX] BulkInsertItems: Prepare busy/locked: %v", err)
|
||||
return false, true, nil
|
||||
}
|
||||
return err
|
||||
return false, false, err
|
||||
}
|
||||
defer stmt.Close() // Ensure statement is always closed
|
||||
defer stmt.Close()
|
||||
|
||||
nowUnix := time.Now().Unix()
|
||||
for _, info := range items {
|
||||
@@ -363,28 +403,23 @@ func (db *IndexDB) BulkInsertItems(source string, items []*iteminfo.FileInfo) er
|
||||
)
|
||||
if err != nil {
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
// With EXCLUSIVE locking and mutex, this shouldn't happen.
|
||||
// Log as warning to surface potential issues (another process accessing DB, bug, etc.)
|
||||
logger.Errorf("[DB] BulkInsertItems: Unexpected busy/lock error during Exec: %v", err)
|
||||
return nil
|
||||
logger.Debugf("[DB_TX] BulkInsertItems: Exec busy/locked during insert: %v", err)
|
||||
return false, true, nil
|
||||
}
|
||||
logger.Errorf("[DB] BulkInsertItems: Exec failed for path=%s, source=%s, error=%v", info.Path, source, err)
|
||||
return err
|
||||
return false, false, err
|
||||
}
|
||||
}
|
||||
|
||||
// Try to commit
|
||||
if err := tx.Commit(); err != nil {
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
// With EXCLUSIVE locking and mutex, this shouldn't happen.
|
||||
// Log as warning to surface potential issues (another process accessing DB, bug, etc.)
|
||||
logger.Errorf("[DB] BulkInsertItems: Unexpected busy/lock error during Commit: %v", err)
|
||||
return nil
|
||||
logger.Debugf("[DB_TX] BulkInsertItems: Commit busy/locked: %v", err)
|
||||
return false, true, nil
|
||||
}
|
||||
return err
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
return nil
|
||||
committed = true
|
||||
return true, false, nil
|
||||
}
|
||||
|
||||
// isBusyError checks if an error is SQLITE_BUSY (error code 5)
|
||||
@@ -474,51 +509,6 @@ func (db *IndexDB) GetItemsByPaths(source string, paths []string) (map[string]*i
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetHasPreviewBatch retrieves hasPreview status for multiple directory paths in a single query.
|
||||
// This is optimized for the N+1 query pattern in GetDirInfo where we need hasPreview for all subdirectories.
|
||||
// Returns map[path]hasPreview. Missing paths in the result indicate they're not in the database yet.
|
||||
func (db *IndexDB) GetHasPreviewBatch(source string, paths []string) (map[string]bool, error) {
|
||||
if len(paths) == 0 {
|
||||
return make(map[string]bool), nil
|
||||
}
|
||||
|
||||
// Build query with IN clause - only select path and has_preview for efficiency
|
||||
placeholders := make([]string, len(paths))
|
||||
args := make([]interface{}, len(paths)+1)
|
||||
args[0] = source
|
||||
for i, path := range paths {
|
||||
placeholders[i] = "?"
|
||||
args[i+1] = path
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT path, has_preview
|
||||
FROM index_items WHERE source = ? AND path IN (%s) AND is_dir = 1
|
||||
`, strings.Join(placeholders, ","))
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
// Soft failure: DB is busy or locked, return empty map
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
return make(map[string]bool), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var path string
|
||||
var hasPreview bool
|
||||
if err := rows.Scan(&path, &hasPreview); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[path] = hasPreview
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetDirectoryChildren retrieves all children of a directory for a specific source.
|
||||
func (db *IndexDB) GetDirectoryChildren(source, dirPath string) ([]*iteminfo.FileInfo, error) {
|
||||
query := `
|
||||
@@ -1077,17 +1067,31 @@ func (db *IndexDB) UpdateFolderSizesIfChanged(source string, pathSizes map[strin
|
||||
args = append(args, path)
|
||||
}
|
||||
|
||||
result, err := db.Exec(query, args...)
|
||||
if err != nil {
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
logger.Debugf("[DB_TX] UpdateFolderSizesIfChanged: DB busy/locked, skipping update")
|
||||
return 0, nil // Non-fatal: sizes will be synced on next scan
|
||||
startTime := time.Now()
|
||||
for attempt := 1; attempt <= dbTransientOpAttempts; attempt++ {
|
||||
result, err := db.Exec(query, args...)
|
||||
if err != nil {
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
if attempt < dbTransientOpAttempts {
|
||||
logger.Warningf("[DB_TX] UpdateFolderSizesIfChanged: busy/locked (attempt %d/%d), retrying after %v: %v",
|
||||
attempt, dbTransientOpAttempts, dbTransientRetryBackoff, err)
|
||||
time.Sleep(dbTransientRetryBackoff)
|
||||
continue
|
||||
}
|
||||
logger.Warningf("[DB_TX] UpdateFolderSizesIfChanged: still busy after %d attempts (%d paths, %v) — skipped",
|
||||
dbTransientOpAttempts, len(pathSizes), time.Since(startTime))
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return 0, err
|
||||
n, _ := result.RowsAffected()
|
||||
if attempt > 1 {
|
||||
logger.Infof("[DB_TX] UpdateFolderSizesIfChanged: succeeded on retry (%d rows, attempt %d, %v)",
|
||||
n, attempt, time.Since(startTime))
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
return rowsAffected, nil
|
||||
return 0, nil // defensive: unreachable when dbTransientOpAttempts >= 1
|
||||
}
|
||||
|
||||
// LoadFolderSizes loads all folder sizes for a source from the database
|
||||
@@ -1320,6 +1324,7 @@ func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileIn
|
||||
return nil, err
|
||||
}
|
||||
info.ModTime = time.Unix(modTime, 0)
|
||||
info.IsDir = isDir
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ func getDirectoryPreview(r *http.Request, d *requestContext, frameIndex int) (*i
|
||||
// Build list of previewable item names in stable order (same as d.fileInfo.Files)
|
||||
var previewableNames []string
|
||||
for _, item := range d.fileInfo.Files {
|
||||
if !item.HasPreview || !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
|
||||
if !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
|
||||
continue
|
||||
}
|
||||
previewableNames = append(previewableNames, item.Name)
|
||||
|
||||
@@ -16,44 +16,26 @@ func TestFetchExtendedAttributes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
adjustedPath string
|
||||
combinedPath string
|
||||
files []os.FileInfo
|
||||
opts Options
|
||||
expectHasPreview bool
|
||||
expectMapNotEmpty bool
|
||||
}{
|
||||
{
|
||||
name: "SkipExtendedAttrs returns empty",
|
||||
adjustedPath: "/test/",
|
||||
combinedPath: "/test/",
|
||||
files: []os.FileInfo{},
|
||||
opts: Options{
|
||||
SkipExtendedAttrs: true,
|
||||
Recursive: false,
|
||||
},
|
||||
expectHasPreview: false,
|
||||
name: "SkipExtendedAttrs returns empty",
|
||||
adjustedPath: "/test/",
|
||||
opts: Options{SkipExtendedAttrs: true, Recursive: false},
|
||||
expectMapNotEmpty: false,
|
||||
},
|
||||
{
|
||||
name: "Recursive returns empty",
|
||||
adjustedPath: "/test/",
|
||||
combinedPath: "/test/",
|
||||
files: []os.FileInfo{},
|
||||
opts: Options{
|
||||
SkipExtendedAttrs: false,
|
||||
Recursive: true,
|
||||
},
|
||||
expectHasPreview: false,
|
||||
name: "Recursive returns empty",
|
||||
adjustedPath: "/test/",
|
||||
opts: Options{SkipExtendedAttrs: false, Recursive: true},
|
||||
expectMapNotEmpty: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hasPreview, subdirMap := idx.fetchExtendedAttributes(tt.adjustedPath, tt.files, tt.opts)
|
||||
if hasPreview != tt.expectHasPreview {
|
||||
t.Errorf("expected hasPreview=%v, got %v", tt.expectHasPreview, hasPreview)
|
||||
}
|
||||
subdirMap := idx.fetchExtendedAttributes(tt.adjustedPath, tt.opts)
|
||||
if (len(subdirMap) > 0) != tt.expectMapNotEmpty {
|
||||
t.Errorf("expected mapNotEmpty=%v, got %v", tt.expectMapNotEmpty, len(subdirMap) > 0)
|
||||
}
|
||||
@@ -61,6 +43,28 @@ func TestFetchExtendedAttributes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchExtendedAttributes_subdirHasPreviewFromMetadata checks folder hasPreview comes from one GetMetadataInfo (non-shallow).
|
||||
func TestFetchExtendedAttributes_subdirHasPreviewFromMetadata(t *testing.T) {
|
||||
idx, _, cleanup := setupTestIndex(t)
|
||||
defer cleanup()
|
||||
|
||||
deepdir, err := idx.db.GetItem("test", "/subdir/deepdir/")
|
||||
if err != nil || deepdir == nil {
|
||||
t.Fatalf("deepdir row: err=%v", err)
|
||||
}
|
||||
deepdir.HasPreview = true
|
||||
_ = idx.db.InsertItem("test", "/subdir/deepdir/", deepdir)
|
||||
|
||||
subdirMap := idx.fetchExtendedAttributes("/subdir/", Options{
|
||||
SkipExtendedAttrs: false,
|
||||
Recursive: false,
|
||||
})
|
||||
|
||||
if !subdirMap["/subdir/deepdir/"] {
|
||||
t.Errorf("expected hasPreview map for /subdir/deepdir/, got %v", subdirMap)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDirectoryName tests the directory name extraction logic
|
||||
func TestGetDirectoryName(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
@@ -734,50 +734,26 @@ func (idx *Index) GetFsInfoViewableOnly(adjustedPath string, followSymlinks bool
|
||||
})
|
||||
}
|
||||
|
||||
// fetchExtendedAttributes fetches hasPreview for the current directory and batch fetches for subdirectories
|
||||
func (idx *Index) fetchExtendedAttributes(indexPath string, files []os.FileInfo, opts Options) (bool, map[string]bool) {
|
||||
hasPreview := false
|
||||
// fetchExtendedAttributes loads indexed subfolder has_preview for the current directory from GetMetadataInfo (database).
|
||||
// The listed directory's own HasPreview is derived only in GetDirInfoCore from children (never from a stale parent DB row).
|
||||
func (idx *Index) fetchExtendedAttributes(indexPath string, opts Options) map[string]bool {
|
||||
subdirHasPreviewMap := make(map[string]bool)
|
||||
|
||||
if opts.SkipExtendedAttrs || opts.Recursive {
|
||||
return hasPreview, subdirHasPreviewMap
|
||||
return subdirHasPreviewMap
|
||||
}
|
||||
|
||||
// Fetch hasPreview for current directory
|
||||
realDirInfo, exists := idx.GetMetadataInfo(indexPath, true, true)
|
||||
if exists {
|
||||
hasPreview = realDirInfo.HasPreview
|
||||
realDirInfo, exists := idx.GetMetadataInfo(indexPath, true, false)
|
||||
if !exists {
|
||||
return subdirHasPreviewMap
|
||||
}
|
||||
|
||||
// Batch fetch hasPreview for all subdirectories
|
||||
var subdirPaths []string
|
||||
for _, file := range files {
|
||||
if !iteminfo.IsDirectory(file) {
|
||||
continue
|
||||
}
|
||||
baseName := file.Name()
|
||||
if indexPath == "/" {
|
||||
if !idx.shouldInclude(baseName) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if omitList[baseName] {
|
||||
continue
|
||||
}
|
||||
dirPath := indexPath + baseName
|
||||
childIndexPath := utils.AddTrailingSlashIfNotExists(dirPath)
|
||||
subdirPaths = append(subdirPaths, childIndexPath)
|
||||
for _, folder := range realDirInfo.Folders {
|
||||
key := utils.AddTrailingSlashIfNotExists(indexPath + folder.Name)
|
||||
subdirHasPreviewMap[key] = folder.HasPreview
|
||||
}
|
||||
|
||||
if len(subdirPaths) > 0 {
|
||||
var err error
|
||||
subdirHasPreviewMap, err = idx.db.GetHasPreviewBatch(idx.Name, subdirPaths)
|
||||
if err != nil {
|
||||
subdirHasPreviewMap = make(map[string]bool)
|
||||
}
|
||||
}
|
||||
|
||||
return hasPreview, subdirHasPreviewMap
|
||||
return subdirHasPreviewMap
|
||||
}
|
||||
|
||||
// processDirectoryItem processes a directory item and returns the itemInfo, size, and whether it should be counted
|
||||
@@ -825,11 +801,7 @@ func (idx *Index) processDirectoryItem(file os.FileInfo, indexPath string, subdi
|
||||
if !opts.SkipExtendedAttrs && subdirHasPreviewMap != nil {
|
||||
if hasPreviewValue, exists := subdirHasPreviewMap[childIndexPath]; exists {
|
||||
itemInfo.HasPreview = hasPreviewValue
|
||||
} else {
|
||||
itemInfo.HasPreview = false
|
||||
}
|
||||
} else {
|
||||
itemInfo.HasPreview = false
|
||||
}
|
||||
|
||||
return itemInfo, itemInfo.Size, true
|
||||
@@ -875,9 +847,7 @@ func (idx *Index) processFileItem(file os.FileInfo, indexPath string, opts Optio
|
||||
if !usedCachedPreview {
|
||||
setFilePreviewFlags(itemInfo, fullCombined)
|
||||
}
|
||||
if itemInfo.HasPreview && iteminfo.ShouldBubbleUpToFolderPreview(*itemInfo) {
|
||||
bubblesUpHasPreview = true
|
||||
}
|
||||
bubblesUpHasPreview = iteminfo.ShouldBubbleUpToFolderPreview(*itemInfo)
|
||||
} else {
|
||||
itemInfo.HasPreview = false
|
||||
}
|
||||
@@ -894,8 +864,7 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Fetch extended attributes (hasPreview for current dir and subdirs)
|
||||
hasPreview, subdirHasPreviewMap := idx.fetchExtendedAttributes(indexPath, files, opts)
|
||||
subdirHasPreviewMap := idx.fetchExtendedAttributes(indexPath, opts)
|
||||
|
||||
baseName := filepath.Base(indexPath)
|
||||
if indexPath == "/" {
|
||||
@@ -906,6 +875,7 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
|
||||
fileInfos := []iteminfo.ExtendedItemInfo{}
|
||||
dirInfos := []iteminfo.ItemInfo{}
|
||||
processedCount := 0
|
||||
hasPreview := false
|
||||
|
||||
for _, file := range files {
|
||||
subfileBaseName := file.Name()
|
||||
@@ -960,12 +930,12 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
|
||||
}
|
||||
} else {
|
||||
itemInfo, size, shouldCount, bubblesUp := idx.processFileItem(file, indexPath, opts, scanner)
|
||||
if shouldCount {
|
||||
totalSize += size
|
||||
}
|
||||
if bubblesUp {
|
||||
hasPreview = true
|
||||
}
|
||||
if shouldCount {
|
||||
totalSize += size
|
||||
}
|
||||
fileInfos = append(fileInfos, iteminfo.ExtendedItemInfo{ItemInfo: *itemInfo})
|
||||
if opts.IsRoutineScan {
|
||||
idx.incrementScannerFiles()
|
||||
@@ -1132,6 +1102,7 @@ func (idx *Index) updateFolderSizeAndParents(path string, newSize uint64, previo
|
||||
if parentDir == "" {
|
||||
break // Reached the top
|
||||
}
|
||||
// Keys in folderSizes match indexing paths (trailing slash for non-root dirs; root stays "/").
|
||||
if parentDir != "/" {
|
||||
parentDir = utils.AddTrailingSlashIfNotExists(parentDir)
|
||||
}
|
||||
@@ -1188,20 +1159,6 @@ func (idx *Index) getFileSizeForDisplay(file os.FileInfo, realPath string) int64
|
||||
return getDiskUsage(file, realPath, false)
|
||||
}
|
||||
|
||||
// RecursiveUpdateDirSizes is now a wrapper for backwards compatibility
|
||||
// This maintains API compatibility while using the new optimized approach
|
||||
func (idx *Index) RecursiveUpdateDirSizes(path string, previousSize uint64) {
|
||||
currentSize, exists := idx.GetFolderSize(path)
|
||||
if !exists {
|
||||
logger.Debugf("[FOLDER_SIZE] Path %s not found in folderSizes map", path)
|
||||
return
|
||||
}
|
||||
|
||||
if currentSize != previousSize {
|
||||
idx.updateFolderSizeAndParents(path, currentSize, previousSize, false) // updateTarget=false, only update parents
|
||||
}
|
||||
}
|
||||
|
||||
// SyncFolderSizesToDB syncs in-memory folder sizes to the database after a scan completes
|
||||
// Only syncs folders marked as dirty (changed) and processes in batches to avoid large SQL statements
|
||||
func (idx *Index) SyncFolderSizesToDB() error {
|
||||
@@ -1609,14 +1566,13 @@ func (idx *Index) shouldInclude(baseName string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Save persists the index and scanner information to the database
|
||||
func (idx *Index) Save() error {
|
||||
// writePersistedIndexInfo writes scanners, stats, and disk totals to Bolt (no SSE broadcast).
|
||||
func (idx *Index) writePersistedIndexInfo() error {
|
||||
if indexingStorage == nil {
|
||||
return nil // No storage available, skip persistence
|
||||
return nil
|
||||
}
|
||||
|
||||
idx.mu.RLock()
|
||||
// Collect scanner information
|
||||
scanners := make(map[string]*dbindex.PersistedScannerInfo)
|
||||
for path, scanner := range idx.scanners {
|
||||
scanners[path] = &dbindex.PersistedScannerInfo{
|
||||
@@ -1631,32 +1587,37 @@ func (idx *Index) Save() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Get current index stats
|
||||
complexity := idx.getComplexityUnlocked()
|
||||
numDirs := idx.getNumDirsUnlocked()
|
||||
numFiles := idx.getNumFilesUnlocked()
|
||||
usedAsIndexed := idx.UsedAsIndexed
|
||||
usedDisk := idx.UsedDisk
|
||||
diskTotal := idx.DiskTotal
|
||||
idx.mu.RUnlock()
|
||||
|
||||
// Create IndexInfo for persistence
|
||||
info := &dbindex.IndexInfo{
|
||||
Path: idx.Path, // Use real filesystem path as key
|
||||
Source: idx.Name,
|
||||
Complexity: complexity,
|
||||
NumDirs: numDirs,
|
||||
NumFiles: numFiles,
|
||||
Scanners: scanners,
|
||||
Path: idx.Path,
|
||||
Source: idx.Name,
|
||||
Complexity: complexity,
|
||||
NumDirs: numDirs,
|
||||
NumFiles: numFiles,
|
||||
UsedAsIndexed: usedAsIndexed,
|
||||
UsedDisk: usedDisk,
|
||||
DiskTotal: diskTotal,
|
||||
Scanners: scanners,
|
||||
}
|
||||
|
||||
err := indexingStorage.Save(info)
|
||||
if err != nil {
|
||||
return indexingStorage.Save(info)
|
||||
}
|
||||
|
||||
// Save persists the index and scanner information to the database and notifies clients via SSE.
|
||||
func (idx *Index) Save() error {
|
||||
if err := idx.writePersistedIndexInfo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send SSE update event after successful save
|
||||
if err := idx.SendSourceUpdateEvent(); err != nil {
|
||||
logger.Errorf("[%s] Failed to send source update event: %v", idx.Name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1684,6 +1645,9 @@ func (idx *Index) Load() error {
|
||||
idx.Complexity = info.Complexity
|
||||
idx.NumDirs = info.NumDirs
|
||||
idx.NumFiles = info.NumFiles
|
||||
idx.UsedAsIndexed = info.UsedAsIndexed
|
||||
idx.UsedDisk = info.UsedDisk
|
||||
idx.DiskTotal = info.DiskTotal
|
||||
|
||||
// Restore scanner information (will be applied when scanners are created)
|
||||
// Store in a temporary map that setupMultiScanner can use
|
||||
|
||||
@@ -502,7 +502,6 @@ func (s *Scanner) calculateSleepTime() time.Duration {
|
||||
// updateSchedule adjusts the scanner's schedule based on whether files changed
|
||||
func (s *Scanner) updateSchedule() {
|
||||
s.statsMu.Lock()
|
||||
defer s.statsMu.Unlock()
|
||||
|
||||
// Calculate sleep time BEFORE updating schedule (based on current/old schedule)
|
||||
s.nextSleepTime = scanSchedule[s.currentSchedule] + s.smartModifier
|
||||
@@ -539,7 +538,10 @@ func (s *Scanner) updateSchedule() {
|
||||
s.currentSchedule = len(scanSchedule) - 1
|
||||
}
|
||||
|
||||
// Persist index after schedule update (happens after each scan)
|
||||
s.statsMu.Unlock()
|
||||
|
||||
// Save must NOT run while holding statsMu (see deadlock: GetScannerStatus = idx.mu then
|
||||
// scanner stats; Save = idx.mu RL while GetIndexInfo may queue idx.mu LW).
|
||||
if err := s.idx.Save(); err != nil {
|
||||
logger.Errorf("Failed to save index after schedule update: %v", err)
|
||||
}
|
||||
@@ -549,8 +551,6 @@ func (s *Scanner) updateSchedule() {
|
||||
// 0: unknown
|
||||
func (s *Scanner) updateComplexity() {
|
||||
s.statsMu.Lock()
|
||||
defer s.statsMu.Unlock()
|
||||
|
||||
s.complexity = calculateComplexity(s.fullScanTime, s.numDirs)
|
||||
|
||||
// Set smartModifier based on complexity level
|
||||
@@ -559,7 +559,9 @@ func (s *Scanner) updateComplexity() {
|
||||
} else {
|
||||
s.smartModifier = 0
|
||||
}
|
||||
// Persist index after complexity update (happens after full scans)
|
||||
s.statsMu.Unlock()
|
||||
|
||||
// Persist after full scans — release statsMu before Save (same ordering as updateSchedule).
|
||||
if err := s.idx.Save(); err != nil {
|
||||
logger.Errorf("Failed to save index after complexity update: %v", err)
|
||||
}
|
||||
|
||||
@@ -205,7 +205,9 @@ func (idx *Index) SendSourceUpdateEvent() error {
|
||||
if idx.mock {
|
||||
return nil
|
||||
}
|
||||
reducedIndex, err := GetIndexInfo(idx.Name, true)
|
||||
// Avoid force=true: otherwise GetIndexInfo invalidates DiskUsageCache, recomputes, and writePersistedIndexInfo
|
||||
// nests under Save recursion; false uses cached probe / current idx totals for the broadcast payload.
|
||||
reducedIndex, err := GetIndexInfo(idx.Name, false)
|
||||
if err != nil {
|
||||
logger.Errorf("[%s] Error getting index info: %v", idx.Name, err)
|
||||
return err
|
||||
@@ -226,7 +228,8 @@ func (idx *Index) SendSourceUpdateEvent() error {
|
||||
// setupMultiScanner creates and starts the multi-scanner system
|
||||
// isNewDb: if true, skip loading persisted complexity values (database is new or recreated)
|
||||
func (idx *Index) setupMultiScanner(isNewDb bool) {
|
||||
// Load persisted index and scanner information
|
||||
// Load persisted index-level stats from Bolt (complexity, numDirs/Files, used/usedAlt/total, etc.)
|
||||
// Scanner-level stats below use a second GetByPath only for the Scanners map.
|
||||
if err := idx.Load(); err != nil {
|
||||
logger.Errorf("Failed to load persisted index data for [%v]: %v", idx.Name, err)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ type FileInfo struct {
|
||||
Files []ExtendedItemInfo `json:"files,omitempty"` // files in the directory with optional metadata
|
||||
Folders []ItemInfo `json:"folders,omitempty"` // folders in the directory
|
||||
Path string `json:"path,omitempty"` // path scoped to the associated index
|
||||
// IsDir is the index DB is_dir column; Type alone can disagree (legacy or malformed rows).
|
||||
IsDir bool `json:"-"`
|
||||
}
|
||||
|
||||
// MediaMetadata contains metadata extracted from audio and video files
|
||||
|
||||
@@ -405,6 +405,9 @@ func IsDirectory(fileInfo os.FileInfo) bool {
|
||||
// Text files, office documents, and PDFs should NOT bubble up to folder previews.
|
||||
// This ensures consistency between indexing and preview generation.
|
||||
func ShouldBubbleUpToFolderPreview(item ItemInfo) bool {
|
||||
if item.Type == "directory" || !item.HasPreview {
|
||||
return false
|
||||
}
|
||||
// Get the simple type (e.g., "image", "video", "text")
|
||||
simpleType := strings.Split(item.Type, "/")[0]
|
||||
// Text files should NOT bubble up
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package iteminfo
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShouldBubbleUpToFolderPreview(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item ItemInfo
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "directory type never bubbles",
|
||||
item: ItemInfo{Name: "sub", Type: "directory"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "text markdown does not bubble",
|
||||
item: ItemInfo{Name: "README.md", Type: "text/markdown"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "plain text does not bubble",
|
||||
item: ItemInfo{Name: "LICENSE", Type: "text/plain; charset=utf-8"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "image does not bubble without HasPreview",
|
||||
item: ItemInfo{Name: "a.png", Type: "image/png", HasPreview: false},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "image bubbles when HasPreview",
|
||||
item: ItemInfo{Name: "a.png", Type: "image/png", HasPreview: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "video bubbles when HasPreview",
|
||||
item: ItemInfo{Name: "clip.mp4", Type: "video/mp4", HasPreview: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "audio bubbles when HasPreview (e.g. album art)",
|
||||
item: ItemInfo{Name: "track.mp3", Type: "audio/mpeg", HasPreview: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "OnlyOffice-backed extension does not bubble",
|
||||
item: ItemInfo{Name: "report.docx", Type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unknown blob type does not bubble",
|
||||
item: ItemInfo{Name: "data.bin", Type: "application/octet-stream"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty type does not bubble",
|
||||
item: ItemInfo{Name: "weird", Type: ""},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ShouldBubbleUpToFolderPreview(tt.item); got != tt.want {
|
||||
t.Fatalf("ShouldBubbleUpToFolderPreview() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
|
||||
// scanner parameter is optional - if nil (API refresh), directly inserts to database
|
||||
// UpdateMetadata persists a completed directory listing to the index. The directory row's has_preview
|
||||
// scanner is optional — if nil (API refresh), inserts run immediately; otherwise items batch until flush.
|
||||
func (idx *Index) UpdateMetadata(info *iteminfo.FileInfo, scanner *Scanner) bool {
|
||||
items := make([]*iteminfo.FileInfo, 0, len(info.Files)+len(info.Folders)+1)
|
||||
dirItem := *info
|
||||
@@ -297,7 +297,8 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
|
||||
}
|
||||
|
||||
// Only update disk total if cache is missing or explicitly forced.
|
||||
// Indexed "used" bytes for the UI come from the SQLite root row (see below), not in-memory scan state.
|
||||
// Indexed "used" sums root-level files from SQLite; top-level folders use idx.folderSizes (scanned rollup)
|
||||
// because directory size columns in SQLite can lag folderSizes/SyncFolderSizesToDB.
|
||||
sourcePath := idx.Path
|
||||
cacheKey := "usageCache-" + sourceName
|
||||
if forceCacheRefresh {
|
||||
@@ -313,11 +314,21 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
|
||||
}
|
||||
itemInfo, dbErr := idx.db.GetDirectoryChildren(idx.Name, "/")
|
||||
if dbErr != nil {
|
||||
logger.Debugf("GetIndexInfo: could not read root folder size from index DB for %s: %v", sourceName, dbErr)
|
||||
logger.Debugf("GetIndexInfo: could not read root listing from index DB for %s: %v", sourceName, dbErr)
|
||||
}
|
||||
indexedSizeFromDB := uint64(0)
|
||||
var indexedSizeFromDB uint64
|
||||
for _, item := range itemInfo {
|
||||
indexedSizeFromDB += uint64(item.Size)
|
||||
isFolder := item.IsDir || item.Type == "directory"
|
||||
if !isFolder {
|
||||
indexedSizeFromDB += uint64(item.Size)
|
||||
continue
|
||||
}
|
||||
folderKey := utils.AddTrailingSlashIfNotExists(item.Path)
|
||||
if agg, ok := idx.GetFolderSize(folderKey); ok {
|
||||
indexedSizeFromDB += agg
|
||||
} else {
|
||||
indexedSizeFromDB += uint64(item.Size)
|
||||
}
|
||||
}
|
||||
// Also fetch OS-reported partition used space (total - free)
|
||||
partitionUsed, err := fileutils.GetPartitionUsed(sourcePath)
|
||||
@@ -326,6 +337,11 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
|
||||
}
|
||||
idx.SetUsage(totalPartitionSize, partitionUsed, indexedSizeFromDB)
|
||||
utils.DiskUsageCache.Set(cacheKey, true)
|
||||
if indexingStorage != nil {
|
||||
if err := idx.writePersistedIndexInfo(); err != nil {
|
||||
logger.Warningf("GetIndexInfo: failed to persist index disk stats for %s: %v", sourceName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build scanner info for client
|
||||
|
||||
@@ -5775,7 +5775,7 @@ const docTemplate = `{
|
||||
]
|
||||
},
|
||||
"createUserDir": {
|
||||
"description": "deprecated: create a user directory for each user under defaultUserScope + username (always true now)",
|
||||
"description": "create a user directory for each user under defaultUserScope + username",
|
||||
"type": "boolean"
|
||||
},
|
||||
"defaultEnabled": {
|
||||
|
||||
@@ -5764,7 +5764,7 @@
|
||||
]
|
||||
},
|
||||
"createUserDir": {
|
||||
"description": "deprecated: create a user directory for each user under defaultUserScope + username (always true now)",
|
||||
"description": "create a user directory for each user under defaultUserScope + username",
|
||||
"type": "boolean"
|
||||
},
|
||||
"defaultEnabled": {
|
||||
|
||||
@@ -1177,8 +1177,8 @@ definitions:
|
||||
- $ref: '#/definitions/settings.ConditionalFilter'
|
||||
description: 'deprecated: use source.rules instead'
|
||||
createUserDir:
|
||||
description: 'deprecated: create a user directory for each user under defaultUserScope
|
||||
+ username (always true now)'
|
||||
description: create a user directory for each user under defaultUserScope
|
||||
+ username
|
||||
type: boolean
|
||||
defaultEnabled:
|
||||
description: should be added as a default source for new users?
|
||||
|
||||
@@ -43,6 +43,7 @@ server:
|
||||
ignoreSymlinks: false # Excludes symbolic links
|
||||
defaultUserScope: "/" # defaults to root of index "/" should match folders under path
|
||||
defaultEnabled: true # should be added as a default source for new users?
|
||||
createUserDir: false # create a user directory for each user under defaultUserScope + username
|
||||
useLogicalSize: false # calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
|
||||
externalUrl: "" # used by share links if set (eg. http://mydomain.com)
|
||||
internalUrl: "" # used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
|
||||
@@ -462,7 +462,18 @@ export function post(
|
||||
error.response = { status: request.status, responseText: request.responseText };
|
||||
reject(error);
|
||||
} else {
|
||||
reject(new Error(request.responseText || "Upload failed"));
|
||||
let errorMessage = "Upload failed";
|
||||
try {
|
||||
const errorData = JSON.parse(request.responseText);
|
||||
errorMessage = errorData.message || errorMessage;
|
||||
} catch (e) {
|
||||
errorMessage = request.responseText || errorMessage;
|
||||
}
|
||||
const error = new Error(errorMessage);
|
||||
error.status = request.status;
|
||||
error.response = { status: request.status, responseText: request.responseText };
|
||||
notify.showError(errorMessage);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -35,19 +35,19 @@
|
||||
</div>
|
||||
<hr v-if="showDivider" class="divider">
|
||||
<action
|
||||
v-if="showCreateActions"
|
||||
v-if="showCreateFiles"
|
||||
icon="create_new_folder"
|
||||
:label="$t('files.newFolder')"
|
||||
@action="showNewDirPrompt"
|
||||
/>
|
||||
<action
|
||||
v-if="showCreateActions"
|
||||
v-if="showCreateFiles"
|
||||
icon="note_add"
|
||||
:label="$t('files.newFile')"
|
||||
@action="showPrompt('newFile')"
|
||||
/>
|
||||
<action
|
||||
v-if="showCreateActions"
|
||||
v-if="showCreateFiles"
|
||||
icon="file_upload"
|
||||
:label="$t('general.upload')"
|
||||
@action="showUploadPrompt"
|
||||
@@ -259,6 +259,10 @@ export default {
|
||||
isShare() {
|
||||
return getters.isShare();
|
||||
},
|
||||
showCreateFiles() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
return getters.permissions().create;
|
||||
},
|
||||
showCreateActions() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
return this.showCreate && !this.isSearchActive;
|
||||
|
||||
@@ -256,7 +256,7 @@ export default {
|
||||
targetDirItems = response?.items;
|
||||
}
|
||||
} catch (error) {
|
||||
notify.showErrorToast(this.$t("files.cannotAccesDir"));
|
||||
notify.showError(this.$t("files.cannotAccesDir"));
|
||||
console.log("Cannot access to target directory", e);
|
||||
return;
|
||||
}
|
||||
@@ -296,7 +296,7 @@ export default {
|
||||
mutations.setReload(true);
|
||||
} catch (error) {
|
||||
mutations.closeTopPrompt();
|
||||
notify.showErrorToast(this.$t("prompts.moveFailed"));
|
||||
notify.showError(this.$t("prompts.moveFailed"));
|
||||
console.log("Move failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -72,10 +72,6 @@
|
||||
:clickable=true
|
||||
:isShared="isShared"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="quickDownloadPlaceholder"
|
||||
class="placeholder"
|
||||
/>
|
||||
</a>
|
||||
<div
|
||||
v-else
|
||||
@@ -222,10 +218,10 @@ export default {
|
||||
// @ts-ignore
|
||||
if (getters.isShare()) {
|
||||
// @ts-ignore
|
||||
return state.shareInfo?.quickDownload && !this.galleryView && !this.isDir;
|
||||
return state.shareInfo?.quickDownload && !this.galleryView;
|
||||
}
|
||||
// @ts-ignore
|
||||
return state.user?.quickDownload && !this.galleryView && !this.isDir;
|
||||
return state.user?.quickDownload && !this.galleryView;
|
||||
},
|
||||
quickDownloadPlaceholder() {
|
||||
// @ts-ignore
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { uploadManager } from "@/utils/upload";
|
||||
import { readAllDirectoryEntries, uploadManager } from "@/utils/upload";
|
||||
import { mutations, state } from "@/store";
|
||||
import { notify } from "@/notify";
|
||||
import { usersApi } from "@/api";
|
||||
@@ -485,9 +485,8 @@ export default {
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
const reader = entry.createReader();
|
||||
const entries = await new Promise((resolve) => {
|
||||
reader.readEntries((e) => resolve(e));
|
||||
});
|
||||
const pathLabel = entry.fullPath || entry.name || "";
|
||||
const entries = await readAllDirectoryEntries(reader, pathLabel);
|
||||
const allFiles = await Promise.all(
|
||||
entries.map((subEntry) => getFilesFromDirectoryEntry(subEntry))
|
||||
);
|
||||
|
||||
@@ -98,11 +98,6 @@
|
||||
<i class="material-symbols material-size">add</i>
|
||||
</button>
|
||||
|
||||
<div class="settings-items">
|
||||
<ToggleSwitch v-if="displayHomeDirectoryCheckbox" class="item" v-model="createUserDir"
|
||||
:name="$t('settings.createUserHomeDirectory')" />
|
||||
</div>
|
||||
|
||||
<p v-if="stateUser.username !== user.username">
|
||||
<label for="locale">{{ $t("general.language") }}</label>
|
||||
<languages class="input" id="locale" v-model:locale="user.locale" @input="emitUpdate"></languages>
|
||||
@@ -240,9 +235,6 @@ export default {
|
||||
passwordPlaceholder() {
|
||||
return this.isNew ? "" : this.$t("settings.avoidChanges");
|
||||
},
|
||||
displayHomeDirectoryCheckbox() {
|
||||
return this.isNew && this.createUserDir;
|
||||
},
|
||||
/** Password change (existing user): target and signed-in user must both use password login. */
|
||||
showPasswordChangeSection() {
|
||||
return (
|
||||
@@ -261,10 +253,6 @@ export default {
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
createUserDir(newVal) {
|
||||
this.user.scopes = newVal ? { default: "" } : this.originalUserScope;
|
||||
this.emitUserUpdate();
|
||||
},
|
||||
stateUser() {
|
||||
this.user.otpEnabled = state.user.otpEnabled;
|
||||
this.emitUserUpdate();
|
||||
@@ -391,7 +379,7 @@ export default {
|
||||
mutations.closeTopPrompt(); // close user prompt since user doens't exist anymore
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
notify.showErrorToast(this.$t("settings.userDeleteFailed"));
|
||||
notify.showError(e);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -427,10 +415,7 @@ export default {
|
||||
mutations.closeTopPrompt();
|
||||
} else {
|
||||
await usersApi.update({ ...this.user, scopes: scopesToSend }, fields);
|
||||
// Only emit usersChanged for admin user management, not profile updates
|
||||
if (state.user.permissions.admin && this.user.id !== state.user.id) {
|
||||
eventBus.emit('usersChanged');
|
||||
}
|
||||
eventBus.emit('usersChanged');
|
||||
notify.showSuccessToast(this.$t("settings.userUpdated"));
|
||||
mutations.closeTopPrompt();
|
||||
}
|
||||
@@ -466,9 +451,7 @@ export default {
|
||||
await usersApi.update(this.user, ["password"], {
|
||||
actorPasswordPromptI18nKey: "prompts.confirmPasswordToSaveUser",
|
||||
});
|
||||
if (state.user.permissions.admin && this.user.id !== state.user.id) {
|
||||
eventBus.emit("usersChanged");
|
||||
}
|
||||
eventBus.emit("usersChanged");
|
||||
notify.showSuccessToast(this.$t("settings.userUpdated"));
|
||||
} catch (e) {
|
||||
notify.showError(e);
|
||||
|
||||
@@ -326,7 +326,6 @@
|
||||
"tusUploadsRetryCount": "عدد مرات إعادة المحاولة التي يجب إجراؤها في حالة فشل تحميل قطعة ما.",
|
||||
"userHomeBasePath": "المسار الأساسي للدلائل الرئيسية للمستخدم",
|
||||
"userScopeGenerationPlaceholder": "سيتم إنشاء النطاق تلقائيًا",
|
||||
"createUserHomeDirectory": "إنشاء الدليل الرئيسي للمستخدم",
|
||||
"shareDeleted": "تم حذف المشاركة",
|
||||
"loginMethodDescription": "طريقة تسجيل الدخول المسموح بها للمستخدم.",
|
||||
"userNotAdmin": "المستخدم ليس مسؤولاً",
|
||||
|
||||
@@ -436,7 +436,6 @@
|
||||
"tusUploadsRetryCount": "Počet pokusů o opětovné nahrání části při chybě.",
|
||||
"userHomeBasePath": "Základní cesta pro domovské adresáře uživatelů",
|
||||
"userScopeGenerationPlaceholder": "Rozsah bude vygenerován automaticky",
|
||||
"createUserHomeDirectory": "Vytvořit domovský adresář uživatele",
|
||||
"customStylesheet": "Vlastní styl",
|
||||
"defaultUserDescription": "Toto jsou výchozí nastavení pro nové uživatele.",
|
||||
"disableExternalLinks": "Zakázat externí odkazy (kromě dokumentace)",
|
||||
|
||||
@@ -327,7 +327,6 @@
|
||||
"UserManagement": "Benutzerverwaltung",
|
||||
"userHomeBasePath": "Basispfad für Benutzer-Home-Verzeichnisse",
|
||||
"userScopeGenerationPlaceholder": "Der Bereich wird automatisch generiert",
|
||||
"createUserHomeDirectory": "Benutzer-Home-Verzeichnis erstellen",
|
||||
"loginMethodDescription": "erlaubte Anmeldemethode für den Benutzer.",
|
||||
"userNotAdmin": "Benutzer ist kein Administrator",
|
||||
"enterPassword": "Passwort eingeben",
|
||||
|
||||
@@ -282,7 +282,6 @@
|
||||
"tusUploadsRetryCount": "Αριθμός επαναληπτικών δοκιμών που θα πραγματοποιηθούν αν αποτύχει η μεταφόρτωση ενός τμήματος.",
|
||||
"userHomeBasePath": "Βασική διαδρομή αρχείων για τους φακέλους των χρηστών",
|
||||
"userScopeGenerationPlaceholder": "Η εμβέλεια εφαρμογής θα δημιουργηθεί αυτόματα",
|
||||
"createUserHomeDirectory": "Δημιουργία φακέλου χρήστη",
|
||||
"customStylesheet": "Προσαρμοσμένο στυλ εμφάνισης (stylesheet)",
|
||||
"defaultUserDescription": "Αυτές είναι οι προεπιλεγμένες ρυθμίσεις για νέους χρήστες.",
|
||||
"disableExternalLinks": "Απενεργοποίηση εξωτερικών συνδέσμων (εκτός από συνδέσμους προς τις οδηγίες χρήσης)",
|
||||
|
||||
@@ -577,7 +577,6 @@
|
||||
"tusUploadsRetryCount": "Number of retries to perform if a chunk fails to upload.",
|
||||
"userHomeBasePath": "Base path for user home directories",
|
||||
"userScopeGenerationPlaceholder": "The scope will be auto generated",
|
||||
"createUserHomeDirectory": "Create user home directory",
|
||||
"customStylesheet": "Custom Stylesheet",
|
||||
"defaultUserDescription": "These are the default settings for new users.",
|
||||
"disableExternalLinks": "Disable external links (except documentation)",
|
||||
|
||||
@@ -276,7 +276,6 @@
|
||||
"createUserDir": "Crea automaticamente una carpeta cuando se agrega un nuevo usuario",
|
||||
"userHomeBasePath": "Ruta base para la carpeta personal del usuario",
|
||||
"userScopeGenerationPlaceholder": "La carpeta se generará automáticamente",
|
||||
"createUserHomeDirectory": "Crea una carpeta personal para cada usuario",
|
||||
"customStylesheet": "Hoja de estilos personalizada",
|
||||
"defaultUserDescription": "Configuración por defecto nuevos usuarios",
|
||||
"disableExternalLinks": "Desactivar enlaces externos",
|
||||
|
||||
@@ -327,7 +327,6 @@
|
||||
"tusUploadsRetryCount": "Nombre de tentatives à effectuer si un bloc ne parvient pas à être importé.",
|
||||
"userHomeBasePath": "Chemin de base pour les répertoires personnels des utilisateurs",
|
||||
"userScopeGenerationPlaceholder": "Le champ d'application sera généré automatiquement",
|
||||
"createUserHomeDirectory": "Créer le répertoire personnel de l'utilisateur",
|
||||
"api": "API",
|
||||
"loginMethodDescription": "méthode de connexion autorisée pour l'utilisateur.",
|
||||
"userNotAdmin": "L'utilisateur n'est pas un administrateur",
|
||||
|
||||
@@ -275,7 +275,6 @@
|
||||
"createUserDir": "צור תקיית בית במהלך הוספת משתמש חדש",
|
||||
"userHomeBasePath": "נתיב ראשי לתקיות הבית של משתמשים",
|
||||
"userScopeGenerationPlaceholder": "התחום יווצר אוטומטית",
|
||||
"createUserHomeDirectory": "צור תקיית בית למשתמש",
|
||||
"customStylesheet": "עיצוב מותאם אישית (Stylesheet)",
|
||||
"defaultUserDescription": "אלה הגדרות ברירת המחדל למשתמשים חדשים",
|
||||
"disableExternalLinks": "השבת קישורים חיצוניים (למעט תיעוד)",
|
||||
|
||||
@@ -275,7 +275,6 @@
|
||||
"createUserDir": "Felhasználók saját mappáinak automatikus létrehozása új felhasználók hozzáadásakor",
|
||||
"userHomeBasePath": "Alap elérési útvonal a felhasználók saját mappáihoz",
|
||||
"userScopeGenerationPlaceholder": "A környezet automatikus lesz létrehozva",
|
||||
"createUserHomeDirectory": "Felhasználói saját mappák létrehozása",
|
||||
"customStylesheet": "Egyéni stíluslap",
|
||||
"defaultUserDescription": "Ezek az alapértelmezett beállítások az új felhasználók számára.",
|
||||
"disableExternalLinks": "Külső linkek letiltása (kivéve a dokumentáció)",
|
||||
|
||||
@@ -577,7 +577,6 @@
|
||||
"tusUploadsRetryCount": "Numero di tentativi se un pezzo fallisce.",
|
||||
"userHomeBasePath": "Percorso base per le home degli utenti",
|
||||
"userScopeGenerationPlaceholder": "L'ambito verrà generato automaticamente",
|
||||
"createUserHomeDirectory": "Crea directory home utente",
|
||||
"customStylesheet": "Foglio di stile personalizzato (CSS)",
|
||||
"defaultUserDescription": "Impostazioni predefinite per i nuovi utenti.",
|
||||
"disableExternalLinks": "Disabilita link esterni (eccetto documentazione)",
|
||||
|
||||
@@ -326,7 +326,6 @@
|
||||
"tusUploadsRetryCount": "チャンクのアップロードに失敗した場合の再試行回数",
|
||||
"userHomeBasePath": "ユーザーホームディレクトリのベースパス",
|
||||
"userScopeGenerationPlaceholder": "スコープは自動生成されます。",
|
||||
"createUserHomeDirectory": "ユーザーホームディレクトリの作成",
|
||||
"shareDeleted": "共有が削除されました",
|
||||
"loginMethodDescription": "ユーザーに許可されるログイン方法",
|
||||
"userNotAdmin": "ユーザーは管理者ではありません",
|
||||
|
||||
@@ -326,7 +326,6 @@
|
||||
"tusUploadsRetryCount": "청크 업로드에 실패한 경우 수행할 재시도 횟수입니다.",
|
||||
"userHomeBasePath": "사용자 홈 디렉터리의 기본 경로",
|
||||
"userScopeGenerationPlaceholder": "범위가 자동으로 생성됩니다.",
|
||||
"createUserHomeDirectory": "사용자 홈 디렉터리 만들기",
|
||||
"shareDeleted": "공유 삭제됨!",
|
||||
"loginMethodDescription": "사용자에게 허용된 로그인 방법을 설정합니다.",
|
||||
"userNotAdmin": "사용자가 관리자가 아닙니다.",
|
||||
|
||||
@@ -332,7 +332,6 @@
|
||||
"tusUploadsRetryCount": "Aantal pogingen dat moet worden uitgevoerd als het uploaden van een chunk mislukt.",
|
||||
"userHomeBasePath": "Basispad voor homedirectory's van gebruikers",
|
||||
"userScopeGenerationPlaceholder": "De scope wordt automatisch gegenereerd",
|
||||
"createUserHomeDirectory": "Thuismap gebruiker aanmaken",
|
||||
"shareSettings": "Instellingen delen",
|
||||
"shareDeleted": "Deel verwijderd!",
|
||||
"adminOptions": "Opties voor beheerders",
|
||||
|
||||
@@ -577,7 +577,6 @@
|
||||
"tusUploadsRetryCount": "Aantal pogingen dat moet worden uitgevoerd als het uploaden van een brok mislukt.",
|
||||
"userHomeBasePath": "Basispad voor thuismappen van gebruikers",
|
||||
"userScopeGenerationPlaceholder": "Het bereik wordt automatisch gegenereerd",
|
||||
"createUserHomeDirectory": "Thuismap voor gebruiker aanmaken",
|
||||
"customStylesheet": "Aangepast Stylesheet",
|
||||
"defaultUserDescription": "Dit zijn de standaardinstellingen voor nieuwe gebruikers.",
|
||||
"disableExternalLinks": "Externe links uitschakelen (behalve documentatie)",
|
||||
|
||||
@@ -326,7 +326,6 @@
|
||||
"tusUploadsRetryCount": "Liczba ponawianych prób w przypadku niepowodzenia przesłania fragmentu.",
|
||||
"userHomeBasePath": "Ścieżka podstawowa dla katalogów domowych użytkownika",
|
||||
"userScopeGenerationPlaceholder": "Zakres zostanie wygenerowany automatycznie",
|
||||
"createUserHomeDirectory": "Tworzenie katalogu domowego użytkownika",
|
||||
"shareDeleted": "Usunięto udostępnianie!",
|
||||
"loginMethodDescription": "dozwolona metoda logowania dla użytkownika.",
|
||||
"userNotAdmin": "Użytkownik nie jest administratorem",
|
||||
|
||||
@@ -275,7 +275,6 @@
|
||||
"createUserDir": "Criar diretório Home para novos usuários",
|
||||
"userHomeBasePath": "Caminho base para diretórios de usuários",
|
||||
"userScopeGenerationPlaceholder": "O escopo será gerado automaticamente",
|
||||
"createUserHomeDirectory": "Criar diretório Home de usuário",
|
||||
"customStylesheet": "Estilos personalizados",
|
||||
"defaultUserDescription": "Estas são as configurações padrão para novos usuários.",
|
||||
"disableExternalLinks": "Desabilitar links externos (exceto documentação)",
|
||||
|
||||
@@ -332,7 +332,6 @@
|
||||
"tusUploadsRetryCount": "Número de tentativas a efetuar se um pedaço não for carregado.",
|
||||
"userHomeBasePath": "Caminho de base para as diretorias pessoais dos utilizadores",
|
||||
"userScopeGenerationPlaceholder": "O âmbito será gerado automaticamente",
|
||||
"createUserHomeDirectory": "Criar o diretório pessoal do utilizador",
|
||||
"shareSettings": "Partilhar definições",
|
||||
"shareDeleted": "Partilhar eliminado!",
|
||||
"adminOptions": "Opções do utilizador administrador",
|
||||
|
||||
@@ -326,7 +326,6 @@
|
||||
"tusUploadsRetryCount": "Numărul de încercări care trebuie efectuate dacă o bucată nu reușește să fie încărcată.",
|
||||
"userHomeBasePath": "Calea de bază pentru directoarele home ale utilizatorilor",
|
||||
"userScopeGenerationPlaceholder": "Domeniul de aplicare va fi generat automat",
|
||||
"createUserHomeDirectory": "Crearea directorului home al utilizatorului",
|
||||
"shareDeleted": "Share șters!",
|
||||
"loginMethodDescription": "metoda de conectare permisă pentru utilizator.",
|
||||
"userNotAdmin": "Utilizatorul nu este administrator",
|
||||
|
||||
@@ -327,7 +327,6 @@
|
||||
"tusUploadsRetryCount": "Количество повторных попыток, которые необходимо выполнить, если чанк не удалось загрузить.",
|
||||
"userHomeBasePath": "Базовый путь к домашним каталогам пользователей",
|
||||
"userScopeGenerationPlaceholder": "Область действия будет сгенерирована автоматически",
|
||||
"createUserHomeDirectory": "Создать домашний каталог пользователя",
|
||||
"loginMethodDescription": "разрешенный метод входа для пользователя.",
|
||||
"userNotAdmin": "Пользователь не является администратором",
|
||||
"enterPassword": "Введите пароль",
|
||||
|
||||
@@ -327,7 +327,6 @@
|
||||
"tusUploadsRetryCount": "Počet opakovaných pokusov, ktoré sa vykonajú, ak sa nepodarí nahrať časť.",
|
||||
"userHomeBasePath": "Základná cesta pre domovské adresáre používateľov",
|
||||
"userScopeGenerationPlaceholder": "Rozsah sa vygeneruje automaticky",
|
||||
"createUserHomeDirectory": "Vytvorenie domovského adresára používateľa",
|
||||
"loginMethodDescription": "povolená metóda prihlásenia pre používateľa.",
|
||||
"userNotAdmin": "Používateľ nie je správca",
|
||||
"enterPassword": "Zadajte heslo",
|
||||
|
||||
@@ -332,7 +332,6 @@
|
||||
"tusUploadsRetryCount": "Antal försök som ska göras om en chunk inte kan laddas upp.",
|
||||
"userHomeBasePath": "Basväg för användares hemkataloger",
|
||||
"userScopeGenerationPlaceholder": "Omfattningen kommer att genereras automatiskt",
|
||||
"createUserHomeDirectory": "Skapa användarens hemkatalog",
|
||||
"shareSettings": "Dela inställningar",
|
||||
"shareDeleted": "Dela borttaget!",
|
||||
"adminOptions": "Alternativ för administratörsanvändare",
|
||||
|
||||
@@ -327,7 +327,6 @@
|
||||
"tusUploadsRetryCount": "Bir yığının yüklenememesi durumunda gerçekleştirilecek yeniden deneme sayısı.",
|
||||
"userHomeBasePath": "Kullanıcı ev dizinleri için temel yol",
|
||||
"userScopeGenerationPlaceholder": "Kapsam otomatik olarak oluşturulacaktır",
|
||||
"createUserHomeDirectory": "Kullanıcı ev dizini oluşturma",
|
||||
"loginMethodDescription": "kullanıcı için izin verilen oturum açma yöntemi.",
|
||||
"userNotAdmin": "Kullanıcı yönetici değil",
|
||||
"enterPassword": "Şifre girin",
|
||||
|
||||
@@ -333,7 +333,6 @@
|
||||
"tusUploadsRetryCount": "Кількість повторних спроб, якщо фрагмент не вдалося завантажити.",
|
||||
"userHomeBasePath": "Базовий шлях для домашніх каталогів користувачів",
|
||||
"userScopeGenerationPlaceholder": "Область дії буде згенеровано автоматично",
|
||||
"createUserHomeDirectory": "Створення домашнього каталогу користувача",
|
||||
"shareSettings": "Налаштування спільного доступу",
|
||||
"adminOptions": "Параметри користувача адміністрування",
|
||||
"searchOptions": "Параметри пошуку",
|
||||
|
||||
@@ -333,7 +333,6 @@
|
||||
"tusUploadsRetryCount": "块上传失败时的重试次数。",
|
||||
"userHomeBasePath": "用户主目录的基本路径",
|
||||
"userScopeGenerationPlaceholder": "范围将自动生成",
|
||||
"createUserHomeDirectory": "创建用户主目录",
|
||||
"shareSettings": "共享设置",
|
||||
"adminOptions": "管理员用户选项",
|
||||
"searchOptions": "搜索选项",
|
||||
|
||||
@@ -333,7 +333,6 @@
|
||||
"tusUploadsRetryCount": "分塊上傳失敗時的重試次數。",
|
||||
"userHomeBasePath": "使用者主目錄的基本路徑",
|
||||
"userScopeGenerationPlaceholder": "範圍會自動產生",
|
||||
"createUserHomeDirectory": "建立使用者主目錄",
|
||||
"shareSettings": "分享設定",
|
||||
"shareDeleted": "分享已刪除!",
|
||||
"adminOptions": "管理員使用者選項",
|
||||
|
||||
@@ -3,6 +3,47 @@ import { resourcesApi } from "@/api";
|
||||
import { state,mutations } from "@/store";
|
||||
import { getters } from "@/store/getters";
|
||||
|
||||
/**
|
||||
* Accumulates every directory entry from a FileSystemDirectoryReader.
|
||||
*
|
||||
* Chromium and Safari cap DirectoryReader.readEntries() at roughly one batch
|
||||
* (often 100 entries) per call — callers must loop until a batch is empty.
|
||||
*
|
||||
* @param {FileSystemDirectoryReader} reader
|
||||
* @param {string} [debugLabel] optional path/name for diagnostic logs
|
||||
* @returns {Promise<FileSystemEntry[]>}
|
||||
*/
|
||||
export async function readAllDirectoryEntries(reader, debugLabel = "") {
|
||||
/** @type {FileSystemEntry[]} */
|
||||
const all = [];
|
||||
let nonemptyBatches = 0;
|
||||
|
||||
/** @type {FileSystemEntry[]} */
|
||||
let batch = await new Promise((resolve, reject) => {
|
||||
reader.readEntries(resolve, reject);
|
||||
});
|
||||
|
||||
while (batch.length > 0) {
|
||||
nonemptyBatches++;
|
||||
all.push(...batch);
|
||||
batch = await new Promise((resolve, reject) => {
|
||||
reader.readEntries(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
if (nonemptyBatches > 1 || all.length > 100) {
|
||||
const suffix =
|
||||
debugLabel !== ""
|
||||
? ` (${debugLabel})`
|
||||
: "";
|
||||
console.info(
|
||||
`[FileBrowser upload] directory listing${suffix}: ${nonemptyBatches} readEntries batches — collected ${all.length} entries (multiple batches indicate a directory with > ~100 immediate children)`
|
||||
);
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
class UploadManager {
|
||||
constructor() {
|
||||
this.queue = reactive([]);
|
||||
@@ -183,6 +224,13 @@ class UploadManager {
|
||||
return upload;
|
||||
});
|
||||
|
||||
const dirCount = newUploads.length;
|
||||
if (fileUploads.length >= 50) {
|
||||
console.info(
|
||||
`[FileBrowser upload] add(): ${fileUploads.length} file job(s), ${dirCount} directory job(s) → base ${basePath}`
|
||||
);
|
||||
}
|
||||
|
||||
this.queue.push(...newUploads, ...fileUploads);
|
||||
|
||||
// Clean up pending items after successful add
|
||||
|
||||
@@ -149,6 +149,7 @@ import * as upload from "@/utils/upload";
|
||||
import throttle from "@/utils/throttle";
|
||||
import { state, mutations, getters } from "@/store";
|
||||
import { url } from "@/utils";
|
||||
import { readAllDirectoryEntries } from "@/utils/upload";
|
||||
|
||||
import Item from "@/components/files/ListingItem.vue";
|
||||
import Upload from "@/components/prompts/Upload.vue";
|
||||
@@ -874,11 +875,10 @@ export default {
|
||||
relativePath: entryPath,
|
||||
});
|
||||
} else if (entry.isDirectory) {
|
||||
// But if it's a directory, read the contents
|
||||
// But if it's a directory, read the contents (readEntries may require
|
||||
// multiple calls — browsers often return at most ~100 entries per batch)
|
||||
const reader = entry.createReader();
|
||||
const entries = await new Promise((resolve, reject) => {
|
||||
reader.readEntries(resolve, reject);
|
||||
});
|
||||
const entries = await readAllDirectoryEntries(reader, entryPath);
|
||||
// and then for each child recursively collect files
|
||||
for (const childEntry of entries) {
|
||||
const childFiles = await this.collectFilesFromEntry(
|
||||
|
||||
Reference in new issue
Block a user