mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-15 13:35:55 -05:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5597b28f10 | ||
|
|
6bfc397419 | ||
|
|
112740bdd4 | ||
|
|
9117fb2b5f | ||
|
|
668efec4d7 | ||
|
|
a5c81c8e2e |
No files matched your search
@@ -2,6 +2,26 @@
|
||||
|
||||
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.9
|
||||
|
||||
**Security**:
|
||||
- [Critical] Unauthenticated Path Traversal in Public Share Delete Allows Arbitrary File Deletion GHSA-fwj3-42wh-8673
|
||||
- [Moderate] Stored XSS via SVG File in Public Share (Missing CSP Header) GHSA-mmpx-jh39-wrv6
|
||||
|
||||
**BugFixes**:
|
||||
- Fix context menu items and adjusted when items show to more accurately reflect permissions.
|
||||
|
||||
## 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**:
|
||||
|
||||
@@ -15,3 +15,4 @@ userDefaults:
|
||||
permissions:
|
||||
download: true
|
||||
create: true
|
||||
share: true
|
||||
@@ -18,7 +18,7 @@ export default defineConfig({
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
retries: 1,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
@@ -53,8 +53,6 @@ export default defineConfig({
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'dark',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
retries: 0,
|
||||
},
|
||||
{
|
||||
name: "light-screenshots",
|
||||
@@ -62,8 +60,6 @@ export default defineConfig({
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'light',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
retries: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -101,15 +101,6 @@ func ApplyUserDefaults(u *users.User) {
|
||||
u.PreferEditorForMarkdown = d.PreferEditorForMarkdown
|
||||
u.FileLoading = d.FileLoading
|
||||
|
||||
u.Permissions.Api = d.Permissions.Api
|
||||
u.Permissions.Admin = d.Permissions.Admin
|
||||
u.Permissions.Modify = d.Permissions.Modify
|
||||
u.Permissions.Share = d.Permissions.Share
|
||||
u.Permissions.Realtime = d.Permissions.Realtime
|
||||
u.Permissions.Delete = d.Permissions.Delete
|
||||
u.Permissions.Create = d.Permissions.Create
|
||||
u.Permissions.Download = boolValueOrDefault(d.Permissions.Download, true)
|
||||
|
||||
u.Preview.DisableHideSidebar = d.Preview.DisableHideSidebar
|
||||
u.Preview.Image = boolValueOrDefault(d.Preview.Image, true)
|
||||
u.Preview.Video = boolValueOrDefault(d.Preview.Video, true)
|
||||
@@ -122,11 +113,24 @@ func ApplyUserDefaults(u *users.User) {
|
||||
u.Preview.Folder = boolValueOrDefault(d.Preview.Folder, true)
|
||||
u.Preview.Models = boolValueOrDefault(d.Preview.Models, true)
|
||||
|
||||
if u.Username == "anonymous" {
|
||||
return
|
||||
}
|
||||
|
||||
u.Permissions.Api = d.Permissions.Api
|
||||
u.Permissions.Admin = d.Permissions.Admin
|
||||
u.Permissions.Modify = d.Permissions.Modify
|
||||
u.Permissions.Share = d.Permissions.Share
|
||||
u.Permissions.Realtime = d.Permissions.Realtime
|
||||
u.Permissions.Delete = d.Permissions.Delete
|
||||
u.Permissions.Create = d.Permissions.Create
|
||||
u.Permissions.Download = boolValueOrDefault(d.Permissions.Download, true)
|
||||
|
||||
if u.LoginMethod == "" && d.LoginMethod != "" {
|
||||
u.LoginMethod = users.LoginMethod(d.LoginMethod)
|
||||
}
|
||||
|
||||
if len(u.Scopes) == 0 && u.Username != "anonymous" {
|
||||
if len(u.Scopes) == 0 {
|
||||
for _, source := range Config.Server.Sources {
|
||||
if source.Config.DefaultEnabled {
|
||||
u.Scopes = append(u.Scopes, users.SourceScope{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,8 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
|
||||
dispositionType := "attachment"
|
||||
if r.URL.Query().Get("inline") == "true" {
|
||||
dispositionType = "inline"
|
||||
// Inline SVG (and similar) can execute embedded scripts when opened as a top-level document; match upstream filebrowser mitigation.
|
||||
w.Header().Set("Content-Security-Policy", "script-src 'none'")
|
||||
}
|
||||
|
||||
// standard: ASCII-only safe fallback
|
||||
|
||||
@@ -150,7 +150,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
// ========================================
|
||||
api.HandleFunc("GET /share/list", withPermShare(shareListHandler))
|
||||
api.HandleFunc("GET /share/direct", withPermShare(shareDirectDownloadHandler))
|
||||
api.HandleFunc("GET /share", withPermShare(shareGetHandler))
|
||||
api.HandleFunc("GET /share", withUser(shareGetHandler))
|
||||
api.HandleFunc("POST /share", withPermShare(sharePostHandler))
|
||||
api.HandleFunc("PATCH /share", withPermShare(sharePatchHandler))
|
||||
api.HandleFunc("DELETE /share", withPermShare(shareDeleteHandler))
|
||||
|
||||
@@ -53,7 +53,11 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContex
|
||||
func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
return withOrWithoutUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
path := r.URL.Query().Get("path")
|
||||
inputPath := r.URL.Query().Get("path")
|
||||
path, err := utils.SanitizeUserPath(inputPath)
|
||||
if err != nil && inputPath != "" {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
// Get the file link by hash
|
||||
link, err := store.Share.GetByHash(hash)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+23
-13
@@ -251,25 +251,35 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
|
||||
// Process each item one at a time
|
||||
for _, item := range items {
|
||||
rawPath := item.Path
|
||||
// Validate item
|
||||
if item.Path == "" {
|
||||
if rawPath == "" {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: rawPath,
|
||||
Message: "path was empty",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Prevent deletion of root
|
||||
if item.Path == "/" {
|
||||
if rawPath == "/" {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: rawPath,
|
||||
Message: "cannot delete root directory",
|
||||
})
|
||||
continue
|
||||
}
|
||||
sanitizedPath, err := utils.SanitizeUserPath(rawPath)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: sanitizedPath,
|
||||
Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if d.share != nil {
|
||||
sourceName := d.share.GetSourceName()
|
||||
@@ -281,13 +291,13 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: "user does not have access",
|
||||
})
|
||||
continue
|
||||
}
|
||||
withoutUserScope := strings.TrimPrefix(d.share.Path, userScope)
|
||||
indexPath := utils.JoinPathAsUnix(withoutUserScope, item.Path)
|
||||
indexPath := utils.JoinPathAsUnix(withoutUserScope, sanitizedPath)
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
@@ -305,7 +315,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
logger.Errorf("resource bulk delete handler: error deleting file/directory: %v", err)
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: "error deleting file/directory, admin must check the logs",
|
||||
})
|
||||
continue
|
||||
@@ -317,7 +327,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if item.Source == "" {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: "source was empty, source is required",
|
||||
})
|
||||
continue
|
||||
@@ -328,7 +338,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: fmt.Sprintf("user does not have access: %v", err),
|
||||
})
|
||||
continue
|
||||
@@ -338,7 +348,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if idx == nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: "source not found",
|
||||
})
|
||||
continue
|
||||
@@ -347,14 +357,14 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
// Get file info
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Source: item.Source,
|
||||
ShowHidden: true,
|
||||
}, store.Access, filePermUser, store.Share)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
@@ -363,7 +373,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -44,7 +44,8 @@ export default defineConfig({
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'dark',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
/* Include every spec under testDir (prompts.spec.ts, settings-screenshots, etc.) */
|
||||
testMatch: /\.spec\.ts$/,
|
||||
retries: 0,
|
||||
},
|
||||
{
|
||||
@@ -53,7 +54,7 @@ export default defineConfig({
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'light',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
testMatch: /\.spec\.ts$/,
|
||||
retries: 0,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -14,11 +14,23 @@
|
||||
:class="{ 'dark-mode': isDarkMode, 'centered': centered }"
|
||||
:key="showCreate ? 'create-mode' : 'normal-mode'"
|
||||
>
|
||||
<div v-if="!showLimitedOptions" class="context-menu-header">
|
||||
<div
|
||||
v-if="showCreateToggle"
|
||||
class="context-menu-header"
|
||||
>
|
||||
<div
|
||||
class="action button clickable"
|
||||
v-if="showCreateButton"
|
||||
@click="toggleShowCreate"
|
||||
v-if="showCreateToggle"
|
||||
class="action button clickable context-menu-create-toggle"
|
||||
aria-label="Create Actions Toggle"
|
||||
:class="{ 'context-menu-create-toggle--disabled': createToggleDisabled }"
|
||||
role="button"
|
||||
:aria-disabled="createToggleDisabled ? 'true' : 'false'"
|
||||
:tabindex="createToggleDisabled ? -1 : 0"
|
||||
@click="onCreateToggleClick"
|
||||
@keydown.enter.prevent="onCreateToggleClick"
|
||||
@keydown.space.prevent="onCreateToggleClick"
|
||||
@mouseenter="onCreateToggleMouseEnter"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<i v-if="!showCreate" class="material-symbols">add</i>
|
||||
<i v-else class="material-symbols">arrow_back</i>
|
||||
@@ -35,23 +47,29 @@
|
||||
</div>
|
||||
<hr v-if="showDivider" class="divider">
|
||||
<action
|
||||
v-if="showCreateActions"
|
||||
v-if="showCreateFileActions"
|
||||
icon="create_new_folder"
|
||||
:label="$t('files.newFolder')"
|
||||
@action="showNewDirPrompt"
|
||||
/>
|
||||
<action
|
||||
v-if="showCreateActions"
|
||||
v-if="showCreateFileActions"
|
||||
icon="note_add"
|
||||
:label="$t('files.newFile')"
|
||||
@action="showPrompt('newFile')"
|
||||
/>
|
||||
<action
|
||||
v-if="showCreateActions"
|
||||
v-if="showCreateFileActions"
|
||||
icon="file_upload"
|
||||
:label="$t('general.upload')"
|
||||
@action="showUploadPrompt"
|
||||
/>
|
||||
<action
|
||||
v-if="showArchive"
|
||||
icon="archive"
|
||||
:label="$t('prompts.archive')"
|
||||
@action="showArchivePrompt"
|
||||
/>
|
||||
<action
|
||||
v-if="showInfo"
|
||||
icon="info"
|
||||
@@ -65,12 +83,6 @@
|
||||
:label="$t('general.download')"
|
||||
@action="startDownload"
|
||||
/>
|
||||
<action
|
||||
v-if="showArchive"
|
||||
icon="archive"
|
||||
:label="$t('prompts.archive')"
|
||||
@action="showArchivePrompt"
|
||||
/>
|
||||
<action
|
||||
v-if="showUnarchive"
|
||||
icon="unarchive"
|
||||
@@ -229,7 +241,10 @@ export default {
|
||||
computed: {
|
||||
// Either from prop or from state
|
||||
providedItems() {
|
||||
if (this.items) return this.items;
|
||||
// Only treat non-empty explicit `items` as overriding selection ([] means "use global state")
|
||||
if (Array.isArray(this.items) && this.items.length > 0) {
|
||||
return this.items;
|
||||
}
|
||||
// Fallback to global selection (indices or objects)
|
||||
if (state.selected.length === 0) return [];
|
||||
// Map to actual items from state.req
|
||||
@@ -253,39 +268,60 @@ export default {
|
||||
permissions() {
|
||||
return getters.permissions();
|
||||
},
|
||||
/**
|
||||
* Whether the (+) panel can be opened (create file ops and/or admin-only rows like access rules).
|
||||
*/
|
||||
canOpenCreatePanel() {
|
||||
if (getters.isShare()) {
|
||||
return !!getters.permissions().create;
|
||||
}
|
||||
return getters.isAdmin() || !!state.user?.permissions?.create;
|
||||
},
|
||||
/** New folder / new file / upload — requires permissions.create only (not admin alone). */
|
||||
showCreateFileActions() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
if (!this.permissions.create) return false;
|
||||
return this.showCreate && !this.isSearchActive;
|
||||
},
|
||||
req() {
|
||||
return state.req;
|
||||
},
|
||||
isShare() {
|
||||
return getters.isShare();
|
||||
},
|
||||
showCreateActions() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
return this.showCreate && !this.isSearchActive;
|
||||
},
|
||||
showInfo() {
|
||||
if (this.showLimitedOptions) return this.selectedCount == 1;
|
||||
return !this.showCreate && this.selectedCount == 1;
|
||||
},
|
||||
showDownload() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
return !this.showCreate && this.permissions.download && this.selectedCount > 0;
|
||||
return (
|
||||
!this.showCreate &&
|
||||
!!this.permissions.download &&
|
||||
this.selectedCount > 0
|
||||
);
|
||||
},
|
||||
showArchive() {
|
||||
if (this.showLimitedOptions || getters.isShare()) return false;
|
||||
if (!this.permissions.create) return false;
|
||||
return !this.showCreate && this.selectedCount > 0 && !this.showUnarchive;
|
||||
return (
|
||||
this.showCreate &&
|
||||
this.selectedCount > 0 &&
|
||||
!this.showUnarchive &&
|
||||
!this.isSearchActive
|
||||
);
|
||||
},
|
||||
showUnarchive() {
|
||||
if (this.showLimitedOptions || getters.isShare()) return false;
|
||||
if (!this.permissions.create) return false;
|
||||
if (this.showCreate) return false;
|
||||
if (this.selectedCount !== 1) return false;
|
||||
const item = this.firstSelected;
|
||||
return item && isArchivePath(item.path || item.from || item.name);
|
||||
},
|
||||
showShareAction() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
return (this.showCreate || this.selectedCount == 1) && this.showShare;
|
||||
return this.selectedCount <= 1 && this.showShare;
|
||||
},
|
||||
showRename() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
@@ -310,16 +346,17 @@ export default {
|
||||
if (this.showLimitedOptions) return false;
|
||||
return !this.showCreate && !this.isSearchActive && this.req?.items?.length > 0;
|
||||
},
|
||||
showCreateButton() {
|
||||
/** + control in listing / share directory context; disabled when the panel cannot be opened. */
|
||||
showCreateToggle() {
|
||||
if (this.showLimitedOptions || this.createOnly) return false;
|
||||
return !this.isSearchActive && this.permissions.create && !this.isShare;
|
||||
return !this.isSearchActive;
|
||||
},
|
||||
createToggleDisabled() {
|
||||
return !this.canOpenCreatePanel;
|
||||
},
|
||||
showDivider() {
|
||||
if (this.showLimitedOptions || this.createOnly) return false;
|
||||
if (getters.isShare()) {
|
||||
return state.shareInfo?.allowCreate
|
||||
}
|
||||
return state.user?.permissions?.create || state.user?.permissions?.share || state.user?.permissions?.admin;
|
||||
return this.showCreateToggle || this.selectedCount > 0;
|
||||
},
|
||||
showSelectMultiple() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
@@ -359,11 +396,14 @@ export default {
|
||||
return false;
|
||||
}
|
||||
const cv = getters.currentView();
|
||||
const showDelete = cv != "settings" && !this.isSearchActive && this.permissions.delete;
|
||||
return showDelete;
|
||||
return (
|
||||
cv != "settings" &&
|
||||
!this.isSearchActive &&
|
||||
!!this.permissions.delete
|
||||
);
|
||||
},
|
||||
hasDownload() {
|
||||
return this.selectedCount > 0 && this.permissions.download;
|
||||
return this.selectedCount > 0 && !!this.permissions.download;
|
||||
},
|
||||
isPreview() {
|
||||
return getters.isPreviewView();
|
||||
@@ -380,7 +420,10 @@ export default {
|
||||
if (getters.isShare()) {
|
||||
return false;
|
||||
}
|
||||
return this.permissions.admin && this.showCreate;
|
||||
if (this.selectedCount > 1) {
|
||||
return false;
|
||||
}
|
||||
return getters.isAdmin() && this.showCreate;
|
||||
},
|
||||
showShare() {
|
||||
if (getters.isShare()) {
|
||||
@@ -476,8 +519,16 @@ export default {
|
||||
y: event.clientY,
|
||||
});
|
||||
},
|
||||
onCreateToggleClick() {
|
||||
if (this.createToggleDisabled) return;
|
||||
this.toggleShowCreate();
|
||||
},
|
||||
onCreateToggleMouseEnter(event) {
|
||||
if (!this.createToggleDisabled) return;
|
||||
this.showTooltip(event, this.$t("messages.noCreateOptions"));
|
||||
},
|
||||
toggleShowCreate() {
|
||||
if (!this.permissions.create) {
|
||||
if (!this.canOpenCreatePanel) {
|
||||
this.showCreate = false;
|
||||
return;
|
||||
}
|
||||
@@ -488,11 +539,9 @@ export default {
|
||||
},
|
||||
showAccessPrompt() {
|
||||
mutations.closeHovers();
|
||||
let sourceName = this.firstSelected?.source || state.req.source;
|
||||
let path = this.firstSelected?.path || state.req.path;
|
||||
if (this.firstSelected && !this.firstSelected.isDir) {
|
||||
path = url.removeLastDir(path) + '/';
|
||||
}
|
||||
const item = this.firstSelected;
|
||||
let sourceName = item?.source || state.req.source;
|
||||
let path = item?.path || state.req.path;
|
||||
mutations.showPrompt({
|
||||
name: "access",
|
||||
props: {
|
||||
@@ -564,7 +613,7 @@ export default {
|
||||
}, 300);
|
||||
},
|
||||
startShowCreate() {
|
||||
if (!this.permissions.create) {
|
||||
if (!this.canOpenCreatePanel) {
|
||||
return;
|
||||
}
|
||||
this.showCreate = true;
|
||||
@@ -609,17 +658,12 @@ export default {
|
||||
this.posY = contextProps.posY;
|
||||
},
|
||||
initializeCreateState() {
|
||||
// If createOnly is set, always show create actions
|
||||
if (this.createOnly) {
|
||||
this.showCreate = true;
|
||||
return;
|
||||
}
|
||||
// Only set initial showCreate state, don't override user choices
|
||||
if (this.selectedCount > 0 || !this.permissions.create) {
|
||||
this.showCreate = false;
|
||||
} else {
|
||||
this.showCreate = true;
|
||||
}
|
||||
// Right-click / prompt: start in normal mode; user opens create via + or sidebar (createOnly).
|
||||
this.showCreate = false;
|
||||
},
|
||||
toggleMultipleSelection() {
|
||||
mutations.setMultiple(true);
|
||||
@@ -849,6 +893,11 @@ export default {
|
||||
padding: 0.25em;
|
||||
}
|
||||
|
||||
.context-menu-create-toggle--disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#context-menu .action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
:description="$t('fileLoading.clearAllDescription')" />
|
||||
</div>
|
||||
</SettingsItem>
|
||||
<div class="upload-prompt" :class="{ dropping: isDragging }" @dragenter.prevent="onDragEnter"
|
||||
<div class="upload-prompt" :class="{ dropping: isDragging }"
|
||||
@dragenter.prevent="onDragEnter"
|
||||
@dragover.prevent="onDragOver" @dragleave.prevent="onDragLeave" @drop.prevent="onDrop">
|
||||
<div class="upload-prompt-container">
|
||||
<i v-if="files.length === 0" class="material-symbols">cloud_upload</i>
|
||||
@@ -132,7 +133,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 +486,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": "المستخدم ليس مسؤولاً",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "إعادة تعيين المسار",
|
||||
"pathReassigned": "تم تحديث المسار بنجاح!",
|
||||
"pathReassignFailed": "فشل تحديث المسار. يرجى المحاولة مرة أخرى.",
|
||||
"incompleteResultsDetails": "عرض أقصى النتائج ({max}). حاول تضييق نطاق البحث للحصول على المزيد من النتائج تحت {max}."
|
||||
"incompleteResultsDetails": "عرض أقصى النتائج ({max}). حاول تضييق نطاق البحث للحصول على المزيد من النتائج تحت {max}.",
|
||||
"noCreateOptions": "لا توجد خيارات إنشاء متاحة"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "أداة البحث عن الملفات المكررة",
|
||||
|
||||
@@ -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)",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "Změna přiřazení cesty",
|
||||
"pathReassigned": "Cesta byla úspěšně aktualizována!",
|
||||
"pathReassignFailed": "Nepodařilo se aktualizovat cestu. Zkuste to prosím znovu.",
|
||||
"incompleteResultsDetails": "Zobrazeno max. výsledků ({max}). Zkuste zúžit hledání a získat více výsledků pod {max}."
|
||||
"incompleteResultsDetails": "Zobrazeno max. výsledků ({max}). Zkuste zúžit hledání a získat více výsledků pod {max}.",
|
||||
"noCreateOptions": "Nejsou k dispozici žádné možnosti vytváření"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Vyhledávač duplicitních souborů",
|
||||
|
||||
@@ -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",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "Pfad neu zuweisen",
|
||||
"pathReassigned": "Pfad erfolgreich aktualisiert!",
|
||||
"pathReassignFailed": "Der Pfad konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut.",
|
||||
"incompleteResultsDetails": "Zeigt max Ergebnisse ({max}). Versuchen Sie, Ihre Suche einzugrenzen, um mehr Ergebnisse unter {max} zu erhalten."
|
||||
"incompleteResultsDetails": "Zeigt max Ergebnisse ({max}). Versuchen Sie, Ihre Suche einzugrenzen, um mehr Ergebnisse unter {max} zu erhalten.",
|
||||
"noCreateOptions": "Es sind keine Erstellungsoptionen verfügbar"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Duplicate File Finder",
|
||||
|
||||
@@ -282,7 +282,6 @@
|
||||
"tusUploadsRetryCount": "Αριθμός επαναληπτικών δοκιμών που θα πραγματοποιηθούν αν αποτύχει η μεταφόρτωση ενός τμήματος.",
|
||||
"userHomeBasePath": "Βασική διαδρομή αρχείων για τους φακέλους των χρηστών",
|
||||
"userScopeGenerationPlaceholder": "Η εμβέλεια εφαρμογής θα δημιουργηθεί αυτόματα",
|
||||
"createUserHomeDirectory": "Δημιουργία φακέλου χρήστη",
|
||||
"customStylesheet": "Προσαρμοσμένο στυλ εμφάνισης (stylesheet)",
|
||||
"defaultUserDescription": "Αυτές είναι οι προεπιλεγμένες ρυθμίσεις για νέους χρήστες.",
|
||||
"disableExternalLinks": "Απενεργοποίηση εξωτερικών συνδέσμων (εκτός από συνδέσμους προς τις οδηγίες χρήσης)",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "Επαναπροσδιορισμός διαδρομής",
|
||||
"pathReassigned": "Η διαδρομή ενημερώθηκε επιτυχώς!",
|
||||
"pathReassignFailed": "Απέτυχε η ενημέρωση της διαδρομής. Προσπαθήστε ξανά.",
|
||||
"incompleteResultsDetails": "Εμφάνιση μέγιστων αποτελεσμάτων ({max}). Προσπαθήστε να περιορίσετε την αναζήτησή σας για να πάρετε περισσότερα αποτελέσματα κάτω από {max}."
|
||||
"incompleteResultsDetails": "Εμφάνιση μέγιστων αποτελεσμάτων ({max}). Προσπαθήστε να περιορίσετε την αναζήτησή σας για να πάρετε περισσότερα αποτελέσματα κάτω από {max}.",
|
||||
"noCreateOptions": "Δεν υπάρχουν διαθέσιμες επιλογές δημιουργίας"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Αναζήτηση διπλών αρχείων",
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"reassignPath": "Reassign Path",
|
||||
"pathReassigned": "Path successfully updated!",
|
||||
"pathReassignFailed": "Failed to update path. Please try again.",
|
||||
"incompleteResultsDetails": "Showing max results ({max}). Try narrowing your search to get more results under {max}."
|
||||
"incompleteResultsDetails": "Showing max results ({max}). Try narrowing your search to get more results under {max}.",
|
||||
"noCreateOptions": "No create options are available"
|
||||
},
|
||||
"general": {
|
||||
"divider": "Divider{suffix}",
|
||||
@@ -577,7 +578,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",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "Reasignar ruta",
|
||||
"pathReassigned": "¡La ruta se actualizó correctamente!",
|
||||
"pathReassignFailed": "No se pudo actualizar la ruta. Inténtalo de nuevo",
|
||||
"incompleteResultsDetails": "Mostrando resultados máximos ({max}). Intenta restringir la búsqueda para obtener más resultados en {max}."
|
||||
"incompleteResultsDetails": "Mostrando resultados máximos ({max}). Intenta restringir la búsqueda para obtener más resultados en {max}.",
|
||||
"noCreateOptions": "No hay opciones de creación disponibles"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Buscador de archivos duplicados",
|
||||
|
||||
@@ -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",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "Réaffecter le chemin",
|
||||
"pathReassigned": "Mise à jour du chemin d'accès réussie !",
|
||||
"pathReassignFailed": "Échec de la mise à jour du chemin d'accès. Veuillez réessayer.",
|
||||
"incompleteResultsDetails": "Résultats maximums ({max}). Essayez d'affiner votre recherche pour obtenir plus de résultats sous {max}."
|
||||
"incompleteResultsDetails": "Résultats maximums ({max}). Essayez d'affiner votre recherche pour obtenir plus de résultats sous {max}.",
|
||||
"noCreateOptions": "Aucune option de création n'est disponible"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Recherche de fichiers en double",
|
||||
|
||||
@@ -275,7 +275,6 @@
|
||||
"createUserDir": "צור תקיית בית במהלך הוספת משתמש חדש",
|
||||
"userHomeBasePath": "נתיב ראשי לתקיות הבית של משתמשים",
|
||||
"userScopeGenerationPlaceholder": "התחום יווצר אוטומטית",
|
||||
"createUserHomeDirectory": "צור תקיית בית למשתמש",
|
||||
"customStylesheet": "עיצוב מותאם אישית (Stylesheet)",
|
||||
"defaultUserDescription": "אלה הגדרות ברירת המחדל למשתמשים חדשים",
|
||||
"disableExternalLinks": "השבת קישורים חיצוניים (למעט תיעוד)",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "הקצאת נתיב מחדש",
|
||||
"pathReassigned": "הנתיב עודכן בהצלחה!",
|
||||
"pathReassignFailed": "לא ניתן לעדכן את הנתיב. אנא נסה שוב.",
|
||||
"incompleteResultsDetails": "הצגת תוצאות מקסימליות ({max}). נסה לצמצם את החיפוש כדי לקבל תוצאות נוספות תחת {max}."
|
||||
"incompleteResultsDetails": "הצגת תוצאות מקסימליות ({max}). נסה לצמצם את החיפוש כדי לקבל תוצאות נוספות תחת {max}.",
|
||||
"noCreateOptions": "אין אפשרויות יצירה זמינות"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "מאתר קבצים כפולים",
|
||||
|
||||
@@ -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ó)",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "Útvonal újbóli kijelölése",
|
||||
"pathReassigned": "Az útvonal sikeresen frissült!",
|
||||
"pathReassignFailed": "Nem sikerült frissíteni az elérési utat. Kérjük, próbálja újra.",
|
||||
"incompleteResultsDetails": "Maximális eredmények megjelenítése ({max}). Próbálja szűkíteni a keresést, hogy több eredményt kapjon a {max} alatt ."
|
||||
"incompleteResultsDetails": "Maximális eredmények megjelenítése ({max}). Próbálja szűkíteni a keresést, hogy több eredményt kapjon a {max} alatt .",
|
||||
"noCreateOptions": "Nincs lehetőség létrehozására"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Duplikátum fájl kereső",
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"reassignPath": "Riassegna percorso",
|
||||
"pathReassigned": "Percorso aggiornato con successo!",
|
||||
"pathReassignFailed": "Aggiornamento del percorso fallito. Riprova.",
|
||||
"incompleteResultsDetails": "Visualizzazione dei risultati massimi ({max}). Prova a restringere la ricerca per ottenere più risultati sotto {max}."
|
||||
"incompleteResultsDetails": "Visualizzazione dei risultati massimi ({max}). Prova a restringere la ricerca per ottenere più risultati sotto {max}.",
|
||||
"noCreateOptions": "Non sono disponibili opzioni di creazione"
|
||||
},
|
||||
"general": {
|
||||
"divider": "Divisore{suffix}",
|
||||
@@ -577,7 +578,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": "ユーザーは管理者ではありません",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "パスの再割り当て",
|
||||
"pathReassigned": "パスは正常に更新されました!",
|
||||
"pathReassignFailed": "パスの更新に失敗しました。再試行してください。",
|
||||
"incompleteResultsDetails": "最大結果を表示 ({max}).{max} でより多くの結果を得るために絞り込んで検索してみてください。"
|
||||
"incompleteResultsDetails": "最大結果を表示 ({max}).{max} でより多くの結果を得るために絞り込んで検索してみてください。",
|
||||
"noCreateOptions": "作成オプションはありません"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "重複ファイルファインダー",
|
||||
|
||||
@@ -326,7 +326,6 @@
|
||||
"tusUploadsRetryCount": "청크 업로드에 실패한 경우 수행할 재시도 횟수입니다.",
|
||||
"userHomeBasePath": "사용자 홈 디렉터리의 기본 경로",
|
||||
"userScopeGenerationPlaceholder": "범위가 자동으로 생성됩니다.",
|
||||
"createUserHomeDirectory": "사용자 홈 디렉터리 만들기",
|
||||
"shareDeleted": "공유 삭제됨!",
|
||||
"loginMethodDescription": "사용자에게 허용된 로그인 방법을 설정합니다.",
|
||||
"userNotAdmin": "사용자가 관리자가 아닙니다.",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "경로 재할당",
|
||||
"pathReassigned": "경로가 성공적으로 업데이트되었습니다!",
|
||||
"pathReassignFailed": "경로를 업데이트하지 못했습니다. 다시 시도하세요.",
|
||||
"incompleteResultsDetails": "최대 결과 표시 ({max}). 검색 범위를 좁히면 {max} 에서 더 많은 결과를 확인할 수 있습니다."
|
||||
"incompleteResultsDetails": "최대 결과 표시 ({max}). 검색 범위를 좁히면 {max} 에서 더 많은 결과를 확인할 수 있습니다.",
|
||||
"noCreateOptions": "사용 가능한 생성 옵션이 없습니다."
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "중복 파일 찾기",
|
||||
|
||||
@@ -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",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "Pad opnieuw toewijzen",
|
||||
"pathReassigned": "Pad succesvol bijgewerkt!",
|
||||
"pathReassignFailed": "Het bijwerken van het pad is mislukt. Probeer het opnieuw.",
|
||||
"incompleteResultsDetails": "Max resultaten ({max}). Probeer uw zoekopdracht te verfijnen voor meer resultaten onder {max}."
|
||||
"incompleteResultsDetails": "Max resultaten ({max}). Probeer uw zoekopdracht te verfijnen voor meer resultaten onder {max}.",
|
||||
"noCreateOptions": "Er zijn geen opties beschikbaar"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Duplicaat bestand zoeken",
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"reassignPath": "Pad opnieuw toewijzen",
|
||||
"pathReassigned": "Pad succesvol bijgewerkt!",
|
||||
"pathReassignFailed": "Het bijwerken van het pad is mislukt. Probeer het opnieuw.",
|
||||
"incompleteResultsDetails": "Max. resultaten weergeven({max}). Probeer de zoekopdracht aan te passen voor meer resultaten onder {max}."
|
||||
"incompleteResultsDetails": "Max. resultaten weergeven({max}). Probeer de zoekopdracht aan te passen voor meer resultaten onder {max}.",
|
||||
"noCreateOptions": "Er zijn geen opties beschikbaar"
|
||||
},
|
||||
"general": {
|
||||
"divider": "Scheiding{suffix}",
|
||||
@@ -577,7 +578,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",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "Ponowne przypisanie ścieżki",
|
||||
"pathReassigned": "Ścieżka została pomyślnie zaktualizowana!",
|
||||
"pathReassignFailed": "Nie udało się zaktualizować ścieżki. Spróbuj ponownie.",
|
||||
"incompleteResultsDetails": "Wyświetlanie maksymalnych wyników ({max}). Spróbuj zawęzić wyszukiwanie, aby uzyskać więcej wyników pod {max}."
|
||||
"incompleteResultsDetails": "Wyświetlanie maksymalnych wyników ({max}). Spróbuj zawęzić wyszukiwanie, aby uzyskać więcej wyników pod {max}.",
|
||||
"noCreateOptions": "Opcje tworzenia nie są dostępne"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Wyszukiwarka duplikatów plików",
|
||||
|
||||
@@ -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)",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "Reatribuir caminho",
|
||||
"pathReassigned": "Caminho atualizado com sucesso!",
|
||||
"pathReassignFailed": "Falha ao atualizar o caminho. Tente novamente.",
|
||||
"incompleteResultsDetails": "Mostrando o máximo de resultados ({max}). Tente restringir sua pesquisa para obter mais resultados em {max}."
|
||||
"incompleteResultsDetails": "Mostrando o máximo de resultados ({max}). Tente restringir sua pesquisa para obter mais resultados em {max}.",
|
||||
"noCreateOptions": "Nenhuma opção de criação está disponível"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Localizador de arquivos duplicados",
|
||||
|
||||
@@ -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",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "Reatribuir trajetória",
|
||||
"pathReassigned": "Caminho atualizado com sucesso!",
|
||||
"pathReassignFailed": "Falha ao atualizar o caminho. Por favor, tente novamente.",
|
||||
"incompleteResultsDetails": "Mostrando resultados máximos ({max}). Tente restringir a sua pesquisa para obter mais resultados em {max}."
|
||||
"incompleteResultsDetails": "Mostrando resultados máximos ({max}). Tente restringir a sua pesquisa para obter mais resultados em {max}.",
|
||||
"noCreateOptions": "Não estão disponíveis opções de criação"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Localizador de ficheiros duplicados",
|
||||
|
||||
@@ -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",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "Reatribuiți calea",
|
||||
"pathReassigned": "Calea a fost actualizată cu succes!",
|
||||
"pathReassignFailed": "A eșuat actualizarea căii. Vă rugăm să încercați din nou.",
|
||||
"incompleteResultsDetails": "Afișarea rezultatelor maxime ({max}). Încercați să restrângeți căutarea pentru a obține mai multe rezultate la {max}."
|
||||
"incompleteResultsDetails": "Afișarea rezultatelor maxime ({max}). Încercați să restrângeți căutarea pentru a obține mai multe rezultate la {max}.",
|
||||
"noCreateOptions": "Nu sunt disponibile opțiuni de creare"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Duplicate fișier Finder",
|
||||
|
||||
@@ -327,7 +327,6 @@
|
||||
"tusUploadsRetryCount": "Количество повторных попыток, которые необходимо выполнить, если чанк не удалось загрузить.",
|
||||
"userHomeBasePath": "Базовый путь к домашним каталогам пользователей",
|
||||
"userScopeGenerationPlaceholder": "Область действия будет сгенерирована автоматически",
|
||||
"createUserHomeDirectory": "Создать домашний каталог пользователя",
|
||||
"loginMethodDescription": "разрешенный метод входа для пользователя.",
|
||||
"userNotAdmin": "Пользователь не является администратором",
|
||||
"enterPassword": "Введите пароль",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "Переназначить путь",
|
||||
"pathReassigned": "Путь успешно обновлен!",
|
||||
"pathReassignFailed": "Не удалось обновить путь. Пожалуйста, попробуйте еще раз.",
|
||||
"incompleteResultsDetails": "Показано максимум результатов ({max}). Попробуйте сузить поиск, чтобы получить больше результатов по запросу {max}."
|
||||
"incompleteResultsDetails": "Показано максимум результатов ({max}). Попробуйте сузить поиск, чтобы получить больше результатов по запросу {max}.",
|
||||
"noCreateOptions": "Опции создания недоступны"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Поиск дубликатов файлов",
|
||||
|
||||
@@ -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",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "Opätovné priradenie cesty",
|
||||
"pathReassigned": "Cesta bola úspešne aktualizovaná!",
|
||||
"pathReassignFailed": "Nepodarilo sa aktualizovať cestu. Skúste to prosím znova.",
|
||||
"incompleteResultsDetails": "Zobrazené maximálne výsledky ({max}). Skúste zúžiť vyhľadávanie, aby ste získali viac výsledkov pod {max}."
|
||||
"incompleteResultsDetails": "Zobrazené maximálne výsledky ({max}). Skúste zúžiť vyhľadávanie, aby ste získali viac výsledkov pod {max}.",
|
||||
"noCreateOptions": "Nie sú k dispozícii žiadne možnosti vytvárania"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Vyhľadávač duplicitných súborov",
|
||||
|
||||
@@ -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",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "Omfördela sökväg",
|
||||
"pathReassigned": "Sökvägen har uppdaterats!",
|
||||
"pathReassignFailed": "Misslyckades med att uppdatera sökvägen. Vänligen försök igen.",
|
||||
"incompleteResultsDetails": "Visar max resultat ({max}). Försök att begränsa din sökning för att få fler resultat under {max}."
|
||||
"incompleteResultsDetails": "Visar max resultat ({max}). Försök att begränsa din sökning för att få fler resultat under {max}.",
|
||||
"noCreateOptions": "Inga alternativ för skapande är tillgängliga"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Hitta duplicerade filer",
|
||||
|
||||
@@ -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",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "Yolu Yeniden Atayın",
|
||||
"pathReassigned": "Yol başarıyla güncellendi!",
|
||||
"pathReassignFailed": "Yol güncellenemedi. Lütfen tekrar deneyin.",
|
||||
"incompleteResultsDetails": "Maksimum sonuç gösteriliyor ({max}). {max} altında daha fazla sonuç almak için aramanızı daraltmayı deneyin."
|
||||
"incompleteResultsDetails": "Maksimum sonuç gösteriliyor ({max}). {max} altında daha fazla sonuç almak için aramanızı daraltmayı deneyin.",
|
||||
"noCreateOptions": "Oluşturma seçeneği mevcut değil"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Yinelenen Dosya Bulucu",
|
||||
|
||||
@@ -333,7 +333,6 @@
|
||||
"tusUploadsRetryCount": "Кількість повторних спроб, якщо фрагмент не вдалося завантажити.",
|
||||
"userHomeBasePath": "Базовий шлях для домашніх каталогів користувачів",
|
||||
"userScopeGenerationPlaceholder": "Область дії буде згенеровано автоматично",
|
||||
"createUserHomeDirectory": "Створення домашнього каталогу користувача",
|
||||
"shareSettings": "Налаштування спільного доступу",
|
||||
"adminOptions": "Параметри користувача адміністрування",
|
||||
"searchOptions": "Параметри пошуку",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "Перепризначити шлях",
|
||||
"pathReassigned": "Шлях успішно оновлено!",
|
||||
"pathReassignFailed": "Не вдалося оновити шлях. Спробуйте ще раз.",
|
||||
"incompleteResultsDetails": "Показано максимум результатів ({max}). Спробуйте звузити пошук, щоб отримати більше результатів за темою {max}."
|
||||
"incompleteResultsDetails": "Показано максимум результатів ({max}). Спробуйте звузити пошук, щоб отримати більше результатів за темою {max}.",
|
||||
"noCreateOptions": "Опції створення недоступні"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Пошук дублікатів файлів",
|
||||
|
||||
@@ -333,7 +333,6 @@
|
||||
"tusUploadsRetryCount": "块上传失败时的重试次数。",
|
||||
"userHomeBasePath": "用户主目录的基本路径",
|
||||
"userScopeGenerationPlaceholder": "范围将自动生成",
|
||||
"createUserHomeDirectory": "创建用户主目录",
|
||||
"shareSettings": "共享设置",
|
||||
"adminOptions": "管理员用户选项",
|
||||
"searchOptions": "搜索选项",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "重新分配路径",
|
||||
"pathReassigned": "路径更新成功!",
|
||||
"pathReassignFailed": "更新路径失败。请重试。",
|
||||
"incompleteResultsDetails": "显示最大结果 ({max})。请尝试缩小搜索范围,以获得{max} 下的更多结果。"
|
||||
"incompleteResultsDetails": "显示最大结果 ({max})。请尝试缩小搜索范围,以获得{max} 下的更多结果。",
|
||||
"noCreateOptions": "没有创建选项"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "重复文件查找器",
|
||||
|
||||
@@ -333,7 +333,6 @@
|
||||
"tusUploadsRetryCount": "分塊上傳失敗時的重試次數。",
|
||||
"userHomeBasePath": "使用者主目錄的基本路徑",
|
||||
"userScopeGenerationPlaceholder": "範圍會自動產生",
|
||||
"createUserHomeDirectory": "建立使用者主目錄",
|
||||
"shareSettings": "分享設定",
|
||||
"shareDeleted": "分享已刪除!",
|
||||
"adminOptions": "管理員使用者選項",
|
||||
@@ -867,7 +866,8 @@
|
||||
"reassignPath": "重新指定路徑",
|
||||
"pathReassigned": "路徑已成功更新!",
|
||||
"pathReassignFailed": "更新路徑失敗。請再試一次。",
|
||||
"incompleteResultsDetails": "目前只顯示前 {max} 筆結果。請縮小搜尋範圍,以取得少於 {max} 筆的結果。"
|
||||
"incompleteResultsDetails": "目前只顯示前 {max} 筆結果。請縮小搜尋範圍,以取得少於 {max} 筆的結果。",
|
||||
"noCreateOptions": "沒有建立選項"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "重複檔案搜尋器",
|
||||
|
||||
@@ -580,7 +580,8 @@ export const getters = {
|
||||
};
|
||||
}
|
||||
return {
|
||||
share: state.user?.permissions?.share,
|
||||
share:
|
||||
!!(state.user?.permissions?.share || state.user?.permissions?.admin),
|
||||
modify: state.user?.permissions?.modify,
|
||||
create: state.user?.permissions?.create,
|
||||
delete: state.user?.permissions?.delete,
|
||||
|
||||
@@ -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(
|
||||
@@ -1143,6 +1143,7 @@ export default {
|
||||
showCentered: getters.isMobile(),
|
||||
posX: event.clientX,
|
||||
posY: event.clientY,
|
||||
createOnly: this.selectedCount == 0,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -35,7 +35,7 @@ test("info from listing - regular item", async({ page, checkForErrors, context }
|
||||
await expect(page.locator('button[aria-label="Move file"]')).toBeVisible();
|
||||
await expect(page.locator('button[aria-label="Copy file"]')).toBeVisible();
|
||||
await expect(page.locator('button[aria-label="Select all"]')).toBeVisible();
|
||||
await expect(page.locator('button[aria-label="Create archive"]')).toBeVisible();
|
||||
await expect(page.locator('button[aria-label="Create archive"]')).toBeHidden();
|
||||
checkForErrors();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from "../test-setup";
|
||||
|
||||
const jpgQuality = 85;
|
||||
|
||||
// Filename ends with `screenshots.spec.ts` so legacy Playwright configs that only match that pattern still pick up this test (Docker screenshot builds).
|
||||
|
||||
test("upload prompt", async ({ page, checkForErrors, openContextMenu, theme }) => {
|
||||
test.setTimeout(45_000);
|
||||
|
||||
if (theme === "light") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Screenshots Docker uses a single "playwright" source (see _docker/src/screenshots/backend/config.yaml).
|
||||
await page.goto("/files/");
|
||||
await expect(page).toHaveTitle("FileBrowser Quantum - Files - playwright-files");
|
||||
await openContextMenu();
|
||||
|
||||
// Context Action uses :label="$t('general.upload')" → accessible name can include suffix placeholders; match loosely.
|
||||
await page.locator("#context-menu").getByRole("button", { name: /Upload/i }).click();
|
||||
|
||||
// Prompt shell sets aria-label="upload-prompt" (see Prompts.vue: prompt.name + '-prompt').
|
||||
const uploadPrompt = page.locator('.floating-window[aria-label="upload-prompt"]');
|
||||
await uploadPrompt.waitFor({ state: "visible", timeout: 15_000 });
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
await uploadPrompt.screenshot({
|
||||
path: `./generated/prompts/upload-${theme}.jpg`,
|
||||
type: "jpeg",
|
||||
quality: jpgQuality,
|
||||
});
|
||||
checkForErrors();
|
||||
});
|
||||
@@ -44,6 +44,19 @@ test("context menu", async ({ page, checkForErrors, context, theme }) => {
|
||||
return;
|
||||
}
|
||||
await page.screenshot({ path: `./generated/listing/right-click-${theme}.jpg`, quality: jpgQuality });
|
||||
// assert options exist and don't exist
|
||||
await expect(page.locator('button[aria-label="New folder"]')).toBeHidden();
|
||||
await expect(page.locator('button[aria-label="New file"]')).toBeHidden();
|
||||
await expect(page.locator('button[aria-label="Upload"]')).toBeHidden();
|
||||
await expect(page.locator('button[aria-label="Info"]')).toBeVisible();
|
||||
await expect(page.locator('button[aria-label="Download"]')).toBeVisible();
|
||||
await expect(page.locator('button[aria-label="Share"]')).toBeVisible();
|
||||
await expect(page.locator('button[aria-label="Delete"]')).toBeVisible();
|
||||
await expect(page.locator('button[aria-label="Rename"]')).toBeVisible();
|
||||
await expect(page.locator('button[aria-label="Move file"]')).toBeVisible();
|
||||
await expect(page.locator('button[aria-label="Copy file"]')).toBeVisible();
|
||||
await expect(page.locator('button[aria-label="Select all"]')).toBeVisible();
|
||||
await expect(page.locator('button[aria-label="Create archive"]')).toBeHidden();
|
||||
});
|
||||
|
||||
test("info from search", async ({ page, checkForErrors, context, theme }) => {
|
||||
@@ -76,7 +89,6 @@ test("no viewer available", async ({ page, checkForErrors, context, theme }) =>
|
||||
await page.screenshot({ path: `./generated/viewer/no-viewer-available-${theme}.jpg`, quality: jpgQuality });
|
||||
})
|
||||
|
||||
|
||||
test("copy from listing to new folder", async ({ page, checkForErrors, context, theme }) => {
|
||||
if (theme === 'light') {
|
||||
return;
|
||||
|
||||
@@ -38,3 +38,14 @@ test("profile settings", async ({ page, checkForErrors, context, theme }) => {
|
||||
await page.locator('div[aria-label="themeLanguage"]').click();
|
||||
await page.waitForTimeout(300);
|
||||
});
|
||||
|
||||
// run npx playwright test --ui to run these tests locally in ui mode
|
||||
test("Uploads & Downloads settings", async ({ page, checkForErrors, context, theme }) => {
|
||||
if (theme === 'light') {
|
||||
return;
|
||||
}
|
||||
await page.goto("/files/settings#fileLoading-main");
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: `./generated/settings/uploads-downloads-options-${theme}.jpg`, quality: jpgQuality });
|
||||
|
||||
});
|
||||
Reference in new issue
Block a user