Compare commits

...
29 Commits
Author SHA1 Message Date
Graham Steffaniak a32d7a7e6a updated workflow 2025-11-28 12:14:44 -06:00
Graham Steffaniak edba1a80ef updated with a few fixes (#1678) 2025-11-28 12:08:17 -06:00
Graham Steffaniak 6a489c6f7c Fix sidebar custom link validation (#1665) 2025-11-26 16:27:29 -06:00
Kevin B.andGraham Steffaniak ba443398ca Improve captions styles in plyr (#1661)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2025-11-26 16:06:52 -06:00
Graham Steffaniak 17ff68c00d fix utf-8 filename prefix (#1664) 2025-11-26 16:06:34 -06:00
Graham Steffaniak cb71dd8940 fixed concurrent media duration issue (#1663) 2025-11-26 15:24:12 -06:00
Graham Steffaniak 764155d612 updated indexing changes (#1662) 2025-11-26 14:42:43 -06:00
Graham Steffaniak 9013152d9d Merge branch 'dev/v1.1.2' of github.com:gtsteffaniak/filebrowser into dev/v1.1.2 2025-11-26 09:14:09 -06:00
27bc000cda Experiment with sqllite (#1660)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-26 09:13:50 -06:00
8f4e420e73 Experiment with sqllite (#1660)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-26 09:12:09 -06:00
d35fb56587 Beta/v1.1.1 (#1655)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-24 15:01:49 -06:00
Graham Steffaniak e4d8a5cf20 fix editor issues (#1654) 2025-11-24 13:07:59 -06:00
Kevin B.andGraham Steffaniak fa8ceb50e6 Fix drag and drop and rows in normal view (#1651)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2025-11-24 12:12:12 -06:00
Graham Steffaniak c8073db4e6 access control download changes (#1653) 2025-11-24 10:02:39 -06:00
Graham Steffaniak 23fac83e7e adjusted list duration 2025-11-24 09:44:40 -06:00
Graham Steffaniak f36ca31c7f updated some build and comments (#1652) 2025-11-24 09:39:21 -06:00
Graham Steffaniak c0466e96fc updated translation for chinese 2025-11-24 09:05:20 -06:00
Graham Steffaniak dc753fe326 consolidate share url creation to backend api (#1647) 2025-11-24 08:18:32 -06:00
Graham Steffaniak c39a1937b4 updated with various bugfixes (#1646) 2025-11-21 19:30:37 -06:00
Graham Steffaniak 5d38b1e66a Download notification (#1645) 2025-11-21 17:31:31 -06:00
Graham Steffaniak e5e93b4b70 Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into dev/v1.1.1 2025-11-21 17:10:18 -06:00
eaedbbe05e Fix user notifications (#1644)
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-21 16:27:58 -06:00
Graham Steffaniak c6d5f32bd0 Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into dev/v1.1.1 2025-11-21 16:23:26 -06:00
Graham Steffaniak ee022bc4a9 Fix only office viewing (#1643) 2025-11-21 15:21:54 -06:00
Kevin B. daabefa6c3 Update spanish translation (#1636) 2025-11-21 15:00:28 -06:00
Graham Steffaniak 86aebde566 Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into dev/v1.1.1 2025-11-21 10:59:58 -06:00
Graham Steffaniak 58a01ff986 Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into dev/v1.1.1 2025-11-20 13:36:10 -06:00
Graham Steffaniak 97d16d1663 add exiftool for orientation 2025-11-19 16:46:47 -06:00
Graham Steffaniak 5dab4c7b30 Update README.md (#1624) 2025-11-19 14:30:37 -06:00
89 changed files with 2291 additions and 1132 deletions

No files matched your search

+1 -3
View File
@@ -71,9 +71,7 @@ jobs:
if any(.tags[]; test("beta")) then
.tags += [
"gtstef/filebrowser:beta",
"ghcr.io/gtsteffaniak/filebrowser:beta",
"gtstef/filebrowser:latest",
"ghcr.io/gtsteffaniak/filebrowser:latest"
"ghcr.io/gtsteffaniak/filebrowser:beta"
]
else . end |
if any(.tags[]; test("stable")) then
+28
View File
@@ -2,14 +2,42 @@
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.1.2-beta
**Notes**:
- changes to duplicate detector
- temp SQLite database each query to reduce memory pressure
- max limit is 500 groups total
- Downloads from UI requests utf-8 formatted names to support chinese and other characters. Updated swagger docs.
- beta releases no longer publish to `latest` docker tag #1675
**BugFixes**:
- better index status updates, fixing delays #1649
- fixed long load times for listings with media info due sequential processing of files.
- downloaded files always included `utf-8` in filename #1671
- custom sidebar links allow external links like `https://google.com`
- html title not populated correctly for links #1676
## v1.1.1-beta
**Notes**:
- [docker] upgraded ffmpeg 8.0 to 8.0.1
- stricter access control checks on file downloads.
- Wrong translation on Chinese "save" button #1650
- Incorrect Spanish Translations in Folder Creation and Rename Actions #1626
- duplicate file detector has stricter partial checksum match #1617
**BugFixes**:
- added missing exiftool to docker image for heic conversion orientation support
- v1.1.0-beta - Incorrect naming of 1 file in directory-info #1621
- disable only office viewing settings not applying
- OnlyOffice integration does not work behind proxy authentication #1422
- Newly created users "add on" to defined scope of previous user #1628 #1518
- disable chown on upload / file saving #1469 #1546
- Uploading a file will silently overwrite any existing file with the same name #1564
- share file url issue
- Fix drag and drop and rows in normal view #1651
- Some text based files are not able to edit. #1567
## v1.1.0-beta
+2 -2
View File
@@ -6,7 +6,7 @@ tmp_dir = "tmp"
bin = "tmp/filebrowser"
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "http/dist", "http/embed", "swagger/docs"]
exclude_dir = ["tmp", "vendor", "http/dist", "http/embed", "swagger/docs", "preview/thumbnails", "preview/heic"]
log = "air.log"
delay = 1000 # ms
@@ -14,4 +14,4 @@ tmp_dir = "tmp"
time = true
[misc]
clean_on_exit = true
clean_on_exit = false
+1 -1
View File
@@ -18,4 +18,4 @@ tmp_dir = "tmp"
time = true
[misc]
clean_on_exit = true
clean_on_exit = false
+138
View File
@@ -0,0 +1,138 @@
package files
import (
"os"
"path/filepath"
"testing"
"unicode/utf8"
"github.com/stretchr/testify/require"
)
func TestGetContent_UTF8Truncation(t *testing.T) {
// Get the path to the test file
// The test file is in frontend/tests/playwright-files/utf8-truncated.txt
// We need to find it relative to the test execution directory
cwd, err := os.Getwd()
require.NoError(t, err)
// Navigate from backend/adapters/fs/files to the project root
// backend/adapters/fs/files -> backend/adapters/fs -> backend/adapters -> backend -> root
testFilePath := filepath.Join(cwd, "..", "..", "..", "..", "frontend", "tests", "playwright-files", "utf8-truncated.txt")
// Check if file exists, if not try alternative path
if _, err = os.Stat(testFilePath); os.IsNotExist(err) {
// Try from project root
testFilePath = filepath.Join("frontend", "tests", "playwright-files", "utf8-truncated.txt")
if _, err = os.Stat(testFilePath); os.IsNotExist(err) {
t.Skipf("Test file not found at %s, skipping test", testFilePath)
return
}
}
// Get absolute path
var absPath string
absPath, err = filepath.Abs(testFilePath)
require.NoError(t, err)
t.Run("file with UTF-8 truncation at 4096 byte boundary", func(t *testing.T) {
// The test file is longer than 4096 bytes, with valid UTF-8 throughout.
// However, when reading exactly 4096 bytes as a header, it cuts off in the
// middle of a multi-byte UTF-8 sequence (e6 9c, missing the last byte 88 of '月').
// This should trigger the truncation handling in getContent's header validation.
// The fix should trim the incomplete sequence from the header, allowing the
// header check to pass, and then the full file (which is valid UTF-8) should
// pass the full file validation.
content, err := getContent(absPath)
// Should not return an error - the header truncation is handled, and the full file is valid
require.NoError(t, err)
// The content should be the full valid UTF-8 file
require.NotEmpty(t, content, "Content should not be empty - full file is valid UTF-8")
// Verify it contains the expected text
require.Contains(t, content, "文件已备份", "Content should contain Chinese characters")
require.Contains(t, content, "2024年", "Content should contain date information")
})
t.Run("regular UTF-8 text file", func(t *testing.T) {
// Test with a simple text file to ensure normal files still work
tmpFile, err := os.CreateTemp("", "test-utf8-*.txt")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
testText := "Hello, 世界! This is a test file with UTF-8 characters.\n"
_, err = tmpFile.WriteString(testText)
require.NoError(t, err)
tmpFile.Close()
content, err := getContent(tmpFile.Name())
require.NoError(t, err)
require.Equal(t, testText, content)
})
t.Run("file smaller than header size", func(t *testing.T) {
// Test with a file smaller than 4096 bytes
tmpFile, err := os.CreateTemp("", "test-small-*.txt")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
testText := "Small file content"
_, err = tmpFile.WriteString(testText)
require.NoError(t, err)
tmpFile.Close()
content, err := getContent(tmpFile.Name())
require.NoError(t, err)
require.Equal(t, testText, content)
})
t.Run("file with Chinese characters at boundary", func(t *testing.T) {
// Create a file that's exactly 4094 bytes, ending with a complete Chinese character
tmpFile, err := os.CreateTemp("", "test-chinese-*.txt")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
// Create content that's exactly 4094 bytes, ending with complete UTF-8
baseText := "2024年 06月 17日 星期一 04:05:58 CST 文件已备份\n"
content := ""
for len([]byte(content)) < 4094 {
content += baseText
}
// Trim to exactly 4094 bytes
encoded := []byte(content)
if len(encoded) > 4094 {
encoded = encoded[:4094]
}
// Ensure it ends with a complete character by finding the last complete rune
for len(encoded) > 0 {
lastRune, _ := decodeLastRune(encoded)
if lastRune != 0xFFFD { // RuneError
break
}
encoded = encoded[:len(encoded)-1]
}
_, err = tmpFile.Write(encoded)
require.NoError(t, err)
tmpFile.Close()
result, err := getContent(tmpFile.Name())
require.NoError(t, err)
require.NotEmpty(t, result)
require.Equal(t, string(encoded), result)
})
}
// Helper function to check if last rune is valid
func decodeLastRune(p []byte) (rune, int) {
if len(p) == 0 {
return 0, 0
}
r, size := utf8.DecodeLastRune(p)
return r, size
}
+102 -54
View File
@@ -9,6 +9,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
@@ -88,38 +89,60 @@ func FileInfoFaster(opts utils.FileOptions, access *access.Storage) (*iteminfo.E
startTime := time.Now()
metadataCount := 0
for i := range response.Files {
fileItem := &response.Files[i]
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
// Create a single shared FFmpegService instance for all files to coordinate concurrency
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
if sharedFFmpegService != nil {
// Process files concurrently using goroutines
var wg sync.WaitGroup
var mu sync.Mutex // Protects metadataCount
if isItemAudio || isItemVideo {
// Get the real path for this file
itemRealPath, _, _ := index.GetRealPath(opts.Path, fileItem.Name)
for i := range response.Files {
fileItem := &response.Files[i]
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
// Extract metadata for audio files (without album art for performance)
if isItemAudio {
err := extractAudioMetadata(context.Background(), fileItem, itemRealPath, opts.AlbumArt || opts.Content, opts.Metadata)
if err != nil {
logger.Debugf("failed to extract metadata for file: "+fileItem.Name, err)
} else {
metadataCount++
}
} else if isItemVideo {
// Extract duration for video files
err := extractVideoMetadata(context.Background(), fileItem, itemRealPath)
if err != nil {
logger.Debugf("failed to extract video metadata for file: "+fileItem.Name, err)
} else {
metadataCount++
}
if isItemAudio || isItemVideo {
// Get the real path for this file
itemRealPath, _, _ := index.GetRealPath(opts.Path, fileItem.Name)
// Capture loop variables in local copies to avoid closure issues
item := fileItem
itemPath := itemRealPath
isAudio := isItemAudio
wg.Go(func() {
// Extract metadata for audio files (without album art for performance)
if isAudio {
err := extractAudioMetadata(context.Background(), item, itemPath, opts.AlbumArt || opts.Content, opts.Metadata, sharedFFmpegService)
if err != nil {
logger.Debugf("failed to extract metadata for file: "+item.Name, err)
} else {
mu.Lock()
metadataCount++
mu.Unlock()
}
} else {
// Extract duration for video files
err := extractVideoMetadata(context.Background(), item, itemPath, sharedFFmpegService)
if err != nil {
logger.Debugf("failed to extract video metadata for file: "+item.Name, err)
} else {
mu.Lock()
metadataCount++
mu.Unlock()
}
}
})
}
}
// Wait for all goroutines to complete
wg.Wait()
}
if metadataCount > 0 {
elapsed := time.Since(startTime)
logger.Debugf("Extracted metadata for %d audio/video files in %v (avg: %v per file)",
logger.Debugf("Extracted metadata for %d audio/video files concurrently in %v (avg: %v per file)",
metadataCount, elapsed, elapsed/time.Duration(metadataCount))
}
}
@@ -150,7 +173,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
err := extractVideoMetadata(context.Background(), extItem, info.RealPath)
err := extractVideoMetadata(context.Background(), extItem, info.RealPath, nil)
if err != nil {
logger.Debugf("failed to extract video metadata for file: "+info.RealPath, info.Name, err)
} else {
@@ -177,7 +200,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content)
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, nil)
if err != nil {
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
} else {
@@ -214,7 +237,8 @@ func generateOfficeId(realPath string) string {
// extractAudioMetadata extracts metadata from an audio file using dhowden/tag
// and optionally extracts duration using the ffmpeg service with concurrency control
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool) error {
// If ffmpegService is nil, a new service will be created (for backward compatibility)
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, ffmpegService *ffmpeg.FFmpegService) error {
file, err := os.Open(realPath)
if err != nil {
return err
@@ -253,13 +277,17 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
// This respects concurrency limits and gracefully handles missing ffmpeg
if getDuration {
ffmpegService := ffmpeg.NewFFmpegService(5, false, "")
if ffmpegService != nil {
// Use provided service or create a new one for backward compatibility
service := ffmpegService
if service == nil {
service = ffmpeg.NewFFmpegService(5, false, "")
}
if service != nil {
startTime := time.Now()
if duration, err := ffmpegService.GetMediaDuration(ctx, realPath); err == nil {
if duration, err := service.GetMediaDuration(ctx, realPath); err == nil {
item.Metadata.Duration = int(duration)
elapsed := time.Since(startTime)
if elapsed > 50*time.Millisecond {
if elapsed > 100*time.Millisecond {
logger.Debugf("Duration extraction took %v for file: %s", elapsed, item.Name)
}
}
@@ -284,11 +312,15 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
}
// extractVideoMetadata extracts duration from video files using ffprobe
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string) error {
// Extract duration using the ffmpeg VideoService with concurrency control
videoService := ffmpeg.NewFFmpegService(10, false, "")
if videoService != nil {
duration, err := videoService.GetMediaDuration(ctx, realPath)
// If ffmpegService is nil, a new service will be created (for backward compatibility)
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, ffmpegService *ffmpeg.FFmpegService) error {
// Use provided service or create a new one for backward compatibility
service := ffmpegService
if service == nil {
service = ffmpeg.NewFFmpegService(10, false, "")
}
if service != nil {
duration, err := service.GetMediaDuration(ctx, realPath)
if err != nil {
return err
}
@@ -550,17 +582,12 @@ func WriteDirectory(opts utils.FileOptions) error {
}
// Ensure the parent directories exist
// Permissions are set by MkdirAll (subject to umask, which is usually acceptable)
err = os.MkdirAll(realPath, fileutils.PermDir)
if err != nil {
return err
}
// Explicitly set directory permissions to bypass umask
err = os.Chmod(realPath, fileutils.PermDir)
if err != nil {
return err
}
return RefreshIndex(idx.Name, opts.Path, true, true)
}
@@ -580,15 +607,20 @@ func WriteFile(source, path string, in io.Reader) error {
}
var stat os.FileInfo
// Check if the destination exists and is a directory
if stat, err = os.Stat(realPath); err == nil && stat.IsDir() {
// If it's a directory and we're trying to create a file, remove the directory first
err = os.RemoveAll(realPath)
if err != nil {
return fmt.Errorf("could not remove existing directory to create file: %v", err)
if stat, err = os.Stat(realPath); err == nil {
if stat.IsDir() {
// If it's a directory and we're trying to create a file, remove the directory first
err = os.RemoveAll(realPath)
if err != nil {
return fmt.Errorf("could not remove existing directory to create file: %v", err)
}
}
// If file exists, its permissions will be preserved (O_TRUNC doesn't change permissions)
}
// Open the file for writing (create if it doesn't exist, truncate if it does)
// For new files: permissions are set to fileutils.PermFile (subject to umask, which is usually acceptable)
// For existing files: permissions are preserved automatically (O_TRUNC doesn't change them)
file, err := os.OpenFile(realPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
@@ -601,12 +633,6 @@ func WriteFile(source, path string, in io.Reader) error {
return err
}
// Explicitly set file permissions to bypass umask
err = os.Chmod(realPath, fileutils.PermFile)
if err != nil {
return err
}
return RefreshIndex(source, path, false, false)
}
@@ -637,9 +663,31 @@ func getContent(realPath string) (string, error) {
// --- Start of new heuristic checks ---
if n > 0 {
// Trim header to last complete UTF-8 rune to avoid false negatives
// when the header read cuts off in the middle of a multi-byte sequence.
// We decode runes from the end until we find a valid one, trimming
// any incomplete sequences at the end.
trimmedHeader := actualHeader
for len(trimmedHeader) > 0 {
lastRune, size := utf8.DecodeLastRune(trimmedHeader)
if lastRune != utf8.RuneError {
// Found a valid complete rune
break
}
// RuneError occurred - this could be an incomplete sequence or invalid byte
// Trim the last byte and try again
if size == 1 && len(trimmedHeader) > 0 {
trimmedHeader = trimmedHeader[:len(trimmedHeader)-1]
} else {
// Shouldn't happen, but break to avoid infinite loop
break
}
}
// 1. Basic Check: Is the header valid UTF-8?
// If not, it's unlikely an editable UTF-8 text file.
if !utf8.Valid(actualHeader) {
// Use trimmed header to avoid false negatives from truncated sequences
if len(trimmedHeader) > 0 && !utf8.Valid(trimmedHeader) {
return "", nil // Not an error, just not the text file we want
}
+7 -3
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
@@ -31,11 +32,14 @@ func MakeUserDirs(u *users.User, disableScopeChange bool) error {
if filepath.Base(scope.Scope) != cleanedUserName && source.Config.CreateUserDir && !disableScopeChange {
fullPath := filepath.Join(source.Path, scope.Scope, cleanedUserName)
parentDir := filepath.Join(source.Path, scope.Scope)
// validate that scope path exists
// If parent directory doesn't exist and createUserDir is enabled, create it
if !Exists(parentDir) {
return fmt.Errorf("MakeUserDirs: scope path does not exist: %s", scope.Scope)
if err := MakeUserDir(parentDir); err != nil {
return fmt.Errorf("MakeUserDirs: failed to create parent scope directory: %s - %v", scope.Scope, err)
}
}
scope.Scope = filepath.Join(scope.Scope, cleanedUserName)
// Use JoinPathAsUnix to ensure scope remains in Unix format (forward slashes)
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
+15 -6
View File
@@ -62,6 +62,13 @@ func CopyFile(source, dest string) error {
// copySingleFile handles copying a single file.
func copySingleFile(source, dest string) error {
// Get source file info to preserve permissions
srcInfo, err := os.Stat(source)
if err != nil {
return err
}
sourcePerms := srcInfo.Mode().Perm()
// Open the source file.
src, err := os.Open(source)
if err != nil {
@@ -75,8 +82,8 @@ func copySingleFile(source, dest string) error {
return err
}
// Create the destination file.
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, PermFile)
// Create the destination file with source permissions
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, sourcePerms)
if err != nil {
return err
}
@@ -88,11 +95,13 @@ func copySingleFile(source, dest string) error {
return err
}
// Set the configured file permissions instead of copying from source
err = os.Chmod(dest, PermFile)
// Preserve source file permissions
// Handle chmod errors gracefully (e.g., in rootless containers where chmod may be restricted)
err = os.Chmod(dest, sourcePerms)
if err != nil {
return err
// Log but don't fail - chmod may be restricted in some environments
// The file was copied successfully, so we continue
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", dest, err)
}
return nil
+1 -1
View File
@@ -57,7 +57,7 @@ type Server struct {
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
InternalUrl string `json:"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)
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
CacheDirCleanup *bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory (default: true)
CacheDirCleanup *bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: true)
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
Filesystem Filesystem `json:"filesystem"` // filesystem settings
// not exposed to config
+1
View File
@@ -25,6 +25,7 @@ type CommonShare struct {
Source string `json:"source,omitempty"` // backend source is path to maintain between name changes
Path string `json:"path,omitempty"`
DownloadURL string `json:"downloadURL,omitempty"`
ShareURL string `json:"shareURL,omitempty"`
DisableShareCard bool `json:"disableShareCard,omitempty"`
EnforceDarkLightMode string `json:"enforceDarkLightMode,omitempty"` // "dark" or "light"
ViewMode string `json:"viewMode,omitempty"` // default view mode for anonymous users: "list", "compact", "normal", "gallery"
+121
View File
@@ -0,0 +1,121 @@
package sql
import (
"database/sql"
"fmt"
)
// FileLocation represents a file location in the index with metadata
// needed for duplicate detection operations.
type FileLocation struct {
DirPath string
FileIdx int
Name string
NormalizedName string
Extension string
}
// CreateDuplicatesTable creates the files table and indexes needed for duplicate detection.
// This should be called once after creating a TempDB for duplicate operations.
// Indexes are created before data insertion so they're immediately available for queries.
// For small datasets (typical for duplicate detection), the performance difference is negligible.
func (t *TempDB) CreateDuplicatesTable() error {
createTableSQL := `
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dir_path TEXT NOT NULL,
file_idx INTEGER NOT NULL,
size INTEGER NOT NULL,
name TEXT NOT NULL,
normalized_name TEXT NOT NULL,
extension TEXT NOT NULL,
UNIQUE(dir_path, file_idx)
);
CREATE INDEX IF NOT EXISTS idx_size ON files(size);
CREATE INDEX IF NOT EXISTS idx_size_count ON files(size, normalized_name);
`
_, err := t.Exec(createTableSQL)
return err
}
// InsertFileForDuplicates inserts a file entry into the duplicates table.
// This is called during the first pass through the index to stream files into the database.
func (t *TempDB) InsertFileForDuplicates(dirPath string, fileIdx int, size int64, name, normalizedName, extension string) error {
_, err := t.Exec(
"INSERT OR IGNORE INTO files (dir_path, file_idx, size, name, normalized_name, extension) VALUES (?, ?, ?, ?, ?, ?)",
dirPath, fileIdx, size, name, normalizedName, extension,
)
return err
}
// GetSizeGroupsForDuplicates queries for all size groups that have 2+ files.
// Returns sizes in descending order (largest first) as a slice, and a map of size -> count.
// This is used to identify potential duplicate groups before detailed comparison.
// The SQL query efficiently filters and sorts, avoiding the need to create intermediate maps.
func (t *TempDB) GetSizeGroupsForDuplicates(minSize int64) ([]int64, map[int64]int, error) {
// Query to get sizes with 2+ files, sorted by size DESC
rows, err := t.Query(`
SELECT size, COUNT(*) as count
FROM files
WHERE size >= ?
GROUP BY size
HAVING COUNT(*) >= 2
ORDER BY size DESC
`, minSize)
if err != nil {
return nil, nil, fmt.Errorf("failed to query size groups: %w", err)
}
defer rows.Close()
var sizes []int64
sizeCounts := make(map[int64]int)
for rows.Next() {
var size int64
var count int
if err := rows.Scan(&size, &count); err != nil {
return nil, nil, fmt.Errorf("failed to scan row: %w", err)
}
sizes = append(sizes, size)
sizeCounts[size] = count
}
return sizes, sizeCounts, rows.Err()
}
// GetFilesBySizeForDuplicates queries for all files with a specific size.
// Used for processing one size group at a time to minimize memory usage.
func (t *TempDB) GetFilesBySizeForDuplicates(size int64) ([]FileLocation, error) {
rows, err := t.Query(`
SELECT dir_path, file_idx, name, normalized_name, extension
FROM files
WHERE size = ?
ORDER BY normalized_name
`, size)
if err != nil {
return nil, fmt.Errorf("failed to query files by size: %w", err)
}
defer rows.Close()
var locations []FileLocation
for rows.Next() {
var loc FileLocation
if err := rows.Scan(&loc.DirPath, &loc.FileIdx, &loc.Name, &loc.NormalizedName, &loc.Extension); err != nil {
return nil, fmt.Errorf("failed to scan row: %w", err)
}
locations = append(locations, loc)
}
return locations, rows.Err()
}
// BulkInsertFilesForDuplicates inserts multiple files in a single transaction.
// This is more efficient than calling InsertFileForDuplicates multiple times.
// The transaction must be started by the caller using BeginTransaction().
func BulkInsertFilesForDuplicates(tx *sql.Tx, dirPath string, fileIdx int, size int64, name, normalizedName, extension string) error {
_, err := tx.Exec(
"INSERT OR IGNORE INTO files (dir_path, file_idx, size, name, normalized_name, extension) VALUES (?, ?, ?, ?, ?, ?)",
dirPath, fileIdx, size, name, normalizedName, extension,
)
return err
}
+253
View File
@@ -0,0 +1,253 @@
package sql
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/go-logger/logger"
_ "modernc.org/sqlite"
)
// TempDB manages a temporary SQLite database for operations that need
// to stream large datasets without loading everything into memory.
// This can be used by any part of the codebase that needs temporary SQLite storage.
type TempDB struct {
db *sql.DB
path string
mu sync.Mutex
startTime time.Time
config *TempDBConfig
}
// TempDBConfig holds configuration options for temporary SQLite databases.
type TempDBConfig struct {
// CacheSizeKB is the page cache size in KB. Negative values are in pages.
// For one-time databases, a smaller cache (e.g., -2000 = ~8MB) is often sufficient.
// Default: -2000 (approximately 8MB)
CacheSizeKB int
// MmapSize is the memory-mapped I/O size in bytes. Set to 0 to disable mmap.
// For databases that fit in RAM, set this larger than the expected DB size.
// Default: 2GB (2147483648 bytes)
MmapSize int64
// Synchronous controls the synchronous mode. OFF is fastest but less safe.
// For temporary databases, OFF is acceptable.
// Default: OFF
Synchronous string
// TempStore controls where temporary tables and indices are stored.
// Valid values: "FILE" (default), "MEMORY", "DEFAULT"
// Default: FILE (temporary tables stored on disk)
TempStore string
// EnableLogging enables performance logging for debugging.
// Default: false
EnableLogging bool
}
// mergeConfig merges the provided config with defaults, returning a new config.
// If provided config is nil or empty, returns default config.
func mergeConfig(provided *TempDBConfig) *TempDBConfig {
defaults := &TempDBConfig{
CacheSizeKB: -2000, // ~8MB, appropriate for one-time databases
MmapSize: 2147483648, // 2GB
Synchronous: "OFF",
TempStore: "FILE", // Default to FILE, not MEMORY
EnableLogging: false,
}
if provided == nil {
return defaults
}
merged := *defaults // Copy defaults
// Override with provided values if they are non-zero/non-empty
if provided.CacheSizeKB != 0 {
merged.CacheSizeKB = provided.CacheSizeKB
}
if provided.MmapSize != 0 {
merged.MmapSize = provided.MmapSize
}
if provided.Synchronous != "" {
merged.Synchronous = provided.Synchronous
}
if provided.TempStore != "" {
merged.TempStore = provided.TempStore
}
merged.EnableLogging = provided.EnableLogging
return &merged
}
// NewTempDB creates a new temporary SQLite database.
// The database is created in the cache directory's sql/ subdirectory.
// The database will be cleaned up on Close().
//
// The database is optimized for bulk write-then-read operations with:
// - WAL journal mode for better concurrency
// - Configurable cache size (default: ~8MB for one-time DBs)
// - Memory-mapped I/O for faster access
// - OFF synchronous mode for maximum write performance
// - Configurable temp_store (default: FILE, can be set to MEMORY via config)
func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
startTime := time.Now()
// Merge provided config with defaults
var providedConfig *TempDBConfig
if len(config) > 0 {
providedConfig = config[0]
}
cfg := mergeConfig(providedConfig)
// Create sql subdirectory in the cache directory
dbDir := filepath.Join(settings.Config.Server.CacheDir, "sql")
if err := os.MkdirAll(dbDir, fileutils.PermDir); err != nil {
return nil, fmt.Errorf("failed to create sql directory: %w", err)
}
// Create temporary file in the sql subdirectory
tmpFile, err := os.CreateTemp(dbDir, fmt.Sprintf("%s.db", id))
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
tmpPath := tmpFile.Name()
tmpFile.Close()
// Open SQLite database with basic connection string
// We'll set PRAGMAs after connection for better control and logging
db, err := sql.Open("sqlite", tmpPath)
if err != nil {
os.Remove(tmpPath)
return nil, fmt.Errorf("failed to open SQLite database: %w", err)
}
// Test the connection
if err := db.Ping(); err != nil {
db.Close()
os.Remove(tmpPath)
return nil, fmt.Errorf("failed to ping SQLite database: %w", err)
}
// Apply optimizations via PRAGMA statements
// Execute them individually for compatibility and better error reporting
pragmaStart := time.Now()
pragmas := []struct {
sql string
err string
}{
{"PRAGMA journal_mode = WAL;", "failed to set WAL mode"},
{fmt.Sprintf("PRAGMA cache_size = %d;", cfg.CacheSizeKB), "failed to set cache_size"},
{fmt.Sprintf("PRAGMA synchronous = %s;", cfg.Synchronous), "failed to set synchronous"},
{fmt.Sprintf("PRAGMA temp_store = %s;", cfg.TempStore), "failed to set temp_store"},
}
if cfg.MmapSize > 0 {
pragmas = append(pragmas, struct {
sql string
err string
}{fmt.Sprintf("PRAGMA mmap_size = %d;", cfg.MmapSize), "failed to set mmap_size"})
}
for _, pragma := range pragmas {
if _, err := db.Exec(pragma.sql); err != nil {
db.Close()
os.Remove(tmpPath)
return nil, fmt.Errorf("%s: %w", pragma.err, err)
}
}
pragmaDuration := time.Since(pragmaStart)
// Log configuration if enabled
if cfg.EnableLogging {
logger.Debugf("[TempDB:%s] Created with cache_size=%d KB, mmap_size=%d bytes, synchronous=%s, temp_store=%s (setup took %v)",
id, cfg.CacheSizeKB, cfg.MmapSize, cfg.Synchronous, cfg.TempStore, pragmaDuration)
}
return &TempDB{
db: db,
path: tmpPath,
startTime: startTime,
config: cfg,
}, nil
}
// DB returns the underlying *sql.DB connection.
// This allows callers to execute custom SQL queries if needed.
func (t *TempDB) DB() *sql.DB {
return t.db
}
// BeginTransaction starts a transaction for bulk operations.
// The caller must call Commit() or Rollback() on the returned transaction.
// The mutex is NOT held during the transaction - caller is responsible for coordination.
func (t *TempDB) BeginTransaction() (*sql.Tx, error) {
return t.db.Begin()
}
// Exec executes a SQL statement that doesn't return rows.
// This is a convenience method that handles locking.
func (t *TempDB) Exec(query string, args ...interface{}) (sql.Result, error) {
t.mu.Lock()
defer t.mu.Unlock()
return t.db.Exec(query, args...)
}
// Query executes a query that returns rows.
// This is a convenience method that handles locking.
func (t *TempDB) Query(query string, args ...interface{}) (*sql.Rows, error) {
t.mu.Lock()
defer t.mu.Unlock()
return t.db.Query(query, args...)
}
// QueryRow executes a query that is expected to return at most one row.
// This is a convenience method that handles locking.
func (t *TempDB) QueryRow(query string, args ...interface{}) *sql.Row {
t.mu.Lock()
defer t.mu.Unlock()
return t.db.QueryRow(query, args...)
}
// Close closes the database connection and removes the temporary file.
// This should always be called when done with the database, typically in a defer statement.
// If logging is enabled, it will log the total lifetime and file size for performance analysis.
func (t *TempDB) Close() error {
t.mu.Lock()
defer t.mu.Unlock()
if t.config != nil && t.config.EnableLogging {
totalDuration := time.Since(t.startTime)
fileInfo, err := os.Stat(t.path)
var fileSize int64
if err == nil {
fileSize = fileInfo.Size()
}
logger.Debugf("[TempDB] Closed after %v, final size: %d bytes (%.2f MB)",
totalDuration, fileSize, float64(fileSize)/(1024*1024))
}
if t.db != nil {
if err := t.db.Close(); err != nil {
os.Remove(t.path)
return err
}
}
return os.Remove(t.path)
}
// Path returns the path to the temporary database file.
// This is useful for debugging or if you need to inspect the database.
func (t *TempDB) Path() string {
return t.path
}
+21 -2
View File
@@ -109,13 +109,20 @@ func SendToUsers(eventType, message string, users []string) {
}
func SendSourceUpdate(source string, message string) {
sourceUpdateChan <- sourceEvent{
event := sourceEvent{
source: source,
event: EventMessage{
EventType: "sourceUpdate",
Message: message,
},
}
select {
case sourceUpdateChan <- event:
// Event sent successfully
default:
// Channel is full, log warning but don't block
// This shouldn't happen under normal circumstances
}
}
func DebouncedBroadcast(eventType, message string) {
@@ -129,15 +136,27 @@ func handleSourceUpdates() {
for update := range sourceUpdateChan {
sourceClientsMu.RLock()
clients := sourceClients[update.source]
clientCount := len(clients)
sourceClientsMu.RUnlock()
if clientCount == 0 {
// No clients registered for this source - this is normal if no one is connected
continue
}
sentCount := 0
for ch := range clients {
select {
case ch <- update.event:
sentCount++
default:
// Optional: log dropped message
// Channel full, message dropped
}
}
// Log if we have clients but couldn't send to all
//if sentCount < clientCount {
// // Some messages were dropped due to full channels
//}
}
}
+9
View File
@@ -27,6 +27,7 @@ require (
golang.org/x/sys v0.38.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.40.1
)
require (
@@ -91,6 +92,7 @@ require (
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
@@ -141,6 +143,7 @@ require (
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gordonklaus/ineffassign v0.2.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.5.0 // indirect
@@ -190,6 +193,7 @@ require (
github.com/moricho/tparallel v0.3.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.21.2 // indirect
@@ -207,6 +211,7 @@ require (
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/raeperd/recvcheck v0.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
@@ -258,6 +263,7 @@ require (
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
@@ -267,6 +273,9 @@ require (
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.6.1 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
+34
View File
@@ -236,6 +236,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LV
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -468,6 +470,8 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs=
@@ -635,6 +639,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg=
@@ -718,6 +724,8 @@ github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4l
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ=
github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI=
github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -1256,6 +1264,32 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4=
mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s=
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI=
+208 -114
View File
@@ -14,23 +14,23 @@ import (
"unicode"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/sql"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
)
// duplicateSearchMutex serializes duplicate searches to run one at a time
// This is separate from the index's scanMutex to avoid conflicts with indexing
var duplicateSearchMutex sync.Mutex
const maxGroups = 500 // Limit total duplicate groups
// duplicateResultsCache caches duplicate search results for 15 seconds
var duplicateResultsCache = cache.NewCache[[]duplicateGroup](15 * time.Second)
// fileLocation is a minimal reference to a file in the index
// Used during duplicate detection to avoid allocating full SearchResult objects
type fileLocation struct {
dirPath string // path to directory in index
fileIdx int // index in directory's Files slice
}
// checksumCache caches file checksums for 1 hour, keyed by source/path/modtime
var checksumCache = cache.NewCache[string](1 * time.Hour)
type duplicateGroup struct {
Size int64 `json:"size"`
@@ -48,13 +48,14 @@ type duplicatesOptions struct {
// duplicatesHandler handles requests to find duplicate files
//
// This endpoint finds files with the same size and uses fuzzy filename matching
// to verify potential duplicates.
// This endpoint finds files with the same size, uses fuzzy filename matching
// for initial grouping (fast), then verifies all final groups with checksums.
//
// It's optimized to handle large directories efficiently by:
// 1. Filtering by minimum size first
// 2. Grouping by size
// 3. Verifying matches with fuzzy filename similarity
// 3. Initial grouping with fuzzy filename similarity (fast)
// 4. Final verification with checksums on all groups (accurate)
//
// @Summary Find Duplicate Files
// @Description Finds duplicate files based on size and fuzzy filename matching
@@ -79,7 +80,8 @@ func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *requestContext
}
// Generate cache key from all input parameters that affect results
cacheKey := fmt.Sprintf("%s:%s:%d:%t", index.Path, opts.combinedPath, opts.minSize, opts.useChecksum)
// Checksums are always enabled, so cache key doesn't need to include that flag
cacheKey := fmt.Sprintf("%s:%s:%d", index.Path, opts.combinedPath, opts.minSize)
// Check cache first (before acquiring mutex)
if cachedResults, ok := duplicateResultsCache.Get(cacheKey); ok {
@@ -107,15 +109,55 @@ func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *requestContext
return renderJSON(w, r, duplicateGroups)
}
// findDuplicatesInIndex finds duplicates by working directly with index structures
// This minimizes memory allocation by only creating SearchResult objects for final results
// findDuplicatesInIndex finds duplicates using SQLite streaming approach
// This minimizes memory allocation by:
// 1. Streaming files into a temporary SQLite database (no in-memory map)
// 2. Querying SQLite for size groups with 2+ files
// 3. Processing each size group sequentially (only one in memory at a time)
// 4. Only creating SearchResult objects for final verified duplicates
func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions) []duplicateGroup {
const maxTotalFiles = 1000 // Limit total files across all groups
// Step 1: Group files by size, working directly with index structures
// Key: size, Value: list of (dirPath, fileIndex) minimal references
sizeGroups := make(map[int64][]fileLocation)
// Create temporary SQLite database for streaming in cache directory
tempDB, err := sql.NewTempDB("duplicates", &sql.TempDBConfig{
CacheSizeKB: -2000, // ~8MB, sufficient for small-medium datasets
MmapSize: 104857600, // 100MB - reasonable limit
Synchronous: "OFF", // Maximum write performance for temp DB
EnableLogging: false,
})
if err != nil {
// Return empty results if SQLite fails
return []duplicateGroup{}
}
defer tempDB.Close()
// Create the duplicates table with indexes
// Indexes are created before insert so they're immediately available for queries
tableStart := time.Now()
if err = tempDB.CreateDuplicatesTable(); err != nil {
return []duplicateGroup{}
}
if time.Since(tableStart) > 10*time.Millisecond {
logger.Debugf("[Duplicates] Table and index creation took %v", time.Since(tableStart))
}
// Step 1: Stream files into SQLite database
// This avoids building a huge map in memory
tx, err := tempDB.BeginTransaction()
if err != nil {
return []duplicateGroup{}
}
// Prepare statement for bulk inserts
stmt, err := tx.Prepare("INSERT OR IGNORE INTO files (dir_path, file_idx, size, name, normalized_name, extension) VALUES (?, ?, ?, ?, ?, ?)")
if err != nil {
if err2 := tx.Rollback(); err2 != nil {
logger.Errorf("[Duplicates] Failed to rollback transaction: %v", err2)
}
return []duplicateGroup{}
}
defer stmt.Close()
insertErr := error(nil)
index.ReadOnlyOperation(func() {
for dirPath, dir := range index.GetDirectories() {
// Skip directories not in scope
@@ -126,94 +168,137 @@ func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions) []dup
for i := range dir.Files {
file := &dir.Files[i]
if file.Size >= opts.minSize {
sizeGroups[file.Size] = append(sizeGroups[file.Size], fileLocation{dirPath, i})
// Normalize filename for efficient matching
normalizedName := normalizeFilename(file.Name)
extension := strings.ToLower(filepath.Ext(file.Name))
// Insert into SQLite (will be committed in batch)
_, err = stmt.Exec(dirPath, i, file.Size, file.Name, normalizedName, extension)
if err != nil && insertErr == nil {
insertErr = err
}
}
}
}
})
// Step 2: Process each size group to find duplicates
// Commit the transaction
if insertErr != nil || tx.Commit() != nil {
return []duplicateGroup{}
}
// Step 2: Query SQLite for size groups with 2+ files (already sorted by SQL)
sizes, _, err := tempDB.GetSizeGroupsForDuplicates(opts.minSize)
if err != nil {
return []duplicateGroup{}
}
// Step 3: Process each size group sequentially to minimize memory
duplicateGroups := []duplicateGroup{}
totalFiles := 0
totalFileQueryTime := time.Duration(0)
fileQueryCount := 0
for size, locations := range sizeGroups {
if len(locations) < 2 {
continue // Skip files with no potential duplicates
for _, size := range sizes {
// Stop if we've hit the group limit
if len(duplicateGroups) >= maxGroups {
break
}
var groups [][]fileLocation
if opts.useChecksum {
groups = groupLocationsByChecksum(locations, index, size)
} else {
groups = groupLocationsByFilename(locations, index, size)
// Get files for this size from SQLite
fileQueryStart := time.Now()
locations, err := tempDB.GetFilesBySizeForDuplicates(size)
fileQueryDuration := time.Since(fileQueryStart)
totalFileQueryTime += fileQueryDuration
fileQueryCount++
if err != nil || len(locations) < 2 {
continue
}
// Step 3: Only now create SearchResult objects for actual duplicates
// Use filename matching for initial grouping (fast)
// Work directly with sql.FileLocation - no conversion needed
groups := groupLocationsByFilenameWithMetadata(locations, index, size)
// Process candidate groups up to the limit
for _, locGroup := range groups {
if len(locGroup) < 2 {
continue
}
// Check if adding this group would exceed the limit
if totalFiles+len(locGroup) > maxTotalFiles {
// Stop if we've hit the group limit
if len(duplicateGroups) >= maxGroups {
break
}
// Create SearchResult objects only for these confirmed duplicates
resultGroup := make([]*indexing.SearchResult, 0, len(locGroup))
index.ReadOnlyOperation(func() {
dirs := index.GetDirectories()
for _, loc := range locGroup {
dir := dirs[loc.dirPath]
if dir == nil {
continue
}
if loc.fileIdx >= len(dir.Files) {
continue
}
file := &dir.Files[loc.fileIdx]
// Verify with checksums
verifiedGroups := groupLocationsByChecksumFromSQL(locGroup, index, size)
// CRITICAL: Verify size matches the expected size for this group
// Index could have changed between initial grouping and now
if file.Size != size {
continue
// Create SearchResult objects only for verified duplicates
for _, verifiedGroup := range verifiedGroups {
if len(verifiedGroup) < 2 {
continue
}
resultGroup := make([]*indexing.SearchResult, 0, len(verifiedGroup))
index.ReadOnlyOperation(func() {
dirs := index.GetDirectories()
for _, loc := range verifiedGroup {
dir := dirs[loc.DirPath]
if dir == nil {
continue
}
if loc.FileIdx >= len(dir.Files) {
continue
}
file := &dir.Files[loc.FileIdx]
// CRITICAL: Verify size matches the expected size for this group
if file.Size != size {
continue
}
// Construct full path
fullPath := filepath.Join(loc.DirPath, file.Name)
// Remove the user scope from path
adjustedPath := strings.TrimPrefix(fullPath, opts.combinedPath)
if adjustedPath == "" {
adjustedPath = "/"
}
resultGroup = append(resultGroup, &indexing.SearchResult{
Path: adjustedPath,
Type: file.Type,
Size: file.Size,
Modified: file.ModTime.Format(time.RFC3339),
HasPreview: file.HasPreview,
})
}
})
// Construct full path
fullPath := filepath.Join(loc.dirPath, file.Name)
// Remove the user scope from path
adjustedPath := strings.TrimPrefix(fullPath, opts.combinedPath)
if adjustedPath == "" {
adjustedPath = "/"
}
resultGroup = append(resultGroup, &indexing.SearchResult{
Path: adjustedPath,
Type: file.Type,
Size: file.Size,
Modified: file.ModTime.Format(time.RFC3339),
HasPreview: file.HasPreview,
if len(resultGroup) >= 2 {
duplicateGroups = append(duplicateGroups, duplicateGroup{
Size: size,
Count: len(resultGroup),
Files: resultGroup,
})
}
})
if len(resultGroup) >= 2 {
duplicateGroups = append(duplicateGroups, duplicateGroup{
Size: size,
Count: len(resultGroup),
Files: resultGroup,
})
totalFiles += len(resultGroup)
}
}
// Stop processing more size groups if we've hit the limit
if totalFiles >= maxTotalFiles {
break
// Stop if we've hit the group limit
if len(duplicateGroups) >= maxGroups {
break
}
}
}
// Log aggregate query performance
if fileQueryCount > 0 {
avgFileQueryTime := totalFileQueryTime / time.Duration(fileQueryCount)
logger.Debugf("[Duplicates] File-by-size queries: %d queries, total %v, avg %v per query",
fileQueryCount, totalFileQueryTime, avgFileQueryTime)
}
// Groups are already sorted by size (largest to smallest) from SQL query
return duplicateGroups
}
@@ -221,9 +306,9 @@ func prepDuplicatesOptions(r *http.Request, d *requestContext) (*duplicatesOptio
source := r.URL.Query().Get("source")
scope := r.URL.Query().Get("scope")
minSizeMbStr := r.URL.Query().Get("minSizeMb")
// Checksum feature disabled due to performance issues with large file systems
// Always use fuzzy filename matching instead
useChecksum := false // r.URL.Query().Get("useChecksum") == "true"
// Checksums are always enabled by default for final verification
// First pass uses filename matching for speed, then checksums verify final groups
useChecksum := true
// Default minimum size: 1MB
minSizeMb := int64(1)
@@ -263,27 +348,29 @@ func prepDuplicatesOptions(r *http.Request, d *requestContext) (*duplicatesOptio
}, nil
}
// groupLocationsByChecksum groups file locations by partial checksum
// Works with minimal fileLocation references instead of full SearchResult objects
func groupLocationsByChecksum(locations []fileLocation, index *indexing.Index, fileSize int64) [][]fileLocation {
checksumMap := make(map[string][]fileLocation)
// groupLocationsByChecksumFromSQL groups SQLite file locations by partial checksum
// Works directly with sql.FileLocation instead of converting to fileLocation
func groupLocationsByChecksumFromSQL(locations []sql.FileLocation, index *indexing.Index, fileSize int64) [][]sql.FileLocation {
checksumMap := make(map[string][]sql.FileLocation)
// Build checksum groups
for _, loc := range locations {
// Construct filesystem path for checksum computation
// index.Path is the absolute filesystem root, loc.dirPath is index-relative
fullPath := filepath.Join(index.Path, loc.dirPath)
// index.Path is the absolute filesystem root, loc.DirPath is index-relative
fullPath := filepath.Join(index.Path, loc.DirPath)
// Get the filename from the index and verify size still matches
// Get the filename and modtime from the index and verify size still matches
var fileName string
var fileModTime time.Time
var sizeMatches bool
index.ReadOnlyOperation(func() {
if dir := index.GetDirectories()[loc.dirPath]; dir != nil {
if loc.fileIdx < len(dir.Files) {
file := &dir.Files[loc.fileIdx]
if dir := index.GetDirectories()[loc.DirPath]; dir != nil {
if loc.FileIdx < len(dir.Files) {
file := &dir.Files[loc.FileIdx]
// CRITICAL: Verify size matches expected size for this group
if file.Size == fileSize {
fileName = file.Name
fileModTime = file.ModTime
sizeMatches = true
}
}
@@ -295,7 +382,7 @@ func groupLocationsByChecksum(locations []fileLocation, index *indexing.Index, f
}
filePath := filepath.Join(fullPath, fileName)
checksum, err := computePartialChecksum(filePath, fileSize)
checksum, err := computePartialChecksum(index.Path, filePath, fileSize, fileModTime)
if err != nil {
continue
}
@@ -303,7 +390,7 @@ func groupLocationsByChecksum(locations []fileLocation, index *indexing.Index, f
}
// Convert map to slice of groups
groups := make([][]fileLocation, 0, len(checksumMap))
groups := make([][]sql.FileLocation, 0, len(checksumMap))
for _, group := range checksumMap {
if len(group) >= 2 {
groups = append(groups, group)
@@ -319,8 +406,17 @@ func groupLocationsByChecksum(locations []fileLocation, index *indexing.Index, f
// - Always read first 8KB (header/metadata)
// - For files > 24KB: sample middle 8KB and last 8KB
// - Total read: ~24KB max per file regardless of file size
func computePartialChecksum(path string, size int64) (string, error) {
file, err := os.Open(path)
// Checksums are cached for 1 hour based on source/path/modtime
func computePartialChecksum(sourcePath, filePath string, size int64, modTime time.Time) (string, error) {
// Generate cache key from source path, file path, and modification time
cacheKey := fmt.Sprintf("%s:%s:%d:%d", sourcePath, filePath, size, modTime.Unix())
// Check cache first
if cachedChecksum, ok := checksumCache.Get(cacheKey); ok {
return cachedChecksum, nil
}
file, err := os.Open(filePath)
if err != nil {
return "", err
}
@@ -361,17 +457,22 @@ func computePartialChecksum(path string, size int64) (string, error) {
}
}
return fmt.Sprintf("%x", hash.Sum(nil)), nil
checksum := fmt.Sprintf("%x", hash.Sum(nil))
// Cache the result
checksumCache.Set(cacheKey, checksum)
return checksum, nil
}
// groupLocationsByFilename groups file locations by fuzzy filename matching
// Works with minimal fileLocation references instead of full SearchResult objects
func groupLocationsByFilename(locations []fileLocation, index *indexing.Index, expectedSize int64) [][]fileLocation {
// groupLocationsByFilenameWithMetadata uses pre-computed normalized names from SQLite
// Works directly with sql.FileLocation - no conversion needed
func groupLocationsByFilenameWithMetadata(locations []sql.FileLocation, index *indexing.Index, expectedSize int64) [][]sql.FileLocation {
if len(locations) == 0 {
return nil
}
// First, fetch all file metadata we need for comparison in a single lock
// Verify sizes from index (still need to check index is up to date)
type fileMetadata struct {
name string
size int64
@@ -381,11 +482,9 @@ func groupLocationsByFilename(locations []fileLocation, index *indexing.Index, e
index.ReadOnlyOperation(func() {
dirs := index.GetDirectories()
for i, loc := range locations {
if dir := dirs[loc.dirPath]; dir != nil {
if loc.fileIdx < len(dir.Files) {
file := &dir.Files[loc.fileIdx]
// CRITICAL: Only include files that match the expected size
// Index could have changed since locations were collected
if dir := dirs[loc.DirPath]; dir != nil {
if loc.FileIdx < len(dir.Files) {
file := &dir.Files[loc.FileIdx]
if file.Size == expectedSize {
metadata[i] = fileMetadata{
name: file.Name,
@@ -397,8 +496,7 @@ func groupLocationsByFilename(locations []fileLocation, index *indexing.Index, e
}
})
// Now group by fuzzy filename matching without holding the lock
groups := [][]fileLocation{}
groups := [][]sql.FileLocation{}
used := make(map[int]bool)
for i := 0; i < len(locations); i++ {
@@ -406,33 +504,29 @@ func groupLocationsByFilename(locations []fileLocation, index *indexing.Index, e
continue
}
group := []fileLocation{locations[i]}
group := []sql.FileLocation{locations[i]}
used[i] = true
baseSize := metadata[i].size
baseName1 := metadata[i].name
ext1 := strings.ToLower(filepath.Ext(baseName1))
filename1 := normalizeFilename(baseName1)
// Use pre-computed normalized name and extension from SQLite
filename1 := locations[i].NormalizedName
ext1 := locations[i].Extension
for j := i + 1; j < len(locations); j++ {
if used[j] || metadata[j].name == "" {
continue
}
// CRITICAL: Ensure exact size match
if metadata[j].size != baseSize {
continue
}
baseName2 := metadata[j].name
ext2 := strings.ToLower(filepath.Ext(baseName2))
// CRITICAL: Extensions must match exactly (case-insensitive)
ext2 := locations[j].Extension
if ext1 != ext2 {
continue
}
filename2 := normalizeFilename(baseName2)
filename2 := locations[j].NormalizedName
// Check if filenames are similar enough
if filenamesSimilar(filename1, filename2) {
+1 -1
View File
@@ -23,7 +23,7 @@ func getJobsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
sources := settings.GetSources(d.user)
reducedIndexes := map[string]indexing.ReducedIndex{}
for _, source := range sources {
reducedIndex, err := indexing.GetIndexInfo(source)
reducedIndex, err := indexing.GetIndexInfo(source, false)
if err != nil {
logger.Debugf("error getting index info: %v", err)
continue
+11 -1
View File
@@ -126,7 +126,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
link.IncrementUserDownload(data.user.Username)
}
}
file.Path = "/" + strings.TrimPrefix(strings.TrimPrefix(file.Path, link.Path), "/")
file.Path = utils.AddTrailingSlashIfNotExists(path)
// Set the file info in the `data` object
data.fileInfo = *file
// Call the next handler with the data
@@ -395,6 +395,16 @@ func getProxyUser(w http.ResponseWriter, r *http.Request, data *requestContext,
if data.user.Username == "" {
return http.StatusForbidden, errors.ErrUnauthorized
}
// Generate a token for proxy users if they don't have one
if data.token == "" {
expires := time.Hour * time.Duration(config.Auth.TokenExpirationHours)
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions)
if err != nil {
logger.Errorf("Failed to generate token for proxy user %s: %v", proxyUser, err)
return http.StatusInternalServerError, fmt.Errorf("failed to generate token")
}
data.token = signed.Key
}
// Call the handler function, passing in the context (or return OK if no handler)
if fn == nil {
return http.StatusOK, nil
+57 -10
View File
@@ -179,10 +179,10 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
sendOnlyOfficeLogEvent(logContext, "INFO", "config", fmt.Sprintf("OnlyOffice session started for document: %s ", path))
// Build download URL that OnlyOffice server will use
downloadURL := buildOnlyOfficeDownloadURL(source, providedPath, d.fileInfo.Hash, d.token)
downloadURL := buildOnlyOfficeDownloadURL(r, source, providedPath, d.fileInfo.Hash, d.token)
// Build callback URL for OnlyOffice to notify us of changes
callbackURL := buildOnlyOfficeCallbackURL(source, providedPath, d.fileInfo.Hash, d.token)
callbackURL := buildOnlyOfficeCallbackURL(r, source, providedPath, d.fileInfo.Hash, d.token)
// Build OnlyOffice client configuration
clientConfig := map[string]interface{}{
@@ -228,14 +228,37 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
}
// buildOnlyOfficeDownloadURL constructs the download URL that OnlyOffice server will use to fetch the file
func buildOnlyOfficeDownloadURL(source, path, hash, token string) string {
func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token string) string {
// Determine base URL (internal URL takes priority for OnlyOffice server communication)
baseURL := settings.Config.Server.BaseURL
var baseURL string
if settings.Config.Server.InternalUrl != "" {
// Ensure proper URL joining without double slashes
// InternalUrl is a full URL (e.g., http://localhost:8080), so use it directly
internalURL := strings.TrimSuffix(settings.Config.Server.InternalUrl, "/")
baseURLPath := strings.TrimPrefix(settings.Config.Server.BaseURL, "/")
baseURL = internalURL + "/" + baseURLPath
if baseURLPath != "" {
baseURL = internalURL + "/" + baseURLPath
} else {
baseURL = internalURL
}
} else {
// Extract scheme and host from request (respecting X-Forwarded-* headers)
var host string
var scheme string
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
// Use X-Forwarded-Proto if available, otherwise default to https for proxied requests
if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" {
scheme = forwardedProto
} else {
scheme = "https"
}
} else {
// Fallback to simple approach
host = r.Host
scheme = getScheme(r)
}
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, settings.Config.Server.BaseURL)
}
var downloadURL string
@@ -255,13 +278,37 @@ func buildOnlyOfficeDownloadURL(source, path, hash, token string) string {
}
// buildOnlyOfficeCallbackURL constructs the callback URL that OnlyOffice server will use to notify us of changes
func buildOnlyOfficeCallbackURL(source, path, hash, token string) string {
baseURL := settings.Config.Server.BaseURL
func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token string) string {
// Determine base URL (internal URL takes priority for OnlyOffice server communication)
var baseURL string
if settings.Config.Server.InternalUrl != "" {
// Ensure proper URL joining without double slashes
// InternalUrl is a full URL (e.g., http://localhost:8080), so use it directly
internalURL := strings.TrimSuffix(settings.Config.Server.InternalUrl, "/")
baseURLPath := strings.TrimPrefix(settings.Config.Server.BaseURL, "/")
baseURL = internalURL + "/" + baseURLPath
if baseURLPath != "" {
baseURL = internalURL + "/" + baseURLPath
} else {
baseURL = internalURL
}
} else {
// Extract scheme and host from request (respecting X-Forwarded-* headers)
var host string
var scheme string
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
// Use X-Forwarded-Proto if available, otherwise default to https for proxied requests
if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" {
scheme = forwardedProto
} else {
scheme = "https"
}
} else {
// Fallback to simple approach
host = r.Host
scheme = getScheme(r)
}
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, settings.Config.Server.BaseURL)
}
var callbackURL string
+87 -23
View File
@@ -58,21 +58,45 @@ func (r *throttledReadSeeker) Seek(offset int64, whence int) (int64, error) {
return r.rs.Seek(offset, whence)
}
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
if r.URL.Query().Get("inline") == "true" {
w.Header().Set("Content-Disposition", "inline; filename*=utf-8''"+url.PathEscape(fileName))
} else {
// As per RFC6266 section 4.3
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(fileName))
// toASCIIFilename converts a filename to ASCII-safe format by replacing non-ASCII characters with underscores
func toASCIIFilename(fileName string) string {
var result strings.Builder
for _, r := range fileName {
if r > 127 {
// Replace non-ASCII characters with underscore
result.WriteRune('_')
} else {
result.WriteRune(r)
}
}
return result.String()
}
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
dispositionType := "attachment"
if r.URL.Query().Get("inline") == "true" {
dispositionType = "inline"
}
// standard: ASCII-only safe fallback
asciiFileName := toASCIIFilename(fileName)
// RFC 5987: UTF-8 encoded
encodedFileName := url.PathEscape(fileName)
// Always set both filename (ASCII) and filename* (UTF-8) for maximum compatibility (RFC 6266)
w.Header().Set("Content-Disposition", fmt.Sprintf("%s; filename=%q; filename*=utf-8''%s", dispositionType, asciiFileName, encodedFileName))
}
// rawHandler serves the raw content of a file, multiple files, or directory in various formats.
// @Summary Get raw content of a file, multiple files, or directory
// @Description Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.
// @Description
// @Description **Filename Encoding:**
// @Description - The Content-Disposition header will always include both:
// @Description 1. `filename="..."`: An ASCII-safe version of the filename for compatibility.
// @Description 2. `filename*=utf-8”...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.
// @Tags Resources
// @Accept json
// @Produce json
// @Param files query string true "a list of files in the following format 'source::filename' and separated by '||' with additional items in the list. (required)"
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
@@ -111,20 +135,20 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
if idx == nil {
return fmt.Errorf("source %s is not available", source)
}
if d.share != nil {
_, err = files.FileInfoFaster(utils.FileOptions{
Path: path,
Source: source,
Expand: false,
}, nil)
} else {
_, err = files.FileInfoFaster(utils.FileOptions{
Username: d.user.Username,
Path: path,
Source: source,
Expand: false,
}, store.Access)
// Check access control directly for each file and silently skip if access is denied
if d.share == nil && store.Access != nil {
if !store.Access.Permitted(idx.Path, path, d.user.Username) {
return nil // Silently skip this file/folder
}
}
// Verify file exists
_, err = files.FileInfoFaster(utils.FileOptions{
Path: path,
Source: source,
Expand: false,
}, nil)
if err != nil {
return err
}
@@ -158,6 +182,23 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
return nil
}
// Check access control for each file/folder during walk
if d.share == nil {
// Construct the index-relative path for this file/folder
// relPath is relative to realPath, so we need to join it with the original path
indexRelPath := filepath.Join(path, relPath)
indexRelPath = filepath.ToSlash(indexRelPath) // Normalize separators
if !store.Access.Permitted(idx.Path, indexRelPath, d.user.Username) {
// Skip this file/folder silently
if fileInfo.IsDir() {
// Skip the entire directory by returning filepath.SkipDir
return filepath.SkipDir
}
return nil
}
}
// Prepend base folder name unless flatten is true
if !flatten {
relPath = filepath.Join(baseName, relPath)
@@ -322,6 +363,19 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
}
// Verify access control before opening the file (direct rule check)
if d.share == nil && store.Access != nil {
if !store.Access.Permitted(idx.Path, firstFilePath, d.user.Username) {
logger.Debugf("user %s denied access to path %s", d.user.Username, firstFilePath)
// Send OnlyOffice error log if this was an OnlyOffice download
if isOnlyOffice && logContext != nil {
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
fmt.Sprintf("OnlyOffice download failed - access denied by rule: %s", firstFilePath))
}
return http.StatusForbidden, fmt.Errorf("access denied to path %s", firstFilePath)
}
}
fd, err2 := os.Open(realPath)
if err2 != nil {
// Send OnlyOffice error log if this was an OnlyOffice download
@@ -365,6 +419,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
// serve content allows for range requests.
// video scrubbing, etc.
// Note: http.ServeContent will respect our already-set Content-Disposition header
var reader io.ReadSeeker = fd
if d.share != nil && d.share.MaxBandwidth > 0 {
// convert KB/s to B/s
@@ -402,7 +457,8 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
if len(fileList) == 1 && isDir {
baseDirName = filepath.Base(realPath)
}
fileName = url.PathEscape(baseDirName + extension)
// Store original filename before any encoding
originalFileName := baseDirName + extension
archiveData := filepath.Join(config.Server.CacheDir, utils.InsecureRandomIdentifier(10))
if extension == ".zip" {
@@ -432,11 +488,12 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
sizeInMB := fileInfo.Size() / 1024 / 1024
if sizeInMB > 500 {
logger.Debugf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName)
logger.Debugf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, originalFileName)
}
// Set headers AFTER computing actual archive size
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+fileName)
// Use the same setContentDisposition logic for archives
setContentDisposition(w, r, originalFileName)
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
w.Header().Set("Content-Type", "application/octet-stream")
@@ -480,6 +537,12 @@ func computeArchiveSize(fileList []string, d *requestContext) (int64, error) {
return 0, fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
}
path = utils.JoinPathAsUnix(userScope, path)
// Check access control for each file in the archive
// Silently skip if access is denied (as if the file doesn't exist)
if store.Access != nil && !store.Access.Permitted(idx.Path, path, d.user.Username) {
continue // Skip this file and continue with the next one
}
}
// For shares, the path is already correctly resolved by publicRawHandler
realPath, isDir, err := idx.GetRealPath(path)
@@ -512,6 +575,7 @@ func createZip(d *requestContext, tmpDirPath string, filenames ...string) error
for _, fname := range filenames {
err := addFile(fname, d, nil, zipWriter, false)
if err != nil {
// Access control failures return nil, so any error here is a real error
logger.Errorf("Failed to add %s to ZIP: %v", fname, err)
return err
}
+6 -1
View File
@@ -382,7 +382,12 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
fileInfo, err := files.FileInfoFaster(fileOpts, accessStore)
if err != nil {
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
return http.StatusConflict, nil
}
// If overriding, delete existing thumbnails
preview.DelThumbs(r.Context(), *fileInfo)
}
err = files.WriteFile(fileOpts.Source, fileOpts.Path, r.Body)
+30 -17
View File
@@ -26,10 +26,9 @@ import (
// ShareResponse represents a share with computed username field and download URL
type ShareResponse struct {
*share.Link
Source string `json:"source"` // Override embedded field to show source name
Username string `json:"username,omitempty"`
DownloadURL string `json:"downloadURL,omitempty"`
PathExists bool `json:"pathExists"`
Source string `json:"source"` // Override embedded field to show source name
Username string `json:"username,omitempty"`
PathExists bool `json:"pathExists"`
}
// convertToFrontendShareResponse converts shares to response format with usernames
@@ -63,14 +62,15 @@ func convertToFrontendShareResponse(r *http.Request, shares []*share.Link) ([]*S
pathExists := utils.CheckPathExists(filepath.Join(sourceInfo.Path, s.Path))
s.CommonShare.HasPassword = s.HasPassword()
s.DownloadURL = getShareURL(r, s.Hash, true)
s.ShareURL = getShareURL(r, s.Hash, false)
// Create response with source name (overrides the embedded Link's source field)
responses = append(responses, &ShareResponse{
Link: s,
Source: sourceInfo.Name, // Override to show source name instead of backend path
Username: username,
DownloadURL: getDownloadURL(r, s.Hash),
PathExists: pathExists,
Link: s,
Source: sourceInfo.Name, // Override to show source name instead of backend path
Username: username,
PathExists: pathExists,
})
}
return responses, nil
@@ -392,6 +392,7 @@ type DirectDownloadResponse struct {
Status string `json:"status"`
Hash string `json:"hash"`
DownloadURL string `json:"url"`
ShareURL string `json:"shareUrl"`
}
// shareDirectDownloadHandler creates a direct download link for files only.
@@ -509,7 +510,8 @@ func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *reque
response := DirectDownloadResponse{
Status: "201",
Hash: existing.Hash,
DownloadURL: getDownloadURL(r, existing.Hash),
DownloadURL: getShareURL(r, existing.Hash, true),
ShareURL: getShareURL(r, existing.Hash, false),
}
return renderJSON(w, r, response)
}
@@ -540,21 +542,26 @@ func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *reque
response := DirectDownloadResponse{
Status: "200",
Hash: secureHash,
DownloadURL: getDownloadURL(r, secureHash),
DownloadURL: getShareURL(r, secureHash, true),
ShareURL: getShareURL(r, secureHash, false),
}
return renderJSON(w, r, response)
}
func getDownloadURL(r *http.Request, hash string) string {
var downloadURL string
func getShareURL(r *http.Request, hash string, isDirectDownload bool) string {
var shareURL string
if config.Server.ExternalUrl != "" {
downloadURL = fmt.Sprintf("%s%spublic/api/raw?hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, hash)
if isDirectDownload {
shareURL = fmt.Sprintf("%s%spublic/api/raw?hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, hash)
} else {
shareURL = fmt.Sprintf("%s%spublic/share/%s", config.Server.ExternalUrl, config.Server.BaseURL, hash)
}
} else {
// Prefer X-Forwarded-Host for proxy support
var host string
var scheme string
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
// Use X-Forwarded-Proto if available, otherwise default to https for proxied requests
@@ -568,9 +575,13 @@ func getDownloadURL(r *http.Request, hash string) string {
host = r.Host
scheme = getScheme(r)
}
downloadURL = fmt.Sprintf("%s://%s%spublic/api/raw?hash=%s", scheme, host, config.Server.BaseURL, hash)
if isDirectDownload {
shareURL = fmt.Sprintf("%s://%s%spublic/api/raw?hash=%s", scheme, host, config.Server.BaseURL, hash)
} else {
shareURL = fmt.Sprintf("%s://%s%spublic/share/%s", scheme, host, config.Server.BaseURL, hash)
}
}
return downloadURL
return shareURL
}
// shareInfoHandler retrieves share information by hash.
@@ -590,6 +601,8 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if err != nil {
return http.StatusNotFound, fmt.Errorf("share hash not found")
}
commonShare.DownloadURL = getShareURL(r, hash, true)
commonShare.ShareURL = getShareURL(r, hash, false)
return renderJSON(w, r, commonShare)
}
+12 -126
View File
@@ -69,6 +69,7 @@ type Index struct {
FoundHardLinks map[string]uint64 `json:"-"` // hardlink path -> size
processedInodes map[uint64]struct{} `json:"-"`
totalSize uint64 `json:"-"`
previousTotalSize uint64 `json:"-"` // Track previous totalSize for change detection
// Scanner management (new multi-scanner system)
scanners map[string]*Scanner `json:"-"` // path -> scanner
@@ -233,25 +234,24 @@ func (idx *Index) GetFsDirInfo(adjustedPath string) (*iteminfo.FileInfo, error)
}
if !dirInfo.IsDir() {
// Use handleFile for consistent size calculation across platforms
// API calls (GetFsDirInfo) should not update totalSize, so pass false for isRoutineScan
realSize, _ := idx.handleFile(dirInfo, adjustedPath, realPath, false)
size := int64(realSize)
fileInfo := iteminfo.FileInfo{
Path: adjustedPath,
ItemInfo: iteminfo.ItemInfo{
Name: filepath.Base(originalPath),
Size: dirInfo.Size(),
Size: size,
ModTime: dirInfo.ModTime(),
},
}
fileInfo.DetectType(realPath, false)
// Set HasPreview flags using consolidated helper
setFilePreviewFlags(&fileInfo.ItemInfo, realPath)
return &fileInfo, nil
}
// Normalize directory path to always have trailing slash
adjustedPath = utils.AddTrailingSlashIfNotExists(adjustedPath)
// adjustedPath is already normalized with trailing slash
combinedPath := adjustedPath
var response *iteminfo.FileInfo
response, err = idx.GetDirInfo(dir, dirInfo, realPath, adjustedPath, combinedPath, actionConfig{
@@ -268,7 +268,6 @@ func (idx *Index) GetFsDirInfo(adjustedPath string) (*iteminfo.FileInfo, error)
found := false
for _, item := range response.Files {
if item.Name == baseName {
// Clean path to remove trailing slashes before joining
filePath := strings.TrimSuffix(adjustedPath, "/") + "/" + item.Name
response = &iteminfo.FileInfo{
Path: filePath,
@@ -288,9 +287,7 @@ func (idx *Index) GetFsDirInfo(adjustedPath string) (*iteminfo.FileInfo, error)
}
func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjustedPath, combinedPath string, config actionConfig) (*iteminfo.FileInfo, error) {
// Ensure combinedPath has exactly one trailing slash to prevent double slashes in subdirectory paths
combinedPath = strings.TrimRight(combinedPath, "/") + "/"
// Read directory contents
combinedPath = utils.AddTrailingSlashIfNotExists(combinedPath)
files, err := dirInfo.Readdir(-1)
if err != nil {
return nil, err
@@ -305,8 +302,6 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
hasPreview = realDirInfo.HasPreview
}
}
// Process each file and directory in the current directory
for _, file := range files {
hidden := isHidden(file, idx.Path+combinedPath)
isDir := iteminfo.IsDirectory(file)
@@ -317,14 +312,11 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
continue
}
}
// Skip logic based on mode
if config.CheckViewable {
// When checking viewable items: skip if shouldSkip=true AND not viewable
if idx.shouldSkip(isDir, hidden, fullCombined, baseName, config) && !idx.IsViewable(isDir, fullCombined) {
continue
}
} else {
// Normal indexing mode: skip if shouldSkip=true
if idx.shouldSkip(isDir, hidden, fullCombined, baseName, config) {
continue
}
@@ -337,7 +329,6 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
if isDir {
dirPath := combinedPath + file.Name()
// Check NeverWatchPaths map - O(1) lookup for paths with neverWatch: true
if idx.wasIndexed && config.Recursive && idx.Config.ResolvedConditionals != nil {
if _, exists := idx.Config.ResolvedConditionals.NeverWatchPaths[fullCombined]; exists {
realDirInfo, exists := idx.GetMetadataInfo(dirPath, true)
@@ -351,11 +342,9 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
if omitList[file.Name()] {
continue
}
if config.Recursive {
// clear for garbage collection
file = nil
// Recursively index the subdirectory
err = idx.indexDirectory(dirPath, config)
if err != nil {
logger.Errorf("Failed to index directory %s: %v", dirPath, err)
@@ -372,16 +361,14 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
dirInfos = append(dirInfos, *itemInfo)
if config.IsRoutineScan {
idx.NumDirs++
// Also update the active scanner's counter
idx.incrementScannerDirs()
}
} else {
size, shouldCountSize := idx.handleFile(file, fullCombined)
itemInfo.DetectType(realPath+"/"+file.Name(), false)
// Set HasPreview flags - use cached metadata optimization only when indexing is enabled
realFilePath := realPath + "/" + file.Name()
size, shouldCountSize := idx.handleFile(file, fullCombined, realFilePath, config.IsRoutineScan)
itemInfo.DetectType(realFilePath, false)
usedCachedPreview := false
if !idx.Config.DisableIndexing && config.Recursive {
// Optimization: For audio files during indexing, check if we can use cached album art info
simpleType := strings.Split(itemInfo.Type, "/")[0]
if simpleType == "audio" {
previousInfo, exists := idx.GetReducedMetadata(fullCombined, false)
@@ -392,19 +379,13 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
}
}
}
// When indexing is disabled or CheckViewable mode, always check directly
// Skip if we already used cached preview data (avoids redundant HasAlbumArt checks)
if !usedCachedPreview {
setFilePreviewFlags(itemInfo, realPath+"/"+file.Name())
}
itemInfo.Size = int64(size)
// Update parent folder preview status for images, videos, and audio with album art
// Use shared function to determine if this file type should bubble up to folder preview
if itemInfo.HasPreview && iteminfo.ShouldBubbleUpToFolderPreview(*itemInfo) {
hasPreview = true
}
// Wrap ItemInfo in ExtendedItemInfo for files array
extItemInfo := iteminfo.ExtendedItemInfo{
ItemInfo: *itemInfo,
}
@@ -414,23 +395,13 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
}
if config.IsRoutineScan {
idx.NumFiles++
// Also update the active scanner's counter
idx.incrementScannerFiles()
}
}
}
if totalSize == 0 && idx.Config.Conditionals.ZeroSizeFolders {
return nil, errors.ErrNotIndexed
}
if adjustedPath == "/" {
idx.mu.Lock()
idx.DiskUsed = uint64(totalSize)
idx.mu.Unlock()
}
// Create FileInfo for the current directory (adjustedPath is already normalized with trailing slash)
dirFileInfo := &iteminfo.FileInfo{
Path: adjustedPath,
Files: fileInfos,
@@ -444,29 +415,20 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
HasPreview: hasPreview,
}
dirFileInfo.SortItems()
// Metadata will be updated by the caller (indexDirectory or GetFsDirInfo)
return dirFileInfo, nil
}
// RecursiveUpdateDirSizes updates parent directory sizes recursively up the tree
// childInfo should have the NEW size, previousSize should be the OLD size
func (idx *Index) RecursiveUpdateDirSizes(childInfo *iteminfo.FileInfo, previousSize int64) {
parentDir := utils.GetParentDirectoryPath(childInfo.Path)
parentInfo, exists := idx.GetMetadataInfo(parentDir, true)
if !exists || parentDir == "" {
return
}
// Calculate size delta and update parent
previousParentSize := parentInfo.Size
sizeDelta := childInfo.Size - previousSize
parentInfo.Size = previousParentSize + sizeDelta
idx.UpdateMetadata(parentInfo)
// Recursively update grandparents
idx.RecursiveUpdateDirSizes(parentInfo, previousParentSize)
}
@@ -478,12 +440,10 @@ func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
if ok && cached != "" {
return cached, isDir, nil
}
// Convert relative path to absolute path
absolutePath, err := filepath.Abs(joinedPath)
if err != nil {
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", joinedPath, err)
}
// Resolve symlinks and get the real path
realPath, isDir, err := iteminfo.ResolveSymlinks(absolutePath)
if err == nil {
RealPathCache.Set(joinedPath, realPath)
@@ -497,32 +457,23 @@ func (idx *Index) RefreshFileInfo(opts utils.FileOptions) error {
Quick: false,
Recursive: opts.Recursive,
}
targetPath := opts.Path
if !opts.IsDir {
targetPath = idx.MakeIndexPath(filepath.Dir(targetPath))
}
// Get PREVIOUS metadata BEFORE indexing
previousInfo, previousExists := idx.GetMetadataInfo(targetPath, true)
var previousSize int64
if previousExists {
previousSize = previousInfo.Size
}
// Re-index the directory
err := idx.indexDirectoryWithOptions(targetPath, config)
if err != nil {
return err
}
// Get the NEW metadata after indexing
newInfo, exists := idx.GetMetadataInfo(targetPath, true)
if !exists {
return fmt.Errorf("file/folder does not exist in metadata: %s", targetPath)
}
// If size changed, propagate to parents
if previousSize != newInfo.Size {
idx.RecursiveUpdateDirSizes(newInfo, previousSize)
}
@@ -531,24 +482,19 @@ func (idx *Index) RefreshFileInfo(opts utils.FileOptions) error {
}
func isHidden(file os.FileInfo, srcPath string) bool {
// Check if the file starts with a dot (common on Unix systems)
if file.Name()[0] == '.' {
return true
}
if runtime.GOOS == "windows" {
return CheckWindowsHidden(filepath.Join(srcPath, file.Name()))
}
// Default behavior for non-Windows systems
return false
}
// setFilePreviewFlags determines if a file should have a preview based on its type
// This consolidates the logic used in both GetFsDirInfo and GetDirInfo
func setFilePreviewFlags(fileInfo *iteminfo.ItemInfo, realPath string) {
simpleType := strings.Split(fileInfo.Type, "/")[0]
// Check for HEIC/HEIF
switch fileInfo.Type {
case "image/heic", "image/heif":
fileInfo.HasPreview = settings.CanConvertImage("heic")
@@ -586,11 +532,9 @@ func (idx *Index) IsViewable(isDir bool, adjustedPath string) bool {
baseName := filepath.Base(strings.TrimSuffix(adjustedPath, "/"))
if isDir {
// Exact match (O(1))
if rule, exists := rules.FolderNames[baseName]; exists && rule.Viewable {
return true
}
// Prefix/suffix (O(n))
for _, rule := range rules.FolderPaths {
if strings.HasPrefix(adjustedPath, rule.FolderPath) && rule.Viewable {
return true
@@ -607,11 +551,9 @@ func (idx *Index) IsViewable(isDir bool, adjustedPath string) bool {
}
}
} else {
// Exact match (O(1))
if rule, exists := rules.FileNames[baseName]; exists && rule.Viewable {
return true
}
// Prefix/suffix (O(n))
for _, rule := range rules.FilePaths {
if strings.HasPrefix(adjustedPath, rule.FilePath) && rule.Viewable {
return true
@@ -639,22 +581,13 @@ func (idx *Index) shouldSkip(isDir bool, isHidden bool, fullCombined, baseName s
if fullCombined == "/" {
return false
}
// When indexing is disabled globally, behavior depends on the mode
if idx.Config.DisableIndexing {
// If checking viewable (filesystem access), don't skip - show everything from filesystem
if config.CheckViewable {
return false
}
// If indexing mode, skip everything
return true
return !config.CheckViewable
}
if isDir && config.IsRoutineScan {
// Check NeverWatch: Skip directories with index=true AND neverWatch=true during routine scans
// This allows them to be indexed once but never re-scanned
_, ok := rules.NeverWatchPaths[fullCombined]
if ok {
// skip scanning, but is viewable
return true
}
}
@@ -662,18 +595,13 @@ func (idx *Index) shouldSkip(isDir bool, isHidden bool, fullCombined, baseName s
if isDir {
if rule, ok := rules.FolderNames[baseName]; ok {
if _, ok := rules.FolderPaths[fullCombined]; !ok {
// create a rule so sub folders can be skipped
rules.FolderPaths[fullCombined] = rule
}
return true
}
// Check FolderPaths: Two-stage check for performance
// Stage 1: O(1) exact match (fast path for the folder itself)
if _, ok := rules.FolderPaths[fullCombined]; ok {
return true
}
// Stage 2: O(n) prefix match (for child folders)
for path := range rules.FolderPaths {
if strings.HasPrefix(fullCombined, path) {
return true
@@ -694,17 +622,12 @@ func (idx *Index) shouldSkip(isDir bool, isHidden bool, fullCombined, baseName s
}
}
} else {
// Check FileNames (exact match on base name) - O(1) lookup
if _, ok := rules.FileNames[baseName]; ok {
return true
}
// Check FilePaths: Two-stage check for performance
// Stage 1: O(1) exact match (fast path for the file itself)
if _, ok := rules.FilePaths[fullCombined]; ok {
return true
}
// Stage 2: O(n) prefix match (for files in excluded folders)
for path := range rules.FilePaths {
if strings.HasPrefix(fullCombined, path) {
return true
@@ -758,43 +681,6 @@ func (idx *Index) SetStatus(status IndexStatus) error {
return idx.SendSourceUpdateEvent()
}
func (idx *Index) handleFile(file os.FileInfo, fullCombined string) (size uint64, shouldCountSize bool) {
var realSize uint64
var nlink uint64 = 1
var ino uint64 = 0
canUseSyscall := false
if sys := file.Sys(); sys != nil {
realSize, nlink, ino, canUseSyscall = getFileDetails(sys)
}
if !canUseSyscall {
// Fallback for non-unix systems or if syscall info is unavailable
realSize = uint64(file.Size())
}
if nlink > 1 {
// It's a hard link
idx.mu.Lock()
defer idx.mu.Unlock()
if _, exists := idx.processedInodes[ino]; exists {
// Already seen, don't count towards global total, or directory total.
return realSize, false
}
// First time seeing this inode.
idx.processedInodes[ino] = struct{}{}
idx.FoundHardLinks[fullCombined] = realSize
idx.totalSize += realSize
return realSize, true // Count size for directory total.
}
// It's a regular file.
idx.mu.Lock()
idx.totalSize += realSize
idx.mu.Unlock()
return realSize, true // Count size.
}
// input should be non-index path.
func (idx *Index) MakeIndexPath(path string) string {
if path == "." || strings.HasPrefix(path, "./") {
+39 -112
View File
@@ -8,25 +8,23 @@ import (
"github.com/gtsteffaniak/go-logger/logger"
)
// Scanner represents an independent scanner for a specific directory path
// Each scanner has its own schedule and stats, but only one can run at a time (protected by Index.scanMutex)
// Each scanner has its own schedule and stats, but only one can run at a time
type Scanner struct {
// Identity
scanPath string // "/" for root scanner, "/Documents/" for child scanners
// Per-scanner scheduling (not shared between scanners)
currentSchedule int
smartModifier time.Duration
complexity uint // 0-10 scale: 0=unknown, 1=simple, 2-6=normal, 7-9=complex, 10=highlyComplex
fullScanCounter int // every 5th scan is a full scan
// Per-scanner stats (not shared)
filesChanged bool
lastScanned time.Time
quickScanTime int
fullScanTime int
numDirs uint64 // Local count for this path
numFiles uint64 // Local count for this path
scannerSize uint64 // Size contributed by this scanner (for delta calculation)
// Reference back to parent index
idx *Index
@@ -35,63 +33,6 @@ type Scanner struct {
stopChan chan struct{}
}
// calculateTimeScore returns a 1-10 score based on full scan time
func (s *Scanner) calculateTimeScore() uint {
if s.fullScanTime == 0 {
return 1 // No data yet, assume simple
}
// Time-based thresholds (in seconds)
switch {
case s.fullScanTime < 2:
return 1
case s.fullScanTime < 5:
return 2
case s.fullScanTime < 10:
return 3
case s.fullScanTime < 15:
return 4
case s.fullScanTime < 30:
return 5
case s.fullScanTime < 60:
return 6
case s.fullScanTime < 90:
return 7
case s.fullScanTime < 120:
return 8
case s.fullScanTime < 180:
return 9
default:
return 10
}
}
// calculateDirScore returns a 1-10 score based on directory count
func (s *Scanner) calculateDirScore() uint {
// Directory-based thresholds
switch {
case s.numDirs < 2500:
return 1
case s.numDirs < 5000:
return 2
case s.numDirs < 10000:
return 3
case s.numDirs < 25000:
return 4
case s.numDirs < 50000:
return 5
case s.numDirs < 100000:
return 6
case s.numDirs < 250000:
return 7
case s.numDirs < 500000:
return 8
case s.numDirs < 1000000:
return 9
default:
return 10
}
}
// start begins the scanner's main loop
func (s *Scanner) start() {
// Do initial scan for all scanners
@@ -118,7 +59,6 @@ func (s *Scanner) start() {
return
case <-time.After(sleepTime):
// Time to scan! But must acquire mutex first
s.tryAcquireAndScan()
}
}
@@ -126,19 +66,6 @@ func (s *Scanner) start() {
// tryAcquireAndScan attempts to acquire the global scan mutex and run a scan
func (s *Scanner) tryAcquireAndScan() {
// Child scanners must wait for root scanner to go first each round
if s.scanPath != "/" {
s.idx.mu.RLock()
lastRootScan := s.idx.lastRootScanTime
myLastScan := s.lastScanned
s.idx.mu.RUnlock()
// If we've scanned more recently than the root, skip this cycle
if !myLastScan.IsZero() && myLastScan.After(lastRootScan) {
return
}
}
s.idx.scanMutex.Lock()
// Mark which scanner is active (for status/logging)
@@ -146,14 +73,12 @@ func (s *Scanner) tryAcquireAndScan() {
s.idx.activeScannerPath = s.scanPath
s.idx.mu.Unlock()
// Determine if quick or full scan
// First scan (fullScanCounter=0) is always full
// Scans 1-4 are quick, scan 5 is full, then repeat
quick := s.fullScanCounter > 0 && s.fullScanCounter < 5
s.fullScanCounter++
if s.fullScanCounter >= 5 {
s.fullScanCounter = 0
}
s.runIndexing(quick)
// Update this scanner's schedule based on results
@@ -173,17 +98,15 @@ func (s *Scanner) tryAcquireAndScan() {
s.idx.scanMutex.Unlock()
// Aggregate stats to Index level and update status (after releasing active scanner)
// Aggregate stats to Index level and update status
s.idx.aggregateStatsFromScanners()
}
// runIndexing performs the actual indexing work
func (s *Scanner) runIndexing(quick bool) {
if s.scanPath == "/" {
// ROOT SCANNER: Non-recursive, just scan root directory itself
s.runRootScan(quick)
} else {
// CHILD SCANNER: Recursive scan of assigned directory
s.runChildScan(quick)
}
@@ -204,6 +127,25 @@ func (s *Scanner) runRootScan(quick bool) {
s.numFiles = 0
}
// Track size before scan for delta calculation
previousScannerSize := s.scannerSize
s.idx.mu.Lock()
if previousScannerSize > 0 {
if s.idx.totalSize >= previousScannerSize {
s.idx.totalSize -= previousScannerSize
} else {
// Safety check: if totalSize is less than previousScannerSize, something is wrong
// Reset to 0 to avoid underflow
logger.Warningf("[%s] Scanner [%s] WARNING: totalSize (%d) < previousScannerSize (%d), resetting totalSize to 0", s.idx.Name, s.scanPath, s.idx.totalSize, previousScannerSize)
s.idx.totalSize = 0
}
}
indexSizeBefore := s.idx.totalSize
s.idx.mu.Unlock()
logger.Debugf("[%s] Scanner [%s] START: indexSizeBefore=%d, previousScannerSize=%d (quick=%v)", s.idx.Name, s.scanPath, indexSizeBefore, previousScannerSize, quick)
s.filesChanged = false
startTime := time.Now()
@@ -212,6 +154,12 @@ func (s *Scanner) runRootScan(quick bool) {
logger.Errorf("Root scanner error: %v", err)
}
// Calculate delta for this scanner
s.idx.mu.RLock()
indexSizeAfter := s.idx.totalSize
s.idx.mu.RUnlock()
newScannerSize := indexSizeAfter - indexSizeBefore
s.scannerSize = newScannerSize
scanDuration := int(time.Since(startTime).Seconds())
if quick {
s.quickScanTime = scanDuration
@@ -219,8 +167,6 @@ func (s *Scanner) runRootScan(quick bool) {
s.fullScanTime = scanDuration
s.updateComplexity()
}
// Check for new top-level directories and create scanners for them
s.checkForNewChildDirectories()
}
@@ -237,15 +183,21 @@ func (s *Scanner) runChildScan(quick bool) {
s.numDirs = 0
s.numFiles = 0
}
s.idx.mu.RLock()
indexSizeBefore := s.idx.totalSize
s.idx.mu.RUnlock()
s.filesChanged = false
startTime := time.Now()
err := s.idx.indexDirectory(s.scanPath, config)
if err != nil {
logger.Errorf("Scanner [%s] error: %v", s.scanPath, err)
}
s.idx.mu.RLock()
indexSizeAfter := s.idx.totalSize
s.idx.mu.RUnlock()
newScannerSize := indexSizeAfter - indexSizeBefore
s.scannerSize = newScannerSize
scanDuration := int(time.Since(startTime).Seconds())
if quick {
s.quickScanTime = scanDuration
@@ -323,35 +275,23 @@ func (s *Scanner) getTopLevelDirs() []string {
for _, file := range files {
baseName := file.Name()
// Skip files - only create scanners for directories
if !file.IsDir() {
// Note: includeRootItems may contain files, but we only create scanners for directories
continue
}
dirPath := "/" + baseName + "/"
// Skip directories in omit list
if omitList[baseName] {
logger.Debugf("Skipping scanner creation for omitted directory: %s", dirPath)
continue
}
// Check if we should include this directory (respects includeRootItems filter)
// When includeRootItems is set, ONLY those items (that are directories) get scanners
if !s.idx.shouldInclude(baseName) {
logger.Debugf("Skipping scanner creation for non-included directory: %s", dirPath)
continue
}
// Check if this directory should be excluded from indexing (respects exclusion rules)
hidden := isHidden(file, s.idx.Path+dirPath)
if s.idx.shouldSkip(true, hidden, dirPath, baseName, actionConfig{}) {
logger.Debugf("Skipping scanner creation for excluded directory: %s", dirPath)
continue
}
dirs = append(dirs, dirPath)
}
@@ -360,10 +300,7 @@ func (s *Scanner) getTopLevelDirs() []string {
// calculateSleepTime determines how long to wait before the next scan
func (s *Scanner) calculateSleepTime() time.Duration {
// Get base schedule time and apply complexity modifier
sleepTime := scanSchedule[s.currentSchedule] + s.smartModifier
// Allow manual override via config
if s.idx.Config.IndexingInterval > 0 {
sleepTime = time.Duration(s.idx.Config.IndexingInterval) * time.Minute
}
@@ -404,19 +341,9 @@ func (s *Scanner) updateSchedule() {
}
// updateComplexity calculates the complexity level (1-10) for this scanner's directory
// 0: unknown, 1: simple, 2-6: normal, 7-9: complex, 10: highlyComplex
// 0: unknown
func (s *Scanner) updateComplexity() {
// Calculate complexity based on both scan time and directory count
timeScore := s.calculateTimeScore()
dirScore := s.calculateDirScore()
// Use the higher score (more conservative approach)
complexity := timeScore
if dirScore > timeScore {
complexity = dirScore
}
s.complexity = complexity
s.complexity = calculateComplexity(s.fullScanTime, s.numDirs)
// Set smartModifier based on complexity level
if modifier, ok := complexityModifier[s.complexity]; ok {
+102 -43
View File
@@ -2,6 +2,7 @@ package indexing
import (
"encoding/json"
"strconv"
"time"
"github.com/gtsteffaniak/filebrowser/backend/events"
@@ -23,8 +24,6 @@ var scanSchedule = map[int]time.Duration{
}
// complexityModifier defines time adjustments based on complexity level (0-10)
// 0: unknown (not yet scanned), 1: simple, 2-6: normal, 7-9: complex, 10: highlyComplex
// Each level gets progressively more aggressive with scan timing adjustments
var complexityModifier = map[uint]time.Duration{
0: 0 * time.Minute, // unknown: no modifier
1: -4 * time.Minute, // simple: scan more frequently
@@ -39,6 +38,72 @@ var complexityModifier = map[uint]time.Duration{
10: 16 * time.Minute, // highlyComplex: scan less frequently
}
// calculateTimeScore returns a 1-10 score based on full scan time
func calculateTimeScore(fullScanTime int) uint {
if fullScanTime == 0 {
return 1 // No data yet, assume simple
}
switch {
case fullScanTime < 2:
return 1
case fullScanTime < 5:
return 2
case fullScanTime < 10:
return 3
case fullScanTime < 15:
return 4
case fullScanTime < 30:
return 5
case fullScanTime < 60:
return 6
case fullScanTime < 90:
return 7
case fullScanTime < 120:
return 8
case fullScanTime < 180:
return 9
default:
return 10
}
}
// calculateDirScore returns a 1-10 score based on directory count
func calculateDirScore(numDirs uint64) uint {
// Directory-based thresholds
switch {
case numDirs < 2500:
return 1
case numDirs < 5000:
return 2
case numDirs < 10000:
return 3
case numDirs < 25000:
return 4
case numDirs < 50000:
return 5
case numDirs < 100000:
return 6
case numDirs < 250000:
return 7
case numDirs < 500000:
return 8
case numDirs < 1000000:
return 9
default:
return 10
}
}
func calculateComplexity(fullScanTime int, numDirs uint64) uint {
timeScore := calculateTimeScore(fullScanTime)
dirScore := calculateDirScore(numDirs)
complexity := timeScore
if dirScore > timeScore {
complexity = dirScore
}
return complexity
}
var fullScanAnchor = 3 // index of the schedule for a full scan
// Removed: Old single-scanner implementation - replaced by multi-scanner system in indexingScanner.go
@@ -108,15 +173,14 @@ func (idx *Index) garbageCollection() {
idx.DirectoriesLedger = make(map[string]struct{})
}
// Removed: UpdateSchedule - now handled per-scanner in Scanner.updateSchedule()
func (idx *Index) SendSourceUpdateEvent() error {
if idx.mock {
logger.Debug("Skipping source update event for mock index.")
return nil
}
reducedIndex, err := GetIndexInfo(idx.Name)
reducedIndex, err := GetIndexInfo(idx.Name, false)
if err != nil {
logger.Errorf("[%s] Error getting index info: %v", idx.Name, err)
return err
}
sourceAsMap := map[string]ReducedIndex{
@@ -124,14 +188,16 @@ func (idx *Index) SendSourceUpdateEvent() error {
}
message, err := json.Marshal(sourceAsMap)
if err != nil {
logger.Errorf("[%s] Error marshaling source update: %v", idx.Name, err)
return err
}
events.SendSourceUpdate(idx.Name, string(message))
// Quote the JSON string so it's sent as a string in the SSE message, not as an object
// The sendEvent function expects the message to be properly quoted (like "\"connection established\"")
quotedMessage := strconv.Quote(string(message))
events.SendSourceUpdate(idx.Name, quotedMessage)
return nil
}
// Removed: RunIndexing - replaced by multi-scanner system where each scanner handles its own indexing
// setupMultiScanner creates and starts the multi-scanner system
// Creates a root scanner (non-recursive) and child scanners for each top-level directory
func (idx *Index) setupMultiScanner() {
@@ -200,19 +266,25 @@ func (idx *Index) setupIndexingScanners() {
// aggregateStatsFromScanners aggregates stats from all scanners to Index-level stats
func (idx *Index) aggregateStatsFromScanners() {
idx.mu.Lock()
defer idx.mu.Unlock()
if len(idx.scanners) == 0 {
idx.mu.Unlock()
return
}
// Store previous stats and status to detect changes
// Use stored previous totalSize for change detection
prevNumDirs := idx.NumDirs
prevNumFiles := idx.NumFiles
prevDiskUsed := idx.previousTotalSize // Use stored previous value (0 on first call, which will trigger initial event)
prevStatus := idx.Status
// Aggregate stats from all scanners
var totalDirs uint64 = 0
var totalFiles uint64 = 0
var totalQuickScanTime = 0
var totalFullScanTime = 0
var mostRecentScan time.Time
var maxComplexity uint = 0 // Start at 0 (unknown)
allScannedAtLeastOnce := true
for _, scanner := range idx.scanners {
@@ -220,66 +292,53 @@ func (idx *Index) aggregateStatsFromScanners() {
totalFiles += scanner.numFiles
totalQuickScanTime += scanner.quickScanTime
totalFullScanTime += scanner.fullScanTime
// Track most recent scan
if scanner.lastScanned.After(mostRecentScan) {
mostRecentScan = scanner.lastScanned
}
// Check if all scanners have scanned at least once
if scanner.lastScanned.IsZero() {
allScannedAtLeastOnce = false
}
if !allScannedAtLeastOnce {
continue
}
// Track highest complexity (most conservative assessment)
// Only consider scanners that have been assessed (complexity > 0)
if scanner.complexity > 0 && scanner.complexity > maxComplexity {
maxComplexity = scanner.complexity
}
}
if allScannedAtLeastOnce && idx.FullScanTime == 0 {
maxComplexity = 1 // assess as simple because it took 0 seconds total
}
// Update Index-level stats
idx.NumDirs = totalDirs
idx.NumFiles = totalFiles
idx.QuickScanTime = totalQuickScanTime
idx.FullScanTime = totalFullScanTime
idx.Complexity = maxComplexity
// Update last indexed time
if allScannedAtLeastOnce {
idx.Complexity = calculateComplexity(totalFullScanTime, totalDirs)
} else {
idx.Complexity = 0
}
if !mostRecentScan.IsZero() {
idx.LastIndexed = mostRecentScan
idx.LastIndexedUnix = mostRecentScan.Unix()
idx.wasIndexed = true
}
// Log first complete scan round (once)
if allScannedAtLeastOnce && !idx.hasLoggedInitialScan {
totalDuration := time.Since(idx.initialScanStartTime)
truncatedToSecond := totalDuration.Truncate(time.Second)
logger.Debugf("Time spent indexing [%v]: %v seconds", idx.Name, truncatedToSecond)
idx.hasLoggedInitialScan = true
}
// Update status: if all scanners have completed at least one scan, mark as READY
if allScannedAtLeastOnce && idx.activeScannerPath == "" {
idx.Status = READY
// Send update event to notify clients
idx.mu.Unlock()
err := idx.SendSourceUpdateEvent()
idx.mu.Lock()
if err != nil {
logger.Errorf("Error sending source update event: %v", err)
}
} else if idx.activeScannerPath != "" {
idx.Status = INDEXING
}
newDiskUsed := idx.totalSize
newStatus := idx.Status
idx.mu.Unlock()
statsChanged := prevNumDirs != totalDirs || prevNumFiles != totalFiles || prevDiskUsed != newDiskUsed
statusChanged := prevStatus != newStatus
if statsChanged || statusChanged {
err := idx.SendSourceUpdateEvent()
if err != nil {
logger.Errorf("Error sending source update event: %v", err)
}
// Update previousTotalSize after sending event so next aggregation can detect changes
idx.mu.Lock()
idx.previousTotalSize = newDiskUsed
idx.mu.Unlock()
}
}
// GetScannerStatus returns detailed information about all active scanners
+15 -1
View File
@@ -177,15 +177,23 @@ func (idx *Index) GetDirectories() map[string]*iteminfo.FileInfo {
return idx.Directories
}
func GetIndexInfo(sourceName string) (ReducedIndex, error) {
func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, error) {
idx, ok := indexes[sourceName]
if !ok {
return ReducedIndex{}, fmt.Errorf("index %s not found", sourceName)
}
// Only update disk total if cache is missing or explicitly forced
// The "used" value comes from totalSize and is always current
sourcePath := idx.Path
cacheKey := "usageCache-" + sourceName
if forceCacheRefresh {
// Invalidate cache to force update
utils.DiskUsageCache.Delete(cacheKey)
}
_, ok = utils.DiskUsageCache.Get(cacheKey)
if !ok {
// Only fetch disk total if not cached (this is expensive, so we cache it)
totalBytes, err := getPartitionSize(sourcePath)
if err != nil {
idx.mu.Lock()
@@ -215,7 +223,13 @@ func GetIndexInfo(sourceName string) (ReducedIndex, error) {
}
idx.mu.RUnlock()
// Get fresh values from the index (with lock to ensure consistency)
idx.mu.RLock()
reducedIdx := idx.ReducedIndex
// Ensure DiskUsed is up to date from totalSize
reducedIdx.DiskUsed = idx.totalSize
reducedIdx.DiskTotal = idx.DiskTotal
reducedIdx.Scanners = scannerInfos
idx.mu.RUnlock()
return reducedIdx, nil
}
+78 -7
View File
@@ -3,8 +3,11 @@
package indexing
import (
"os"
"strings"
"syscall"
"github.com/gtsteffaniak/go-logger/logger"
)
func CheckWindowsHidden(realpath string) bool {
@@ -24,14 +27,82 @@ func getPartitionSize(path string) (uint64, error) {
return total, nil
}
func getFileDetails(sys any) (uint64, uint64, uint64, bool) {
if stat, ok := sys.(*syscall.Stat_t); ok {
// Use actual file size, not allocated blocks
// This matches what ls -l shows and what FileInfoFaster returns
realSize := uint64(stat.Size)
return realSize, uint64(stat.Nlink), stat.Ino, true
func getFileDetails(sys any, filePath string) (uint64, uint64, uint64, bool) {
// On Unix, we should always be able to get syscall info
// First try from file.Sys() if available (fast path)
if sys != nil {
if stat, ok := sys.(*syscall.Stat_t); ok {
// Use allocated size for `du`-like behavior
realSize := uint64(stat.Blocks * 512)
return realSize, uint64(stat.Nlink), stat.Ino, true
}
}
return 0, 1, 0, false
// If file.Sys() didn't work, try direct stat syscall
// On Unix, we should always have a file path and be able to stat
if filePath == "" {
// No file path available - this is an error condition on Unix
// We cannot proceed without syscall info on Unix
return 0, 1, 0, false
}
// Perform direct stat syscall - this should always work on Unix if file exists
var stat syscall.Stat_t
err := syscall.Stat(filePath, &stat)
if err != nil {
// On Unix, if stat fails, the file likely doesn't exist or we don't have permission
// This is an error condition - we cannot proceed without syscall info on Unix
return 0, 1, 0, false
}
// Use allocated size for `du`-like behavior
realSize := uint64(stat.Blocks * 512)
return realSize, uint64(stat.Nlink), stat.Ino, true
}
// handleFile processes a file and returns its size and whether it should be counted
// On Unix, always uses syscall to get allocated size (du-like behavior)
// isRoutineScan: if true, updates the global totalSize; if false (API calls), only returns size
func (idx *Index) handleFile(file os.FileInfo, fullCombined string, realFilePath string, isRoutineScan bool) (size uint64, shouldCountSize bool) {
var realSize uint64
var nlink uint64
var ino uint64
canUseSyscall := false
sys := file.Sys()
realSize, nlink, ino, canUseSyscall = getFileDetails(sys, realFilePath)
if !canUseSyscall {
logger.Errorf("Failed to get syscall info for file %s on Unix system - file may have been deleted or permission denied. Using file.Size() fallback.", realFilePath)
realSize = uint64(file.Size())
}
if nlink > 1 {
// It's a hard link
idx.mu.Lock()
defer idx.mu.Unlock()
if _, exists := idx.processedInodes[ino]; exists {
// Already seen, don't count towards global total, or directory total.
return realSize, false
}
// First time seeing this inode.
idx.processedInodes[ino] = struct{}{}
idx.FoundHardLinks[fullCombined] = realSize
// Only update totalSize during routine scans (not API calls)
if isRoutineScan {
idx.totalSize += realSize
}
return realSize, true // Count size for directory total.
}
// It's a regular file.
// Only update totalSize during routine scans (not API calls)
if isRoutineScan {
idx.mu.Lock()
idx.totalSize += realSize
idx.mu.Unlock()
}
return realSize, true // Count size.
}
// platform specific rules
+20 -1
View File
@@ -3,6 +3,7 @@
package indexing
import (
"os"
"strings"
"golang.org/x/sys/windows"
@@ -46,13 +47,31 @@ func getPartitionSize(path string) (uint64, error) {
return totalBytes, nil
}
func getFileDetails(sys any) (uint64, uint64, uint64, bool) {
func getFileDetails(sys any, filePath string) (uint64, uint64, uint64, bool) {
// On Windows, os.FileInfo.Sys() returns *syscall.Win32FileAttributeData,
// which does not contain inode or link count information.
// We return false to indicate that we should use the fallback mechanism.
// filePath parameter is ignored on Windows
return 0, 1, 0, false
}
// handleFile processes a file and returns its size and whether it should be counted
// On Windows, uses file.Size() directly (no syscall support for allocated size)
// isRoutineScan: if true, updates the global totalSize; if false (API calls), only returns size
func (idx *Index) handleFile(file os.FileInfo, fullCombined string, realFilePath string, isRoutineScan bool) (size uint64, shouldCountSize bool) {
// On Windows, just use the actual file size
realSize := uint64(file.Size())
// Windows doesn't support hard links in the same way, so always count size
// Only update totalSize during routine scans (not API calls)
if isRoutineScan {
idx.mu.Lock()
idx.totalSize += realSize
idx.mu.Unlock()
}
return realSize, true
}
// input should be non-index path.
func (idx *Index) MakeIndexPathPlatform(path string) string {
split := strings.Split(path, "\\")
+6 -2
View File
@@ -276,7 +276,9 @@ func TestService_Resize(t *testing.T) {
for name, test := range testCases {
t.Run(name, func(t *testing.T) {
svc := NewPreviewGenerator(1, "")
// Use a temporary directory for cache to avoid creating directories in the source tree
tmpDir := t.TempDir()
svc := NewPreviewGenerator(1, tmpDir)
source := test.source(t)
defer source.Close()
@@ -434,7 +436,9 @@ func TestService_FormatFromExtension(t *testing.T) {
for name, test := range testCases {
t.Run(name, func(t *testing.T) {
svc := NewPreviewGenerator(1, "")
// Use a temporary directory for cache to avoid creating directories in the source tree
tmpDir := t.TempDir()
svc := NewPreviewGenerator(1, tmpDir)
got, err := svc.FormatFromExtension(test.ext)
require.Truef(t, errors.Is(err, test.wantErr), "error = %v, wantErr %v", err, test.wantErr)
if err != nil {
+15 -7
View File
@@ -30,6 +30,7 @@ var (
type Service struct {
fileCache diskcache.Interface
cacheDir string // Cache directory used for thumbnails and temp files
debug bool
docGenMutex sync.Mutex // Mutex to serialize access to doc generation
docSemaphore chan struct{} // Semaphore for document generation
@@ -44,36 +45,43 @@ func NewPreviewGenerator(concurrencyLimit int, cacheDir string) *Service {
}
// get round up half value of concurrencyLimit
ffmpegConcurrencyLimit := (concurrencyLimit + 1) / 2
actualCacheDir := cacheDir
if actualCacheDir == "" {
actualCacheDir = os.TempDir()
}
var fileCache diskcache.Interface
// Use file cache if cacheDir is specified
var err error
fileCache, err = diskcache.NewFileCache(cacheDir)
fileCache, err = diskcache.NewFileCache(actualCacheDir)
if err != nil {
logger.Error("The cache dir could not be created. Make sure the user that you executed the program with has access to create directories in the local path. See ")
logger.Fatalf("failed to create file cache path, which is now require to run the server: %v", err)
}
// Create directories recursively
err = os.MkdirAll(filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "docs"), fileutils.PermDir)
// Create directories recursively using the determined cache directory
err = os.MkdirAll(filepath.Join(actualCacheDir, "thumbnails", "docs"), fileutils.PermDir)
if err != nil {
logger.Error(err)
}
err = os.MkdirAll(filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "videos"), fileutils.PermDir)
err = os.MkdirAll(filepath.Join(actualCacheDir, "thumbnails", "videos"), fileutils.PermDir)
if err != nil {
logger.Error(err)
}
err = os.MkdirAll(filepath.Join(settings.Config.Server.CacheDir, "heic"), fileutils.PermDir)
err = os.MkdirAll(filepath.Join(actualCacheDir, "heic"), fileutils.PermDir)
if err != nil {
logger.Error(err)
}
videoService := ffmpeg.NewFFmpegService(ffmpegConcurrencyLimit, settings.Config.Integrations.Media.Debug, "")
imageService := ffmpeg.NewFFmpegService(concurrencyLimit, settings.Config.Integrations.Media.Debug, filepath.Join(settings.Config.Server.CacheDir, "heic"))
imageService := ffmpeg.NewFFmpegService(concurrencyLimit, settings.Config.Integrations.Media.Debug, filepath.Join(actualCacheDir, "heic"))
settings.Env.MuPdfAvailable = docEnabled()
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
return &Service{
fileCache: fileCache,
cacheDir: actualCacheDir,
debug: settings.Config.Integrations.Media.Debug,
docSemaphore: make(chan struct{}, 1), // must be 1 because cgo thread limit
officeSem: make(chan struct{}, concurrencyLimit),
@@ -179,7 +187,7 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
convertHEIC := strings.HasPrefix(file.Type, "image/heic") && settings.Config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview]
// Generate an image from office document
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
tempFilePath := filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "docs", hash) + ".txt"
tempFilePath := filepath.Join(service.cacheDir, "thumbnails", "docs", hash) + ".txt"
imageBytes, err = service.GenerateImageFromDoc(ctx, file, tempFilePath, 0) // 0 for the first page
if err != nil {
return nil, fmt.Errorf("failed to create image for PDF file: %w", err)
+17 -5
View File
@@ -1214,13 +1214,10 @@ const docTemplate = `{
},
"/api/raw": {
"get": {
"description": "Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.",
"description": "Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.\n\n**Filename Encoding:**\n- The Content-Disposition header will always include both:\n1. ` + "`" + `filename=\"...\"` + "`" + `: An ASCII-safe version of the filename for compatibility.\n2. ` + "`" + `filename*=utf-8”...` + "`" + `: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Resources"
],
@@ -2917,6 +2914,9 @@ const docTemplate = `{
"hash": {
"type": "string"
},
"shareUrl": {
"type": "string"
},
"status": {
"type": "string"
},
@@ -3063,6 +3063,9 @@ const docTemplate = `{
"description": "type of share: normal, upload, max",
"type": "string"
},
"shareURL": {
"type": "string"
},
"sidebarLinks": {
"description": "customizable sidebar links",
"type": "array",
@@ -3844,7 +3847,7 @@ const docTemplate = `{
"type": "string"
},
"cacheDirCleanup": {
"description": "whether to automatically cleanup the cache directory (default: true)",
"description": "whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: true)",
"type": "boolean"
},
"database": {
@@ -4348,6 +4351,9 @@ const docTemplate = `{
"description": "type of share: normal, upload, max",
"type": "string"
},
"shareURL": {
"type": "string"
},
"sidebarLinks": {
"description": "customizable sidebar links",
"type": "array",
@@ -4478,6 +4484,9 @@ const docTemplate = `{
"description": "type of share: normal, upload, max",
"type": "string"
},
"shareURL": {
"type": "string"
},
"sidebarLinks": {
"description": "customizable sidebar links",
"type": "array",
@@ -4614,6 +4623,9 @@ const docTemplate = `{
"description": "type of share: normal, upload, max",
"type": "string"
},
"shareURL": {
"type": "string"
},
"sidebarLinks": {
"description": "customizable sidebar links",
"type": "array",
+17 -5
View File
@@ -1203,13 +1203,10 @@
},
"/api/raw": {
"get": {
"description": "Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.",
"description": "Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.\n\n**Filename Encoding:**\n- The Content-Disposition header will always include both:\n1. `filename=\"...\"`: An ASCII-safe version of the filename for compatibility.\n2. `filename*=utf-8”...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Resources"
],
@@ -2906,6 +2903,9 @@
"hash": {
"type": "string"
},
"shareUrl": {
"type": "string"
},
"status": {
"type": "string"
},
@@ -3052,6 +3052,9 @@
"description": "type of share: normal, upload, max",
"type": "string"
},
"shareURL": {
"type": "string"
},
"sidebarLinks": {
"description": "customizable sidebar links",
"type": "array",
@@ -3833,7 +3836,7 @@
"type": "string"
},
"cacheDirCleanup": {
"description": "whether to automatically cleanup the cache directory (default: true)",
"description": "whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: true)",
"type": "boolean"
},
"database": {
@@ -4337,6 +4340,9 @@
"description": "type of share: normal, upload, max",
"type": "string"
},
"shareURL": {
"type": "string"
},
"sidebarLinks": {
"description": "customizable sidebar links",
"type": "array",
@@ -4467,6 +4473,9 @@
"description": "type of share: normal, upload, max",
"type": "string"
},
"shareURL": {
"type": "string"
},
"sidebarLinks": {
"description": "customizable sidebar links",
"type": "array",
@@ -4603,6 +4612,9 @@
"description": "type of share: normal, upload, max",
"type": "string"
},
"shareURL": {
"type": "string"
},
"sidebarLinks": {
"description": "customizable sidebar links",
"type": "array",
+19 -5
View File
@@ -16,6 +16,8 @@ definitions:
properties:
hash:
type: string
shareUrl:
type: string
status:
type: string
url:
@@ -115,6 +117,8 @@ definitions:
shareType:
description: 'type of share: normal, upload, max'
type: string
shareURL:
type: string
sidebarLinks:
description: customizable sidebar links
items:
@@ -695,7 +699,8 @@ definitions:
files
type: string
cacheDirCleanup:
description: 'whether to automatically cleanup the cache directory (default:
description: 'whether to automatically cleanup the cache directory. Note:
docker must also mount a persistent volume to persist the cache (default:
true)'
type: boolean
database:
@@ -1073,6 +1078,8 @@ definitions:
shareType:
description: 'type of share: normal, upload, max'
type: string
shareURL:
type: string
sidebarLinks:
description: customizable sidebar links
items:
@@ -1164,6 +1171,8 @@ definitions:
shareType:
description: 'type of share: normal, upload, max'
type: string
shareURL:
type: string
sidebarLinks:
description: customizable sidebar links
items:
@@ -1259,6 +1268,8 @@ definitions:
shareType:
description: 'type of share: normal, upload, max'
type: string
shareURL:
type: string
sidebarLinks:
description: customizable sidebar links
items:
@@ -2355,8 +2366,13 @@ paths:
get:
consumes:
- application/json
description: Returns the raw content of a file, multiple files, or a directory.
Supports downloading files as archives in various formats.
description: |-
Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.
**Filename Encoding:**
- The Content-Disposition header will always include both:
1. `filename="..."`: An ASCII-safe version of the filename for compatibility.
2. `filename*=utf-8”...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.
parameters:
- description: a list of files in the following format 'source::filename' and
separated by '||' with additional items in the list. (required)
@@ -2374,8 +2390,6 @@ paths:
in: query
name: algo
type: string
produces:
- application/json
responses:
"200":
description: Raw file or directory content, or archive for multiple files
+1 -1
View File
@@ -49,7 +49,7 @@ server:
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)
cacheDir: "tmp" # path to the cache directory, used for thumbnails and other cached files
cacheDirCleanup: true # whether to automatically cleanup the cache directory (default: true)
cacheDirCleanup: true # whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: true)
maxArchiveSize: 50 # max pre-archive combined size of files/folder that are allowed to be archived (in GB)
filesystem: # filesystem settings
createFilePermission: "644" # Unix permissions like 644, 755, 2755 (default: 644) validate:required,file_permission
+1 -1
View File
@@ -5,7 +5,7 @@
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>{{ .title }}</title>
<title>{{ .htmlVars.title }}</title>
<link rel="icon" type="image/png" sizes="256x256" href="{{ .htmlVars.favicon }}">
+49 -5
View File
@@ -81,7 +81,7 @@ export async function put(source, path, content = '') {
return await resourceAction(source, path, 'PUT', content)
}
export function download(format, files, shareHash = "") {
export async function download(format, files, shareHash = "") {
if (format !== 'zip') {
format = 'tar.gz'
}
@@ -102,13 +102,57 @@ export function download(format, files, shareHash = "") {
})
const url = window.origin + apiPath
// Create a temporary <a> element to trigger the download
// Use fetch to capture errors in the console
const response = await fetch(url, {
credentials: 'same-origin',
headers: {
"sessionId": state.sessionId,
},
})
if (!response.ok) {
const errorText = await response.text()
const error = new Error(`Download failed: ${response.status} ${response.statusText}`)
console.error('Download API error:', {
status: response.status,
statusText: response.statusText,
body: errorText,
url: url
})
throw error
}
// Get the blob from the response
const blob = await response.blob()
// Determine filename from Content-Disposition header or use default
let filename = 'download'
const contentDisposition = response.headers.get('Content-Disposition')
if (contentDisposition) {
const utf8 = contentDisposition.split("filename*=utf-8''")[1]
if (utf8) {
filename = decodeURIComponent(utf8.split(';')[0].trim())
} else {
const ascii = contentDisposition.split('filename="')[1]
if (ascii) {
filename = ascii.split('"')[0]
}
}
} else {
filename = `download.${format}`
}
// Create a blob URL and trigger download
const blobUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.setAttribute('download', '') // Ensures it triggers a download
link.href = blobUrl
link.setAttribute('download', filename)
document.body.appendChild(link)
link.click()
document.body.removeChild(link) // Clean up
document.body.removeChild(link)
// Clean up the blob URL
window.URL.revokeObjectURL(blobUrl)
}
export function post(
+1 -15
View File
@@ -1,7 +1,6 @@
import { adjustedData } from "./utils";
import { notify } from "@/notify";
import { getApiPath, getPublicApiPath, encodedPath, doubleEncode } from "@/utils/url.js";
import { globalVars } from "@/utils/constants";
import { getPublicApiPath, encodedPath, doubleEncode } from "@/utils/url.js";
import { state } from "@/store";
// ============================================================================
@@ -92,19 +91,6 @@ export function getPreviewURL(path,size="small") {
}
}
// Get the shareable URL for a share
/**
* @param {{ hash: string }} share
* @returns {string}
*/
export function getShareURL(share) {
if (globalVars.externalUrl) {
const apiPath = getApiPath(`public/share/${share.hash}`)
return globalVars.externalUrl + apiPath;
}
return window.origin + getApiPath(`public/share/${share.hash}`);
}
export function post(
hash,
+19
View File
@@ -186,6 +186,10 @@ export default {
position: 'relative'
};
if (this.status === 'indexing') {
style['background'] = '#fff8d6'; // Muted yellow/amber color
}
if (this.textPosition == "middle" || this.textPosition == "inside") {
style["min-height"] = this.size_px + "px";
}
@@ -211,6 +215,12 @@ export default {
transition: this.barTransition,
};
// Add pulse animation for indexing status
if (this.status === 'indexing') {
style['animation'] = 'progress-pulse 4s ease-in-out infinite';
style['background'] = 'rgb(237 161 41)'; // orange
}
if (this.barBorderRadius > 0) {
style["border-radius"] = this.barBorderRadius + "px";
}
@@ -301,4 +311,13 @@ export default {
.tooltip-info-icon:hover {
opacity: 1;
}
@keyframes progress-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
</style>
+19 -2
View File
@@ -78,6 +78,7 @@ import { filesApi,publicApi } from "@/api";
import * as upload from "@/utils/upload";
import { state, getters, mutations } from "@/store"; // Import your custom store
import { url } from "@/utils";
import { notify } from "@/notify";
import Icon from "@/components/files/Icon.vue";
export default {
@@ -358,6 +359,7 @@ export default {
/** @param {DragEvent} event */
async drop(event) {
this.isDraggedOver = false;
if (!this.canDrop) return;
// Only allow internal drags (from filebrowser items), not external files from desktop
const isInternal = Array.from(event.dataTransfer.types).includes(
@@ -440,9 +442,24 @@ export default {
} else {
await filesApi.moveCopy(items, "move", overwrite, rename);
}
// Close the prompt after successful operation
notify.showSuccess(this.$t("prompts.moveSuccess"));
// Notification to move into the folder
const buttonAction = () => {
this.open();
};
const buttonProps = {
icon: "folder",
buttons: [
{
label: this.$t("buttons.goToItem"),
primary: true,
action: buttonAction
}
]
};
notify.showSuccess(this.$t("prompts.moveSuccess"), buttonProps);
// Close the prompt after successful operation and reload items for reflect the changes
mutations.closeHovers();
mutations.setReload(true);
} catch (error) {
// Close the prompt and let error handling continue
mutations.closeHovers();
@@ -25,10 +25,6 @@
<strong>{{ $t("prompts.size", { suffix: ":" }) }}</strong> {{ humanSize }}
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div v-if="!shareInfo.disableDownload" class="share__box__element share__box__center">
<button class="button button--flat clickable" @click="goToDownload()"> {{ $t("general.download") }}
</button>
</div>
</div>
</div>
</div>
@@ -87,7 +87,6 @@ export default {
},
confirm() {
this.onConfirm();
mutations.setReload(true);
mutations.closeHovers();
},
},
+2 -1
View File
@@ -188,10 +188,11 @@ export default {
// Sets the current path and resets
// the current items.
this.current = req.path;
this.source = req.source;
this.source = req.source || null; // For shares, source might be null/undefined
this.items = [];
// Emit both path and source
// For shares, source will be null, which is handled by MoveCopy
this.$emit("update:selected", {
path: this.current,
source: this.source
+8 -9
View File
@@ -122,12 +122,8 @@ export default {
return this.destPath.startsWith(itemPath + "/") || this.destPath === itemPath;
},
canCreateFolder() {
// Detect correct permission for shares for show the new folder button.
if (getters.isShare()) {
return state.shareInfo?.allowCreate;
} else {
return state.user?.permissions?.modify;
}
const perms = getters.permissions();
return !!perms?.create;
},
closeHovers() {
return mutations.closeHovers();
@@ -316,14 +312,17 @@ export default {
const destPath = this.destPath;
// Show success notification with optional button to navigate to destination
// For shares, destSource might be null, but goToItem handles shares via state.shareInfo.hash
const buttonAction = () => {
if (destSource && destPath) {
goToItem(destSource, destPath, {});
if (destPath) {
// For shares, goToItem will use state.shareInfo.hash, so source can be null
// For regular files, destSource should be set
goToItem(destSource || null, destPath, {});
}
};
const buttonProps = {
icon: "folder",
buttons: destSource && destPath ? [
buttons: destPath ? [
{
label: this.$t("buttons.goToItem"),
primary: true,
+10 -10
View File
@@ -41,6 +41,7 @@
import { filesApi, publicApi } from "@/api";
import { mutations, state, getters } from "@/store";
import { notify } from "@/notify";
import { getFileExtension, removePrefix } from '@/utils/files.js'
export default {
name: "rename",
props: {
@@ -51,16 +52,14 @@ export default {
}
},
data() {
// Separate filename and extension for avoid renmame mistakenly the extension too
const itemName = this.item.name;
if (this.item.type !== 'directory') {
const lastDotIndex = this.item.name.lastIndexOf('.');
const hasExtension = lastDotIndex > 0;
const ext = getFileExtension(this.item.name);
const filenamePrefix = this.item.name.substring(0, this.item.name.length - ext.length);
return {
fileName: hasExtension ? itemName.substring(0, lastDotIndex) : itemName,
fileExtension: hasExtension ? itemName.substring(lastDotIndex + 1) : "",
name: itemName,
fileName: filenamePrefix,
fileExtension: removePrefix(ext, "."),
name: itemName, // Initialize name for non-directory items
};
}
return {
@@ -108,7 +107,8 @@ export default {
* @param {string} value
*/
validateFileName(value) {
if (value === "") {
// Handle undefined, null, or empty values
if (!value || value === "") {
return { valid: true };
}
@@ -125,7 +125,7 @@ export default {
// Check if the item already exists
for (const item of state.req.items) {
if (item.path === this.item.path) continue;
if (item.name.toLowerCase() === value.toLowerCase()) {
if (item.name && item.name.toLowerCase() === value.toLowerCase()) {
return { valid: false, reason: 'conflict' };
}
}
@@ -194,7 +194,7 @@ export default {
}
.extension-input {
width: 80px;
width: 6em;
}
.extension-separator {
+10 -32
View File
@@ -55,14 +55,14 @@
</button>
</td>
<td class="small">
<button class="action copy-clipboard" :data-clipboard-text="buildLink(link)"
<button class="action copy-clipboard" :data-clipboard-text="link.shareURL"
:aria-label="$t('buttons.copyToClipboard')" :title="$t('buttons.copyToClipboard')">
<i class="material-icons">content_paste</i>
</button>
</td>
<td class="small" v-if="hasDownloadLink()">
<td class="small">
<button :disabled="link.shareType == 'upload'" class="action copy-clipboard"
:data-clipboard-text="buildDownloadLink(link)" :aria-label="$t('buttons.copyDownloadLinkToClipboard')"
:data-clipboard-text="link.downloadURL" :aria-label="$t('buttons.copyDownloadLinkToClipboard')"
:title="$t('buttons.copyDownloadLinkToClipboard')">
<i class="material-icons">content_paste_go</i>
</button>
@@ -307,10 +307,10 @@
<script>
import { notify } from "@/notify";
import { state, getters, mutations } from "@/store";
import { publicApi, shareApi } from "@/api";
import { shareApi } from "@/api";
import Clipboard from "clipboard";
import { fromNow } from "@/utils/moment";
import { buildItemUrl, fixDownloadURL } from "@/utils/url";
import { buildItemUrl } from "@/utils/url";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import SettingsItem from "@/components/settings/SettingsItem.vue";
import FileList from "./FileList.vue";
@@ -526,6 +526,7 @@ export default {
this.listing = false;
// Set default sidebar links for new shares
this.setDefaultSidebarLinks();
this.populateDefaults();
}
},
mounted() {
@@ -746,31 +747,6 @@ export default {
humanTime(time) {
return fromNow(time, state.user.locale)
},
/**
* @param {Share} share
*/
buildLink(share) {
return publicApi.getShareURL(share);
},
hasDownloadLink() {
// Check if we have a single selected item that can be downloaded
return !this.item?.isDir;
},
/**
* @param {Share} share
*/
buildDownloadLink(share) {
if (share.downloadURL) {
// Only fix the URL if it doesn't already have the correct external domain
if (globalVars.externalUrl) {
// URL already has the correct external domain, use as-is
return share.downloadURL;
}
// URL needs fixing (internal domain or no externalUrl set)
return this.fixDownloadURL(share.downloadURL);
}
return publicApi.getDownloadURL(share, [this.item.name]);
},
sort() {
this.links = this.links.sort((a, b) => {
if (a.expire === 0) return -1;
@@ -795,6 +771,7 @@ export default {
this.isChangingPassword = false;
// Set default sidebar links for new shares
this.setDefaultSidebarLinks();
this.populateDefaults();
}
},
setDefaultSidebarLinks() {
@@ -816,8 +793,9 @@ export default {
];
}
},
fixDownloadURL(downloadUrl) {
return fixDownloadURL(downloadUrl);
populateDefaults() {
this.title = this.$t("share.titleDefault", { title: this.item.name || "share" });
this.description = this.$t("share.descriptionDefault");
},
/**
* @param {{path: string, source: string}} pathOrData
@@ -97,9 +97,7 @@ export default {
},
methods: {
getShareLink() {
return publicApi.getShareURL({
hash: state.share.hash,
});
return state.shareInfo.shareURL;
},
close() {
mutations.closeHovers();
@@ -570,28 +570,18 @@ export default {
// Close the form and return to list view
this.cancelAddLink();
},
processCustomUrl(url) {
try {
// If it's a full URL, try to extract the path
if (url.startsWith('http://') || url.startsWith('https://')) {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
// Remove subpath if present
const subpath = window.location.pathname.split('/')[1];
if (subpath && pathname.startsWith('/' + subpath)) {
return pathname.substring(subpath.length + 1);
}
return pathname;
}
// If it's already a path, return as is
return url;
} catch (e) {
// If URL parsing fails, return as is
processCustomUrl(url) {
// Check if it's an external URL (case insensitive)
const lowerUrl = url.toLowerCase();
if (lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://')) {
// Leave external URLs as-is
return url;
}
// For internal paths, normalize by adding a starting slash if missing
if (!url.startsWith('/')) {
return '/' + url; // assume relative path
}
return url;
},
removeLink(index) {
this.links.splice(index, 1);
+29 -3
View File
@@ -341,6 +341,21 @@ export default {
}
this.user = { ...(await usersApi.get(id)) };
this.user.password = "";
// Normalize scopes to ensure they're in {name, scope} format only
if (this.user.scopes && Array.isArray(this.user.scopes)) {
this.user.scopes = this.user.scopes.map(scope => {
// If it's already in the correct format, use it
if (scope.name !== undefined && scope.scope !== undefined) {
return { name: scope.name, scope: scope.scope || "" };
}
// If it's a full source object, extract just name and scope
if (scope.name && typeof scope.name === 'string') {
return { name: scope.name, scope: scope.scope || "" };
}
// Fallback: try to extract from any object structure
return { name: "", scope: "" };
});
}
// Ensure loginMethod is valid, set to first available method if not set or invalid
const validMethods = [];
if (this.globalVars.passwordAvailable) validMethods.push("password");
@@ -376,7 +391,11 @@ export default {
if (this.isNew && this.availableSources.length) {
const newSource = this.availableSources.shift();
if (newSource) {
this.selectedSources.push(newSource);
// Only store {name, scope} format, not the full source config
this.selectedSources.push({
name: newSource.name || "",
scope: "" // Empty scope - backend will handle defaults
});
this.emitUserUpdate();
}
}
@@ -388,18 +407,25 @@ export default {
event.preventDefault();
try {
let fields = ["all"];
// Transform selectedSources to only include {name, scope} format
// Empty scope strings should be passed as "" for backend to handle defaults
const scopesToSend = this.selectedSources.map(source => ({
name: source.name || "",
scope: source.scope || ""
}));
if (this.isNew) {
if (!state.user.permissions.admin) {
notify.showError(this.$t("settings.userNotAdmin"));
return;
}
await usersApi.create({ ...this.user, scopes: this.selectedSources });
await usersApi.create({ ...this.user, scopes: scopesToSend });
// Emit event to refresh user list
eventBus.emit('usersChanged');
// Close the modal
mutations.closeHovers();
} else {
await usersApi.update({ ...this.user, scopes: this.selectedSources }, fields);
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');
+39 -12
View File
@@ -21,9 +21,9 @@
<!-- Otherwise show animated status indicator -->
<svg v-else-if="isLinkAccessible(link)" class="realtime-pulse" :class="{
active: realtimeActive,
danger: getSourceInfo(link).status != 'indexing' && getSourceInfo(link).status != 'ready',
warning: getSourceInfo(link).status == 'indexing',
ready: getSourceInfo(link).status == 'ready',
danger: (sourceInfo[link.sourceName] || {}).status != 'indexing' && (sourceInfo[link.sourceName] || {}).status != 'ready',
warning: (sourceInfo[link.sourceName] || {}).status == 'indexing',
ready: (sourceInfo[link.sourceName] || {}).status == 'ready',
}">
<circle class="center" cx="50%" cy="50%" r="7px"></circle>
<circle class="pulse" cx="50%" cy="50%" r="10px"></circle>
@@ -34,12 +34,18 @@
</i>
<span>{{ link.name }}</span>
<i v-if="isLinkAccessible(link)" class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showSourceTooltip($event, getSourceInfo(link))" @mouseleave="hideTooltip">
@mouseenter="showSourceTooltip($event, sourceInfo[link.sourceName] || {})" @mouseleave="hideTooltip">
info <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</i>
</div>
<div v-if="hasSourceInfo && isLinkAccessible(link)" class="usage-info">
<ProgressBar :val="getSourceInfo(link).used" :max="getSourceInfo(link).total" unit="bytes"></ProgressBar>
<ProgressBar
:key="`progress-${link.sourceName}-${sourceInfo[link.sourceName]?.used || 0}-${sourceInfo[link.sourceName]?.total || 0}`"
:val="getProgressBarValue(sourceInfo[link.sourceName] || {})"
:max="(sourceInfo[link.sourceName] || {}).total || 1"
:status="getProgressBarStatus(sourceInfo[link.sourceName] || {})"
unit="bytes">
</ProgressBar>
</div>
</a>
@@ -77,7 +83,10 @@ export default {
return this.sidebarLinksToDisplay?.length > 0;
},
user: () => (state.user || {username: 'anonymous'}),
sourceInfo: () => state.sources.info,
sourceInfo() {
// Access state.sources.info to create reactive dependency
return state.sources.info;
},
activeSource: () => state.sources.current,
realtimeActive: () => state.realtimeActive,
hasSourceInfo: () => state.sources.hasSourceInfo,
@@ -109,7 +118,8 @@ export default {
getLinkHref(link) {
// Add baseURL to target for href display
if (!link.target) return '#';
if (link.target.startsWith('http://') || link.target.startsWith('https://')) return link.target;
const lowerTarget = link.target.toLowerCase();
if (lowerTarget.startsWith('http://') || lowerTarget.startsWith('https://')) return link.target;
const baseURL = globalVars.baseURL || '';
let fullPath = '';
@@ -166,11 +176,6 @@ export default {
// Tools and custom links are always accessible
return true;
},
getSourceInfo(link) {
// Get source info for a source link
if (link.category !== 'source' || !link.sourceName) return {};
return this.sourceInfo && link.sourceName ? this.sourceInfo[link.sourceName] || {} : {};
},
isLinkActive(link) {
// Check if the current route matches this link
if (link.category === 'source') {
@@ -180,6 +185,23 @@ export default {
// For all other links (tools, custom, share), compare target with route path
return this.$route.path === link.target;
},
getSourceInfoForLink(link) {
// Method that directly accesses reactive sourceInfo
// Vue will track this dependency when called in template
if (link.category !== 'source' || !link.sourceName) return {};
// Direct access to reactive computed property ensures Vue tracks changes
return this.sourceInfo && link.sourceName ? this.sourceInfo[link.sourceName] || {} : {};
},
getProgressBarStatus(sourceInfo) {
if (sourceInfo.status === 'indexing' && sourceInfo.complexity == 0) {
return 'indexing';
}
return 'default';
},
getProgressBarValue(sourceInfo) {
// Otherwise return the actual used value
return sourceInfo.used || 0;
},
handleLinkClick(link) {
// Handle special share actions
if (link.category === 'shareInfo') {
@@ -204,6 +226,11 @@ export default {
return;
}
const lowerTarget = link.target.toLowerCase();
if (lowerTarget.startsWith('http://') || lowerTarget.startsWith('https://')) {
window.open(link.target, "_blank");
return;
}
// For all other links (tools, custom, share), navigate using target directly
if (link.target) {
this.$router.push(link.target);
+2 -1
View File
@@ -274,7 +274,8 @@ body.rtl #listingView {
#listingView .header .duration,
#listingView.list .listing-item .duration,
#listingView.compact .listing-item .duration {
margin: auto;
margin-left: auto;
padding-right: 1em;
}
#listingView.list .listing-item .size.adjustment,
+4 -4
View File
@@ -272,7 +272,6 @@
"download": "تنزيل الملفات"
},
"permissions-name": "الصلاحيات",
"permissionsHelp": "يمكنك تعيين المستخدم كـ \"مدير\" أو تحديد الصلاحيات بشكل منفرد.\n إذا قمت بتحديد المستخدم كـ \"مدير\"، باقي الخيارات سيتم تحديدها تلقائياً.\n إدارة المستخدمين تبقى صلاحية فريدة للـ \"مدير\" فقط.\n",
"profileSettings": "إعدادات الحساب",
"ruleExample1": "منع الوصول إلى الملفات التي تبدأ بنقطة مثل (.git، و .gitignore) في كل مجلد.\n",
"ruleExample2": "منع الوصول إلى الملف المسمى Caddyfile في نطاق الجذر.",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "الوكيل"
},
"newUserHintSubFolder": "على سبيل المثال '/مجلد فرعي' - فارغ للافتراضي"
"newUserHintSubFolder": "على سبيل المثال '/مجلد فرعي' - فارغ للافتراضي",
"permissionsHelp": "تتحكم أذونات المستخدم في الوصول إلى نوع الإجراءات التي يمكن للمستخدم تنفيذها. يمنح الوصول إلى المسؤول التحكم الكامل لتحديث وتغيير إعدادات أي مستخدم بالإضافة إلى العديد من إعدادات النظام."
},
"sidebar": {
"siteSettings": "إعدادات الموقع",
@@ -738,7 +738,6 @@
"emptyState": "أدخل معايير البحث وانقر فوق تحليل لتصور أحجام الملفات",
"completeResults": "النتائج الكاملة المعروضة!",
"incompleteResults": "قد تكون النتائج غير مكتملة",
"incompleteResultsDetails": "عرض الحد الأقصى للنتائج (100). حاول تضييق نطاق البحث للحصول على المزيد من النتائج أقل من 100.",
"relativePercentage": "النسبة المئوية النسبية{suffix}",
"percentageOfResults": "{percentage}النسبة المئوية للنتائج"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "لم يعد المسار الخاص بهذا موجوداً على نظام الملفات. ربما تم نقله أو حذفه.",
"reassignPath": "إعادة تعيين المسار",
"pathReassigned": "تم تحديث المسار بنجاح!",
"pathReassignFailed": "فشل تحديث المسار. يرجى المحاولة مرة أخرى."
"pathReassignFailed": "فشل تحديث المسار. يرجى المحاولة مرة أخرى.",
"incompleteResultsDetails": "عرض أقصى النتائج ({max}). حاول تضييق نطاق البحث للحصول على المزيد من النتائج تحت {max}."
},
"duplicateFinder": {
"title": "أداة البحث عن الملفات المكررة",
+4 -4
View File
@@ -426,7 +426,6 @@
"download": "Soubory ke stažení"
},
"permissions-name": "Oprávnění",
"permissionsHelp": "Uživatele můžete nastavit jako administrátora nebo vybrat jednotlivá oprávnění. Pokud zvolíte \"Administrátor\", ostatní možnosti budou automaticky zaškrtnuty. Správa uživatelů zůstává pouze výsadou administrátora.\n",
"profileSettings": "Nastavení profilu",
"ruleExample1": "zamezí přístup ke všem skrytým souborům (např. .git, .gitignore) ve všech složkách.\n",
"ruleExample2": "blokuje přístup k souboru s názvem Caddyfile v kořeni rozsahu.",
@@ -454,7 +453,8 @@
"oidc": "OIDC",
"proxy": "Proxy"
},
"newUserHintSubFolder": "např. '/subfolder' - prázdný pro výchozí nastavení"
"newUserHintSubFolder": "např. '/subfolder' - prázdný pro výchozí nastavení",
"permissionsHelp": "Oprávnění uživatele řídí přístup k typům akcí, které může uživatel provádět. Přístup administrátora poskytuje plnou kontrolu nad aktualizací a změnou nastavení libovolného uživatele i nad mnoha nastaveními systému."
},
"sidebar": {
"siteSettings": "Nastavení webu",
@@ -738,7 +738,6 @@
"emptyState": "Zadejte kritéria vyhledávání a kliknutím na tlačítko Analyzovat zobrazte velikosti souborů.",
"completeResults": "Zobrazeny kompletní výsledky!",
"incompleteResults": "Výsledky mohou být neúplné",
"incompleteResultsDetails": "Zobrazují se maximální výsledky (100). Zkuste zúžit vyhledávání, abyste získali více výsledků pod 100.",
"relativePercentage": "Relativní procento{suffix}",
"percentageOfResults": "{percentage}% výsledků"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "Cesta k němu již v souborovém systému neexistuje. Mohla být přesunuta nebo odstraněna.",
"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."
"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}."
},
"duplicateFinder": {
"title": "Vyhledávač duplicitních souborů",
+4 -4
View File
@@ -276,7 +276,6 @@
"download": "Dateien herunterladen"
},
"permissions-name": "Berechtigungen",
"permissionsHelp": "Sie können einem Benutzer Administratorrechte einräumen oder die Berechtigunen individuell festlegen. Wenn Sie \"Administrator\" auswählen, werden alle anderen Rechte automatisch vergeben. Die Nutzerverwaltung kann nur durch einen Administrator erfolgen.\n",
"profileSettings": "Darstellung",
"ruleExample1": "Verhindert den Zugang zu versteckten Dateien (dot-Files, wie .git, .gitignore) in allen Ordnern\n",
"ruleExample2": "Blockiert den Zugang auf Dateien mit dem Namen Caddyfile in der Wurzel/Basis des Scopes.",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "Proxy"
},
"newUserHintSubFolder": "z.B. '/subfolder' - leer für Standard"
"newUserHintSubFolder": "z.B. '/subfolder' - leer für Standard",
"permissionsHelp": "Die Benutzerrechte steuern den Zugriff auf die Art der Aktionen, die der Benutzer durchführen kann. Der Administrator-Zugang gibt Ihnen die volle Kontrolle über die Aktualisierung und Änderung der Benutzereinstellungen sowie vieler Systemeinstellungen."
},
"sidebar": {
"siteSettings": "Seiteneinstellungen",
@@ -738,7 +738,6 @@
"emptyState": "Geben Sie Suchkriterien ein und klicken Sie auf Analysieren, um die Dateigrößen zu visualisieren.",
"completeResults": "Vollständige Ergebnisse werden angezeigt!",
"incompleteResults": "Ergebnisse können unvollständig sein",
"incompleteResultsDetails": "Zeige maximale Ergebnisse (100). Versuchen Sie, Ihre Suche einzuschränken, um mehr Ergebnisse unter 100 zu erhalten.",
"relativePercentage": "Relativer Prozentsatz{suffix}",
"percentageOfResults": "{percentage}% der Ergebnisse"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "Der Pfad hierfür existiert nicht mehr im Dateisystem. Er kann verschoben oder gelöscht worden sein.",
"reassignPath": "Pfad neu zuweisen",
"pathReassigned": "Pfad erfolgreich aktualisiert!",
"pathReassignFailed": "Der Pfad konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut."
"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."
},
"duplicateFinder": {
"title": "Duplicate File Finder",
+4 -4
View File
@@ -282,7 +282,6 @@
"download": "Λήψη αρχείων"
},
"permissions-name": "Δικαιώματα",
"permissionsHelp": "Μπορείτε να ορίσετε τον χρήστη ως διαχειριστή ή να επιλέξετε τα δικαιώματα μεμονωμένα. Αν επιλέξετε \"Διαχειριστής\", όλες οι υπόλοιπες επιλογές θα είναι αυτόματα επιλεγμένες. Η διαχείριση χρηστών παραμένει προνόμιο ενός χρήστη με τον ρόλο του διαχειριστή.\n",
"profileSettings": "Ρυθμίσεις προφίλ",
"ruleExample1": "αποκλείει την πρόσβαση σε οποιοδήποτε κρυφό αρχείο (όπως .git, .gitignore) σε κάθε φάκελο.\n",
"ruleExample2": "αποκλείει την πρόσβαση στο αρχείο με το όνομα Caddyfile στον ριζικό φάκελο της εμβέλειας του κανόνα.",
@@ -326,7 +325,8 @@
"oidc": "OIDC",
"proxy": "Πληρεξούσιος"
},
"newUserHintSubFolder": "π.χ. '/subfolder' - κενό για προεπιλογή"
"newUserHintSubFolder": "π.χ. '/subfolder' - κενό για προεπιλογή",
"permissionsHelp": "Τα δικαιώματα χρήστη ελέγχουν την πρόσβαση σε είδος ενεργειών που μπορεί να εκτελέσει ο χρήστης. Η πρόσβαση διαχειριστή παρέχει πλήρη έλεγχο για την ενημέρωση και την αλλαγή των ρυθμίσεων οποιουδήποτε χρήστη, καθώς και πολλών ρυθμίσεων του συστήματος."
},
"sidebar": {
"siteSettings": "Ρυθμίσεις ιστότοπου",
@@ -738,7 +738,6 @@
"emptyState": "Εισάγετε τα κριτήρια αναζήτησης και κάντε κλικ στην επιλογή Ανάλυση για να απεικονίσετε τα μεγέθη των αρχείων",
"completeResults": "Παρουσιάζονται πλήρη αποτελέσματα!",
"incompleteResults": "Τα αποτελέσματα μπορεί να είναι ελλιπή",
"incompleteResultsDetails": "Εμφάνιση μέγιστων αποτελεσμάτων (100). Προσπαθήστε να περιορίσετε την αναζήτησή σας για να λάβετε περισσότερα αποτελέσματα κάτω από 100.",
"relativePercentage": "Σχετικό ποσοστό{suffix}",
"percentageOfResults": "{percentage}% των αποτελεσμάτων"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "Η διαδρομή για αυτό δεν υπάρχει πλέον στο σύστημα αρχείων. Μπορεί να έχει μετακινηθεί ή διαγραφεί.",
"reassignPath": "Επαναπροσδιορισμός διαδρομής",
"pathReassigned": "Η διαδρομή ενημερώθηκε επιτυχώς!",
"pathReassignFailed": "Απέτυχε η ενημέρωση της διαδρομής. Προσπαθήστε ξανά."
"pathReassignFailed": "Απέτυχε η ενημέρωση της διαδρομής. Προσπαθήστε ξανά.",
"incompleteResultsDetails": "Εμφάνιση μέγιστων αποτελεσμάτων ({max}). Προσπαθήστε να περιορίσετε την αναζήτησή σας για να πάρετε περισσότερα αποτελέσματα κάτω από {max}."
},
"duplicateFinder": {
"title": "Αναζήτηση διπλών αρχείων",
+3 -3
View File
@@ -4,7 +4,8 @@
"pathNotFoundMessage": "The path for this no longer exists on the filesystem. It may have been moved or deleted.",
"reassignPath": "Reassign Path",
"pathReassigned": "Path successfully updated!",
"pathReassignFailed": "Failed to update path. Please try again."
"pathReassignFailed": "Failed to update path. Please try again.",
"incompleteResultsDetails": "Showing max results ({max}). Try narrowing your search to get more results under {max}."
},
"general": {
"name": "Name{suffix}",
@@ -550,7 +551,7 @@
"download": "Download files"
},
"permissions-name": "Permissions",
"permissionsHelp": "You can set the user to be an administrator or choose the permissions individually. If you select \"Administrator\", all of the other options will be automatically checked. The management of users remains a privilege of an administrator.\n",
"permissionsHelp": "User permissions control access to what type of actions the user can perform. Administrar access gives full control to update and change any user's settings as well as many system settings.",
"profileSettings": "Profile Settings",
"ruleExample1": "prevents the access to any dot file (such as .git, .gitignore) in every folder.\n",
"ruleExample2": "blocks the access to the file named Caddyfile on the root of the scope.",
@@ -745,7 +746,6 @@
"emptyState": "Enter search criteria and click Analyze to visualize file sizes",
"completeResults": "Complete results shown!",
"incompleteResults": "Results may be incomplete",
"incompleteResultsDetails": "Showing max results (100). Try narrowing your search to get more results under 100.",
"relativePercentage": "Relative Percentage{suffix}",
"percentageOfResults": "{percentage}% of results"
},
+207 -207
View File
@@ -3,7 +3,7 @@
"copyFile": "Copiar archivo",
"copyToClipboard": "Copiar al portapapeles",
"moveFile": "Mover archivo",
"permalink": "Link sin expiración",
"permalink": "Enlace permanente",
"reportIssue": "Reportar problema",
"selectMultiple": "Selección múltiple",
"shell": "Presiona Enter para buscar...",
@@ -30,7 +30,7 @@
"saveAndExit": "Guardar y salir",
"keepEditing": "Seguir editando",
"closeAnyway": "Cerrar de todos modos",
"goToItem": "Ir al artículo"
"goToItem": "Ir al elemento"
},
"download": {
"downloadFile": "Descargar archivo",
@@ -42,8 +42,8 @@
"internal": "Error interno :(",
"notFound": "No se encontró el directorio",
"connection": "No se puede conectar al servidor.",
"shareNotFound": "No se encuentra lo que buscas, tal vez el enlace expiró.",
"failedToConnectToServer": "No se ha podido conectar con el servidor, ¿sigue funcionando?"
"shareNotFound": "No se encuentró lo que buscas, tal vez el enlace expiró.",
"failedToConnectToServer": "No se ha podido conectar con el servidor, ¿Sigue funcionando?"
},
"files": {
"closePreview": "Cerrar vista previa",
@@ -60,7 +60,7 @@
"myFiles": "Mis archivos{suffix}",
"newFile": "Nuevo archivo{suffix}",
"newFolder": "Nueva carpeta{suffix}",
"duration": "Duración{suffix}",
"duration": "Duración {suffix}",
"sortByDuration": "Ordenar por duración"
},
"help": {
@@ -71,7 +71,7 @@
"ctrl": {
"click": "Selecciona varios elementos (Selección multiple)",
"d": "Descargar elementos seleccionados",
"b": "Mostrar/ocultar la barra lateral",
"b": "Mostrar/ocultar el panel lateral",
"comma": "Ir a configuración",
"c": "Copiar elementos seleccionados",
"v": "Pegar elementos copiados o cortados",
@@ -81,10 +81,10 @@
"esc": "Cancela la selección o cierra la ventana actual",
"f2": "Cambiar nombre del archivo o carpeta",
"f1": "Abrir ventana de ayuda",
"description": "Aquí puedes ver todos los atajos de teclado básicos. Si quieres saber más, visita la",
"slash": "Activar búsqueda",
"description": "Aquí puedes ver todos los atajos de teclado básicos. Para saber más, visita la",
"slash": "Abrir panel de búsqueda",
"wiki": "documentación oficial de FileBrowser Quantum",
"marqueeSelect": "seleccionar varios archivos o directorios arrastrando sobre el espacio vacío"
"marqueeSelect": "Selecciona archivos o carpetas con el mouse arrastrando en un espacio vacio"
},
"languages": {
"he": "עברית",
@@ -139,17 +139,17 @@
"moveMessage": "Selecciona un nuevo destino:",
"newArchetype": "Crear una nueva publicación en base a una plantilla, tu archivo se creara en la misma carpeta",
"newDir": "Nueva carpeta",
"newDirMessage": "Nombre de la carpeta",
"newDirMessage": "Nombre de la carpeta:",
"newFile": "Nuevo archivo",
"newFileMessage": "Nombre del archivo",
"newFileMessage": "Nombre del archivo:",
"rename": "Cambiar nombre",
"replace": "Reemplazar",
"replaceMessage": "Un archivo con el mismo nombre ya existe. ¿Quieres cambiar el nombre?\n",
"schedule": "Programar",
"scheduleMessage": "Selecciona una fecha y hora para la publicacion:",
"show": "Mostrar",
"upload": "Subir",
"optionalPassword": "Contraseña (opcional)",
"upload": "Subir Archivos",
"optionalPassword": "Contraseña: (opcional)",
"deleteUserMessage": "¿Estas seguro que quieres eliminar este usuario?",
"filesSelected": "Archivos seleccionados",
"typeName": "Tipo:",
@@ -168,7 +168,7 @@
"optionalPasswordHelp": "Si eliges una contraseña, los usuarios deberán introducir esa misma contraseña para ver el contenido.",
"dragAndDrop": "Arrastra y suelta los archivos aquí",
"completed": "Completado",
"conflictsDetected": "Conflictos detectados",
"conflictsDetected": "Se detectaron conflictos",
"viewMode": "Cambiar modo de vista",
"renameMessage": "Introduce un nuevo nombre:",
"renameMessageInvalid": "El archivo no puede tener \"/\" o \"\\\"",
@@ -176,10 +176,10 @@
"deleteMessageShare": "¿Estás seguro que quieres borrar \"{path}\" de los recursos compartidos?",
"numberDirs": "Número de carpetas {suffix}",
"numberFiles": "Número de archivos {suffix}",
"renameMessageConflict": "Ya existe un archivo llamado \"{filename}\".",
"renameMessageConflict": "Ya existe un archivo llamado \"{filename}\"",
"uploadSettingsChunked": "Subidas simultáneas: {maxConcurrentUpload} - Tamaño de fragmento: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Subidas simultáneas: {maxConcurrentUpload} - Subida fragmentada: Desactivada",
"quickJump": "Salta rápidamente a otros archivos",
"quickJump": "Navegación rápida",
"moveSuccess": "¡Archivos movidos con éxito!",
"copySuccess": "¡Archivos copiados correctamente!",
"lastModified": "Última modificación: {suffix}",
@@ -192,37 +192,37 @@
"moveItemQuestion": "¿Quieres mover este archivo a la nueva ubicación?",
"sourcePath": "Fuente:",
"destinationPath": "Nueva ubicación:",
"closeWithActiveUploadsMessage": "Si cierras este aviso, todas las subidas activas se interrumpirán, y puede que algunos archivos queden incompletos. ¿Que quieres hacer?",
"closeWithActiveUploadsMessage": "Si cierras esta ventana, todos los archivos que estés subiendo se interrumpirán, y puede que algunos queden incompletos. ¿Que quieres hacer?",
"closeWithActiveUploads": "Subida de archivos activa",
"basicInfo": "Información básica",
"directoryInfo": "Información del directorio",
"additionalInfo": "Información adicional",
"hidden": "Oculto",
"hasPreview": "Tiene Vista Previa",
"hasPreview": "Vista Previa",
"checksums": "Sumas de control",
"hashAlgorithm": "Algoritmo Hash",
"hashAlgorithm": "Algoritmo hash",
"hashValue": "Valor Hash",
"selectHashAlgorithm": "Seleccione un algoritmo hash",
"generate": "Genere",
"errorGeneratingHash": "Error al generar el hash",
"hashCopied": "Hash copiado en el portapapeles",
"errorCopyingHash": "Error al copiar el hash",
"token": "Ficha",
"selectHashAlgorithm": "Seleccione un algoritmo",
"generate": "Generar",
"errorGeneratingHash": "Hubo un error al generar el hash",
"hashCopied": "¡Se copio el hash al portapapeles!",
"errorCopyingHash": "Hubo un error al copiar el hash",
"token": "Token",
"onlyOfficeId": "OnlyOffice ID",
"firstLoadTitle": "¡Bienvenido a FileBrowser Quantum!",
"halloweenTitle": "¡Feliz Halloween!",
"halloweenBody": "Se ha aplicado el tema de Halloween. Puedes desactivarlo si no te gusta, o desactivarlo permanentemente en los ajustes del perfil seleccionando cualquier otro tema.",
"firstLoadBody": "Se ha creado una nueva base de datos al iniciar. Si esto no es lo que esperaba, por favor, compruebe su archivo de configuración para la ruta `server.database` y asegúrese de que existe. En docker, la ruta de la base de datos puede ser `./data/database.db` o `./database.db`.",
"firstLoadBody": "Se acabó de crear una nueva base de datos al iniciar. Si esto no es lo que esperabas, por favor, verifica si tu archivo de configuración tiene una entrada para la ruta de la base de datos: `server.database` y aseguráte de que existe. Si usas Docker, la ruta de la base de datos puede ser `./data/database.db` o `./database.db`.",
"selectPath": "Seleccionar ruta",
"renameSuccess": "El cambio de nombre se ha realizado correctamente.",
"newFileSuccess": "¡Fichero creado con éxito!",
"newDirSuccess": "¡Directorio creado con éxito!",
"copyToClipboardFailed": "No se puede copiar en el portapapeles"
"renameSuccess": "El cambio de nombre se ha realizado correctamente",
"newFileSuccess": "¡Archivo creado con éxito!",
"newDirSuccess": "¡Carpeta creada con éxito!",
"copyToClipboardFailed": "Hubo un error al copiar en el portapapeles"
},
"search": {
"smallerThan": "Menor que:",
"largerThan": "Mayor que:",
"helpText1": "La busqueda se realiza por cada letra introducida (empezando de 3)",
"helpText1": "La busqueda se realiza por cada letra introducida (empezando de {minSearchLength})",
"helpText2": "El índice se actualiza automáticamente cada 5 min por defecto. Si se interrumpe pueden haber errores",
"onlyFolders": "Solo carpetas",
"onlyFiles": "Solo archivos",
@@ -239,7 +239,7 @@
"allowEdit": "Editar, borrar y cambiar nombres de archivos o carpetas",
"allowNew": "Crear nuevos archivos y carpetas",
"allowPublish": "Publicar posts/páginas",
"allowSignup": "Permitir registro de nuevos usuarios",
"allowSignup": "Permitir el registro de nuevos usuarios",
"avoidChanges": "(dejar en blanco para evitar cambios)",
"brandingDirectoryPath": "Ruta del directorio con los archivos que usarás para personalizar FileBrowser Quantum",
"changePassword": "Cambiar contraseña",
@@ -277,17 +277,16 @@
"download": "Descargar archivos"
},
"permissions-name": "Permisos",
"permissionsHelp": "Puedes elegir los permisos individualmente. Si seleccionas \"Administrador\", todas las demás opciones serán activadas, y los usuarios administradores podrán modificar a otros usuarios.\n",
"profileSettings": "Ajustes del perfil",
"ruleExample1": "Bloquea el acceso a una extensión especifica extensión de archivo (ej: .git) en todas las carpetas.\n",
"ruleExample2": "Bloquea el acceso al archivo llamado \"Caddyfile\" en la carpeta raíz (/)",
"rulesHelp": "Puedes definir un conjunto de reglas para este usuario. Los archivos bloqueados no se mostrarán y no serán accesibles pora el usuario. Puedes utilizar regex y rutas relativas al alcance (Ruta base) del usuario.\n",
"settingsUpdated": "¡Ajustes actualizados!",
"shareManagement": "Gestión de recursos compartidos",
"shareDeleted": "¡Compartido eliminado!",
"shareDeleted": "¡Recurso compartido eliminado!",
"singleClick": "Abrir con un solo clic",
"userCommandsHelp": "Comandos permitidos (separados por espacios). Ejemplo:\n",
"userCreated": "¡Usuario creado!",
"userCreated": "¡Usuario creado con éxito!",
"userDefaults": "Configuración de usuario por defecto",
"userDeleted": "¡Usuario eliminado!",
"userManagement": "Gestión de usuarios",
@@ -299,8 +298,8 @@
"emptyGlobal": "Actualmente no hay configuracion global, edita el archivo YAML para establecer las configuraciones del sistema",
"UserManagement": "Gestión de usuarios",
"tusUploads": "Subida fragmentada",
"tusUploadsHelp": "File Browser Quantum permite subir archivos en fragmentos, lo que permite que sean subidos de manera eficiente, fiable, y que sea reanudable incluso si tu internet es inestable",
"tusUploadsChunkSize": "Tamaño máximo del \"fragmento\" del archivo a subir. Puedes escribir un número en bytes o una cadena como 10 MB, 1 GB, etc",
"tusUploadsHelp": "FileBrowser Quantum permite subir archivos en fragmentos, lo que permite que sean subidos de manera eficiente, fiable, y que sea reanudable incluso si tu internet es inestable",
"tusUploadsChunkSize": "Tamaño máximo del fragmento del archivo a subir. Puedes escribir un número en bytes o una cadena como 10 MB, 1 GB, etc",
"tusUploadsRetryCount": "Número de reintentos. Por si falla la subida de algún archivo",
"api": "Claves API",
"loginMethodDescription": "Método de inicio de sesión",
@@ -323,45 +322,46 @@
"configViewerShowComments": "Mostrar comentarios",
"configViewerLoadConfig": "Cargar configuración",
"brandingHelp": "Puedes personalizar la apariencia de FileBrowser Quantum cambiando el nombre, el logo, puedes añadir tus propios estilos personalizados, desactivar los enlaces externos a GitHub, etc. \nSi quieres saber más, visita {0}.",
"commandRunnerHelp": "Aquí puede establecer los comandos que se ejecutan en los eventos nombrados. Debe escribir uno por línea. Las variables de entorno {0} y {1} estarán disponibles, siendo {0} relativa a {1}. Para más información sobre esta característica y las variables de entorno disponibles, por favor lea el {2}."
"commandRunnerHelp": "Aquí puede establecer los comandos que se ejecutan en los eventos nombrados. Debe escribir uno por línea. Las variables de entorno {0} y {1} estarán disponibles, siendo {0} relativa a {1}. Para más información sobre esta característica y las variables de entorno disponibles, por favor lea el {2}.",
"permissionsHelp": "Los permisos de usuario controlan el acceso al tipo de acciones que el usuario puede realizar. El acceso de administrador proporciona un control total para actualizar y cambiar la configuración de cualquier usuario, así como muchos ajustes del sistema."
},
"sidebar": {
"siteSettings": "Ajustes del sitio",
"updateIsAvailable": "¡Actualización disponible!",
"fileActions": "Acciones de archivo",
"customizeLinks": "Personalizar los enlaces de la barra lateral",
"customizeLinksDescription": "Personaliza los enlaces que aparecen en tu barra lateral. Arrastre y suelte para reordenar.",
"currentLinks": "Enlaces actuales",
"addNewLink": "Añadir nuevo enlace",
"linkType": "Tipo de enlace",
"selectLinkType": "Seleccione un tipo de enlace",
"customLink": "Enlace personalizado",
"selectSource": "Seleccionar fuente",
"chooseSource": "Elija una fuente",
"selectTool": "Seleccionar herramienta",
"chooseTool": "Elija una herramienta",
"linkName": "Nombre del enlace",
"linkNamePlaceholder": "Introduzca el nombre del enlace",
"linkUrl": "Enlace URL",
"linkUrlPlaceholder": "Introduzca la URL o la ruta (por ejemplo, /archivos/ruta o https://example.com)",
"linkIcon": "Icono de enlace",
"linksUpdatedSuccess": "Los enlaces de la barra lateral se han actualizado correctamente.",
"linksUpdateFailed": "Error al actualizar los enlaces de la barra lateral",
"sourceNotAccessible": "Ya no puede acceder a esta fuente",
"noLinksYet": "Aún no hay enlaces configurados. Añada su primer enlace a continuación.",
"customizeShareLinks": "Personalizar los enlaces compartidos de la barra lateral",
"customizeShareLinksDescription": "Personalice los enlaces que aparecen en la barra lateral para esta acción. Arrastre y suelte para reordenar.",
"editLink": "Editar enlace",
"selectShare": "Seleccione Compartir",
"chooseShare": "Elija una acción",
"selectSharePath": "Ruta dentro del recurso compartido (opcional)",
"sharePathPlaceholder": "por ejemplo, /subcarpeta o dejar vacío para root",
"browseIcons": "Iconos de navegación",
"customizeLinks": "Personalizar accesos directos",
"customizeLinksDescription": "Personaliza los accesos directos que aparecen en el panel lateral - Puedes arrastrar y soltar para reordenarlos",
"currentLinks": "Accesos directos actuales",
"addNewLink": "Crear acceso directo",
"linkType": "Tipo:",
"selectLinkType": "Selecciona un tipo de acceso directo",
"customLink": "Acceso directo personalizado",
"selectSource": "Seleccionar Fuente",
"chooseSource": "Elije una fuente de archivos",
"selectTool": "Selecciona una herramienta",
"chooseTool": "Elije una herramienta",
"linkName": "Nombre del acceso directo",
"linkNamePlaceholder": "Introduzca el nombre del acceso directo",
"linkUrl": "Enlace",
"linkUrlPlaceholder": "Introduzca una ruta o URL (ej: /archivos/ruta o https://example.com)",
"linkIcon": "Icono del acceso directo:",
"linksUpdatedSuccess": "¡Se actualizaron los accesos directos!",
"linksUpdateFailed": "Hubo un error al actualizar los accesos directos",
"sourceNotAccessible": "No se pudo acceder a la fuente",
"noLinksYet": "Aún no configuraste ningún acceso directo, ¡Añade uno!",
"customizeShareLinks": "Accesos directos",
"customizeShareLinksDescription": "Aquí puedes personalizar los accesos directos del panel lateral para este recurso compartido.",
"editLink": "Editar acceso directo",
"selectShare": "Seleccionar recurso compartido",
"chooseShare": "Seleccionar recurso compartido",
"selectSharePath": "Ruta que se usará dentro del recurso compartido (opcional)",
"sharePathPlaceholder": "ej: /subcarpeta o en blanco para \"/\"",
"browseIcons": "Material Icons",
"pickIcon": "Elija un icono",
"pickIconDescription": "Busque y seleccione un icono de la biblioteca Material Icons",
"shareLinksUpdatedSuccess": "Los enlaces de la barra lateral compartida se han actualizado correctamente.",
"shareHome": "Compartir",
"linkIconPlaceholder": "Opcionalmente, especifique un nombre de icono de material"
"pickIconDescription": "Aquí puedes explorar los iconos más populates de la biblioteca \"Material Icons\"",
"shareLinksUpdatedSuccess": "¡Se actualizaron los accesos directos del recurso compartido!",
"shareHome": "Recurso compartido",
"linkIconPlaceholder": "Opcionalmente, puedes elegir un Material Icon que más te guste"
},
"success": {
"linkCopied": "¡Enlace copiado!"
@@ -374,16 +374,16 @@
"unit": "Unidad"
},
"upload": {
"abortUpload": "¿Cancelar subida?"
"abortUpload": "¿Cancelar subida de archivos?"
},
"share": {
"notice": "Nota: Cuando se crea un recurso compartido, este queda más expuesto al público y tendrá diferentes normas de acceso. Si el recurso compartido no está configurado de forma segura, cualquiera podrá acceder a él.",
"disableAnonymousDescription": "Cuando está activado, sólo los usuarios registrados podrán acceder al recurso compartido.",
"disableThumbnailsDescription": "Las miniaturas de archivos, fotos y videos no estarán activadas.",
"enableAllowedUsernamesDescription": "Cuando se activa, sólo los usuarios que especifique pueden acceder al recurso compartido. El usuario debe tener acceso al origen del recurso compartido.",
"downloadsLimitDescription": "Número máximo de veces que se puede descargar un archivo/carpeta del recurso compartido - Dejar vacío para ilimitado.",
"maxBandwidthDescription": "El ancho de banda máximo de descarga en kilobytes por segundo - Dejar vacío para ilimitado.",
"shareThemeDescription": "El tema que se utilizará para los usuarios que accedan al recurso compartidos",
"notice": "Cuando se crea un recurso compartido, este quedará más expuesto al público y tendrá diferentes reglas de acceso. Si no está configurado de forma forma segura, cualquiera podrá acceder a él.",
"disableAnonymousDescription": "Cuando está activado, solo los usuarios registrados podrán acceder al recurso compartido.",
"disableThumbnailsDescription": "Las miniaturas de archivos, fotos, audios y videos no estarán activadas.",
"enableAllowedUsernamesDescription": "Cuando está activado, sólo usuarios especificados podrán acceder al recurso compartido. El usuario deberá tener acceso al origen del recurso compartido para poder entrar",
"downloadsLimitDescription": "Número máximo de veces que se puede descargar un archivo/carpeta del recurso compartido - Dejar en blanco para ilimitado.",
"maxBandwidthDescription": "Velocidad máxima de descarga en kilobytes por segundo (kb/s) - Dejar en blanco para ilimitado.",
"shareThemeDescription": "El tema que se usará para los usuarios que accedan al recurso compartidos",
"disableAnonymous": "Desactivar el acceso anónimo",
"disableThumbnails": "Desactivar miniaturas",
"enableAllowedUsernames": "Compartir sólo con usuarios registrados",
@@ -404,25 +404,25 @@
"hideNavButtonsDescription": "Oculta los botones de navegación en la parte superior, es útil si quieres crear un aspecto minimalista.",
"viewModeDescription": "Diseño por defecto de la página compartida.",
"descriptionDefault": "Han compartido esto contigo.",
"disableShareCard": "Desactivar código QR",
"disableShareCardDescription": "Desactiva el código QR en el panel lateral o en la parte superior de la página en móvil",
"disableShareCard": "Desactivar tarjeta de compartir",
"disableShareCardDescription": "Desactiva la tarjeta de compartir y el código QR en el panel lateral o en la parte superior de la página en móvil",
"disableSidebar": "Desactivar el panel lateral",
"disableSidebarDescription": "Desactivar el panel lateral en la página compartida.",
"disableFileViewer": "Desactivar visor de archivos",
"enforceDarkLightMode": "Tema",
"enforceDarkLightModeDescription": "Forzar un modo de tema específico (oscuro o claro) para el recurso compartido, anulando las preferencias del usuario.",
"enforceDarkLightModeDescription": "Forzar un tema (claro u oscuro) para el recurso compartido, anulando las preferencias del usuario - Ocultará el botón de cambio de tema",
"default": "Por defecto",
"dark": "Oscuro",
"light": "Claro",
"enableOnlyOffice": "Activar el visor de OnlyOffice",
"enableOnlyOfficeDescription": "Permitir la visualización de archivos de oficina utilizando OnlyOffice en este recurso compartido.",
"enableOnlyOfficeDescription": "Permitir la visualización de archivos Office usando OnlyOffice en este recurso compartido.",
"titleDefault": "{title}",
"defaultViewMode": "Modo de visualización",
"defaultViewModeDescription": "Establece el modo de visualización predeterminado para los usuarios anónimos que vean esta acción. Los usuarios registrados pueden cambiar su preferencia de visualización.",
"shareType": "Tipo de compartido",
"defaultViewMode": "Modo de vista",
"defaultViewModeDescription": "Establece el modo de vista predeterminada para los que accedan a este recurso compartido. Los usuarios que estén registrados podrán cambiar su modo de vista si ellos quieren",
"shareType": "Tipo de recurso compartido:",
"shareTypeDescription": "El tipo de recurso compartido que se va a crear. Los recursos compartidos normales permiten ver y descargar archivos. Los recursos compartidos de subida permiten subir archivos al recurso compartido.",
"normalShare": "Normal",
"uploadShare": "Solo subidas",
"uploadShare": "Solo subir archivos",
"perUserDownloadLimit": "Límitar descargas para cada usuario",
"perUserDownloadLimitDescription": "Cuando se activa, el límite de descargas se aplicará a cada usuario. Los usuarios anónimos no podrán descargar ningún archivo de este recurso compartido.",
"descriptionUploadDefault": "Te han compartido un enlace para que subas archivos",
@@ -431,18 +431,18 @@
"disableDownload": "Desactivar la descarga de archivos",
"disableDownloadDescription": "Nadie podrá descargar archivos ni ver su contenido.",
"allowModify": "Permitir la edición de archivos",
"allowModifyDescription": "Cualquiera puede editar archivos en este recurso compartido.",
"allowModifyDescription": "Cualquiera podrá editar archivos en este recurso compartido.",
"allowDelete": "Permitir la eliminación de archivos",
"allowDeleteDescription": "Cualquiera puede borrar archivos y carpetas de este recurso compartido.",
"allowDeleteDescription": "Cualquiera podrá borrar archivos y carpetas de este recurso compartido.",
"allowReplacements": "Permitir la sustitución de archivos",
"allowReplacementsDescription": "Cualquiera podrá reemplazar archivos existentes al subir archivos con el mismo nombre.",
"allowReplacementsDescription": "Cualquiera podrá reemplazar archivos existentes al subir/crear archivos con el mismo nombre.",
"allowCreate": "Permitir crear/subir archivos y carpetas",
"allowCreateDescription": "Cualquiera puede crear/subir nuevos archivos y carpetas en este recurso compartido.",
"customizeSidebarLinks": "Personalizar los enlaces de la barra lateral",
"customizeSidebarLinksButton": "Personalizar enlaces",
"shareInfo": "Compartir código QR e información",
"shareInfoDescription": "Este enlace muestra la información de la acción, incluido el título, la descripción, el banner y el código QR en una ventana emergente.",
"downloadDescription": "Este enlace permite descargar todo el contenido compartido como archivo comprimido."
"allowCreateDescription": "Cualquiera podrá crear/subir nuevos archivos y carpetas en este recurso compartido.",
"customizeSidebarLinks": "Personalizar los accesos directos del panel lateral",
"customizeSidebarLinksButton": "Personalizar accesos directos",
"shareInfo": "Código QR e información del archivo",
"shareInfoDescription": "Al abrir este acceso directo, mostrará la información del recurso compartido junto con un código QR",
"downloadDescription": "Este acceso directo permite descargar todo el recurso compartido como archivo comprimido."
},
"api": {
"title": "Gestión de claves API",
@@ -462,12 +462,12 @@
"expires": "Expira el:",
"actions": "Acciones",
"enabled": "Habilitado",
"disabled": "Deshabilitado",
"permissionNote": "Elije al menos un permiso - Tu usuario también debe tener ese permiso.",
"disabled": "Desactivado",
"permissionNote": "Debes de elegir al menos un permiso - Tu usuario también debe tenerlo.",
"createKeyFailed": "Hubo un error al crear la API",
"createKeySuccess": "¡Clave API creada con exito!",
"description": "Las claves API solo podrán tener los permisos que tu usuario tenga. Para más info mira la",
"apiKeyDeleted": "Clave API eliminada"
"apiKeyDeleted": "Clave API borrada"
},
"index": {
"assessment": "Tipo",
@@ -478,34 +478,34 @@
"toggleClick": "Abrir con un solo clic",
"toggleDark": "Activar modo oscuro",
"toggleSticky": "Cambiar visibilidad del panel lateral",
"noSources": "No hay fuentes disponibles. Añada una o active \"defaultEnabled: true\" en el archivo de configuración.",
"noSources": "No se encontró ninguna fuente. Añade una, o usa \"defaultEnabled: true\" en el archivo de configuración.",
"unknown": "Desconocido",
"simple": "Simple",
"normal": "Normal",
"complex": "Complejo",
"highlyComplex": "Alta complejidad",
"highlyComplex": "Muy complejo",
"ready": "Listo",
"indexing": "Indexación",
"indexing": "Escaneando",
"unavailable": "No disponible",
"error": "Error"
},
"otp": {
"name": "Autenticación de dos factores (2FA)",
"verifyInstructions": "Ingresa el código de autenticación",
"generate": "Escanear el código QR con la app de autenticación",
"generate": "Escanear el código QR con tú app de autenticación",
"codeInputPlaceholder": "Código de 6 dígitos",
"invalidCodeType": "Código inválido: debe ser de 6 dígitos",
"invalidCodeType": "Código inválido",
"enabledSuccessfully": "¡2FA activado!",
"verificationFailed": "Verificación fallida",
"verificationSucceed": "Verificación exitosa",
"generationFailed": "La generación del código falló"
"verificationFailed": "No se pudo autenticar",
"verificationSucceed": "¡Verificación exitosa!",
"generationFailed": "Hubo un error al generar el código"
},
"general": {
"name": "Nombre",
"path": "Ruta",
"path": "Ruta:",
"user": "Usuario",
"group": "Grupo",
"signup": "Regístrarse",
"signup": "Crear cuenta",
"password": "Contraseña",
"username": "Nombre de usuario",
"files": "Archivos",
@@ -517,32 +517,32 @@
"source": "Fuente{suffix}",
"percentage": "{percentage}%",
"type": "Tipo{suffix}",
"size": "Talla{suffix}",
"size": "Tamaño{suffix}",
"body": "Cuerpo{suffix}",
"home": "Inicio{suffix}",
"clear": "Claro{suffix}",
"clear": "Limpiar{suffix}",
"help": "Ayuda{suffix}",
"login": "Inicio de sesión{suffix}",
"logout": "Cierre de sesión{suffix}",
"login": "Iniciar sesión{suffix}",
"logout": "Cerrar sesión{suffix}",
"preview": "Vista previa{suffix}",
"settings": "Ajustes{suffix}",
"sources": "Fuentes{suffix}",
"links": "Enlaces{suffix}",
"links": "Accesos directos{suffix}",
"tool": "Herramienta{suffix}",
"used": "usado{suffix}",
"status": "Estado{suffix}",
"analyze": "Analice{suffix}",
"analyze": "Analizar{suffix}",
"results": "Resultados{suffix}",
"users": "Usuarios{suffix}",
"photos": "Fotos",
"audio": "Audio{suffix}",
"videos": "Vídeos{suffix}",
"documents": "Documentos{suffix}",
"archives": "Archivos{suffix}",
"archives": "Archivos comprimidos{suffix}",
"images": "Imágenes{suffix}",
"music": "Música{suffix}",
"pdf": "PDF{suffix}",
"search": "Buscar en{suffix}",
"search": "Buscar{suffix}",
"number": "Número{suffix}",
"types": "Tipos{suffix}",
"video": "Vídeo{suffix}",
@@ -552,22 +552,22 @@
"documentation": "documentación{suffix}",
"examples": "Ejemplos{suffix}",
"dark": "Oscuro{suffix}",
"light": "Luz{suffix}",
"light": "Claro{suffix}",
"title": "Tema{suffix}",
"expires": "Expira en{suffix}",
"scope": "Alcance{suffix}",
"rules": "Reglas{suffix}",
"downloads": "Descargas{suffix}",
"submit": "Inicio de sesión{suffix}",
"submit": "Aceptar{suffix}",
"navigation": "Navegación{suffix}",
"metadata": "Metadatos{suffix}",
"loading": "Cargando{suffix}",
"metadata": "Metadata{suffix}",
"loading": "Cargando...{suffix}",
"add": "Añadir{suffix}",
"cancel": "Cancelar{suffix}",
"verify": "Verifique{suffix}",
"verify": "Verificar{suffix}",
"close": "Cerrar{suffix}",
"copy": "Copia{suffix}",
"create": "Cree{suffix}",
"copy": "Copiar{suffix}",
"create": "Crear{suffix}",
"delete": "Borrar{suffix}",
"download": "Descargar{suffix}",
"file": "Archivo{suffix}",
@@ -577,65 +577,65 @@
"move": "Mover{suffix}",
"new": "Nuevo{suffix}",
"next": "Siguiente{suffix}",
"ok": "OK{suffix}",
"ok": "ok{suffix}",
"previous": "Anterior{suffix}",
"publish": "Publique{suffix}",
"rename": "Cambie el nombre de{suffix}",
"replace": "Sustituir{suffix}",
"publish": "Publicar{suffix}",
"rename": "Cambiar nombre{suffix}",
"replace": "Reemplazar{suffix}",
"save": "Guardar{suffix}",
"schedule": "Horario{suffix}",
"select": "Seleccione{suffix}",
"select": "Seleccionar{suffix}",
"share": "Compartir{suffix}",
"update": "Actualización{suffix}",
"upload": "Cargar{suffix}",
"openFile": "Abrir expediente{suffix}",
"continue": "Continúe en{suffix}",
"update": "Actualizar{suffix}",
"upload": "Subir Archivos{suffix}",
"openFile": "Abrir Archivo{suffix}",
"continue": "Continuar{suffix}",
"edit": "Editar{suffix}",
"pause": "Pausa{suffix}",
"resume": "Currículum{suffix}",
"pause": "Pausar{suffix}",
"resume": "Reanudar{suffix}",
"retry": "Reintentar{suffix}",
"disable": "Desactivar{suffix}",
"confirm": "Confirme{suffix}",
"stay": "Permanezca en{suffix}",
"scopes": "Visores{suffix}",
"confirm": "Confirmar{suffix}",
"stay": "Quedarme{suffix}",
"scopes": "Alcances{suffix}",
"commands": "Comandos{suffix}",
"uploading": "Cargar{suffix}",
"uploading": "Subiendo{suffix}",
"paused": "En pausa{suffix}",
"conflict": "Conflicto{suffix}",
"change": "Cambia{suffix}"
"change": "Cambiar{suffix}"
},
"access": {
"rules": "Normas de acceso",
"deny": "Denegar",
"deniedUsers": "Usuarios denegados",
"deniedGroups": "Grupos denegados",
"rules": "Reglas de acceso",
"deny": "Bloquear",
"deniedUsers": "Usuarios bloqueados",
"deniedGroups": "Grupos bloqueados",
"allowedUsers": "Usuarios permitidos",
"allowedGroups": "Grupos permitidos",
"allow": "Permitir",
"allowDeny": "Permitir/Denegar",
"allowDeny": "Permitir/Bloquear",
"userGroup": "Usuario/Grupo",
"enterName": "Escribe el nombre del usuario, o del grupo",
"enterName": "Escribe el nombre del usuario o grupo",
"deleted": "Regla de acceso eliminada",
"added": "Regla de acceso añadida",
"accessManagement": "Gestión de accesos",
"totalDenied": "Denegado",
"totalAllowed": "Permitido",
"all": "Denegar todo",
"defaultBehaviorDescription": "Muestra lo que ocurre cuando un usuario intenta acceder a una ruta en la que no existen reglas de acceso específicas -- \"Permitir\" significa que los usuarios pueden acceder (por defecto), \"Denegar\" significa que los usuarios están bloqueados (por defecto) a menos que se les permita explícitamente.",
"defaultBehavior": "Acción por defecto {suffix}",
"deletedCascade": "Reglas de acceso eliminadas de la ruta y de todas las sub-rutas.",
"cascadeDelete": "Supresión recursiva",
"cascadeDeleteDescription": "Cuando está activada, la eliminación de una regla de acceso también la eliminará de todos los subdirectorios. Esto permite eliminar completamente un usuario o grupo de todo el árbol de directorios."
"totalDenied": "Bloqueados",
"totalAllowed": "Permitidos",
"all": "Bloquear todo",
"defaultBehaviorDescription": "La regla por defecto para todos los directorios es \"Permitir\": todos los usuarios tienen acceso a todo a menos que especifiques una regla -- \"Denegar\" significa que los usuarios estarán bloqueados: No podrán acceder a esa carpeta(s) a menos que lo permitas especifcamente",
"defaultBehavior": "Regla por defecto {suffix}",
"deletedCascade": "Reglas de acceso eliminadas",
"cascadeDelete": "Eliminación en cascada",
"cascadeDeleteDescription": "Si eliminas una regla de acceso con esta opción, también la eliminará para todo el contenido (archivos y carpetas), lo cual nos permite eliminar por completo un usuario o grupo de todo el árbol de directorios"
},
"fileLoading": {
"title": "Subir archivos",
"maxConcurrentUpload": "Subidas a la vez",
"maxConcurrentUploadHelp": "El número máximo de archivos a subir a la vez. Los valores más altos suelen dar mejores resultados, pero esto depende de muchos factores",
"title": "Subida y Descarga",
"maxConcurrentUpload": "Archivos a subir a la vez",
"maxConcurrentUploadHelp": "El número máximo de archivos a subir a la vez -- Por ejemplo si el valor es 5 y subes 10 archivos, subirá los archivos de 5 en 5. Los valores más altos suelen ser mejores, pero depende de varios factores",
"uploadChunkSizeMb": "Velocidad de subida (en MB)",
"uploadChunkSizeMbHelp": "Típicamente 5 a 50 MB, más pequeño es mejor para conexiones inestables.",
"clearAll": "Hacer que \"Limpiar completados\" borre todo (incluyendo error, pausa y en conflicto)",
"clearAllDescription": "El botón de borrado de \"subidas completadas\" borrará todas los archivos subidos en todos los estados: Finalizado, error, pausa y conflicto.",
"uploadSettings": "Configuracion de subidas"
"uploadChunkSizeMbHelp": "Subida de archivos fragmentada o \"Chunked uploads\" en inglés - Lo que está opción hace es controlar la velocidad de subida en la que los fragmentos de archivo se subirán.",
"clearAll": "\"Limpiar completados\" debe borrar todo",
"clearAllDescription": "Hará que el botón de \"Limpiar completados\" en la ventana de subir archivos borre todos los estados: Finalizado, error, pausa y conflicto.",
"uploadSettings": "Opciones de subida"
},
"colors": {
"red": "Rojo",
@@ -653,7 +653,7 @@
"editorQuickSave": "Botón de guardado rápido en el editor",
"editorQuickSaveDescription": "El editor de texto mostrará siempre el icono de guardar, ocultando cualquier otra acción",
"previewOffice": "Mostrar miniaturas para los archivos de Office",
"previewOfficeDescription": "Mostrar miniaturas de los tipos de archivos de Office en el tipo de vista: Lista",
"previewOfficeDescription": "Mostrar miniaturas para los archivos de Office",
"disableThumbnailPreviews": "Desactivar miniaturas para las siguientes extensiones:",
"disableThumbnailPreviewsDescription": "Una lista (separada por espacios) de extensiones de archivo para desactivar la vista previa en las miniaturas (por ejemplo, '.txt .html').",
"disableViewingFiles": "Desactivar el visor/editor de archivos para las siguientes extensiones:",
@@ -682,7 +682,7 @@
"editOffice": "Ver y editar archivos de ofimática con OnlyOffice",
"editOfficeDescription": "Cuando está activado, el visor/editor de archivos estará desactivado para todas las extensiones de archivo relacionadas con archivos office, en vez, las abrirá en OnlyOffice.",
"popupPreview": "Vista previa emergente",
"popupPreviewDescription": "Se mostrará una ventana emergente superpuesta sobre la interfaz de usuario al pasar el ratón por encima de los archivos que tengan una miniatura",
"popupPreviewDescription": "Se mostrará una ventana emergente superpuesta sobre la interfaz de usuario al pasar el mouse por encima de los archivos que tengan una miniatura",
"filePreviewOptions": "Opciones de previsualización de archivos",
"disableUpdateNotifications": "Desactivar las notificaciones de actualización",
"disableUpdateNotificationsDescription": "Desactivar el banner de notificación de actualización que aparece para los usuarios administradores en el panel lateral.",
@@ -691,7 +691,7 @@
"disableQuickToggles": "Desactivar ajustes rápidos",
"disableQuickTogglesDescription": "Desactiva los ajustes rápidos que aparecen en el panel lateral. (cambiar tema, visibilidad del panel y modo de un solo click)",
"disableSearchOptions": "Desactivar las opciones de busqueda",
"disableFileExtensions": "Escribe aquí las extensiones para deshabilitar",
"disableFileExtensions": "Escribe aquí las extensiones para desactivar",
"disableSearchOptionsDescription": "Desactivar las opciones de busqueda que aparecen en el panel.",
"hideSidebarFileActions": "Ocultar el botón de acciones de archivo en el panel",
"deleteWithoutConfirming": "Eliminar archivos sin mensaje de confirmación",
@@ -708,10 +708,10 @@
"editor": {
"uninitialized": "Hubo un error al iniciar el editor",
"saveFailed": "No se ha podido guardar el archivo. Por favor, inténtelo de nuevo.",
"saveAborted": "No se ha podido guardar el archivo. El nombre del archivo no coincide con el que estás editando.",
"saveAbortedMessage": "Operación de guardado abortada. El archivo activo ({activeFile}) no coincide con el que estas intentando guardar ({tryingToSave}).",
"syncFailed": "No se ha podido sincronizar el estado con la ruta para \"{filename}\" después de 5 intentos - Se abortará la configuración del editor para evitar la corrupción de datos.",
"fileSaved": "¡Fichero guardado!",
"saveAborted": "No se ha podido guardar el archivo.",
"saveAbortedMessage": "No se ha podido guardar el archivo. El nombre del archivo \"{activeFile}\" no coincide con el que estas intentando guardar ({tryingToSave}).",
"syncFailed": "No se ha podido encontrar la ruta para \"{filename}\" después de 5 intentos - Se cerrará el editor para evitar la corrupción de datos.",
"fileSaved": "!Se guardo el archivo correctamente!",
"errorSavingFile": "Error al guardar el archivo: {error}"
},
"player": {
@@ -727,20 +727,19 @@
"emptyQueue": "La cola de reproducción está vacia..."
},
"fileSizeAnalyzer": {
"title": "Analizador de tamaño de archivos",
"title": "Analizador de archivos",
"searchLocation": "Buscar ubicación",
"selectPath": "Seleccione el lugar de búsqueda",
"largerThan": "Mayor que (MB)",
"selectPath": "Selecciona el lugar de búsqueda",
"largerThan": "Mayor que (en MB)",
"includeFolders": "Incluir carpetas",
"includeFoldersDescription": "Incluir directorios en el análisis",
"includeFoldersDescription": "Incluir carpetas en el análisis",
"totalFiles": "Archivos totales{suffix}",
"totalSize": "Tamaño total{suffix}",
"emptyState": "Introduzca los criterios de búsqueda y haga clic en Analizar para visualizar el tamaño de los archivos",
"completeResults": "Resultados completos",
"incompleteResults": "Los resultados pueden ser incompletos",
"incompleteResultsDetails": "Mostrando resultados máximos (100). Intenta restringir la búsqueda para obtener más resultados por debajo de 100.",
"emptyState": "Introduce los criterios de búsqueda y haz clic en \"Analizar\" para visualizar el tamaño de los archivos",
"completeResults": "Análisis completo",
"incompleteResults": "Los resultados podrían estar incompletos",
"relativePercentage": "Porcentaje relativo{suffix}",
"percentageOfResults": "{percentage}% de resultados"
"percentageOfResults": "{percentage}% de los resultados"
},
"fileTypes": {
"video": "Vídeo",
@@ -753,65 +752,66 @@
"other": "Otros"
},
"tools": {
"description": "Utilidades y herramientas de análisis para sus archivos",
"description": "Utilidades y herramientas que te seŕan de ayuda",
"sizeAnalyzer": {
"name": "Analizador de tamaño de archivos",
"description": "Visualice archivos y directorios de gran tamaño con un mapa de árbol interactivo"
"name": "Analizador de archivos",
"description": "Encuentra que archivos y carpetas están ocupando más espacio en tu disco."
},
"title": "Herramientas",
"duplicateFinder": {
"name": "Buscador de archivos duplicados",
"description": "Encuentre y gestione los archivos duplicados para recuperar espacio de almacenamiento"
"description": "Encuentra y gestiona archivos duplicados para recuperar espacio."
},
"materialIconPicker": {
"name": "Icono de selección de material",
"description": "Navega y busca Material Icons con vista previa en directo",
"name": "Material Icons",
"description": "¡Explora y descubre Iconos de la colección Material Icons!",
"searchPlaceholder": "Buscar iconos...",
"popularIcons": "Mostrar {count} iconos populares",
"showingResults": "Mostrando resultados de {count} ",
"noResults": "No se han encontrado iconos",
"tryDifferentSearch": "Pruebe con otro término de búsqueda",
"copiedToClipboard": "¡Copiado '{name}' al portapapeles!",
"copyFailed": "Error al copiar al portapapeles",
"custom": "A medida",
"popularIcons": "Mostrando {count} iconos populares",
"showingResults": "Se encontraron {count} iconos",
"noResults": "No se pudieorn encontrar iconos",
"tryDifferentSearch": "Prueba con otro nombre",
"copiedToClipboard": "¡Se copió '{name}' al portapapeles!",
"copyFailed": "Hubo un error al copiar al portapapeles",
"custom": "Personalizar",
"useCustomIcon": "Utilizar icono personalizado: {name}",
"iconLibraryUrl": "fonts.google.com/icons",
"browseFullLibrary": "Explorar la biblioteca completa de iconos"
"browseFullLibrary": "Mira la la colección completa aqui"
},
"toolNotFound": "Herramienta no encontrada",
"toolNotFound": "No se encontró la herramienta",
"backToTools": "Volver a Herramientas"
},
"messages": {
"pathNotFound": "La ruta no existe en el sistema de archivos",
"pathNotFoundMessage": "La ruta para esto ya no existe en el sistema de archivos. Puede que se haya movido o borrado.",
"pathNotFound": "La ruta no se encuentra en el sistema de archivos",
"pathNotFoundMessage": "La ruta ya no existe en el sistema de archivos. Quizá fue movida o borrada",
"reassignPath": "Reasignar ruta",
"pathReassigned": "¡Ruta actualizada correctamente!",
"pathReassignFailed": "No se ha podido actualizar la ruta. Inténtelo de nuevo."
"pathReassigned": "¡La ruta se actualizó correctamente!",
"pathReassignFailed": "No se ha podido actualizar la ruta. Inténtalo de nuevo",
"incompleteResultsDetails": "Mostrando resultados máximos ({max}). Intente restringir la búsqueda para obtener más resultados en {max}."
},
"duplicateFinder": {
"title": "Buscador de archivos duplicados",
"minSize": "Tamaño mínimo (MB){suffix}",
"minSizeHint": "Analizar sólo archivos mayores que este tamaño (en megabytes)",
"minSize": "Tamaño mínimo (en MB){suffix}",
"minSizeHint": "Solo analizar archivos que sean mayores que ese tamaño",
"useChecksums": "Comparar el contenido de los archivos",
"useChecksumsDescription": "Incluya también el contenido del archivo en la comparación. Sin esto, los archivos se comparan por tamaño y similitud de nombre.",
"useChecksumsDescription": "También incluir el contenido de los archivos en la comparación. Si no, los archivos se compararán solamente nombre y tmanaño",
"findDuplicates": "Buscar duplicados",
"groupsFound": "Grupos duplicados encontrados{suffix}",
"totalWastedSpace": "Espacio total desperdiciado{suffix}",
"groupsFound": "Grupos de duplicados{suffix}",
"totalWastedSpace": "Espacio desperdiciado{suffix}",
"wasted": "Desperdiciado{suffix}",
"emptyState": "Introduzca los criterios de búsqueda y haga clic en Buscar duplicados para identificar los archivos duplicados."
"emptyState": "Introduce los criterios de búsqueda y haz clic en \"Buscar duplicados\" para encontrar que archivos pueden estar gastando espacio."
},
"notifications": {
"title": "Notificaciones",
"description": "Ver todas las notificaciones de la sesión actual",
"empty": "Aún no hay notificaciones",
"description": "Aquí verás todas las notificaciones de tu sesión actual",
"empty": "Aún no tienes notificaciones",
"clearAll": "Borrar todo",
"clearConfirm": "¿Estás seguro de que quieres borrar todo el historial de notificaciones?",
"total": "{count} notificaciones"
},
"events": {
"serverShutdown": "El servidor se ha apagado.",
"authenticationFailed": "Error de autenticación. Actualice la página para volver a iniciar sesión.",
"reconnected": "Reconectado al servidor.",
"connectionLost": "Se ha perdido la conexión con el servidor. Intentando reconectar..."
"serverShutdown": "El servidor fúe apagado",
"authenticationFailed": "Hubo un error de autenticación - Actualiza la página y vuelve a iniciar sesión",
"reconnected": "!Se reconecto con el servidor!",
"connectionLost": "Se perdió la conexión con el servidor. Intentando reconectar..."
}
}
+4 -4
View File
@@ -273,7 +273,6 @@
"download": "Télécharger les fichiers"
},
"permissions-name": "Permissions",
"permissionsHelp": "Vous pouvez définir l'utilisateur comme étant un administrateur ou encore choisir les permissions individuellement. Si vous sélectionnez \"Administrateur\", toutes les autres options seront automatiquement activées. La gestion des utilisateurs est un privilège que seul l'administrateur possède.\n",
"profileSettings": "Paramètres du profil",
"ruleExample1": "bloque l'accès à tous les fichiers commençant par un point (comme par exemple .git, .gitignore) dans tous les dossiers",
"ruleExample2": "bloque l'accès au fichier nommé \"Caddyfile\" à la racine du dossier utilisateur",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "Proxy"
},
"newUserHintSubFolder": "par exemple \"/sous-dossier\" - vide par défaut"
"newUserHintSubFolder": "par exemple \"/sous-dossier\" - vide par défaut",
"permissionsHelp": "Les autorisations de l'utilisateur contrôlent l'accès au type d'actions que l'utilisateur peut effectuer. L'accès administrateur permet de mettre à jour et de modifier les paramètres de tout utilisateur ainsi que de nombreux paramètres du système."
},
"sidebar": {
"siteSettings": "Paramètres du site",
@@ -738,7 +738,6 @@
"emptyState": "Saisissez des critères de recherche et cliquez sur Analyser pour visualiser la taille des fichiers.",
"completeResults": "Résultats complets affichés !",
"incompleteResults": "Les résultats peuvent être incomplets",
"incompleteResultsDetails": "Résultats maximums (100). Essayez d'affiner votre recherche pour obtenir plus de résultats en dessous de 100.",
"relativePercentage": "Pourcentage relatif{suffix}",
"percentageOfResults": "{percentage}% de résultats"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "Le chemin d'accès n'existe plus dans le système de fichiers. Il peut avoir été déplacé ou supprimé.",
"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."
"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}."
},
"duplicateFinder": {
"title": "Recherche de fichiers en double",
+4 -4
View File
@@ -274,7 +274,6 @@
"download": "הורדת קבצים"
},
"permissions-name": "הרשאות",
"permissionsHelp": "אתה יכול להגדיר את המשתמש להיות מנהל מערכת או לבחור את ההרשאות בנפרד. אם תבחר \"מנהל מערכת\", כל ההרשאות ייבחרו אוטומטית. ניהול המשתמשים נשאר הרשאה של מנהל מערכת.\n",
"profileSettings": "הגדרות פרופיל",
"ruleExample1": "מנע גישה לקבצים נסתרים (כל קובץ שמתחיל בנקודה, לדוגמא .git)",
"ruleExample2": "חסימת גישה לקובץ בשם Caddyfile בתחום הראשי.",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "פרוקסי"
},
"newUserHintSubFolder": "לדוגמה, '/subfolder' - ריק עבור ברירת המחדל"
"newUserHintSubFolder": "לדוגמה, '/subfolder' - ריק עבור ברירת המחדל",
"permissionsHelp": "הרשאות המשתמש שולטות בגישה לסוגי הפעולות שהמשתמש יכול לבצע. גישה לניהול מעניקה שליטה מלאה לעדכון ולשינוי הגדרות המשתמש, כמו גם הגדרות רבות של המערכת."
},
"sidebar": {
"siteSettings": "הגדרות",
@@ -738,7 +738,6 @@
"emptyState": "הזן את קריטריוני החיפוש ולחץ על \"Analyze\" (נתח) כדי להציג את גודל הקבצים",
"completeResults": "תוצאות מלאות מוצגות!",
"incompleteResults": "התוצאות עשויות להיות חלקיות",
"incompleteResultsDetails": "הצגת תוצאות מקסימליות (100). נסה לצמצם את החיפוש כדי לקבל יותר מ-100 תוצאות.",
"relativePercentage": "אחוז יחסי{suffix}",
"percentageOfResults": "{percentage}אחוז התוצאות"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "הנתיב הזה כבר לא קיים במערכת הקבצים. ייתכן שהוא הועבר או נמחק.",
"reassignPath": "הקצאת נתיב מחדש",
"pathReassigned": "הנתיב עודכן בהצלחה!",
"pathReassignFailed": "לא ניתן לעדכן את הנתיב. אנא נסה שוב."
"pathReassignFailed": "לא ניתן לעדכן את הנתיב. אנא נסה שוב.",
"incompleteResultsDetails": "הצגת תוצאות מקסימליות ({max}). נסה לצמצם את החיפוש כדי לקבל תוצאות נוספות תחת {max}."
},
"duplicateFinder": {
"title": "מאתר קבצים כפולים",
+4 -4
View File
@@ -274,7 +274,6 @@
"download": "Fájlok letöltése"
},
"permissions-name": "Jogosultságok",
"permissionsHelp": "A felhasználót beállíthatja rendszergazdának, vagy egyénileg is kiválaszthatja a jogosultságokat. Ha a \"Rendszergazda\" lehetőséget választja, az összes többi opció automatikusan be lesz jelölve. A felhasználók kezelése továbbra is a rendszergazda kiváltsága marad.\n",
"profileSettings": "Profilbeállítások",
"ruleExample1": "megakadályozza a hozzáférést bármely rejtett fájlhoz (pl. .git, .gitignore) bármely mappában.\n",
"ruleExample2": "blokkolja a hozzáférést a Caddyfile nevű fájlhoz a hatókör gyökerében.",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "Proxy"
},
"newUserHintSubFolder": "pl. '/subfolder' - üres az alapértelmezéshez"
"newUserHintSubFolder": "pl. '/subfolder' - üres az alapértelmezéshez",
"permissionsHelp": "A felhasználói engedélyek szabályozzák, hogy a felhasználó milyen típusú műveleteket hajthat végre. A rendszergazdai hozzáférés teljes körű ellenőrzést biztosít bármely felhasználó beállításainak, valamint számos rendszerbeállításnak a frissítéséhez és módosításához."
},
"sidebar": {
"siteSettings": "Oldalbeállítások",
@@ -738,7 +738,6 @@
"emptyState": "Adja meg a keresési feltételeket, és kattintson az Elemzés gombra a fájlméretek megjelenítéséhez.",
"completeResults": "Teljes körű eredmények megjelenítése!",
"incompleteResults": "Az eredmények hiányosak lehetnek",
"incompleteResultsDetails": "Maximális eredmények megjelenítése (100). Próbálja szűkíteni a keresést, hogy több eredményt kapjon 100 alatt.",
"relativePercentage": "Relatív százalékos arány{suffix}",
"percentageOfResults": "{percentage}% az eredményekből"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "A fájlrendszerben már nem létezik az elérési útvonal. Lehet, hogy áthelyezték vagy törölték.",
"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."
"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 ."
},
"duplicateFinder": {
"title": "Duplikátum fájl kereső",
+4 -4
View File
@@ -272,7 +272,6 @@
"download": "Scarica i file"
},
"permissions-name": "Permessi",
"permissionsHelp": "È possibile impostare l'utente come amministratore o scegliere i permessi singolarmente. Se si seleziona \"Amministratore\", tutte le altre opzioni saranno automaticamente assegnate. La gestione degli utenti rimane un privilegio di un amministratore.\n",
"profileSettings": "Impostazioni del profilo",
"ruleExample1": "impedisci l'accesso a qualsiasi file avente come prefisso un punto\n (ad esempio .git, .gitignore) presente in ogni cartella.\n",
"ruleExample2": "blocca l'accesso al file denominato Caddyfile nella radice del campo di applicazione.",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "Proxy"
},
"newUserHintSubFolder": "es. '/sottocartella' - vuoto per l'impostazione predefinita"
"newUserHintSubFolder": "es. '/sottocartella' - vuoto per l'impostazione predefinita",
"permissionsHelp": "I permessi utente controllano l'accesso al tipo di azioni che l'utente può eseguire. L'accesso dell'amministratore consente di aggiornare e modificare le impostazioni di qualsiasi utente e molte impostazioni del sistema."
},
"sidebar": {
"siteSettings": "Impostazioni del sito",
@@ -738,7 +738,6 @@
"emptyState": "Immettere i criteri di ricerca e fare clic su Analizza per visualizzare le dimensioni dei file.",
"completeResults": "Mostra i risultati completi!",
"incompleteResults": "I risultati possono essere incompleti",
"incompleteResultsDetails": "Risultati massimi (100). Provi a restringere la ricerca per ottenere più risultati al di sotto di 100.",
"relativePercentage": "Percentuale relativa{suffix}",
"percentageOfResults": "{percentage}% dei risultati"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "Il percorso di questo file non esiste più nel filesystem. Potrebbe essere stato spostato o cancellato.",
"reassignPath": "Riassegnare il percorso",
"pathReassigned": "Il percorso è stato aggiornato con successo!",
"pathReassignFailed": "Non è stato possibile aggiornare il percorso. Riprovare."
"pathReassignFailed": "Non è stato possibile aggiornare il percorso. Riprovare.",
"incompleteResultsDetails": "Mostra i risultati massimi ({max}). Prova a restringere la ricerca per ottenere più risultati sotto {max}."
},
"duplicateFinder": {
"title": "Trova file duplicati",
+4 -4
View File
@@ -272,7 +272,6 @@
"download": "ファイルのダウンロード"
},
"permissions-name": "権限",
"permissionsHelp": "ユーザーを管理者に設定するか、権限を個別に設定できます。「管理者」を選択した場合、その他のすべての選択肢は自動的に設定されます。ユーザーの管理は管理者の権限として保持されます。",
"profileSettings": "プロファイル設定",
"ruleExample1": "各フォルダ内で名前がドットで始まるファイル(例:.git、.gitignore)へのアクセスを制限します。",
"ruleExample2": "スコープのルートパスで名前がCaddyfileのファイルへのアクセスを制限します。",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "プロキシ"
},
"newUserHintSubFolder": "例 '/subfolder' - デフォルトは空白"
"newUserHintSubFolder": "例 '/subfolder' - デフォルトは空白",
"permissionsHelp": "ユーザー権限は、ユーザーが実行できるアクションの種類へのアクセスを制御します。管理者アクセスは、多くのシステム設定だけでなく、任意のユーザーの設定を更新および変更するための完全なコントロールを提供します。"
},
"sidebar": {
"siteSettings": "サイト設定",
@@ -738,7 +738,6 @@
"emptyState": "検索条件を入力し、「分析」をクリックするとファイルサイズが可視化されます。",
"completeResults": "完全な結果を示す!",
"incompleteResults": "結果は不完全かもしれない",
"incompleteResultsDetails": "最大結果(100)を表示しています。検索結果を100件以下に絞り込んでください。",
"relativePercentage": "相対パーセンテージ{suffix}",
"percentageOfResults": "{percentage}結果の割合"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "このパスはファイルシステム上に存在しません。移動または削除された可能性があります。",
"reassignPath": "パスの再割り当て",
"pathReassigned": "パスは正常に更新されました!",
"pathReassignFailed": "パスの更新に失敗しました。再試行してください。"
"pathReassignFailed": "パスの更新に失敗しました。再試行してください。",
"incompleteResultsDetails": "最大結果を表示 ({max}).{max} でより多くの結果を得るために絞り込んで検索してみてください。"
},
"duplicateFinder": {
"title": "重複ファイルファインダー",
+4 -4
View File
@@ -272,7 +272,6 @@
"download": "파일 다운로드"
},
"permissions-name": "권한",
"permissionsHelp": "사용자를 관리자로 만들거나 권한을 부여할 수 있습니다. 관리자를 선택하면, 모든 옵션이 자동으로 선택됩니다. 사용자 관리는 현재 관리자만 할 수 있습니다.\n",
"profileSettings": "프로필 설정",
"ruleExample1": "점(.)으로 시작하는 모든 파일의 접근을 방지합니다.(예 .git, .gitignore)\n",
"ruleExample2": "Caddyfile파일의 접근을 방지합니다.",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "프록시"
},
"newUserHintSubFolder": "예: '/subfolder' - 기본값은 비어 있습니다."
"newUserHintSubFolder": "예: '/subfolder' - 기본값은 비어 있습니다.",
"permissionsHelp": "사용자 권한은 사용자가 수행할 수 있는 작업 유형에 대한 액세스를 제어합니다. 관리자 권한은 모든 사용자 설정은 물론 여러 시스템 설정을 업데이트하고 변경할 수 있는 모든 권한을 부여합니다."
},
"sidebar": {
"siteSettings": "사이트 설정",
@@ -738,7 +738,6 @@
"emptyState": "검색 기준을 입력하고 분석을 클릭하여 파일 크기를 시각화합니다.",
"completeResults": "전체 결과가 표시됩니다!",
"incompleteResults": "결과가 불완전할 수 있습니다.",
"incompleteResultsDetails": "최대 결과 표시(100개). 검색 범위를 좁히면 100개 미만의 결과를 더 많이 볼 수 있습니다.",
"relativePercentage": "상대 백분율{suffix}",
"percentageOfResults": "{percentage}결과의 %"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "이 경로가 더 이상 파일 시스템에 존재하지 않습니다. 이동되었거나 삭제되었을 수 있습니다.",
"reassignPath": "경로 재할당",
"pathReassigned": "경로가 성공적으로 업데이트되었습니다!",
"pathReassignFailed": "경로를 업데이트하지 못했습니다. 다시 시도하세요."
"pathReassignFailed": "경로를 업데이트하지 못했습니다. 다시 시도하세요.",
"incompleteResultsDetails": "최대 결과 표시 ({max}). 검색 범위를 좁히면 {max} 에서 더 많은 결과를 확인할 수 있습니다."
},
"duplicateFinder": {
"title": "중복 파일 찾기",
+4 -4
View File
@@ -272,7 +272,6 @@
"download": "Bestanden downloaden"
},
"permissions-name": "Permissies",
"permissionsHelp": "U kunt de gebruiker instellen als beheerder of de machtigingen afzonderlijk kiezen. Als u \"Beheerder\" selecteert, worden alle andere opties automatisch gecontroleerd. Het beheer van gebruikers blijft een privilege van een beheerder.\n",
"profileSettings": "Profielinstellingen",
"ruleExample1": "voorkomt de toegang tot elk puntbestand (zoals .git, .gitignore) in elke map.\n",
"ruleExample2": "blokkeert de toegang tot het bestand met de naam Caddyfile in de hoofdmap van het bereik.",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "Proxy"
},
"newUserHintSubFolder": "bijv. '/submap' - leeg voor standaard"
"newUserHintSubFolder": "bijv. '/submap' - leeg voor standaard",
"permissionsHelp": "Gebruikersrechten bepalen welke acties de gebruiker kan uitvoeren. Administrar toegang geeft volledige controle om de instellingen van elke gebruiker bij te werken en te wijzigen, evenals vele systeeminstellingen."
},
"sidebar": {
"siteSettings": "Site-instellingen",
@@ -738,7 +738,6 @@
"emptyState": "Voer zoekcriteria in en klik op Analyseren om bestandsgroottes te visualiseren",
"completeResults": "Volledige resultaten getoond!",
"incompleteResults": "Resultaten kunnen onvolledig zijn",
"incompleteResultsDetails": "Max. resultaten (100). Probeer je zoekopdracht te verfijnen om meer resultaten onder de 100 te krijgen.",
"relativePercentage": "Relatief percentage{suffix}",
"percentageOfResults": "{percentage}% resultaten"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "Het pad hiervoor bestaat niet meer op het bestandssysteem. Het kan verplaatst of verwijderd zijn.",
"reassignPath": "Pad opnieuw toewijzen",
"pathReassigned": "Pad succesvol bijgewerkt!",
"pathReassignFailed": "Het bijwerken van het pad is mislukt. Probeer het opnieuw."
"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}."
},
"duplicateFinder": {
"title": "Duplicaat bestand zoeken",
+4 -4
View File
@@ -272,7 +272,6 @@
"download": "Pobieranie plików"
},
"permissions-name": "Uprawnienia",
"permissionsHelp": "Możesz uczynić użytkownika administratorem, lub wybrać uprawnienia indywidualnie. Jeśli zaznaczysz opcję \"Administrator\", wszystkie pozostałe opcje zostaną automatycznie zaznaczone. Zarządzanie użytkownikami pozostaje przywilejem administratora.\n",
"profileSettings": "Twój profil",
"ruleExample1": "uniemożliwia dostęp do któregokolwiek z ukrytych plików (takich jak .git, .gitignore) w każdym folderze.\n",
"ruleExample2": "blokuje dostęp do pliku Caddyfile w głównym katalogu zakresu.",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "Pełnomocnik"
},
"newUserHintSubFolder": "np. '/subfolder' - domyślnie puste"
"newUserHintSubFolder": "np. '/subfolder' - domyślnie puste",
"permissionsHelp": "Uprawnienia użytkownika kontrolują dostęp do rodzaju działań, które użytkownik może wykonywać. Dostęp administratora daje pełną kontrolę nad aktualizacją i zmianą ustawień dowolnego użytkownika, a także wielu ustawień systemowych."
},
"sidebar": {
"siteSettings": "Ustawienia Strony",
@@ -738,7 +738,6 @@
"emptyState": "Wprowadź kryteria wyszukiwania i kliknij przycisk Analizuj, aby wyświetlić rozmiary plików.",
"completeResults": "Wyświetlono pełne wyniki!",
"incompleteResults": "Wyniki mogą być niekompletne",
"incompleteResultsDetails": "Wyświetlanie maksymalnych wyników (100). Spróbuj zawęzić wyszukiwanie, aby uzyskać więcej wyników poniżej 100.",
"relativePercentage": "Procent względny{suffix}",
"percentageOfResults": "{percentage}% wyników"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "Ścieżka do tego pliku nie istnieje już w systemie plików. Mogła zostać przeniesiona lub usunięta.",
"reassignPath": "Ponowne przypisanie ścieżki",
"pathReassigned": "Ścieżka została pomyślnie zaktualizowana!",
"pathReassignFailed": "Nie udało się zaktualizować ścieżki. Spróbuj ponownie."
"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}."
},
"duplicateFinder": {
"title": "Wyszukiwarka duplikatów plików",
+4 -4
View File
@@ -275,7 +275,6 @@
"download": "Baixar arquivos"
},
"permissions-name": "Permissões",
"permissionsHelp": "Pode definir o usuário como administrador ou escolher as permissões manualmente. Se selecionar a opção \"Administrador\", todas as outras opções serão automaticamente selecionadas. A gestão dos usuários é um privilégio restringido aos administradores.\n",
"profileSettings": "Configurações do usuário",
"ruleExample1": "previne o acesso a qualquer \"dotfile\" (como .git, .gitignore) em qualquer pasta\n",
"ruleExample2": "bloqueia o acesso ao arquivo chamado Caddyfile.",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "Proxy"
},
"newUserHintSubFolder": "Por exemplo, '/subfolder' - em branco para o padrão"
"newUserHintSubFolder": "Por exemplo, '/subfolder' - em branco para o padrão",
"permissionsHelp": "As permissões de usuário controlam o acesso aos tipos de ações que o usuário pode executar. O acesso de administrador dá controle total para atualizar e alterar as configurações de qualquer usuário, bem como muitas configurações do sistema."
},
"sidebar": {
"siteSettings": "Configurações do site",
@@ -738,7 +738,6 @@
"emptyState": "Insira os critérios de pesquisa e clique em Analyze para visualizar os tamanhos dos arquivos",
"completeResults": "Resultados completos mostrados!",
"incompleteResults": "Os resultados podem estar incompletos",
"incompleteResultsDetails": "Mostrando resultados máximos (100). Tente restringir sua pesquisa para obter mais resultados abaixo de 100.",
"relativePercentage": "Porcentagem relativa{suffix}",
"percentageOfResults": "{percentage}% de resultados"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "O caminho para isso não existe mais no sistema de arquivos. Ele pode ter sido movido ou excluído.",
"reassignPath": "Reatribuir caminho",
"pathReassigned": "Caminho atualizado com sucesso!",
"pathReassignFailed": "Falha ao atualizar o caminho. Tente novamente."
"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}."
},
"duplicateFinder": {
"title": "Localizador de arquivos duplicados",
+4 -4
View File
@@ -272,7 +272,6 @@
"download": "Descarregar ficheiros"
},
"permissions-name": "Permissões",
"permissionsHelp": "Pode definir o utilizador como administrador ou escolher as permissões manualmente. Se selecionar a opção \"Administrador\", todas as outras opções serão automaticamente selecionadas. A gestão dos utilizadores é um privilégio restringido aos administradores.\n",
"profileSettings": "Configurações do utilizador",
"ruleExample1": "previne o acesso a qualquer \"dotfile\" (como .git, .gitignore) em qualquer pasta\n",
"ruleExample2": "bloqueia o acesso ao ficheiro chamado Caddyfile na raiz.",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "Proxy"
},
"newUserHintSubFolder": "por exemplo, '/subpasta' - em branco por defeito"
"newUserHintSubFolder": "por exemplo, '/subpasta' - em branco por defeito",
"permissionsHelp": "As permissões de utilizador controlam o acesso ao tipo de acções que o utilizador pode executar. O acesso de administrador dá controlo total para atualizar e alterar as definições de qualquer utilizador, bem como muitas definições do sistema."
},
"sidebar": {
"siteSettings": "Configurações do site",
@@ -738,7 +738,6 @@
"emptyState": "Introduza os critérios de pesquisa e clique em Analisar para visualizar os tamanhos dos ficheiros",
"completeResults": "Resultados completos apresentados!",
"incompleteResults": "Os resultados podem ser incompletos",
"incompleteResultsDetails": "Mostrar resultados máximos (100). Tente restringir a sua pesquisa para obter mais resultados abaixo de 100.",
"relativePercentage": "Percentagem relativa{suffix}",
"percentageOfResults": "{percentage}% de resultados"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "O caminho para isto já não existe no sistema de ficheiros. Pode ter sido movido ou eliminado.",
"reassignPath": "Reatribuir trajetória",
"pathReassigned": "Caminho atualizado com sucesso!",
"pathReassignFailed": "Falha ao atualizar o caminho. Por favor, tente novamente."
"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}."
},
"duplicateFinder": {
"title": "Localizador de ficheiros duplicados",
+4 -4
View File
@@ -272,7 +272,6 @@
"download": "Descărcați fișiere"
},
"permissions-name": "Permisiuni",
"permissionsHelp": "Poți alege ca un utilizator să fie administrator sau să-i alegi permisiunile individual. Dacă alegi \"Administrator\", toate celelalte opțiuni vor fi bifate automat. Gestionarea utilizatorilor rămâne un privilegiu exclusiv al administratorilor.\n",
"profileSettings": "Setări profil",
"ruleExample1": "împiedică accesul la fisiere cu punct in față (.), cum ar fi .git, .gitignore în orice director.\n",
"ruleExample2": "împiedică accesul la fișierul Caddyfile din rădăcina domeniului.",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "Proxy"
},
"newUserHintSubFolder": "de exemplu \"/subfolder\" - gol pentru implicit"
"newUserHintSubFolder": "de exemplu \"/subfolder\" - gol pentru implicit",
"permissionsHelp": "Permisiunile utilizatorului controlează accesul la ce tip de acțiuni poate efectua utilizatorul. Accesul de administrator oferă control deplin pentru actualizarea și modificarea setărilor oricărui utilizator, precum și a multor setări de sistem."
},
"sidebar": {
"siteSettings": "Setări site",
@@ -738,7 +738,6 @@
"emptyState": "Introduceți criteriile de căutare și faceți clic pe Analiză pentru a vizualiza dimensiunile fișierelor",
"completeResults": "Rezultatele complete sunt afișate!",
"incompleteResults": "Rezultatele pot fi incomplete",
"incompleteResultsDetails": "Afișarea rezultatelor maxime (100). Încercați să restrângeți căutarea pentru a obține mai multe rezultate sub 100.",
"relativePercentage": "Procentaj relativ{suffix}",
"percentageOfResults": "{percentage}% din rezultate"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "Calea pentru acest lucru nu mai există pe sistemul de fișiere. Este posibil să fi fost mutat sau șters.",
"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."
"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}."
},
"duplicateFinder": {
"title": "Duplicate fișier Finder",
+4 -4
View File
@@ -272,7 +272,6 @@
"download": "Скачать файлы"
},
"permissions-name": "Права доступа",
"permissionsHelp": "Можно настроить пользователя как администратора или выбрать разрешения индивидуально. При выборе \"Администратор\", все остальные параметры будут автоматически выбраны. Управление пользователями - привилегия администратора.\n",
"profileSettings": "Настройки профиля",
"ruleExample1": "Запретить доступ к любому скрытому файлу (например: .git, .gitignore) в каждой папке.\n",
"ruleExample2": "блокирует доступ к файлу с именем Caddyfile в корневой области.",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "Прокси-сервер"
},
"newUserHintSubFolder": "например, '/subfolder' - пустое место по умолчанию"
"newUserHintSubFolder": "например, '/subfolder' - пустое место по умолчанию",
"permissionsHelp": "Разрешения пользователя контролируют, какие действия он может выполнять. Доступ администратора дает полный контроль над обновлением и изменением настроек любого пользователя, а также многих системных настроек."
},
"sidebar": {
"siteSettings": "Настройки сайта",
@@ -738,7 +738,6 @@
"emptyState": "Введите критерии поиска и нажмите \"Анализ\", чтобы визуализировать размеры файлов.",
"completeResults": "Показаны полные результаты!",
"incompleteResults": "Результаты могут быть неполными",
"incompleteResultsDetails": "Показано максимальное количество результатов (100). Попробуйте сузить поиск, чтобы получить больше результатов ниже 100.",
"relativePercentage": "Относительный процент{suffix}",
"percentageOfResults": "{percentage}% от результатов"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "Путь для этого больше не существует в файловой системе. Возможно, он был перемещен или удален.",
"reassignPath": "Переназначить путь",
"pathReassigned": "Путь успешно обновлен!",
"pathReassignFailed": "Не удалось обновить путь. Пожалуйста, попробуйте еще раз."
"pathReassignFailed": "Не удалось обновить путь. Пожалуйста, попробуйте еще раз.",
"incompleteResultsDetails": "Показано максимум результатов ({max}). Попробуйте сузить поиск, чтобы получить больше результатов по запросу {max}."
},
"duplicateFinder": {
"title": "Поиск дубликатов файлов",
+4 -4
View File
@@ -272,7 +272,6 @@
"download": "Stiahnite si súbory"
},
"permissions-name": "Práva",
"permissionsHelp": "Môžete nastaviť používateľa, aby bol administrátorom alebo vybrať práva jednotlivo. Ak zvolíte \"Administrator\", všetky ďalšie budú automaticky zaškrtnuté. Manažment používateľov ostáva v správe administrátora.\n",
"profileSettings": "Nastavenia profilu",
"ruleExample1": "blokuje prístup ku všetkým súborom začínajúcim bodkou (napríklad .git, .gitignore) v každom priečinku.\n",
"ruleExample2": "blokuje prístup k súborom s názvom Caddyfile v koreňovom priečinku.",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "Proxy"
},
"newUserHintSubFolder": "napr. '/subfolder' - prázdne pre predvolené nastavenie"
"newUserHintSubFolder": "napr. '/subfolder' - prázdne pre predvolené nastavenie",
"permissionsHelp": "Oprávnenia používateľa riadia prístup k typom akcií, ktoré môže používateľ vykonávať. Prístup administrátora poskytuje úplnú kontrolu nad aktualizáciou a zmenou nastavení ktoréhokoľvek používateľa, ako aj nad mnohými systémovými nastaveniami."
},
"sidebar": {
"siteSettings": "Nastavenia stránky",
@@ -738,7 +738,6 @@
"emptyState": "Zadajte kritériá vyhľadávania a kliknutím na tlačidlo Analyzovať zobrazte veľkosti súborov",
"completeResults": "Zobrazené kompletné výsledky!",
"incompleteResults": "Výsledky môžu byť neúplné",
"incompleteResultsDetails": "Zobrazené maximálne výsledky (100). Skúste zúžiť vyhľadávanie, aby ste získali viac výsledkov pod 100.",
"relativePercentage": "Relatívne percento{suffix}",
"percentageOfResults": "{percentage}% výsledkov"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "Cesta k nemu už v súborovom systéme neexistuje. Možno bola presunutá alebo odstránená.",
"reassignPath": "Opätovné priradenie cesty",
"pathReassigned": "Cesta bola úspešne aktualizovaná!",
"pathReassignFailed": "Nepodarilo sa aktualizovať cestu. Skúste to prosím znova."
"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}."
},
"duplicateFinder": {
"title": "Vyhľadávač duplicitných súborov",
+4 -4
View File
@@ -272,7 +272,6 @@
"download": "Ladda ner filer"
},
"permissions-name": "Rättigheter",
"permissionsHelp": "Du kan ange att användaren ska vara administratör eller välja behörigheterna individuellt. Om du väljer \"administratör \" kommer alla andra alternativ att kontrolleras automatiskt. Hanteringen av användare är fortfarande ett privilegium för en administratör.\n",
"profileSettings": "Profil inställningar",
"ruleExample1": "förhindrar åtkomst till en dot-fil (till exempel. git,. gitignore) i varje mapp.\n",
"ruleExample2": "blockerar åtkomsten till filen som heter Caddyfilen i roten av scopet.",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "Proxy"
},
"newUserHintSubFolder": "t.ex. \"/undermapp\" - tom för standard"
"newUserHintSubFolder": "t.ex. \"/undermapp\" - tom för standard",
"permissionsHelp": "Användarbehörigheter styr vilka typer av åtgärder som användaren kan utföra. Administratörsbehörighet ger full kontroll över uppdatering och ändring av alla användarinställningar samt många systeminställningar."
},
"sidebar": {
"siteSettings": "System inställningar",
@@ -738,7 +738,6 @@
"emptyState": "Ange sökkriterier och klicka på Analyze för att visualisera filstorlekar",
"completeResults": "Fullständiga resultat visas!",
"incompleteResults": "Resultaten kan vara ofullständiga",
"incompleteResultsDetails": "Visar max resultat (100). Försök att begränsa din sökning för att få fler resultat under 100.",
"relativePercentage": "Relativ procentandel{suffix}",
"percentageOfResults": "{percentage}% av resultat"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "Sökvägen för detta finns inte längre på filsystemet. Den kan ha flyttats eller tagits bort.",
"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."
"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}."
},
"duplicateFinder": {
"title": "Hitta duplicerade filer",
+4 -4
View File
@@ -272,7 +272,6 @@
"download": "Dosyaları indirin"
},
"permissions-name": "İzinler",
"permissionsHelp": "Kullanıcıyı yönetici olarak ayarlayabilir veya izinleri ayrı ayrı seçebilirsiniz. \"Yönetici\"yi seçerseniz, diğer tüm seçenekler otomatik olarak kontrol edilecektir. Kullanıcıların yönetimi, bir yöneticinin yetkisi olarak kalır.\n",
"profileSettings": "Profil ayarları",
"ruleExample1": "her klasördeki herhangi bir noktalı dosyaya (.git, .gitignore gibi) erişimi engeller.\n",
"ruleExample2": "Root erişimidenki CaddyFile dosyalarına erişimi engelle.",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "Proxy"
},
"newUserHintSubFolder": "örneğin '/subfolder' - varsayılan için boş"
"newUserHintSubFolder": "örneğin '/subfolder' - varsayılan için boş",
"permissionsHelp": "Kullanıcı izinleri, kullanıcının ne tür eylemler gerçekleştirebileceğine erişimi kontrol eder. Yönetici erişimi, herhangi bir kullanıcının ayarlarının yanı sıra birçok sistem ayarını güncellemek ve değiştirmek için tam kontrol sağlar."
},
"sidebar": {
"siteSettings": "Site ayarları!",
@@ -738,7 +738,6 @@
"emptyState": "Arama kriterlerini girin ve dosya boyutlarını görselleştirmek için Analiz Et'e tıklayın",
"completeResults": "Tam sonuçlar gösterilmektedir!",
"incompleteResults": "Sonuçlar eksik olabilir",
"incompleteResultsDetails": "Maksimum sonuç gösteriliyor (100). 100'ün altında daha fazla sonuç almak için aramanızı daraltmayı deneyin.",
"relativePercentage": "Göreceli Yüzde{suffix}",
"percentageOfResults": "{percentage}Sonuçların %'si"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "Bunun yolu artık dosya sisteminde mevcut değil. Taşınmış veya silinmiş olabilir.",
"reassignPath": "Yolu Yeniden Atayın",
"pathReassigned": "Yol başarıyla güncellendi!",
"pathReassignFailed": "Yol güncellenemedi. Lütfen tekrar deneyin."
"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."
},
"duplicateFinder": {
"title": "Yinelenen Dosya Bulucu",
+4 -4
View File
@@ -272,7 +272,6 @@
"download": "Завантажити файли"
},
"permissions-name": "Дозволи",
"permissionsHelp": "Можна настроїти користувача як адміністратора або вибрати індивідуальні дозволи. При виборі \"Адміністратор\" всі інші параметри будуть автоматично вибрані. Керування користувачами - привілей адміністратора.\n",
"profileSettings": "Налаштування профілю",
"ruleExample1": "запобігти доступу до будь-якого прихованого файлу (наприклад: .git, .gitignore) у кожній папці.\n",
"ruleExample2": "блокує доступ до файлу з ім'ям Caddyfile у кореневій області.",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "Довірена особа"
},
"newUserHintSubFolder": "наприклад, '/subfolder' - порожній за замовчуванням"
"newUserHintSubFolder": "наприклад, '/subfolder' - порожній за замовчуванням",
"permissionsHelp": "Дозволи користувача контролюють доступ до того, які дії може виконувати користувач. Доступ адміністратора дає повний контроль над оновленням і зміною будь-яких налаштувань користувача, а також багатьох системних налаштувань."
},
"sidebar": {
"siteSettings": "Налаштування сайту",
@@ -738,7 +738,6 @@
"emptyState": "Введіть критерії пошуку та натисніть \"Проаналізувати\", щоб візуалізувати розміри файлів",
"completeResults": "Показано повні результати!",
"incompleteResults": "Результати можуть бути неповними",
"incompleteResultsDetails": "Показано максимум результатів (100). Спробуйте звузити пошук, щоб отримати більше результатів до 100.",
"relativePercentage": "Відносний відсоток{suffix}",
"percentageOfResults": "{percentage}% результатів"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "Шлях до нього більше не існує у файловій системі. Можливо, його було переміщено або видалено.",
"reassignPath": "Перепризначити шлях",
"pathReassigned": "Шлях успішно оновлено!",
"pathReassignFailed": "Не вдалося оновити шлях. Спробуйте ще раз."
"pathReassignFailed": "Не вдалося оновити шлях. Спробуйте ще раз.",
"incompleteResultsDetails": "Показано максимум результатів ({max}). Спробуйте звузити пошук, щоб отримати більше результатів за темою {max}."
},
"duplicateFinder": {
"title": "Пошук дублікатів файлів",
+5 -5
View File
@@ -273,7 +273,6 @@
"download": "下载文件"
},
"permissions-name": "权限",
"permissionsHelp": "您可以将该用户设置为管理员或单独选择各项权限。如果您选择了“管理员”,则其他的选项会被自动选中,同时该用户可以管理其他用户。\n",
"profileSettings": "个人设置",
"ruleExample1": "阻止用户访问所有文件夹下任何以 . 开头的文件(隐藏文件, 例如: .git, .gitignore)。\n",
"ruleExample2": "阻止用户访问其目录范围的根目录下名为 Caddyfile 的文件。",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "代理"
},
"newUserHintSubFolder": "例如\"/子文件夹\"--默认为空"
"newUserHintSubFolder": "例如\"/子文件夹\"--默认为空",
"permissionsHelp": "用户权限控制用户可以执行哪些类型的操作。管理员权限可完全控制更新和更改任何用户的设置以及许多系统设置。"
},
"sidebar": {
"siteSettings": "网站设置",
@@ -660,7 +660,7 @@
"publish": "出版{suffix}",
"rename": "重新命名{suffix}",
"replace": "更换{suffix}",
"save": "节省{suffix}",
"save": "保存{suffix}",
"schedule": "时间安排{suffix}",
"select": "选择{suffix}",
"share": "分享{suffix}",
@@ -738,7 +738,6 @@
"emptyState": "输入搜索条件并点击分析,即可直观查看文件大小",
"completeResults": "显示完整结果!",
"incompleteResults": "结果可能不完整",
"incompleteResultsDetails": "显示最多结果 (100)。请尝试缩小搜索范围,以获得更多低于 100 的结果。",
"relativePercentage": "相对百分比{suffix}",
"percentageOfResults": "{percentage}结果百分比"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "文件系统中已不存在此路径。可能已被移动或删除。",
"reassignPath": "重新分配路径",
"pathReassigned": "路径更新成功!",
"pathReassignFailed": "更新路径失败。请重试。"
"pathReassignFailed": "更新路径失败。请重试。",
"incompleteResultsDetails": "显示最大结果 ({max})。请尝试缩小搜索范围,以获得{max} 下的更多结果。"
},
"duplicateFinder": {
"title": "重复文件查找器",
+4 -4
View File
@@ -272,7 +272,6 @@
"download": "下載檔案"
},
"permissions-name": "權限",
"permissionsHelp": "您可以將該使用者設置為管理員,也可以單獨選擇各項權限。如果選擇了「管理員」,則其他的選項會被自動勾上,同時該使用者可以管理其他使用者。",
"profileSettings": "個人設定",
"ruleExample1": "封鎖使用者存取所有資料夾下任何以 . 開頭的檔案(隱藏文件, 例如: .git, .gitignore)。",
"ruleExample2": "封鎖使用者存取其目錄範圍的根目錄下名為 Caddyfile 的檔案。",
@@ -323,7 +322,8 @@
"oidc": "OIDC",
"proxy": "代理"
},
"newUserHintSubFolder": "例如 '/subfolder' - 預設為空白"
"newUserHintSubFolder": "例如 '/subfolder' - 預設為空白",
"permissionsHelp": "使用者權限可控制使用者可執行的動作類型。管理員存取權可完全控制更新和變更任何使用者的設定以及許多系統設定。"
},
"sidebar": {
"siteSettings": "網站設定",
@@ -738,7 +738,6 @@
"emptyState": "輸入搜尋準則,然後按一下「分析」以顯示檔案大小",
"completeResults": "顯示完整結果!",
"incompleteResults": "結果可能不完整",
"incompleteResultsDetails": "顯示最大結果 (100)。請嘗試縮小搜尋範圍,以獲得更多低於 100 的結果。",
"relativePercentage": "相對百分比{suffix}",
"percentageOfResults": "{percentage}結果百分比"
},
@@ -786,7 +785,8 @@
"pathNotFoundMessage": "檔案系統中已不存在此路徑。它可能已被移動或刪除。",
"reassignPath": "重新指定路徑",
"pathReassigned": "路徑已成功更新!",
"pathReassignFailed": "更新路徑失敗。請再試一次。"
"pathReassignFailed": "更新路徑失敗。請再試一次。",
"incompleteResultsDetails": "顯示最大結果 ({max})。請嘗試縮小搜尋範圍,以獲得更多{max} 下的結果。"
},
"duplicateFinder": {
"title": "重複檔案搜尋器",
+10 -4
View File
@@ -10,7 +10,7 @@ let isManuallyClosed = false
let authenticationFailed = false
let hasShownShutdownMessage = false
async function updateSourceInfo () {
async function updateSourceInfo() {
try {
const sourceinfo = await filesApi.sources()
mutations.updateSourceInfo(sourceinfo)
@@ -19,7 +19,7 @@ async function updateSourceInfo () {
}
}
function cleanup () {
function cleanup() {
if (eventSrc) {
isManuallyClosed = true
eventSrc.close()
@@ -27,7 +27,7 @@ function cleanup () {
}
}
function scheduleReconnect () {
function scheduleReconnect() {
// Don't reconnect if authentication has failed
if (authenticationFailed) {
console.log('🚫 Not reconnecting due to authentication failure')
@@ -172,7 +172,13 @@ async function eventRouter (eventType, message) {
break
case 'sourceUpdate':
mutations.updateSourceInfo(message)
// Parse the JSON string before passing to updateSourceInfo
try {
const parsedMessage = JSON.parse(message)
mutations.updateSourceInfo(parsedMessage)
} catch (err) {
console.error('Error parsing sourceUpdate message:', err, message)
}
break
case 'acknowledge':
+2 -3
View File
@@ -421,9 +421,8 @@ export const getters = {
},
officeViewingDisabled: filename => {
const ext = ' ' + getFileExtension(filename)
const hasDisabled = state.shareInfo?.disableOfficePreviewExt || state.user.disableOfficePreviewExt
if (hasDisabled) {
const disabledList = state.shareInfo?.disableOfficePreviewExt + ' ' + state.user.disableOfficePreviewExt
const disabledList = state.user.disableOnlyOfficeExt || ''
if (disabledList !== '') {
const disabledExts = ' ' + disabledList.toLowerCase()
if (disabledExts.includes(ext.toLowerCase())) {
return true
+3 -2
View File
@@ -44,9 +44,10 @@ export default function downloadFiles(items) {
async function startDownload(config, files, hash = "") {
try {
filesApi.download(config, files, hash);
await filesApi.download(config, files, hash);
notify.showSuccessToast("Downloading...");
} catch (e) {
notify.showError(`Error downloading: ${e}`);
console.error("Download failed:", e);
notify.showError(`Error downloading: ${e.message || e}`);
}
}
+11 -1
View File
@@ -7,7 +7,7 @@ export function getFileExtension(filename) {
// If no dot exists, return an empty string
if (firstDotIndex === -1) {
return ''
return ""
}
// Default: Get everything after the first dot
@@ -25,3 +25,13 @@ export function getFileExtension(filename) {
const lastDotIndex = filename.lastIndexOf('.')
return filename.substring(lastDotIndex)
}
export function removePrefix(filename, prefix = "") {
if (filename === undefined) {
return ""
}
if (filename.startsWith(prefix)) {
filename = filename.slice(prefix.length);
}
return filename;
}
-27
View File
@@ -48,7 +48,6 @@ export default {
removePrefix,
getApiPath,
extractSourceFromPath,
fixDownloadURL,
base64Encode,
joinPath,
goToItem,
@@ -193,29 +192,3 @@ export function goToItem(source, path, previousHistoryItem) {
export function doubleEncode(str) {
return encodeURIComponent(encodeURIComponent(str));
}
/**
* Fixes download URLs by replacing everything before /public/api
* with the current window.location.origin + globalVars.baseURL
* This is only needed when the backend didn't use externalUrl
* @param {string} downloadUrl - The original download URL from backend
* @returns {string} - The corrected URL using current client origin
*/
export function fixDownloadURL(downloadUrl) {
if (!downloadUrl) {
return downloadUrl;
}
// Find the position of /public/api in the URL
const publicApiIndex = downloadUrl.indexOf('/public/api');
if (publicApiIndex === -1) {
// If /public/api is not found, return the original URL
return downloadUrl;
}
// Extract the part from /public/api onwards
const publicApiPath = downloadUrl.substring(publicApiIndex);
// Build the corrected URL using current client origin and globalVars.baseURL
const correctedBaseURL = removeTrailingSlash(globalVars.baseURL);
return `${window.location.origin}${correctedBaseURL}${publicApiPath}`;
}
+1 -1
View File
@@ -308,7 +308,7 @@ export default {
showLineNumbers: true,
theme: this.isDarkMode ? "ace/theme/tomorrow_night_bright" : "ace/theme/github",
readOnly: this.editorReadOnly,
wrap: false,
wrap: state?.wrapEditor || false,
enableMobileMenu: !this.viewerMode,
useWorker: false,
scrollPastEnd: 0.5,
+2 -1
View File
@@ -471,7 +471,7 @@ export default {
styles['--list-icon-image-size'] = `${iconImageSize}em`;
} else {
// Normal view
styles['--item-width'] = `calc(${(100 / this.numColumns).toFixed(2)}% - 1em)`;
styles['--item-width'] = `calc(${(100 / this.numColumns)}% - 1em)`;
styles['--item-height'] = 'auto';
}
@@ -956,6 +956,7 @@ export default {
console.error("Error moving/copying items:", error);
} finally {
mutations.setLoading("listing", false);
mutations.setReload(true);
}
};
+1
View File
@@ -307,6 +307,7 @@ export default {
subs.push({
name: subtitleTrack.name,
src: vttURL,
language: subtitleTrack.language || ''
});
} else {
console.warn(
+46 -69
View File
@@ -22,7 +22,7 @@
</div>
<!-- Metadata info -->
<div class="notification-info" v-if="metadata">
<div class="audio-metadata" v-if="metadata">
<div class="audio-title">
{{ metadata.title || req.name }}
</div>
@@ -46,11 +46,11 @@
</div>
<!-- Video with plyr -->
<div v-else-if="previewType == 'video' && !useDefaultMediaPlayer" class="video-player-container">
<div v-else-if="previewType == 'video' && !useDefaultMediaPlayer" class="video-player-container" :class="{ 'no-captions': !hasSubtitles }">
<vue-plyr ref="videoPlayer" :options="plyrOptions">
<video :src="raw" :autoplay="shouldAutoPlay" @play="handlePlay" playsinline webkit-playsinline>
<track kind="captions" v-for="(sub, index) in subtitlesList" :key="index" :src="sub.src"
:label="'Subtitle ' + sub.name" :default="index === 0" />
:label="'Subtitle ' + sub.name" :default="false" />
</video>
</vue-plyr>
</div>
@@ -97,15 +97,15 @@
<span v-if="queueCount > 0" class="queue-count">{{ queueCount }}</span>
</button>
<!-- Toast that shows when you press "P" or "L" on the media player -->
<div :class="['loop-toast', toastVisible ? 'visible' : '']">
<!-- Toast that shows when you change playback modes in the media player -->
<div :class="['playback-toast', toastVisible ? 'visible' : '']">
<!-- Loop icon for "single playback", "loop single file" and "loop all files" -->
<i v-if="playbackMode === 'single' || playbackMode === 'loop-single' || playbackMode === 'loop-all'" class="material-icons loop-icon">
<i v-if="playbackMode === 'single' || playbackMode === 'loop-single' || playbackMode === 'loop-all'" class="material-icons">
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
{{ playbackMode === 'loop-single' ? 'repeat_one' : 'repeat' }}
</i>
<i v-else-if="playbackMode === 'shuffle'" class="material-icons shuffle-icon">shuffle</i>
<i v-else class="material-icons sequential-icon">playlist_play</i>
<i v-else-if="playbackMode === 'shuffle'" class="material-icons">shuffle</i>
<i v-else class="material-icons">playlist_play</i>
<span>{{
playbackMode === 'sequential' ? $t('player.PlayAllOncePlayback') :
@@ -241,7 +241,6 @@ export default {
this.setupPlaybackQueue(forceReshuffle);
this.$nextTick(() => {
this.ensurePlaybackModeApplied();
this.syncMediaLoopState();
});
}
},
@@ -285,6 +284,9 @@ export default {
isPlaying() {
return state.playbackQueue?.isPlaying || false;
},
hasSubtitles() {
return this.subtitlesList && this.subtitlesList.length > 0;
},
},
mounted() {
this.updateMedia();
@@ -410,7 +412,6 @@ export default {
currentIndex: this.currentQueueIndex,
mode: newMode
});
this.syncMediaLoopState();
this.showToast();
},
handleKeydown(event) {
@@ -447,7 +448,6 @@ export default {
currentIndex: this.currentQueueIndex,
mode: newMode
});
this.syncMediaLoopState();
this.showToast();
},
showToast() {
@@ -457,7 +457,7 @@ export default {
this.toastVisible = true;
this.toastTimeout = setTimeout(() => {
this.toastVisible = false;
}, 2000);
}, 1500);
},
async updateMedia() {
await this.handleAutoPlay();
@@ -979,10 +979,6 @@ export default {
currentIndex: this.currentQueueIndex,
mode: value
});
// Sync media loop state
this.syncMediaLoopState();
// Show toast
this.showToast();
});
@@ -1010,23 +1006,6 @@ export default {
this.applyCustomPlaybackSettings(player);
}
},
syncMediaLoopState() {
const player = this.getCurrentPlayer();
if (!player) return;
// Only enable loop for "Loop Current" mode
const shouldLoop = this.playbackMode === 'loop-single';
if (this.useDefaultMediaPlayer) {
// HTML5 player
player.loop = shouldLoop;
} else {
// Plyr player
if (player.player) {
player.player.loop = shouldLoop;
}
}
},
},
};
</script>
@@ -1183,10 +1162,28 @@ export default {
height: 100%;
}
/* Force visibility of the buttons */
.plyr.plyr--video .plyr__control[data-plyr="captions"],
.plyr.plyr--video .plyr__control[data-plyr="pip"] {
display: block !important;
/* Hide captions button when there are no subtitle tracks */
.video-player-container.no-captions .plyr__control[data-plyr="captions"] {
display: none !important;
}
/* Subtitles style */
.plyr__captions {
font-size: max(24px, 4.5vmin) !important;
line-height: 150% !important; /* Line height needs to be in both, if not we'll not override the default of plyr */
text-shadow: /* The multiples shadows are for better readability since we are using a transparent background */
0 0 6px #000,
0 0 6px #000,
0 0 6px #000,
0 0 6px #000,
0 0 6px #000 !important;
font-weight: 700 !important;
-webkit-font-smoothing: antialiased;
}
.plyr__caption {
background: transparent !important;
line-height: 150% !important;
}
/************
@@ -1322,13 +1319,13 @@ export default {
}
.audio-title {
font-size: clamp(1.2rem, 4vw, 1.5rem);
font-size: max(1.4rem, 3.1vmin);
font-weight: bold;
margin-bottom: 8px;
word-break: break-word;
}
.notification-info {
.audio-metadata {
text-align: center;
color: whitesmoke;
box-sizing: border-box;
@@ -1339,7 +1336,7 @@ export default {
.audio-artist,
.audio-album,
.audio-year {
font-size: clamp(1rem, 3vw, 1.1rem);
font-size: max(1.2rem, 2.5vmin);
opacity: 0.8;
margin-bottom: 5px;
word-break: break-word;
@@ -1358,7 +1355,7 @@ export default {
padding-top: 1em;
}
.notification-info {
.audio-metadata {
padding: 12px 15px;
}
@@ -1369,14 +1366,6 @@ export default {
}
}
/* For ultra-wide screens. This need test, I'm not sure if will work correctly */
@media (min-width: 1600px) {
.album-art-container {
width: min(400px, 25vw);
height: min(400px, 25vw);
}
}
/* For small screens in landscape orientation (Like a phone) */
@media (max-height: 600px) and (orientation: landscape) {
@@ -1387,9 +1376,7 @@ export default {
}
.audio-player-content {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 1.5em;
width: auto;
@@ -1397,7 +1384,7 @@ export default {
margin: 0 auto;
}
.notification-info {
.audio-metadata {
text-align: left;
margin: 0;
padding: 15px;
@@ -1414,16 +1401,6 @@ export default {
margin: 0;
flex-shrink: 0;
}
.audio-title {
font-size: clamp(1rem, 3vw, 1.3rem);
}
.audio-artist,
.audio-album,
.audio-year {
font-size: clamp(0.85rem, 2vw, 1rem);
}
}
/*******************
@@ -1521,15 +1498,15 @@ export default {
}
}
/*****************
*** LOOP TOAST ***
*****************/
/*********************
*** PLAYBACK TOAST ***
**********************/
.loop-toast {
.playback-toast {
position: fixed;
bottom: 50px;
left: 50%;
transform: translateZ(0);
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 15px 25px;
@@ -1546,11 +1523,11 @@ export default {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.loop-toast.visible {
.playback-toast.visible {
opacity: 1;
}
.loop-toast .material-icons {
.playback-toast .material-icons {
font-size: 24px;
color: white;
width: 24px;
+15 -17
View File
@@ -88,9 +88,9 @@
</i>
</div>
<div class="form-flex-group">
<input class="input form-form flat-right"
<input class="input form-form flat-right disable-viewing"
:class="{ 'form-invalid': !validateExtensions(formDisablePreviews) }" type="text"
:placeholder="$t('profileSettings.disableFileExtensions')" id="disablePreviews"
:placeholder="$t('profileSettings.disableFileExtensions')"
v-model="formDisablePreviews" />
<button type="button" class="button form-button flat-left" @click="submitDisablePreviewsChange">
{{ $t("general.save") }}
@@ -107,9 +107,9 @@
</i>
</div>
<div class="form-flex-group">
<input class="input form-form flat-right"
<input class="input form-form flat-right disable-viewing"
:class="{ 'form-invalid': !validateExtensions(formDisabledViewing) }" type="text"
:placeholder="$t('profileSettings.disableFileExtensions')" id="disableViewing"
:placeholder="$t('profileSettings.disableFileExtensions')"
v-model="formDisabledViewing" />
<button type="button" class="button form-button flat-left" @click="submitDisabledViewingChange">
{{ $t("general.save") }}
@@ -127,10 +127,10 @@
</div>
<div class="form-flex-group">
<input class="input form-form flat-right"
:class="{ 'form-invalid': !validateExtensions(formDisableOfficePreview) }" type="text"
:placeholder="$t('profileSettings.disableFileExtensions')" id="disableOfficePreview"
v-model="formDisableOfficePreview" />
<button type="button" class="button form-button flat-left" @click="submitDisableOfficePreviewChange">
:class="{ 'form-invalid': !validateExtensions(formDisableOfficeViewing) }" type="text"
:placeholder="$t('profileSettings.disableFileExtensions')" id="disableOfficeViewing"
v-model="formDisableOfficeViewing" />
<button type="button" class="button form-button flat-left" @click="submitDisableOfficeViewingChange">
{{ $t("general.save") }}
</button>
</div>
@@ -191,7 +191,7 @@ export default {
localuser: { preview: {}, permissions: {} }, // Initialize localuser with empty objects to avoid undefined errors
formDisablePreviews: "", // holds temporary input before saving
formDisabledViewing: "", // holds temporary input before saving
formDisableOfficePreview: "", // holds temporary input before saving
formDisableOfficeViewing: "", // holds temporary input before saving
expandedSection: 'listingOptions', // Track which section is currently expanded for accordion behavior
};
},
@@ -243,7 +243,7 @@ export default {
}
this.formDisablePreviews = this.localuser.disablePreviewExt;
this.formDisabledViewing = this.localuser.disableViewingExt;
this.formDisableOfficePreview = this.localuser.disableOfficePreviewExt;
this.formDisableOfficeViewing = this.localuser.disableOnlyOfficeExt;
},
methods: {
showTooltip(event, text) {
@@ -279,12 +279,12 @@ export default {
this.localuser.disableViewingExt = this.formDisabledViewing;
this.updateSettings();
},
submitDisableOfficePreviewChange() {
if (!this.validateExtensions(this.formDisableOfficePreview)) {
submitDisableOfficeViewingChange() {
if (!this.validateExtensions(this.formDisableOfficeViewing)) {
notify.showError("Invalid input, does not match requirement.");
return;
}
this.localuser.disableOfficePreviewExt = this.formDisableOfficePreview;
this.localuser.disableOnlyOfficeExt = this.formDisableOfficeViewing;
this.updateSettings();
},
setColor(string) {
@@ -317,7 +317,7 @@ export default {
"quickDownload",
"disablePreviewExt",
"disableViewingExt",
"disableOfficePreviewExt",
"disableOnlyOfficeExt",
"deleteWithoutConfirming",
"preview",
"disableQuickToggles",
@@ -358,9 +358,7 @@ export default {
</script>
<style scoped>
#disablePreviews,
#disableViewing,
#disableOfficePreview {
.disable-viewing {
width: 80%;
}
+5 -16
View File
@@ -57,7 +57,7 @@
</button>
</td>
<td class="small">
<button :disabled="item.shareType == 'upload'" class="action copy-clipboard" :data-clipboard-text="fixDownloadURL(item.downloadURL)" v-if="item.downloadURL"
<button :disabled="item.shareType == 'upload'" class="action copy-clipboard" :data-clipboard-text="item.downloadURL" v-if="item.downloadURL"
:aria-label="$t('buttons.copyDownloadLinkToClipboard')" :title="$t('buttons.copyDownloadLinkToClipboard')">
<i class="material-icons">content_paste_go</i>
</button>
@@ -74,14 +74,12 @@
<script>
import { notify } from "@/notify";
import { publicApi, shareApi } from "@/api";
import { shareApi } from "@/api";
import { state, mutations, getters } from "@/store";
import Clipboard from "clipboard";
import Errors from "@/views/Errors.vue";
import { fromNow } from '@/utils/moment';
import { eventBus } from "@/store/eventBus";
import { fixDownloadURL } from "@/utils/url";
import { globalVars } from "@/utils/constants";
export default {
name: "shares",
@@ -200,19 +198,10 @@ export default {
return fromNow(time);
},
/**
* @param {any} item
* @param {any} share
*/
buildLink(item) {
return publicApi.getShareURL(item);
},
fixDownloadURL(downloadUrl) {
// Only fix the URL if it doesn't already have the correct external domain
if (globalVars.externalUrl) {
// URL already has the correct external domain, use as-is
return downloadUrl;
}
// URL needs fixing (internal domain or no externalUrl set)
return fixDownloadURL(downloadUrl);
buildLink(share) {
return share.shareURL;
},
},
};
+19 -18
View File
@@ -47,7 +47,7 @@
<span>{{ $t('duplicateFinder.totalWastedSpace', { suffix: ': ' }) }}<strong>{{ humanSize(totalWastedSpace) }}</strong></span>
</div>
<div v-if="totalItems < 1000" class="success-message">
<div v-if="duplicateGroups.length < maxGroups" class="success-message">
<i class="material-icons">check_circle</i>
<div>
<strong>{{ $t('fileSizeAnalyzer.completeResults') }}</strong>
@@ -56,7 +56,7 @@
<div v-else class="warning-message">
<i class="material-icons">warning</i>
<div>
<strong>{{ $t('fileSizeAnalyzer.incompleteResults') }}</strong> {{ $t('fileSizeAnalyzer.incompleteResultsDetails') }}
<strong>{{ $t('fileSizeAnalyzer.incompleteResults') }}</strong> {{ $t('messages.incompleteResultsDetails', { max: maxGroups }) }}
</div>
</div>
@@ -141,6 +141,7 @@ export default {
isInitializing: true,
lastRequestTime: 0, // Track last request to prevent rapid-fire
clickTracker: {}, // Track clicks for double-click detection
maxGroups: 500,
};
},
computed: {
@@ -249,7 +250,7 @@ export default {
this.minSizeValue,
false // Checksum disabled
);
// Reset selection when new results arrive
mutations.resetSelected();
} catch (err) {
@@ -340,26 +341,26 @@ export default {
// Ensure the file path includes the full path from root
// If searching in a subpath, the backend may return relative paths
const searchPath = this.searchPath || '/';
// Normalize search path (ensure trailing slash is removed)
const normalizedSearchPath = searchPath === '/' || searchPath === ''
? ''
const normalizedSearchPath = searchPath === '/' || searchPath === ''
? ''
: (searchPath.endsWith('/') ? searchPath.slice(0, -1) : searchPath);
// Check if the path already includes the search path prefix
if (filePath.startsWith(normalizedSearchPath + '/') ||
if (filePath.startsWith(normalizedSearchPath + '/') ||
filePath === normalizedSearchPath ||
(normalizedSearchPath === '' && filePath.startsWith('/'))) {
// Path already has full context, just ensure leading slash
return this.ensureLeadingSlash(filePath);
}
// Path is relative to search path - prepend it
if (normalizedSearchPath === '') {
// Searching from root
return this.ensureLeadingSlash(filePath);
}
// Remove leading slash from file path if present before combining
const cleanFilePath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
return normalizedSearchPath + '/' + cleanFilePath;
@@ -373,35 +374,35 @@ export default {
// Prevent default ListingItem navigation since state.req isn't populated
event.preventDefault();
event.stopPropagation();
// Respect single-click vs double-click setting
if (event.button === 0) {
const quickNav = state.user.singleClick && !state.multiple;
if (quickNav) {
// Single-click navigation enabled - go immediately
this.navigateToFile(file);
} else {
// Double-click navigation - select on first click, navigate on second
// First click always selects the item using state.selected for proper CSS styling
const uniqueIndex = this.getUniqueIndex(groupIndex, fileIndex);
mutations.resetSelected();
mutations.addSelected(uniqueIndex);
// Track clicks for double-click detection
if (!this.clickTracker) {
this.clickTracker = {};
}
const fileKey = file.path;
if (!this.clickTracker[fileKey]) {
this.clickTracker[fileKey] = { count: 0, timeout: null };
}
const tracker = this.clickTracker[fileKey];
tracker.count++;
if (tracker.count >= 2) {
// Double-click detected - navigate
this.navigateToFile(file);
@@ -429,7 +430,7 @@ export default {
source: this.selectedSource,
path: this.$route.path,
};
// Get the full path including search path context
const filePath = this.getFullPath(file.path);
url.goToItem(this.selectedSource, filePath, previousHistoryItem);
+1 -1
View File
@@ -51,7 +51,7 @@
<div v-else class="warning-message">
<i class="material-icons">warning</i>
<div>
<strong>{{ $t('fileSizeAnalyzer.incompleteResults') }}</strong> {{ $t('fileSizeAnalyzer.incompleteResultsDetails') }}
<strong>{{ $t('fileSizeAnalyzer.incompleteResults') }}</strong> {{ $t('messages.incompleteResultsDetails', { max: 100 }) }}
</div>
</div>
@@ -0,0 +1,200 @@
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
2024年 06月 17日 星期一 04:05:58 CST 文件已备份
@@ -64,6 +64,7 @@ test("share file creation", async ({ page, checkForErrors, openContextMenu }) =>
}
await page.goto("public/share/" + shareHashShare);
await expect(page).toHaveTitle("Graham's Filebrowser - Share - share");
await page.waitForTimeout(1000);
await openContextMenu();
await page.locator('button[aria-label="New file"]').click();
await page.locator('input[aria-label="FileName Field"]').waitFor({ state: 'visible' });