Compare commits

..
Author SHA1 Message Date
Graham Steffaniak 5d25220e94 fix advanced user toggle duplicate (#3353) 2026-11-07 10:52:59 -06:00
8355682773 fix(upload): don't pause while the server saves, and make the stall timeout a setting (#3346)
Co-authored-by: FandacqkaCorp <275647464+FandacqkaCorp@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-11-07 10:49:55 -06:00
Graham Steffaniak 48c1ce709b updated changelog v2.1.5 (#3350) 2026-11-06 17:34:04 -06:00
Graham Steffaniak 43682dc809 fix(users): keep requested profile values when creating a user (#3349) 2026-11-06 16:09:36 -06:00
Xyleme-git 80fba42076 Update fr.json (#3343) 2026-11-06 15:51:28 -06:00
Hromad-aandClaude Opus 5.5 a01ab6901c i18n(cz): fix Czech translations (#3333)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:27:14 -06:00
Geekice1andGeekice1 e652538ea8 i18n: fix and improve Italian translation (it.json) (#3332)
Co-authored-by: Geekice1 <geekice1@users.noreply.github.com>
2026-11-06 15:26:49 -06:00
Peter Gao c974da7002 fix(i18n):Update Chinese translations for UI elements (#3330) 2026-11-06 15:26:37 -06:00
Konstantinos Chatzikokolakis eaee271b96 Square image previews: avoid the full-resolution crop copy in imaging.Fill (#3340) 2026-11-06 15:13:20 -06:00
HaneandClaude Opus 5.5 0d6b592046 fix(upload): serialize directory creation before uploading files (#3321)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:10:52 -06:00
1791bb7a36 feat(cache): add server.cacheDirMaxSizeMB to cap the preview cache size (#3314)
Co-authored-by: FandacqkaCorp <275647464+FandacqkaCorp@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 15:08:40 -06:00
86d4994152 fix(preview): open RAW, HEIC and rendered documents in the viewer when previews are disabled (#3313)
Co-authored-by: FandacqkaCorp <275647464+FandacqkaCorp@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 15:03:43 -06:00
64 changed files with 1902 additions and 658 deletions

No files matched your search

+13
View File
@@ -2,6 +2,19 @@
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).
## v2.1.5
**New Features**:
- feat(cache): add server.cacheDirMaxSizeMB to cap the preview cache size (#3314)
- Square image previews: avoid the full-resolution crop copy in imaging.Fill (#3340)
**Bug Fixes**:
- fix(users): save the prompt close button and new file templates when editing a user (#3342) (#3323)
- fix(upload): serialize directory creation before uploading files (#3321)
- fix(preview): open RAW, HEIC and rendered documents in the viewer when previews are disabled (#3313)
- fix(users): keep requested profile values when creating a user
- fix advanced user toggle duplicate (#3353) (#3324)
## v2.1.4
**Security**:
@@ -9,7 +9,10 @@ import (
"io"
"os"
"path/filepath"
"sort"
"sync"
"sync/atomic"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/go-logger/logger"
@@ -21,9 +24,28 @@ type Cache interface {
Set(key string, value []byte) error
}
const (
// evictTargetPercent is the size, as a percentage of the limit, that eviction shrinks the cache to.
// Stopping below the limit avoids running an eviction on every write once the cache is full.
evictTargetPercent = 90
// touchInterval is how old a file's modification time has to be before a read refreshes it. Reads
// refresh it so the files in use are the last ones evicted, but not on every read.
touchInterval = time.Hour
)
// FileCache struct for file-based caching
type FileCache struct {
dir string
// maxBytes is the size limit of the cache in bytes. 0 means no limit.
maxBytes int64
// size is the total size of the cached files in bytes. Only tracked when maxBytes > 0.
size atomic.Int64
// sizeMu keeps size consistent with the files on disk: stores and deletes hold it for reading while
// they change a file and its size, and eviction holds it for writing while it measures the directory,
// so a store can never fall between a measurement and the size being set from it.
sizeMu sync.RWMutex
// evicting is set while an eviction is running so only one runs at a time.
evicting atomic.Bool
// granular locks
scopedLocks struct {
sync.Mutex
@@ -33,6 +55,12 @@ type FileCache struct {
}
func NewFileCache(dir string) (*FileCache, error) {
return NewFileCacheWithLimit(dir, 0)
}
// NewFileCacheWithLimit is like NewFileCache but keeps the cache under maxBytes, deleting the least
// recently used files when it grows past it. 0 means no limit.
func NewFileCacheWithLimit(dir string, maxBytes int64) (*FileCache, error) {
cacheDir := filepath.Join(dir, "diskcache")
// Migrate existing cache files from old structure (if any)
@@ -44,7 +72,13 @@ func NewFileCache(dir string) (*FileCache, error) {
if err := os.MkdirAll(cacheDir, fileutils.PermDir); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
return &FileCache{dir: cacheDir}, nil
cache := &FileCache{dir: cacheDir}
if maxBytes > 0 {
cache.maxBytes = maxBytes
// measures the cache and trims it right away if it is already over the limit
cache.evictPass()
}
return cache, nil
}
// migrateOldCacheStructure moves cache files from old structure (dir/a/...) to new structure (dir/diskcache/a/...)
@@ -99,19 +133,50 @@ func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
return err
}
if f.maxBytes > 0 {
f.sizeMu.RLock()
defer f.sizeMu.RUnlock()
}
previousSize := f.fileSize(fileName)
if err := os.WriteFile(fileName, value, fileutils.PermFile); err != nil {
// a failed write can leave a truncated or partial file, so count what is actually there
f.sizeChanged(f.fileSize(fileName) - previousSize)
return err
}
f.sizeChanged(int64(len(value)) - previousSize)
return nil
}
// fileSize returns the size of a cached file, or 0 if it doesn't exist or the size isn't tracked.
func (f *FileCache) fileSize(fileName string) int64 {
if f.maxBytes <= 0 {
return 0
}
info, err := os.Stat(fileName)
if err != nil {
return 0
}
return info.Size()
}
// sizeChanged adds delta to the tracked size and starts an eviction if that put the cache over its limit.
func (f *FileCache) sizeChanged(delta int64) {
if f.maxBytes <= 0 {
return
}
if f.size.Add(delta) > f.maxBytes {
f.evictAsync()
}
}
func (f *FileCache) Load(ctx context.Context, key string) (value []byte, exist bool, err error) {
r, ok, err := f.open(key)
if err != nil || !ok {
return nil, ok, err
}
defer r.Close()
f.touch(r)
value, err = io.ReadAll(r)
if err != nil {
@@ -125,13 +190,119 @@ func (f *FileCache) Delete(ctx context.Context, key string) error {
mu.Lock()
defer mu.Unlock()
if f.maxBytes > 0 {
f.sizeMu.RLock()
defer f.sizeMu.RUnlock()
}
fileName := f.getFileName(key)
removedSize := f.fileSize(fileName)
if err := os.Remove(fileName); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if removedSize > 0 {
f.size.Add(-removedSize)
}
return nil
}
// touch refreshes the modification time of a file that was just read, which is what eviction orders by.
func (f *FileCache) touch(file *os.File) {
if f.maxBytes <= 0 {
return
}
info, err := file.Stat()
if err != nil || time.Since(info.ModTime()) < touchInterval {
return
}
now := time.Now()
_ = os.Chtimes(file.Name(), now, now)
}
type cachedFile struct {
path string
size int64
modTime time.Time
}
// scan lists the cached files and their total size.
func (f *FileCache) scan() ([]cachedFile, int64) {
var files []cachedFile
var total int64
_ = filepath.WalkDir(f.dir, func(path string, entry os.DirEntry, err error) error {
if err != nil || entry.IsDir() {
return nil
}
info, err := entry.Info()
if err != nil || !info.Mode().IsRegular() {
return nil
}
files = append(files, cachedFile{path: path, size: info.Size(), modTime: info.ModTime()})
total += info.Size()
return nil
})
return files, total
}
// evictAsync starts an eviction in the background unless one is already running, so the request that
// stored the file doesn't wait for it.
func (f *FileCache) evictAsync() {
if !f.evicting.CompareAndSwap(false, true) {
return
}
go func() {
for {
removed := f.evictPass()
f.evicting.Store(false)
if removed == 0 {
return // under the limit, or nothing could be removed: don't spin
}
// Files stored while the pass was running were counted but not necessarily seen by it, and a
// store that came in just before the flag was cleared saw it set and didn't start another
// pass, so check again before stopping.
if f.size.Load() <= f.maxBytes || !f.evicting.CompareAndSwap(false, true) {
return
}
}
}()
}
// evictPass measures the cache and, if it is over the limit, deletes the least recently used files until
// it is under evictTargetPercent of the limit. Deleting a file that is being read or written is harmless:
// readers keep their open file and a removed entry is simply rebuilt the next time it is requested.
func (f *FileCache) evictPass() (removed int) {
f.sizeMu.Lock()
files, total := f.scan()
f.size.Store(total)
f.sizeMu.Unlock()
if total <= f.maxBytes {
return 0
}
target := f.maxBytes / 100 * evictTargetPercent
sort.Slice(files, func(i, j int) bool { return files[i].modTime.Before(files[j].modTime) })
var removedBytes int64
for _, file := range files {
if f.size.Load() <= target {
break
}
// Hold the lock so no store or delete changes this file between measuring it and removing it, and
// subtract the size it has now: the listing is from the scan and the file may have been rewritten since.
f.sizeMu.Lock()
info, err := os.Stat(file.path)
if err == nil {
err = os.Remove(file.path)
}
if err == nil {
f.size.Add(-info.Size())
removed++
removedBytes += info.Size()
}
f.sizeMu.Unlock()
}
logger.Debugf("preview cache limit: removed %d files (%d MB), now %d MB of %d MB", removed, removedBytes>>20, f.size.Load()>>20, f.maxBytes>>20)
return removed
}
func (f *FileCache) open(key string) (*os.File, bool, error) {
fileName := f.getFileName(key)
file, err := os.Open(fileName)
@@ -2,9 +2,12 @@ package diskcache
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/stretchr/testify/require"
@@ -70,3 +73,168 @@ func fileExists(filename string) bool {
}
return !info.IsDir()
}
// storeAged stores a value and sets its modification time, so the eviction order is deterministic.
func storeAged(t *testing.T, cache *FileCache, key string, size int, age time.Duration) {
t.Helper()
require.NoError(t, cache.Store(context.Background(), key, make([]byte, size)))
when := time.Now().Add(-age)
require.NoError(t, os.Chtimes(cache.getFileName(key), when, when))
}
func waitFor(t *testing.T, condition func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !condition() {
if time.Now().After(deadline) {
t.Fatal("condition not met in time")
}
time.Sleep(10 * time.Millisecond)
}
}
func newLimitedCache(t *testing.T, maxBytes int64) *FileCache {
t.Helper()
fileutils.SetFsPermissions(0644, 0755)
cache, err := NewFileCacheWithLimit(t.TempDir(), maxBytes)
require.NoError(t, err)
return cache
}
func TestFileCacheEvictsLeastRecentlyUsedOverLimit(t *testing.T) {
const kb = 1024
cache := newLimitedCache(t, 10*kb)
// 3 files of 3KB are under the 10KB limit
storeAged(t, cache, "oldest", 3*kb, 3*time.Hour)
storeAged(t, cache, "old", 3*kb, 2*time.Hour)
storeAged(t, cache, "recent", 3*kb, time.Hour)
require.True(t, fileExists(cache.getFileName("oldest")), "nothing is removed under the limit")
// the 4th file goes over the limit: the oldest ones are removed until it is under 90% of it
require.NoError(t, cache.Store(context.Background(), "newest", make([]byte, 3*kb)))
waitFor(t, func() bool { return !fileExists(cache.getFileName("oldest")) })
waitFor(t, func() bool { return !cache.evicting.Load() })
require.False(t, fileExists(cache.getFileName("oldest")))
require.False(t, fileExists(cache.getFileName("old")))
require.True(t, fileExists(cache.getFileName("recent")))
require.True(t, fileExists(cache.getFileName("newest")))
require.LessOrEqual(t, cache.size.Load(), int64(9*kb))
}
func TestFileCacheTrimsAnOversizedCacheAtStartup(t *testing.T) {
const kb = 1024
fileutils.SetFsPermissions(0644, 0755)
dir := t.TempDir()
unlimited, err := NewFileCache(dir)
require.NoError(t, err)
storeAged(t, &FileCache{dir: unlimited.dir}, "a", 4*kb, 4*time.Hour)
storeAged(t, &FileCache{dir: unlimited.dir}, "b", 4*kb, 3*time.Hour)
storeAged(t, &FileCache{dir: unlimited.dir}, "c", 4*kb, 2*time.Hour)
cache, err := NewFileCacheWithLimit(dir, 10*kb)
require.NoError(t, err)
// 12KB over a 10KB limit: only the oldest file is removed, which already brings it under 90% (9KB)
require.False(t, fileExists(cache.getFileName("a")))
require.True(t, fileExists(cache.getFileName("b")))
require.True(t, fileExists(cache.getFileName("c")))
require.Equal(t, int64(8*kb), cache.size.Load())
}
func TestFileCacheWithoutLimitKeepsEverything(t *testing.T) {
fileutils.SetFsPermissions(0644, 0755)
cache, err := NewFileCache(t.TempDir())
require.NoError(t, err)
for i := 0; i < 5; i++ {
storeAged(t, cache, fmt.Sprintf("key-%d", i), 1<<20, time.Duration(i)*time.Hour)
}
for i := 0; i < 5; i++ {
require.True(t, fileExists(cache.getFileName(fmt.Sprintf("key-%d", i))))
}
require.Zero(t, cache.size.Load(), "size is only tracked when there is a limit")
}
func TestFileCacheReadRefreshesModTime(t *testing.T) {
cache := newLimitedCache(t, 1<<20)
storeAged(t, cache, "used", 100, 3*time.Hour)
_, ok, err := cache.Load(context.Background(), "used")
require.NoError(t, err)
require.True(t, ok)
info, err := os.Stat(cache.getFileName("used"))
require.NoError(t, err)
require.WithinDuration(t, time.Now(), info.ModTime(), time.Minute, "a file that is read is the last to be evicted")
}
func TestFileCacheDeleteAndOverwriteKeepTheSizeAccurate(t *testing.T) {
const kb = 1024
cache := newLimitedCache(t, 1<<20)
ctx := context.Background()
require.NoError(t, cache.Store(ctx, "k", make([]byte, 5*kb)))
require.Equal(t, int64(5*kb), cache.size.Load())
require.NoError(t, cache.Store(ctx, "k", make([]byte, 2*kb)))
require.Equal(t, int64(2*kb), cache.size.Load(), "overwriting replaces the old size")
require.NoError(t, cache.Delete(ctx, "k"))
require.Zero(t, cache.size.Load())
}
// Stores that land while an eviction is measuring or deleting must not be lost from the tracked size,
// and the cache must end up under its limit even though more files kept arriving during the eviction.
func TestFileCacheStaysUnderTheLimitWithConcurrentStores(t *testing.T) {
const (
kb = 1024
limit = 100 * kb
workers = 16
stores = 40
)
cache := newLimitedCache(t, limit)
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < stores; i++ {
_ = cache.Store(context.Background(), fmt.Sprintf("worker-%d-key-%d", w, i), make([]byte, 4*kb))
}
}(w)
}
wg.Wait()
waitFor(t, func() bool { return !cache.evicting.Load() })
_, onDisk := cache.scan()
require.LessOrEqual(t, onDisk, int64(limit), "the files left on disk are within the limit")
require.Equal(t, onDisk, cache.size.Load(), "the tracked size matches what is on disk")
}
// Files that are rewritten while an eviction is running must be subtracted at the size they have when
// they are removed, not at the size the scan saw, or the tracked size drifts from the disk.
func TestFileCacheKeepsTheSizeAccurateWhenFilesAreRewrittenDuringEviction(t *testing.T) {
const (
kb = 1024
limit = 60 * kb
workers = 8
rounds = 300
keys = 40
)
cache := newLimitedCache(t, limit)
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < rounds; i++ {
// the same keys again and again, with a different size each time
key := fmt.Sprintf("key-%d", (w+i)%keys)
_ = cache.Store(context.Background(), key, make([]byte, (1+(w*7+i)%8)*kb))
}
}(w)
}
wg.Wait()
waitFor(t, func() bool { return !cache.evicting.Load() })
_, onDisk := cache.scan()
require.Equal(t, onDisk, cache.size.Load(), "the tracked size matches what is on disk")
require.LessOrEqual(t, onDisk, int64(limit))
}
+5 -4
View File
@@ -343,10 +343,11 @@ type NonAdminEditable struct {
}
type FileLoading struct {
MaxConcurrent int `json:"maxConcurrentUpload"`
UploadChunkSize int `json:"uploadChunkSizeMb"`
ClearAll bool `json:"clearAll"`
DownloadChunkSize int `json:"downloadChunkSizeMb"`
MaxConcurrent int `json:"maxConcurrentUpload"`
UploadChunkSize int `json:"uploadChunkSizeMb"`
ClearAll bool `json:"clearAll"`
DownloadChunkSize int `json:"downloadChunkSizeMb"`
UploadStallTimeoutSeconds int `json:"uploadStallTimeoutSeconds"` // seconds without progress before an upload is paused, 0 uses the default (10)
}
// SidebarLink represents a customizable link in the sidebar.
+18
View File
@@ -1,6 +1,8 @@
package preview
import (
"bytes"
"image"
"path/filepath"
"strings"
@@ -18,3 +20,19 @@ func hasEmbeddedPreview(fileType, fileName string) bool {
func isJPEG(data []byte) bool {
return len(data) >= 2 && data[0] == 0xff && data[1] == 0xd8
}
// maxEmbeddedPreviewPixels bounds the embedded JPEG that is decoded for orientation and resizing, so a
// crafted or corrupt file can't make the decode allocate memory proportional to a huge declared size.
// Camera previews are at most full sensor resolution (a 100MP medium format JPEG is about 102M pixels).
const maxEmbeddedPreviewPixels = 150_000_000
// embeddedPreviewWithinLimits reports whether a JPEG's declared dimensions are small enough to decode.
// Only the header is read. A JPEG whose header can't be parsed is rejected too: its size can't be
// checked, and the decode steps that follow would run on it unbounded.
func embeddedPreviewWithinLimits(data []byte) bool {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return false
}
return int64(cfg.Width)*int64(cfg.Height) <= maxEmbeddedPreviewPixels
}
+51 -1
View File
@@ -1,6 +1,11 @@
package preview
import "testing"
import (
"bytes"
"image"
"image/jpeg"
"testing"
)
func TestHasEmbeddedPreview(t *testing.T) {
if !hasEmbeddedPreview("image/x-canon-cr2", "photo.cr2") {
@@ -19,3 +24,48 @@ func TestIsJPEGMarker(t *testing.T) {
t.Fatal("expected JPEG SOI marker")
}
}
// jpegWithDeclaredSize returns a valid small JPEG whose SOF header declares the given size.
// Only the header is rewritten, so building it costs nothing even for a huge declared size.
func jpegWithDeclaredSize(t *testing.T, width, height int) []byte {
t.Helper()
var buf bytes.Buffer
if err := jpeg.Encode(&buf, image.NewGray(image.Rect(0, 0, 8, 8)), nil); err != nil {
t.Fatal(err)
}
data := buf.Bytes()
sof := bytes.Index(data, []byte{0xff, 0xc0})
if sof < 0 {
t.Fatal("no SOF0 marker in test JPEG")
}
// marker(2) length(2) precision(1) height(2) width(2)
data[sof+5], data[sof+6] = byte(height>>8), byte(height)
data[sof+7], data[sof+8] = byte(width>>8), byte(width)
return data
}
func TestEmbeddedPreviewWithinLimits(t *testing.T) {
tests := []struct {
name string
width, height int
want bool
}{
{"normal camera preview", 6000, 4000, true},
{"100MP medium format preview", 11648, 8736, true},
{"declared size far beyond any camera", 60000, 60000, false},
{"just over the limit", 12248, 12248, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := embeddedPreviewWithinLimits(jpegWithDeclaredSize(t, tt.width, tt.height)); got != tt.want {
t.Fatalf("embeddedPreviewWithinLimits(%dx%d) = %v, want %v", tt.width, tt.height, got, tt.want)
}
})
}
}
func TestEmbeddedPreviewWithinLimitsRejectsAnUnreadableHeader(t *testing.T) {
if embeddedPreviewWithinLimits([]byte{0xff, 0xd8, 0xff, 0xe0, 0x00}) {
t.Fatal("a header that can't be read can't be size-checked, so it is rejected")
}
}
+121
View File
@@ -0,0 +1,121 @@
package preview
import (
"fmt"
"image"
"image/color"
"runtime"
"testing"
"github.com/kovidgoyal/imaging"
"github.com/stretchr/testify/require"
)
// patternYCbCr is a 4:2:0 image (what a camera JPEG decodes to) with a pattern that differs in
// every row and column, so a crop off by one pixel shows up in the resized output.
func patternYCbCr(w, h int) *image.YCbCr {
return patternYCbCrRatio(w, h, image.YCbCrSubsampleRatio420)
}
// patternYCbCrRatio is patternYCbCr with the given chroma subsampling.
func patternYCbCrRatio(w, h int, r image.YCbCrSubsampleRatio) *image.YCbCr {
img := image.NewYCbCr(image.Rect(0, 0, w, h), r)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Y[img.YOffset(x, y)] = uint8((x*7 + y*13) % 251)
}
}
for i := range img.Cb {
img.Cb[i] = uint8((i*3 + i/img.CStride) % 256)
img.Cr[i] = uint8((i + 5*(i/img.CStride)) % 256)
}
return img
}
// patternNRGBA is an NRGBA image whose pixels differ in every row and column.
func patternNRGBA(w, h int) *image.NRGBA {
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetNRGBA(x, y, color.NRGBA{uint8(x % 256), uint8(y % 256), uint8((x + y) % 256), 255})
}
}
return img
}
// requireSamePixels fails unless want and got are the same size and every pixel is equal,
// comparing from each image's own origin.
func requireSamePixels(t *testing.T, want, got image.Image) {
t.Helper()
require.Equal(t, want.Bounds().Size(), got.Bounds().Size())
wb, gb := want.Bounds().Min, got.Bounds().Min
for y := 0; y < want.Bounds().Dy(); y++ {
for x := 0; x < want.Bounds().Dx(); x++ {
wr, wg, wbl, wa := want.At(wb.X+x, wb.Y+y).RGBA()
gr, gg, gbl, ga := got.At(gb.X+x, gb.Y+y).RGBA()
if wr != gr || wg != gg || wbl != gbl || wa != ga {
t.Fatalf("pixel (%d,%d) differs: imaging.Fill %v, fillCenter %v", x, y,
[4]uint32{wr, wg, wbl, wa}, [4]uint32{gr, gg, gbl, ga})
}
}
}
}
// fillCenter must produce exactly what imaging.Fill(…, imaging.Center, …) produces: the same
// pixels and the same image type, so the encoded preview is the same bytes.
func TestFillCenter_MatchesImagingFill(t *testing.T) {
sources := map[string]image.Image{
"ycbcr landscape": patternYCbCr(1200, 801),
"ycbcr portrait": patternYCbCr(601, 1000),
"nrgba square": patternNRGBA(700, 700),
"nrgba wide": patternNRGBA(1500, 300),
"small (falls back to imaging.Fill)": patternNRGBA(90, 60),
"exact target size": patternNRGBA(256, 256),
"offset origin": patternNRGBA(900, 700).SubImage(image.Rect(37, 11, 837, 611)),
}
// Every subsampling ratio, in shapes whose centered crop has an x (or y) offset and reaches
// the source's last row (or column) -- where a SubImage's buffer ends short of a full stride.
for _, r := range []image.YCbCrSubsampleRatio{
image.YCbCrSubsampleRatio444, image.YCbCrSubsampleRatio422, image.YCbCrSubsampleRatio420,
image.YCbCrSubsampleRatio440, image.YCbCrSubsampleRatio411, image.YCbCrSubsampleRatio410,
} {
for _, sz := range [][2]int{{600, 264}, {264, 600}, {601, 263}} {
sources[fmt.Sprintf("ycbcr %v %dx%d", r, sz[0], sz[1])] = patternYCbCrRatio(sz[0], sz[1], r)
}
}
sources["nycbcra 444 600x264"] = &image.NYCbCrA{YCbCr: *patternYCbCrRatio(600, 264, image.YCbCrSubsampleRatio444), A: make([]uint8, 600*264), AStride: 600}
// 264x264 is the crop of the 600x264 and 264x600 sources itself: nothing to scale.
targets := [][2]int{{256, 256}, {320, 180}, {180, 320}, {264, 264}}
filters := map[string]imaging.ResampleFilter{
"nearest": imaging.NearestNeighbor, "box": imaging.Box, "catmullrom": imaging.CatmullRom,
}
for name, src := range sources {
for _, tg := range targets {
for fname, f := range filters {
t.Run(fmt.Sprintf("%s/%dx%d/%s", name, tg[0], tg[1], fname), func(t *testing.T) {
want := imaging.Fill(src, tg[0], tg[1], imaging.Center, f)
got := fillCenter(src, tg[0], tg[1], f)
require.Equal(t, fmt.Sprintf("%T", want), fmt.Sprintf("%T", got), "image type")
requireSamePixels(t, want, got)
})
}
}
}
}
// A square thumbnail of a large photo must not allocate a full-resolution copy of it.
func TestFillCenter_NoFullSizeCopy(t *testing.T) {
const w, h = 4000, 3000
src := patternYCbCr(w, h)
fullCopy := uint64(h * h * 4) // the square crop imaging.Fill materializes as NRGBA
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
out := fillCenter(src, 256, 256, imaging.Box)
runtime.ReadMemStats(&after)
require.Equal(t, image.Pt(256, 256), out.Bounds().Size())
allocated := after.TotalAlloc - before.TotalAlloc
require.Less(t, allocated, fullCopy/4,
"fillCenter allocated %d bytes; a copy of the crop would be %d", allocated, fullCopy)
}
+46 -1
View File
@@ -7,6 +7,7 @@ import (
"image"
"image/jpeg"
"io"
"math"
exif "github.com/dsoprea/go-exif/v3"
exifcommon "github.com/dsoprea/go-exif/v3/common"
@@ -198,6 +199,50 @@ func (s *Service) ResizeWithSize(ctx context.Context, in io.Reader, out io.Write
return err
}
// fillCenter is imaging.Fill(img, width, height, imaging.Center, filter) without a full-size copy
// of the source. For sources of 100px or more, imaging.Fill first crops into a new NRGBA image
// (4 bytes per pixel of the cropped area, about 144 MB for a 48 MP photo) and only then resizes.
// Here the crop is a SubImage view into the decoded pixels, so only the resize allocates. The
// crop rectangle is computed as imaging's cropAndResize computes it, so the output is the same.
//
// Two cases still go through imaging.Fill: a crop that is already the target size (nothing to
// scale, and Fill's small copy is what keeps the output type the same), and 4:4:4 YCbCr sources,
// whose scanner in imaging v1.9.0 advances one full parent stride past the end of a SubImage's
// last row and panics when the crop has an x offset and reaches the bottom row.
func fillCenter(img image.Image, width, height int, filter imaging.ResampleFilter) image.Image {
b := img.Bounds()
sub, ok := img.(interface {
SubImage(image.Rectangle) image.Image
})
if !ok || is444(img) || width <= 0 || height <= 0 || b.Dx() < 100 || b.Dy() < 100 || (b.Dx() == width && b.Dy() == height) {
return imaging.Fill(img, width, height, imaging.Center, filter)
}
cropW, cropH := b.Dx(), b.Dy()
if float64(b.Dx())/float64(b.Dy()) < float64(width)/float64(height) {
cropH = int(math.Max(1, float64(b.Dx())*float64(height)/float64(width)) + 0.5)
} else {
cropW = int(math.Max(1, float64(b.Dy())*float64(width)/float64(height)) + 0.5)
}
if cropW == width && cropH == height {
return imaging.Fill(img, width, height, imaging.Center, filter)
}
x0 := b.Min.X + (b.Dx()-cropW)/2
y0 := b.Min.Y + (b.Dy()-cropH)/2
crop := image.Rect(x0, y0, x0+cropW, y0+cropH).Intersect(b)
return imaging.Resize(sub.SubImage(crop), width, height, filter)
}
// is444 reports whether img is a YCbCr image without chroma subsampling.
func is444(img image.Image) bool {
switch p := img.(type) {
case *image.YCbCr:
return p.SubsampleRatio == image.YCbCrSubsampleRatio444
case *image.NYCbCrA:
return p.SubsampleRatio == image.YCbCrSubsampleRatio444
}
return false
}
func (s *Service) resizeWithSize(in io.Reader, out io.Writer, fileSize int64, opts ResizeOptions) error {
// Set defaults
if opts.ResizeMode == 0 {
@@ -275,7 +320,7 @@ func (s *Service) resizeWithSize(in io.Reader, out io.Writer, fileSize int64, op
resampleFilter := opts.Quality.resampleFilter()
switch opts.ResizeMode {
case ResizeModeFill:
img = imaging.Fill(img, opts.Width, opts.Height, imaging.Center, resampleFilter)
img = fillCenter(img, opts.Width, opts.Height, resampleFilter)
case ResizeModeFit:
fallthrough
default:
+5 -3
View File
@@ -65,7 +65,7 @@ func NewPreviewGenerator(concurrencyLimit int, cacheDir string) *Service {
var fileCache diskcache.Interface
// Use file cache if cacheDir is specified
var err error
fileCache, err = diskcache.NewFileCache(actualCacheDir)
fileCache, err = diskcache.NewFileCacheWithLimit(actualCacheDir, max(settings.Config.Server.CacheDirMaxSizeMB, 0)*1024*1024)
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)
@@ -449,7 +449,9 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
}
// Enforce file size limit for image preview generation to prevent memory exhaustion
if strings.HasPrefix(file.Type, "image") && file.Size > iteminfo.LargeFileSizeThreshold {
// Camera RAW is exempt: its preview is the embedded JPEG and the RAW data is never decoded.
if strings.HasPrefix(file.Type, "image") && file.Size > iteminfo.LargeFileSizeThreshold &&
!iteminfo.IsRawImage(strings.ToLower(filepath.Ext(file.Name))) {
message := fmt.Sprintf("Image file too large for preview: %s (size: %d bytes, limit: %d bytes)",
file.Name, file.Size, iteminfo.LargeFileSizeThreshold)
logger.Warning(message)
@@ -467,7 +469,7 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
var imageBytes []byte
fromEmbeddedPreview := false
if hasEmbeddedPreview(file.Type, file.Name) {
if previewBytes, _ := imagemeta.ExtractEmbeddedPreview(ctx, file.RealPath); len(previewBytes) >= minPreviewSize && isJPEG(previewBytes) {
if previewBytes, _ := imagemeta.ExtractEmbeddedPreview(ctx, file.RealPath); len(previewBytes) >= minPreviewSize && isJPEG(previewBytes) && embeddedPreviewWithinLimits(previewBytes) {
imageBytes = previewBytes
fromEmbeddedPreview = true
if orient := imagemeta.GetOrientation(ctx, file.RealPath); orient != "" {
+12
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"reflect"
"slices"
"strings"
"sync"
@@ -297,6 +298,17 @@ func ApplyUserDefaults(u *users.User) {
applyToolAccessDefaults(u)
}
// PrefillUserProfileDefaults seeds u with the effective profile defaults so a JSON decode
// overlays only the keys the request sent (encoding/json leaves absent keys untouched).
// Permissions are left zero so omitted permissions stay distinguishable; CreateUser merges them.
func PrefillUserProfileDefaults(u *users.User) {
settings.ApplyFullProfileFromDefaults(u, EffectiveUserDefaults())
// json.Unmarshal reuses an existing slice's backing array, so clone it or a request
// would overwrite the shared in-memory defaults.
u.NewFileTemplate = slices.Clone(u.NewFileTemplate)
u.Permissions = users.Permissions{}
}
func applyUserSettingsDefaults(u *users.User) {
settings.ApplyUserDefaultsFrom(u, EffectiveUserDefaults())
}
+19
View File
@@ -202,6 +202,18 @@ func UserForShareOwner(link share.Share) (users.User, error) {
// may apply default-enabled sources. In-memory/cache users always keep FrontendScopes nil; GET handlers use
// PrepForFrontend to repopulate FrontendScopes from BackendScopes for responses.
func CreateUser(user *users.User, plaintextPassword string) error {
return createUser(user, plaintextPassword, false)
}
// CreateUserKeepingProfile is CreateUser for callers whose user was seeded with
// PrefillUserProfileDefaults before decoding the request: the profile fields already hold
// defaults overlaid with the request, so they are not replaced by userDefaults again.
// Enforced defaults still win.
func CreateUserKeepingProfile(user *users.User, plaintextPassword string) error {
return createUser(user, plaintextPassword, true)
}
func createUser(user *users.User, plaintextPassword string, keepProfile bool) error {
if err := users.ValidateUsername(user.Username); err != nil {
return err
}
@@ -256,12 +268,19 @@ func CreateUser(user *users.User, plaintextPassword string) error {
requestedScopePermissions[backendScopeKeyFor(scope)] = scope.Permissions
}
}
var requestedProfile settings.UserProfile
if keepProfile {
requestedProfile = settings.ProfileFromUser(user)
}
// Apply profile/scope defaults using the final account permissions (admin flag drives scope defaults).
if permissionsRequested {
settings.ApplyUserDefaultsFromKeepingAccountPermissions(user, EffectiveUserDefaults())
} else {
applyUserSettingsDefaults(user)
}
if keepProfile {
settings.ExpandProfileIntoUser(user, requestedProfile)
}
if preserveRequirePasswordChange {
user.RequirePasswordChange = true
}
+34
View File
@@ -22,3 +22,37 @@ func TestErrToStatusWrapsUnsupportedPreviewFormat(t *testing.T) {
t.Fatalf("ErrToStatus() = %d, want %d", status, http.StatusUnsupportedMediaType)
}
}
func TestIsViewerOriginalPreview(t *testing.T) {
tests := []struct {
name string
size string
file string
fileType string
docs bool // the server can render documents to an image
want bool
}{
{"Fuji RAW viewer", "original", "DSCF0308.RAF", "image/x-fuji-raf", false, true},
{"Canon RAW viewer", "original", "IMG_0001.CR3", "image/x-canon-cr3", false, true},
{"Adobe DNG viewer", "original", "a.dng", "image/x-adobe-dng", false, true},
{"HEIC viewer", "original", "a.heic", "image/heic", false, true},
{"HEIF viewer", "original", "a.heif", "image/heif", false, true},
{"rendered document viewer", "original", "a.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", true, true},
{"documents need the server to be able to render them", "original", "a.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", false, false},
{"RAW thumbnail stays disabled", "small", "DSCF0308.RAF", "image/x-fuji-raf", true, false},
{"RAW large stays disabled", "large", "DSCF0308.RAF", "image/x-fuji-raf", true, false},
{"no size stays disabled", "", "DSCF0308.RAF", "image/x-fuji-raf", true, false},
{"video poster stays disabled", "original", "a.mov", "video/quicktime", true, false},
{"audio cover stays disabled", "original", "a.mp3", "audio/mpeg", true, false},
{"JPEG is opened directly, not through previews", "original", "a.jpg", "image/jpeg", true, false},
{"PDF is opened directly, not through previews", "original", "a.pdf", "application/pdf", true, false},
{"text stays disabled", "original", "a.txt", "text/plain", true, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isViewerOriginalPreview(tt.size, tt.file, tt.fileType, tt.docs); got != tt.want {
t.Fatalf("isViewerOriginalPreview(%q, %q, %q, %v) = %v, want %v", tt.size, tt.file, tt.fileType, tt.docs, got, tt.want)
}
})
}
}
+34 -2
View File
@@ -38,6 +38,31 @@ func isClientCancellation(ctx context.Context, err error) bool {
return errors.Is(err, context.Canceled)
}
// viewerConvertedDocExtensions are the documents the viewer opens as a rendered image through the
// "original" preview (keep in sync with pdfConvertable in frontend Preview.vue).
var viewerConvertedDocExtensions = map[string]bool{
".xps": true, ".mobi": true, ".fb2": true, ".cbz": true, ".svg": true,
".docx": true, ".pptx": true, ".xlsx": true, ".hwp": true, ".hwpx": true,
}
// isViewerOriginalPreview reports whether a preview request is the viewer opening a file the browser can't
// display by itself (camera RAW, HEIC/HEIF, rendered documents). Unlike a thumbnail, without it the file can't
// be opened at all, so it is served even when thumbnails are disabled. documentRenderable tells whether the
// server can render documents of this kind to an image (see iteminfo.HasDocConvertableExtension).
func isViewerOriginalPreview(previewSize, fileName, fileType string, documentRenderable bool) bool {
if previewSize != "original" {
return false
}
ext := strings.ToLower(filepath.Ext(fileName))
if iteminfo.IsRawImage(ext) {
return true
}
if fileType == "image/heic" || fileType == "image/heif" {
return true
}
return documentRenderable && viewerConvertedDocExtensions[ext]
}
// previewHandler handles the preview request for images.
// @Summary Get image preview
// @Description Returns a preview image based on the requested path and size.
@@ -55,7 +80,9 @@ func isClientCancellation(ctx context.Context, err error) bool {
// @Failure 501 {object} map[string]string "Preview generation not implemented"
// @Router /api/resources/preview [get]
func previewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
if settings.Config.Server.DisablePreviews {
previewsDisabled := settings.Config.Server.DisablePreviews
// Only the viewer's "original" requests can pass when previews are disabled (checked below once the file is known).
if previewsDisabled && r.URL.Query().Get("size") != "original" {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
}
path := r.URL.Query().Get("path")
@@ -85,6 +112,9 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, er
return ErrToStatus(err), err
}
d.FileInfo = *fileInfo
if previewsDisabled && !isViewerOriginalPreview(r.URL.Query().Get("size"), fileInfo.Name, fileInfo.Type, iteminfo.HasDocConvertableExtension(fileInfo.Name, fileInfo.Type)) {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
}
status, err := PreviewHelperFunc(w, r, d)
if err != nil {
// Error already logged in PreviewHelperFunc or its callees
@@ -109,7 +139,9 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, er
// @Failure 501 {object} map[string]string "Previews disabled globally, for this share, or for upload shares"
// @Router /public/api/resources/preview [get]
func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
if settings.Config.Server.DisablePreviews || d.Share.DisableThumbnails {
previewSize := r.URL.Query().Get("size")
if (settings.Config.Server.DisablePreviews || d.Share.DisableThumbnails) &&
!isViewerOriginalPreview(previewSize, d.FileInfo.Name, d.FileInfo.Type, iteminfo.HasDocConvertableExtension(d.FileInfo.Name, d.FileInfo.Type)) {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
}
if d.Share.ShareType == "upload" {
+3 -1
View File
@@ -176,6 +176,8 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
}
// Parse the JSON into the UserRequest struct
var req UserRequest
// Seed with defaults so the decode below only overrides fields the request sent.
state.PrefillUserProfileDefaults(&req.User)
if err = json.Unmarshal(body, &req); err != nil {
return http.StatusBadRequest, err
}
@@ -199,7 +201,7 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
return status, err
}
plaintextPassword := req.User.Password
err = state.CreateUser(&req.User, plaintextPassword)
err = state.CreateUserKeepingProfile(&req.User, plaintextPassword)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -0,0 +1,73 @@
package web
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
// Omitted permissions and omitted profile keys come from userDefaults; a partially
// specified fileLoading object only overrides the keys it contains.
func TestUsersPostOmittedFieldsFallBackToDefaults(t *testing.T) {
setupTestEnv(t)
defaults := settings.Config.UserDefaults
defaults.UI.Locale = "de"
defaults.Account.Permissions.Share = true
defaults.FileLoading.MaxConcurrent = 7
defaults.FileLoading.UploadChunkSize = 9
state.SetEffectiveUserDefaultsForTest(t, defaults)
admin := createPostTestAdmin(t)
body := []byte(`{"which":[],"data":{"username":"partial","password":"secret","loginMethod":"password",
"fileLoading":{"maxConcurrentUpload":3}}}`)
req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body))
status, err := usersPostHandler(httptest.NewRecorder(), req, &Context{User: admin})
if err != nil || status != http.StatusCreated {
t.Fatalf("usersPostHandler() status=%d err=%v", status, err)
}
got, err := state.GetUserByUsername("partial")
if err != nil {
t.Fatal(err)
}
if got.Locale != "de" {
t.Errorf("locale = %q, want default de", got.Locale)
}
if !got.Permissions.Share {
t.Errorf("share = false, want true from default permissions")
}
if got.FileLoading.MaxConcurrent != 3 {
t.Errorf("maxConcurrentUpload = %d, want requested 3", got.FileLoading.MaxConcurrent)
}
if got.FileLoading.UploadChunkSize != 9 {
t.Errorf("uploadChunkSizeMb = %d, want default 9", got.FileLoading.UploadChunkSize)
}
}
// A create request must not mutate the shared in-memory userDefaults (json.Unmarshal
// reuses slice backing arrays).
func TestUsersPostDoesNotMutateUserDefaults(t *testing.T) {
setupTestEnv(t)
defaults := settings.Config.UserDefaults
defaults.Listing.NewFileTemplate = []string{"default.txt"}
state.SetEffectiveUserDefaultsForTest(t, defaults)
admin := createPostTestAdmin(t)
body := []byte(`{"which":[],"data":{"username":"nomutate","password":"secret","loginMethod":"password",
"newFileTemplate":["custom.txt"]}}`)
req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body))
status, err := usersPostHandler(httptest.NewRecorder(), req, &Context{User: admin})
if err != nil || status != http.StatusCreated {
t.Fatalf("usersPostHandler() status=%d err=%v", status, err)
}
if got := state.EffectiveUserDefaults().Listing.NewFileTemplate; len(got) != 1 || got[0] != "default.txt" {
t.Errorf("userDefaults newFileTemplate = %v, want [default.txt]", got)
}
}
@@ -0,0 +1,140 @@
package web
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func createPostTestAdmin(t *testing.T) *users.User {
t.Helper()
admin := &users.User{
ID: 1,
FrontendUser: users.FrontendUser{
Username: "admin",
LoginMethod: users.LoginMethodOidc,
Permissions: users.Permissions{Admin: true},
},
}
if err := state.CreateUser(admin, ""); err != nil {
t.Fatal(err)
}
return admin
}
// Issue #3322: profile/account values in POST /api/users must not be replaced by userDefaults.
func TestUsersPostKeepsRequestedProfileValues(t *testing.T) {
setupTestEnv(t)
defaults := settings.Config.UserDefaults
defaults.UI.Locale = "en"
defaults.UI.ThemeColor = "var(--blue)"
darkTrue := true
defaults.UI.DarkMode = &darkTrue
defaults.Account.LockPassword = false
defaults.Account.DisableSettings = false
defaults.Account.ShowAdvancedProfile = false
defaults.Listing.PromptRightCloseButton = false
defaults.Listing.NewFileTemplate = []string{""}
defaults.Listing.ShowHidden = true
state.SetEffectiveUserDefaultsForTest(t, defaults)
admin := createPostTestAdmin(t)
body := []byte(`{"which":[],"data":{
"username":"profiletest","password":"secret","loginMethod":"password",
"locale":"zhCN","themeColor":"var(--green)","darkMode":false,
"lockPassword":true,"disableSettings":true,"showAdvancedProfile":true,
"promptRightCloseButton":true,"newFileTemplate":["custom.txt"],
"preview":{"image":false},
"permissions":{"admin":false,"api":true,"share":true,"realtime":false},
"scopes":[{"name":"/","scope":"/","permissions":{"view":true,"download":true,"modify":true,"create":true,"delete":true}}]
}}`)
req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body))
rec := httptest.NewRecorder()
status, err := usersPostHandler(rec, req, &Context{User: admin})
if err != nil || status != http.StatusCreated {
t.Fatalf("usersPostHandler() status=%d err=%v", status, err)
}
got, err := state.GetUserByUsername("profiletest")
if err != nil {
t.Fatal(err)
}
if got.Locale != "zhCN" {
t.Errorf("locale = %q, want zhCN", got.Locale)
}
if got.ThemeColor != "var(--green)" {
t.Errorf("themeColor = %q, want var(--green)", got.ThemeColor)
}
if got.DarkMode {
t.Errorf("darkMode = true, want false")
}
if !got.LockPassword {
t.Errorf("lockPassword = false, want true")
}
if !got.DisableSettings {
t.Errorf("disableSettings = false, want true")
}
if !got.ShowAdvancedProfile {
t.Errorf("showAdvancedProfile = false, want true")
}
if !got.PromptRightCloseButton {
t.Errorf("promptRightCloseButton = false, want true")
}
if len(got.NewFileTemplate) != 1 || got.NewFileTemplate[0] != "custom.txt" {
t.Errorf("newFileTemplate = %v, want [custom.txt]", got.NewFileTemplate)
}
if got.Preview.Image {
t.Errorf("preview.image = true, want false")
}
// Omitted fields still come from userDefaults.
if !got.ShowHidden {
t.Errorf("showHidden = false, want true from userDefaults")
}
if !got.Preview.Video {
t.Errorf("preview.video = false, want true from userDefaults")
}
wantPerms := users.Permissions{Api: true, Share: true}
if got.Permissions.Admin != wantPerms.Admin || got.Permissions.Api != wantPerms.Api ||
got.Permissions.Share != wantPerms.Share || got.Permissions.Realtime != wantPerms.Realtime {
t.Errorf("permissions = %+v, want %+v", got.Permissions, wantPerms)
}
}
// Enforced userDefaults still win over request values on create.
func TestUsersPostEnforcedDefaultsWinOverRequest(t *testing.T) {
setupTestEnv(t)
defaults := settings.Config.UserDefaults
defaults.UI.Locale = "en"
state.SetEffectiveUserDefaultsForTest(t, defaults)
if err := state.PatchUserDefaultsEnforced([]byte(`{"ui":{"locale":true}}`)); err != nil {
t.Fatal(err)
}
admin := createPostTestAdmin(t)
body := []byte(`{"which":[],"data":{"username":"enforced","password":"secret","loginMethod":"password","locale":"zhCN","lockPassword":true}}`)
req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body))
rec := httptest.NewRecorder()
status, err := usersPostHandler(rec, req, &Context{User: admin})
if err != nil || status != http.StatusCreated {
t.Fatalf("usersPostHandler() status=%d err=%v", status, err)
}
got, err := state.GetUserByUsername("enforced")
if err != nil {
t.Fatal(err)
}
if got.Locale != "en" {
t.Errorf("locale = %q, want enforced en", got.Locale)
}
if !got.LockPassword {
t.Errorf("lockPassword = false, want true (not enforced)")
}
}
+4 -1
View File
@@ -1589,7 +1589,10 @@ func setFilePreviewFlags(fileInfo *iteminfo.ItemInfo, realPath string) {
}
switch simpleType {
case "image":
if fileInfo.Size > iteminfo.LargeFileSizeThreshold {
// Camera RAW previews are the embedded JPEG, which is read without decoding the RAW data,
// so large RAW files (medium format, uncompressed) don't need the size limit.
isRaw := iteminfo.IsRawImage(strings.ToLower(filepath.Ext(fileInfo.Name)))
if fileInfo.Size > iteminfo.LargeFileSizeThreshold && !isRaw {
fileInfo.HasPreview = false
return
}
+34
View File
@@ -0,0 +1,34 @@
package indexing
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
)
func TestSetFilePreviewFlagsLargeImages(t *testing.T) {
large := iteminfo.LargeFileSizeThreshold + 1
tests := []struct {
name string
file string
typ string
size int64
want bool
}{
{"normal image under the limit", "a.jpg", "image/jpeg", 5 << 20, true},
{"normal image over the limit", "a.jpg", "image/jpeg", int64(large), false},
{"RAW under the limit", "a.raf", "image/x-fuji-raf", 26 << 20, true},
{"large RAW keeps its preview, the embedded JPEG is read without decoding", "a.RAF", "image/x-fuji-raf", int64(large), true},
{"large DNG keeps its preview", "a.dng", "image/x-adobe-dng", int64(large), true},
{"empty file has no preview", "a.raf", "image/x-fuji-raf", 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := &iteminfo.ItemInfo{Name: tt.file, Type: tt.typ, Size: tt.size}
setFilePreviewFlags(info, "/nonexistent/"+tt.file)
if info.HasPreview != tt.want {
t.Fatalf("HasPreview = %v, want %v", info.HasPreview, tt.want)
}
})
}
}
+4 -3
View File
@@ -990,9 +990,10 @@ func SetDefaults(generate bool) Settings {
Locale: "en",
},
FileLoading: users.FileLoading{
MaxConcurrent: 10,
UploadChunkSize: 10, // 10MB
DownloadChunkSize: 0, // 0MB, default to no chunking
MaxConcurrent: 10,
UploadChunkSize: 10, // 10MB
DownloadChunkSize: 0, // 0MB, default to no chunking
UploadStallTimeoutSeconds: 10, // pause an upload after 10 seconds without progress
},
Account: UserDefaultsAccount{
Permissions: UserDefaultsAccountPermissions{
+4 -3
View File
@@ -71,9 +71,10 @@ type UserDefaultsUIEnforcement struct {
}
type UserDefaultsFileLoadingEnforcement struct {
MaxConcurrent bool `json:"maxConcurrentUpload,omitempty"`
UploadChunkSize bool `json:"uploadChunkSizeMb,omitempty"`
DownloadChunkSize bool `json:"downloadChunkSizeMb,omitempty"`
MaxConcurrent bool `json:"maxConcurrentUpload,omitempty"`
UploadChunkSize bool `json:"uploadChunkSizeMb,omitempty"`
DownloadChunkSize bool `json:"downloadChunkSizeMb,omitempty"`
UploadStallTimeoutSeconds bool `json:"uploadStallTimeoutSeconds,omitempty"`
}
type UserDefaultsAccountEnforcement struct {
+6 -5
View File
@@ -78,11 +78,12 @@ type Server struct {
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
Logging []LogConfig `json:"logging" yaml:"logging"`
Sources []*Source `json:"sources" validate:"required,dive"`
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. Note: docker must also mount a persistent volume to persist the cache (default: false)
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
Filesystem Filesystem `json:"filesystem"` // filesystem settings
IndexSqlConfig IndexSqlConfig `json:"indexSqlConfig"` // Index database SQL configuration
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. Note: docker must also mount a persistent volume to persist the cache (default: false)
CacheDirMaxSizeMB int64 `json:"cacheDirMaxSizeMB"` // maximum size in MB of the preview cache (cacheDir/diskcache). Past it, the least recently used files are deleted. 0 means no limit (default: 0)
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
Filesystem Filesystem `json:"filesystem"` // filesystem settings
IndexSqlConfig IndexSqlConfig `json:"indexSqlConfig"` // Index database SQL configuration
// not exposed to config
SourceMap map[string]*Source `json:"-" validate:"omitempty"` // uses realpath as key
NameToSource map[string]*Source `json:"-" validate:"omitempty"` // uses name as key
@@ -60,9 +60,10 @@ var previewJSONSubfields = map[string]string{
}
var fileLoadingJSONSubfields = map[string]string{
"maxConcurrentUpload": "fileLoading.maxConcurrentUpload",
"uploadChunkSizeMb": "fileLoading.uploadChunkSizeMb",
"downloadChunkSizeMb": "fileLoading.downloadChunkSizeMb",
"maxConcurrentUpload": "fileLoading.maxConcurrentUpload",
"uploadChunkSizeMb": "fileLoading.uploadChunkSizeMb",
"downloadChunkSizeMb": "fileLoading.downloadChunkSizeMb",
"uploadStallTimeoutSeconds": "fileLoading.uploadStallTimeoutSeconds",
}
// EnforcedPathsTouchedByUserJSONField returns enforcement paths a user JSON field name may affect.
+8
View File
@@ -6535,6 +6535,10 @@ const docTemplate = `{
"description": "whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)",
"type": "boolean"
},
"cacheDirMaxSizeMB": {
"description": "maximum size in MB of the preview cache (cacheDir/diskcache). Past it, the least recently used files are deleted. 0 means no limit (default: 0)",
"type": "integer"
},
"database": {
"description": "SQLite database configuration",
"allOf": [
@@ -7711,6 +7715,10 @@ const docTemplate = `{
},
"uploadChunkSizeMb": {
"type": "integer"
},
"uploadStallTimeoutSeconds": {
"description": "seconds without progress before an upload is paused, 0 uses the default (10)",
"type": "integer"
}
}
},
+8
View File
@@ -6524,6 +6524,10 @@
"description": "whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)",
"type": "boolean"
},
"cacheDirMaxSizeMB": {
"description": "maximum size in MB of the preview cache (cacheDir/diskcache). Past it, the least recently used files are deleted. 0 means no limit (default: 0)",
"type": "integer"
},
"database": {
"description": "SQLite database configuration",
"allOf": [
@@ -7700,6 +7704,10 @@
},
"uploadChunkSizeMb": {
"type": "integer"
},
"uploadStallTimeoutSeconds": {
"description": "seconds without progress before an upload is paused, 0 uses the default (10)",
"type": "integer"
}
}
},
+9
View File
@@ -890,6 +890,11 @@ definitions:
docker must also mount a persistent volume to persist the cache (default:
false)'
type: boolean
cacheDirMaxSizeMB:
description: 'maximum size in MB of the preview cache (cacheDir/diskcache).
Past it, the least recently used files are deleted. 0 means no limit (default:
0)'
type: integer
database:
allOf:
- $ref: '#/definitions/settings.Database'
@@ -1719,6 +1724,10 @@ definitions:
type: integer
uploadChunkSizeMb:
type: integer
uploadStallTimeoutSeconds:
description: seconds without progress before an upload is paused, 0 uses the
default (10)
type: integer
type: object
users.FrontendScope:
properties:
+2
View File
@@ -50,6 +50,7 @@ server:
configured: false # true when explicitly set (allows intentional deny-all)
cacheDir: "tmp" # path to the cache directory, used for thumbnails and other cached files
cacheDirCleanup: false # whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
cacheDirMaxSizeMB: 0 # maximum size in MB of the preview cache (cacheDir/diskcache). Past it, the least recently used files are deleted. 0 means no limit (default: 0)
maxArchiveSize: 20 # maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
filesystem: # filesystem settings
createFilePermission: "644" # Unix permissions like 644, 755, 2755 (default: 644) validate:required,file_permission
@@ -223,6 +224,7 @@ userDefaults: # optional signup/CLI defaults; per-us
uploadChunkSizeMb: 10
clearAll: false
downloadChunkSizeMb: 0
uploadStallTimeoutSeconds: 10 # seconds without progress before an upload is paused, 0 uses the default (10)
account:
permissions:
api: false # allow api access
+12 -2
View File
@@ -790,7 +790,8 @@ export function post(
overwrite = false,
onupload,
headers = {},
isDir = false
isDir = false,
onsent
) {
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
@@ -840,6 +841,10 @@ export function post(
}
};
}
if (typeof onsent === "function") {
// The whole body has been sent; from here the server answers when it is done saving it.
request.upload.onload = () => onsent();
}
request.onload = () => {
if (request.status >= 200 && request.status < 300) {
@@ -1330,7 +1335,8 @@ export function postPublic(
overwrite = false,
onupload,
headers = {},
isDir = false
isDir = false,
onsent
) {
if (!hash || hash === undefined || hash === null) {
throw new Error('no hash provided')
@@ -1364,6 +1370,10 @@ export function postPublic(
}
};
}
if (typeof onsent === "function") {
// The whole body has been sent; from here the server answers when it is done saving it.
request.upload.onload = () => onsent();
}
const promise = new Promise((resolve, reject) => {
request.onload = () => {
@@ -6,6 +6,7 @@
<UserProfilePreferences
v-model="preferenceSections"
enforceable
hide-account-options
:enforced="enforced"
:config-locked-paths="lockedFromConfigPaths"
show-extension-inputs
@@ -295,6 +295,8 @@ const PROFILE_SNAPSHOT_FIELDS = [
"debugOffice",
"viewMode",
"gallerySize",
"promptRightCloseButton",
"newFileTemplate",
];
export default {
@@ -13,6 +13,7 @@
<UserProfilePreferences
v-if="session"
v-model="profileSections"
hide-account-options
:enforced="enforcedPreferences"
:default-expanded-section="null"
respect-enforced-policy
@@ -111,7 +111,7 @@
/>
</template>
</ProfileEnforceableField>
<div class="preference-field-block">
<div v-if="!hideAccountOptions" class="preference-field-block">
<h4>{{ $t("profileSettings.accountOptions") }}</h4>
<div class="settings-items">
<ProfilePreferenceToggle
@@ -715,7 +715,7 @@
</SettingsItem>
<SettingsItem
v-if="sectionVisible('accountOptions')"
v-if="sectionVisible('accountOptions') && !hideAccountOptions"
aria-label="accountOptions"
name="accountOptions"
:accordion="!sectionKey"
@@ -825,6 +825,11 @@ export default {
type: String,
default: null,
},
/** Suppress the account options block when a parent renders UserDefaultsAccountSection alongside this component. */
hideAccountOptions: {
type: Boolean,
default: false,
},
},
emits: ["update:modelValue", "change", "enforced-change", "theme-color", "locale-change"],
data() {
+3 -1
View File
@@ -716,7 +716,9 @@
"clearAllDescription": "سيؤدي زر مسح المكتمل إلى مسح جميع التحميلات في حالة الانتهاء، بما في ذلك حالات الخطأ والإيقاف المؤقت والتعارض.",
"uploadSettings": "إعدادات التحميل",
"downloadChunkSizeMb": "حجم قطعة التحميل بالميجابايت",
"downloadChunkSizeMbHelp": "حجم القطع للتحميلات المقطعة. سيتم تنزيل الملفات الأكبر من هذا الحجم على شكل أجزاء لمنع حدوث مشكلات في المهلة (على سبيل المثال، أخطاء Cloudflare 524). يؤدي الإعداد إلى 0 إلى تعطيل التنزيلات المقطعة."
"downloadChunkSizeMbHelp": "حجم القطع للتحميلات المقطعة. سيتم تنزيل الملفات الأكبر من هذا الحجم على شكل أجزاء لمنع حدوث مشكلات في المهلة (على سبيل المثال، أخطاء Cloudflare 524). يؤدي الإعداد إلى 0 إلى تعطيل التنزيلات المقطعة.",
"uploadStallTimeoutSeconds": "مهلة توقف الرفع بالثواني",
"uploadStallTimeoutSecondsHelp": "عدد الثواني التي يمكن أن يستمر فيها الرفع دون أي تقدم قبل إيقافه مؤقتًا. ارفعها في الاتصالات البطيئة أو غير المستقرة، أو عندما تُقرأ الملفات من أقراص بطيئة. القيم الأقل من 1 تستخدم القيمة الافتراضية وهي 10 ثوانٍ."
},
"profileSettings": {
"showTypeColumn": "إظهار عمود النوع",
+2
View File
@@ -821,6 +821,8 @@
"uploadChunkSizeMbHelp": "Обикновено 5 до 50 MB, по-малък е по-добър за нестабилни или ограничени връзки. Задаването на 0 ще изключи качването на части.",
"downloadChunkSizeMb": "Размер на частите за изтегляне в MB",
"downloadChunkSizeMbHelp": "Размер на частите за изтегляне на части. Файлове, по-големи от този размер, ще бъдат изтегляни на части, за да се предотвратят проблеми с изтичане на време (напр. Cloudflare 524 грешки). Задаването на 0 изключва изтеглянето на части.",
"uploadStallTimeoutSeconds": "Време за изчакване при блокирало качване (секунди)",
"uploadStallTimeoutSecondsHelp": "Секунди, през които едно качване може да е без напредък, преди да бъде поставено на пауза. Увеличете стойността при бавни или нестабилни връзки или когато файловете се четат от бавни дискове. Стойности под 1 използват стойността по подразбиране от 10 секунди.",
"clearAll": "Изчистването на завършените трябва да премахва грешки, паузи и конфликти",
"clearAllDescription": "Бутонът за изчистване на завършените ще изчисти всички качвания в завършено състояние, включително грешки, паузи и конфликти.",
"uploadSettings": "Настройки за качване"
+349 -347
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -716,7 +716,9 @@
"clearAllDescription": "Mit der Schaltfläche Abgeschlossen löschen werden alle Uploads im abgeschlossenen Zustand gelöscht, einschließlich Fehler-, Pausen- und Konfliktstatus.",
"uploadSettings": "Upload-Einstellungen",
"downloadChunkSizeMb": "Größe des heruntergeladenen Stücks in MB",
"downloadChunkSizeMbHelp": "Chunk-Größe für Chunked-Downloads. Dateien, die größer als diese Größe sind, werden in Chunks heruntergeladen, um Timeout-Probleme zu vermeiden (z. B. Cloudflare 524-Fehler). Mit dem Wert 0 werden Chunked Downloads deaktiviert."
"downloadChunkSizeMbHelp": "Chunk-Größe für Chunked-Downloads. Dateien, die größer als diese Größe sind, werden in Chunks heruntergeladen, um Timeout-Probleme zu vermeiden (z. B. Cloudflare 524-Fehler). Mit dem Wert 0 werden Chunked Downloads deaktiviert.",
"uploadStallTimeoutSeconds": "Zeitlimit für stockende Uploads in Sekunden",
"uploadStallTimeoutSecondsHelp": "Sekunden, die ein Upload ohne Fortschritt bleiben darf, bevor er pausiert wird. Erhöhen Sie den Wert bei langsamen oder instabilen Verbindungen oder wenn die Dateien von langsamen Datenträgern gelesen werden. Werte unter 1 verwenden den Standardwert von 10 Sekunden."
},
"colors": {
"red": "rot",
+3 -1
View File
@@ -716,7 +716,9 @@
"clearAllDescription": "Το κουμπί Clear completed θα διαγράψει όλες τις μεταφορτώσεις σε κατάσταση ολοκλήρωσης, συμπεριλαμβανομένων των καταστάσεων σφάλματος, παύσης και σύγκρουσης.",
"uploadSettings": "Ρυθμίσεις φόρτωσης",
"downloadChunkSizeMb": "Μέγεθος τμήματος λήψης σε MB",
"downloadChunkSizeMbHelp": "Μέγεθος τεμαχίου για λήψεις σε τεμάχια. Αρχεία μεγαλύτερα από αυτό το μέγεθος θα κατεβαίνουν σε κομμάτια για να αποφευχθούν προβλήματα χρονικού ορίου (π.χ. σφάλματα Cloudflare 524). Η ρύθμιση σε 0 απενεργοποιεί τις ομαδοποιημένες λήψεις."
"downloadChunkSizeMbHelp": "Μέγεθος τεμαχίου για λήψεις σε τεμάχια. Αρχεία μεγαλύτερα από αυτό το μέγεθος θα κατεβαίνουν σε κομμάτια για να αποφευχθούν προβλήματα χρονικού ορίου (π.χ. σφάλματα Cloudflare 524). Η ρύθμιση σε 0 απενεργοποιεί τις ομαδοποιημένες λήψεις.",
"uploadStallTimeoutSeconds": "Χρονικό όριο διακοπής μεταφόρτωσης σε δευτερόλεπτα",
"uploadStallTimeoutSecondsHelp": "Δευτερόλεπτα που μπορεί να περάσουν χωρίς καμία πρόοδο σε μια μεταφόρτωση πριν τεθεί σε παύση. Αυξήστε το σε αργές ή ασταθείς συνδέσεις ή όταν τα αρχεία διαβάζονται από αργούς δίσκους. Τιμές μικρότερες από 1 χρησιμοποιούν την προεπιλογή των 10 δευτερολέπτων."
},
"colors": {
"red": "κόκκινο",
+2
View File
@@ -841,6 +841,8 @@
"uploadChunkSizeMbHelp": "Typically 5 to 50 MB, smaller is better for unstable or restricted connections. Setting to 0 will disable upload chunking.",
"downloadChunkSizeMb": "Download chunk size in MB",
"downloadChunkSizeMbHelp": "Chunk size for chunked downloads. Files larger than this size will be downloaded in chunks to prevent timeout issues (e.g., Cloudflare 524 errors). Setting to 0 disables chunked downloads.",
"uploadStallTimeoutSeconds": "Upload stall timeout in seconds",
"uploadStallTimeoutSecondsHelp": "Seconds an upload can go without any progress before it is paused. Increase it on slow or unstable connections, or when the files are read from slow disks. Values below 1 use the default of 10 seconds.",
"clearAll": "Clear completed should remove error, pause, and conflict",
"clearAllDescription": "The clear completed button will clear all uploads in a finished state, including error, paused, and conflict states.",
"uploadSettings": "Upload Settings"
+3 -1
View File
@@ -716,7 +716,9 @@
"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",
"downloadChunkSizeMb": "Tamaño del fragmento de descarga en MB",
"downloadChunkSizeMbHelp": "Tamaño de fragmento para descargas. Los archivos mayores que este tamaño se descargarán en fragmentos, lo cual puede ayudar a evitar problemas de tiempo de espera (por ejemplo, errores Cloudflare 524). Si lo pones en 0, se desactivarán las descargas fragmentadas."
"downloadChunkSizeMbHelp": "Tamaño de fragmento para descargas. Los archivos mayores que este tamaño se descargarán en fragmentos, lo cual puede ayudar a evitar problemas de tiempo de espera (por ejemplo, errores Cloudflare 524). Si lo pones en 0, se desactivarán las descargas fragmentadas.",
"uploadStallTimeoutSeconds": "Tiempo de espera sin avance de la subida en segundos",
"uploadStallTimeoutSecondsHelp": "Segundos que una subida puede estar sin avanzar antes de pausarse. Auméntalo en conexiones lentas o inestables, o si los archivos se leen de discos lentos. Con valores menores que 1 se usa el valor por defecto de 10 segundos."
},
"colors": {
"red": "Rojo",
+2
View File
@@ -841,6 +841,8 @@
"uploadChunkSizeMbHelp": "Normalean 5 eta 50 MB artean; txikiagoa hobea da konexio ezegonkor edo mugatuetarako. 0 ezarriz gero, zatikako igoera desgaitzen da.",
"downloadChunkSizeMb": "Deskarga-zatien tamaina (MB)",
"downloadChunkSizeMbHelp": "Zatikako deskargetarako zatien tamaina. Tamaina hau baino handiagoak diren fitxategiak zatika deskargatuko dira denbora-mugako arazoak saihesteko (adib. Cloudflare-ren 524 akatsak). 0 ezarriz gero, zatikako deskargak desgaitzen dira.",
"uploadStallTimeoutSeconds": "Igoeraren geldialdi-denbora segundotan",
"uploadStallTimeoutSecondsHelp": "Igoera bat aurrerapenik gabe egon daitekeen segundo kopurua, pausatu aurretik. Handitu ezazu konexio motel edo ezegonkorretan, edo fitxategiak disko motelretatik irakurtzen direnean. 1 baino txikiagoko balioek 10 segundoko balio lehenetsia erabiltzen dute.",
"clearAll": "\"Garbitu osatuak\" aukerak akatsak, pausak eta gatazkak ere kendu behar ditu",
"clearAllDescription": "\"Garbitu osatuak\" botoiak amaitutako egoeran dauden igoera guztiak garbituko ditu, akats, pausa eta gatazka egoerak barne.",
"uploadSettings": "Igoeraren ezarpenak"
+4 -2
View File
@@ -637,7 +637,7 @@
"newUser": "Nouvel utilisateur",
"newFolder": "Nouveau dossier",
"profileSettings": "Paramètres du profil",
"shareSettings": "Partager les paramètres",
"shareSettings": "Paramètres des partages",
"globalSettings": "Paramètres généraux",
"userManagement": "Gestion des utilisateurs",
"shareManagement": "Gestion des actions",
@@ -716,7 +716,9 @@
"clearAllDescription": "Le bouton \"Effacer les importations terminées\" efface toutes les importations terminées, y compris les erreurs, les pauses et les conflits.",
"uploadSettings": "Paramètres d'importation",
"downloadChunkSizeMb": "Taille du bloc de téléchargement en Mo",
"downloadChunkSizeMbHelp": "Taille des morceaux pour les téléchargements par morceaux. Les fichiers dont la taille est supérieure à cette valeur seront téléchargés par morceaux afin d'éviter les problèmes de dépassement de délai (par exemple, les erreurs Cloudflare 524). La valeur 0 désactive les téléchargements par morceaux."
"downloadChunkSizeMbHelp": "Taille des morceaux pour les téléchargements par morceaux. Les fichiers dont la taille est supérieure à cette valeur seront téléchargés par morceaux afin d'éviter les problèmes de dépassement de délai (par exemple, les erreurs Cloudflare 524). La valeur 0 désactive les téléchargements par morceaux.",
"uploadStallTimeoutSeconds": "Délai d'inactivité du téléversement en secondes",
"uploadStallTimeoutSecondsHelp": "Nombre de secondes pendant lesquelles un téléversement peut rester sans progression avant d'être mis en pause. Augmentez-le sur les connexions lentes ou instables, ou lorsque les fichiers sont lus depuis des disques lents. Les valeurs inférieures à 1 utilisent la valeur par défaut de 10 secondes."
},
"colors": {
"red": "rouge",
+3 -1
View File
@@ -716,7 +716,9 @@
"clearAllDescription": "הכפתור 'נקה את ההשלמות' ינקה את כל ההעלאות במצב 'הושלם', כולל מצבי שגיאה, השהיה וקונפליקט.",
"uploadSettings": "הגדרות העלאה",
"downloadChunkSizeMb": "גודל החלק להורדה במגה-בייט",
"downloadChunkSizeMbHelp": "גודל החלקים להורדות בחלקים. קבצים שגודלם עולה על גודל זה יועברו בחלקים כדי למנוע בעיות של זמן המתנה (לדוגמה, שגיאות Cloudflare 524). הגדרה ל-0 מבטלת הורדות בחלקים."
"downloadChunkSizeMbHelp": "גודל החלקים להורדות בחלקים. קבצים שגודלם עולה על גודל זה יועברו בחלקים כדי למנוע בעיות של זמן המתנה (לדוגמה, שגיאות Cloudflare 524). הגדרה ל-0 מבטלת הורדות בחלקים.",
"uploadStallTimeoutSeconds": "זמן קצוב להעלאה תקועה בשניות",
"uploadStallTimeoutSecondsHelp": "מספר השניות שהעלאה יכולה להישאר ללא התקדמות לפני שהיא מושהית. הגדילו אותו בחיבורים איטיים או לא יציבים, או כשהקבצים נקראים מדיסקים איטיים. ערכים הנמוכים מ-1 משתמשים בברירת המחדל של 10 שניות."
},
"colors": {
"red": "אדום",
+3 -1
View File
@@ -716,7 +716,9 @@
"clearAllDescription": "A befejezett állapot törlése gomb az összes befejezett állapotban lévő feltöltést törli, beleértve a hiba, a szüneteltetés és a konfliktus állapotokat is.",
"uploadSettings": "Feltöltési beállítások",
"downloadChunkSizeMb": "Letöltési darabok mérete MB-ban",
"downloadChunkSizeMbHelp": "Darabolt letöltések esetén a darabolt méret. Az ennél nagyobb méretű fájlok letöltése darabokban történik, hogy elkerülhetők legyenek az időkorlátozási problémák (pl. Cloudflare 524 hiba). A 0-ra állítás kikapcsolja a darabolt letöltéseket."
"downloadChunkSizeMbHelp": "Darabolt letöltések esetén a darabolt méret. Az ennél nagyobb méretű fájlok letöltése darabokban történik, hogy elkerülhetők legyenek az időkorlátozási problémák (pl. Cloudflare 524 hiba). A 0-ra állítás kikapcsolja a darabolt letöltéseket.",
"uploadStallTimeoutSeconds": "Feltöltés elakadási időkorlátja másodpercben",
"uploadStallTimeoutSecondsHelp": "Hány másodpercig maradhat egy feltöltés előrehaladás nélkül, mielőtt szüneteltetjük. Növelje lassú vagy instabil kapcsolatoknál, illetve ha a fájlokat lassú lemezekről olvassák. Az 1-nél kisebb értékek az alapértelmezett 10 másodpercet használják."
},
"colors": {
"red": "piros",
+243 -241
View File
@@ -11,6 +11,9 @@
"general": {
"import": "Importa",
"export": "Esporta",
"to": "a{suffix}",
"and": "e{suffix}",
"or": "oppure{suffix}",
"divider": "Divisore{suffix}",
"office": "Office{suffix}",
"format": "Formato{suffix}",
@@ -34,6 +37,7 @@
"permanent": "Permanente{suffix}",
"hash": "Hash{suffix}",
"of": "di{suffix}",
"source": "{prefix}Fonte{suffix}",
"percentage": "{percentage}%",
"type": "Tipo{suffix}",
"size": "Dimensione{suffix}",
@@ -43,6 +47,7 @@
"logout": "Esci{suffix}",
"preview": "Anteprima{suffix}",
"settings": "Impostazioni{suffix}",
"sources": "{prefix}Fonti{suffix}",
"links": "Link{suffix}",
"tool": "Strumento{suffix}",
"status": "Stato{suffix}",
@@ -51,6 +56,7 @@
"users": "Utenti{suffix}",
"scopes": "Ambiti{suffix}",
"photos": "Foto",
"audio": "Audio{suffix}",
"videos": "Video{suffix}",
"documents": "Documenti{suffix}",
"archives": "Archivi{suffix}",
@@ -69,6 +75,7 @@
"navigation": "Navigazione{suffix}",
"loading": "Caricamento{suffix}",
"add": "Aggiungi{suffix}",
"back": "Indietro{suffix}",
"stay": "Rimani{suffix}",
"cancel": "Annulla{suffix}",
"change": "Cambia{suffix}",
@@ -82,6 +89,8 @@
"download": "Scarica{suffix}",
"file": "File{suffix}",
"folder": "Cartella{suffix}",
"camera": "Fotocamera{suffix}",
"video": "Video{suffix}",
"info": "Info{suffix}",
"move": "Sposta{suffix}",
"new": "Nuovo{suffix}",
@@ -108,16 +117,10 @@
"lines": "Linee{suffix}",
"lastUpdate": "Ultimo aggiornamento{suffix}",
"latency": "Latenza{suffix}",
"audio": "Audio{suffix}",
"readOnly": "Solo lettura{suffix}",
"private": "Privato{suffix}",
"yes": "Sì{suffix}",
"no": "No{suffix}",
"to": "a{suffix}",
"and": "e{suffix}",
"or": "oppure{suffix}",
"camera": "Fotocamera{suffix}",
"video": "Video{suffix}",
"anonymous": "anonimo",
"details": "Dettagli{suffix}",
"ipAddress": "Indirizzo IP{suffix}",
@@ -130,7 +133,11 @@
"created": "Creato{suffix}",
"actions": "Azioni{suffix}",
"enabled": "Abilitato{suffix}",
"disabled": "Disabile{suffix}",
"disabled": "Disabilitato{suffix}",
"limit": "Limite{suffix}",
"remove": "Rimuovi{suffix}",
"saved": "Salvato{suffix}",
"removed": "Rimosso{suffix}",
"permissions": "Autorizzazioni{suffix}",
"management": "Gestione{suffix}",
"profile": "Profilo{suffix}",
@@ -139,44 +146,38 @@
"pinnedFolders": "Cartelle in evidenza",
"pinnedFiles": "File in evidenza",
"selectionSingle": "elemento selezionato",
"selectionMultiple": "articoli selezionati",
"selectionMultiple": "elementi selezionati",
"newUser": "Nuovo utente",
"newFolder": "Nuova cartella",
"profileSettings": "Impostazioni del profilo",
"shareSettings": "Condividi le impostazioni",
"shareSettings": "Impostazioni condivisione",
"globalSettings": "Impostazioni generali",
"userManagement": "Gestione degli utenti",
"shareManagement": "Gestione delle azioni",
"shareHash": "Condividi hash",
"shareManagement": "Gestione condivisioni",
"shareHash": "Hash condivisione",
"downloadFiles": "Scarica i file",
"viewFiles": "Visualizza ed elenca i file",
"editFiles": "Modifica file",
"createFiles": "Crea file",
"deleteFiles": "Elimina i file",
"shareFiles": "Condividi file",
"shareTheme": "Condividi il tema",
"shareTitle": "Condividi titolo",
"shareTheme": "Tema condivisione",
"shareTitle": "Titolo condivisione",
"allUsers": "Tutti gli utenti",
"allSources": "Tutte le fonti",
"allShares": "Tutte le azioni",
"allShares": "Tutte le condivisioni",
"playPause": "Riproduci/Pausa",
"clearSelection": "Deseleziona",
"interval": "Intervallo{suffix}",
"none": "Nessuno",
"viewFiles": "Visualizza ed elenca i file",
"source": "{prefix}Fonte{suffix}",
"sources": "{prefix}Fonti{suffix}",
"enforce": "Applicare{suffix}",
"limit": "Limite{suffix}",
"remove": "Rimuovi{suffix}",
"saved": "Salvato{suffix}",
"removed": "Rimosso{suffix}",
"back": "Indietro{suffix}"
"enforce": "Applica{suffix}"
},
"buttons": {
"copySuccess": "Copiato negli appunti!",
"copyFailed": "Copia negli appunti fallita",
"generateNewOtp": "Resetta e genera nuovo codice a due fattori",
"copyFile": "Copia file",
"copyPath": "Copia percorso",
"copyToClipboard": "Copia negli appunti",
"copyDownloadLinkToClipboard": "Copia link di download negli appunti",
"moveFile": "Sposta file",
@@ -200,11 +201,10 @@
"closeAnyway": "Chiudi comunque",
"goToItem": "Vai all'elemento",
"goToSource": "Vai alla posizione sorgente",
"copyPath": "Percorso di copia",
"refresh": "Aggiorna",
"pinItem": "Appunta",
"unpinItem": "Rimuovi puntina",
"sendToApp": "Invia all'app",
"refresh": "Aggiorna"
"sendToApp": "Invia all'app"
},
"errors": {
"forbidden": "Non hai i permessi per accedere a questa risorsa.",
@@ -216,8 +216,6 @@
"requestTimedOut": "La richiesta ha superato il limite di tempo. Controlla le impostazioni di rete o del proxy inverso."
},
"files": {
"sortByCreationTime": "Ordina per data di creazione",
"creationTime": "Data di creazione",
"emptyFile": "File vuoto",
"lastModified": "Ultima modifica{suffix}",
"duration": "Durata{suffix}",
@@ -228,34 +226,38 @@
"sortBySize": "Ordina per dimensione",
"sortByType": "Ordina per tipo",
"sortByDuration": "Ordina per durata",
"sortByCreationTime": "Ordina per data di creazione",
"creationTime": "Data di creazione",
"noPreview": "Anteprima non disponibile per questo file.",
"mobilePdfPreviewHint": "Il tuo browser non è in grado di visualizzare questo PDF nell'anteprima dell'app.",
"noDownloadAccess": "Il download non è disponibile.",
"newFile": "Nuovo file{suffix}",
"newFolder": "Nuova cartella{suffix}",
"sameFolder": "Impossibile spostare nella stessa cartella",
"cannotAccesDir": "Impossibile accedere alla cartella",
"newFolderMessage": "Inserisci il nome della nuova cartella.",
"newFolderSuccess": "La cartella è stata creata con successo!",
"mobilePdfPreviewHint": "Il tuo browser non è in grado di visualizzare questo PDF nell'anteprima dell'app."
"newFolderSuccess": "La cartella è stata creata con successo!"
},
"help": {
"functionKeys": "Tasti funzione",
"mouseActions": "Azioni del mouse",
"fileOperations": "Operazioni sui file",
"click": "Seleziona un file o una cartella",
"dbClick": "Fare doppio clic su uno spazio vuoto per selezionare tutti gli elementi",
"marqueeSelect": "seleziona più file o cartelle trascinandone il cursore su uno spazio vuoto",
"ctrl": {
"f1": "Passa alla vista elenco",
"f2": "Passa alla vista normale",
"f3": "Passa alla vista galleria",
"scroll": "Modificare le dimensioni dell'elenco e il carattere dell'editor",
"click": "seleziona più file o cartelle",
"c": "copia elementi selezionati",
"v": "incolla elementi copiati/tagliati",
"x": "taglia elementi selezionati",
"d": "scarica elementi selezionati",
"b": "attiva/disattiva barra laterale",
"comma": "vai alle impostazioni",
"e": "Passa da editor a visualizzatore markdown (su file .md)",
"click": "seleziona più file o cartelle"
"e": "Passa da editor a visualizzatore markdown (su file .md)"
},
"del": "elimina elementi selezionati",
"esc": "pulisci selezione e/o chiudi il prompt",
@@ -265,6 +267,8 @@
"slash": "attiva ricerca",
"description": "Puoi visualizzare le opzioni di navigazione di base qui sotto. Per ulteriori informazioni, visita",
"wiki": "Documentazione ufficiale FileBrowser Quantum",
"altArrowUp": "Vai alla cartella principale",
"altArrowDown": "Apri il file/la cartella selezionati",
"media": {
"title": "Lettore multimediale",
"playPause": "Riproduci/Pausa",
@@ -278,11 +282,7 @@
"playbackModes": "Passa tra le modalità di riproduzione (escluso loop e singola)",
"fullscreen": "Attiva/disattiva schermo intero nei video",
"captions": "Attiva/disattiva sottotitoli"
},
"altArrowUp": "Vai alla cartella principale",
"altArrowDown": "Apri il file/la cartella selezionati",
"click": "Seleziona un file o una cartella",
"marqueeSelect": "seleziona più file o cartelle trascinandone il cursore su uno spazio vuoto"
}
},
"languages": {
"ar": "العربية",
@@ -304,10 +304,10 @@
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
"ro": "Romanian",
"ro": "Rumeno",
"ru": "Русский",
"sk": "Slovenčina",
"svSE": "Swedish (Sweden)",
"svSE": "Svedese (Svezia)",
"tr": "Türkçe",
"ua": "Українська",
"zhCN": "中文 (简体)",
@@ -329,8 +329,29 @@
"requirePasswordChangeSuccess": "La password è stata aggiornata. Ora hai effettuato l'accesso.",
"requirePasswordChangeMustDiffer": "La nuova password deve essere diversa da quella attuale"
},
"quotas": {
"title": "Quota di archiviazione",
"openDescription": "Apri la finestra di dialogo relativa alla quota di spazio di archiviazione per questa cartella.",
"folderDescription": "Limita lo spazio di archiviazione per questa cartella.",
"shareLimit": "Limita lo spazio di archiviazione su questa condivisione",
"shareLimitDescription": "Conta i caricamenti effettuati tramite questo link. Si applica anche alla quota della sorgente del proprietario.",
"scopeLimit": "Limite di archiviazione per questa sorgente",
"scopeLimitDescription": "Limita lo spazio di archiviazione assegnato a questo utente su questa sorgente.",
"indexingDisabledMeterHint": "L'indicizzazione è disabilitata su questa sorgente. È disponibile solo il conteggio dell'utilizzo tracciato.",
"meterIndexHint": "Utilizza la dimensione totale dei file indicizzati in questa cartella. Include le modifiche apportate al di fuori di FileBrowser al momento dell'aggiornamento dell'indice.",
"meterAccountedHint": "Conta solo i caricamenti, i salvataggi e le eliminazioni effettuati tramite FileBrowser.",
"meterIndexScope": "Dimensione indicizzata",
"meterAccounted": "Monitoraggio dell'utilizzo",
"usageCounting": "Conta l'utilizzo in base a",
"usageSyncPending": "L'utilizzo è ancora in fase di sincronizzazione con l'indice.",
"accountedFallback": "La dimensione indicizzata non è ancora pronta; fino a quando l’indice non sarà disponibile, verrà utilizzato il monitoraggio dell’utilizzo.",
"errors": {
"exceeded": "Quota di archiviazione superata"
}
},
"share": {
"notice": "Nota: quando viene creata una condivisione, essa diventa pubblica e segue regole di accesso diverse. Se non configurata correttamente, chiunque potrebbe accedervi.",
"readOnlySourceNotice": "Questa sorgente è di sola lettura. I link di condivisione con caricamento e i permessi che modificano i file sono disabilitati.",
"disableAnonymous": "Disabilita accesso anonimo",
"disableThumbnails": "Disabilita miniature",
"enableAllowedUsernames": "Condividi solo con determinati utenti",
@@ -380,6 +401,8 @@
"disableLoginOption": "Nascondi opzione di login",
"disableLoginOptionDescription": "Nasconde il pulsante di accesso nella barra laterale per gli utenti anonimi.",
"customizeSidebarLinksButton": "Personalizza Link",
"shareDefaults": "Impostazioni predefinite di condivisione",
"shareDefaultsDescription": "Configura le opzioni predefinite per le nuove condivisioni e applica criteri vincolanti per gli utenti non amministratori.",
"browseFiles": "Sfoglia file",
"selectBannerImage": "Seleziona immagine banner",
"selectFaviconImage": "Seleziona favicon",
@@ -396,10 +419,7 @@
"shareInfo": "QR Code e Info Condivisione",
"shareInfoDescription": "Mostra le informazioni della condivisione (titolo, descrizione, banner, QR) in un popup.",
"downloadDescription": "Permette di scaricare l'intero contenuto della condivisione come file compresso.",
"deleteFailed": "Eliminazione condivisione fallita",
"readOnlySourceNotice": "Questa sorgente è di sola lettura. Le azioni di caricamento e le autorizzazioni che modificano i file sono disabilitate.",
"shareDefaults": "Impostazioni predefinite di condivisione",
"shareDefaultsDescription": "Configurare le opzioni predefinite per le nuove condivisioni e applicare le politiche agli utenti non amministratori."
"deleteFailed": "Eliminazione condivisione fallita"
},
"prompts": {
"noTemplate": "Il tuo modello è vuoto!",
@@ -423,25 +443,26 @@
"deleteTitle": "Elimina file",
"displayName": "Nome visualizzato:",
"downloadMessage": "Scegli il formato in cui vuoi scaricare.",
"downloadFailed": "Il download in chunk non è riuscito: se il problema si ripete, provare a ridurre la dimensione del chunk: {message}",
"archive": "Crea archivio",
"archiveMessage": "Crea un archivio sul server dagli elementi selezionati.",
"archiveDestination": "Scegli la cartella di destinazione (poi inserisci il nome dell'archivio sotto):",
"archiveDestination": "Scegli la cartella di destinazione (poi inserisci il nome dell'archivio in basso):",
"archiveName": "Nome archivio (default archive.zip):",
"archiveCompression": "Livello compressione (0–9):",
"archiveCompression": "Livello di compressione (0-9):",
"archiveSuccess": "Archivio creato con successo!",
"unarchive": "Estrai archivio",
"unarchiveMessage": "Scegli dove estrarre l'archivio.",
"unarchiveDestination": "Estrai in:",
"unarchiveSuccess": "Archivio estratto con successo!",
"zipFilenameEncoding": "Filename encoding",
"zipEncodingLoading": "Checking ZIP filenames…",
"zipEncodingError": "Could not read the ZIP filename preview.",
"zipEncodingRetry": "Retry preview",
"zipEncodingChoose": "Choose an encoding",
"zipEncodingSuggested": "suggested",
"zipEncodingHint": "Check the filenames below before extracting. Suggestions may be incorrect; ZIP Unicode metadata takes precedence.",
"zipFilenamePreview": "Filename preview",
"zipEncodingUnavailable": "No supported encoding can decode all filenames in this ZIP.",
"zipFilenameEncoding": "Codifica dei nomi dei file",
"zipEncodingLoading": "Controllo dei nomi dei file nello ZIP…",
"zipEncodingError": "Impossibile leggere l'anteprima dei nomi dei file dello ZIP.",
"zipEncodingRetry": "Riprova anteprima",
"zipEncodingChoose": "Scegli una codifica",
"zipEncodingSuggested": "consigliata",
"zipEncodingHint": "Verifica i nomi dei file sottostanti prima di estrarre. I suggerimenti potrebbero non essere corretti; i metadati Unicode dello ZIP hanno la precedenza.",
"zipFilenamePreview": "Anteprima nomi dei file",
"zipEncodingUnavailable": "Nessuna codifica supportata può decodificare tutti i nomi dei file in questo ZIP.",
"dragAndDrop": "Trascina i file qui",
"error": "Qualcosa è andato storto",
"completed": "Completato",
@@ -455,7 +476,7 @@
"renameMessageConflict": "Un elemento chiamato \"{filename}\" esiste già!",
"renameMessage": "Inserisci un nuovo nome:",
"renameMessageInvalid": "Un file non può contenere \"/\" o \"\\\"",
"renameFailed": "Rinomina fallita",
"renameFailed": "Rinomina non riuscita",
"replaceMessage": "Uno dei file che stai caricando è in conflitto a causa del nome. Vuoi continuare il caricamento o sostituire quello esistente?\n",
"saveBeforeExit": "Modifiche non salvate",
"saveBeforeExitMessage": "Hai modifiche non salvate nell'editor. Cosa vorresti fare?",
@@ -483,7 +504,7 @@
"additionalInfo": "Informazioni aggiuntive",
"hidden": "Nascosto",
"hasPreview": "Ha anteprima",
"checksums": "Checksums",
"checksums": "Checksum",
"hashAlgorithm": "Algoritmo Hash",
"hashValue": "Valore Hash",
"selectHashAlgorithm": "Seleziona un algoritmo hash",
@@ -497,45 +518,45 @@
"halloweenBody": "Il tema di Halloween è stato applicato. Puoi disabilitarlo o cambiarlo permanentemente nelle impostazioni del profilo.",
"operationFailed": "Operazione fallita",
"partialSuccess": "Alcune operazioni sono fallite: {succeeded} riuscite, {failed} fallite",
"downloadFailed": "Il download in chunk non è riuscito: se il problema si ripete, provare a ridurre la dimensione del chunk: {message}",
"confirmPasswordToSaveUser": "Immettere la password attuale per salvare le modifiche apportate all'utente."
},
"search": {
"searchContext": "Contesto di ricerca: {context}",
"onlyFolders": "Solo cartelle",
"onlyFiles": "Solo file",
"filesAndFolders": "File e cartelle",
"allFileTypes": "Tutti i tipi di file",
"notEnoughCharacters": "Caratteri insufficienti per la ricerca (minimo {minSearchLength})",
"typeToSearch": "Digita {minSearchLength} o più caratteri per iniziare la ricerca.",
"noResults": "Nessun risultato trovato.",
"smallerThan": "Minore di:",
"largerThan": "Maggiore di:",
"olderThanDate": "Più vecchio di:",
"newerThanDate": "Più recente di:",
"showPreviewImages": "Mostra anteprime immagini",
"showPreviewImagesDescription": "Mostra miniature nei risultati di ricerca quando disponibili e icone grandi per tutti i risultati.",
"noResults": "Nessun risultato trovato.",
"helpText1": "La ricerca avviene per ogni carattere digitato ({minSearchLength} minimo).",
"helpText2": "Corrispondenza di sottostringhe solo sui nomi di file o cartelle (non sui percorsi completi). case:exact per una corrispondenza sensibile alle maiuscole e alle minuscole.\n\n per OR tra termini usare il carattere pipe; doppi apici per una frase.\n\nAbilitare la ricerca con caratteri jolly per opzioni più avanzate.",
"useWildcardSearch": "Ricerca con caratteri jolly (sensibile alle maiuscole)",
"useWildcardSearchDescription": "Opzioni jolly supportate:\n\n- Corrisponde a qualsiasi sequenza: *\n\n- Corrisponde a un solo carattere: ?\n\n- Classe di caratteri: [a-z], [0-9]\n\n- Negazione: [^0-9]\n\n- I caratteri letterali *, ?, o [ devono essere racchiusi tra parentesi, ad esempio [*], [?], [[]\n\n- Tutti i risultati sono sensibili alle maiuscole e alle minuscole",
"openInAdvancedSearch": "Aprire la ricerca avanzata",
"caseExact": "Corrispondenza che distingue tra maiuscole e minuscole",
"caseExactDescription": "Abbinare i nomi di file e cartelle utilizzando la distinzione esatta tra maiuscole e minuscole, anziché ignorarla.",
"filesAndFolders": "File e cartelle",
"allFileTypes": "Tutti i tipi di file"
"helpText1": "La ricerca avviene per ogni carattere digitato ({minSearchLength} minimo).",
"helpText2": "Corrispondenza di sottostringhe solo sui nomi di file o cartelle (non sui percorsi completi). case:exact per una corrispondenza sensibile alle maiuscole e alle minuscole.\n\n per OR tra termini usare il carattere pipe; doppi apici per una frase.\n\nAbilitare la ricerca con caratteri jolly per opzioni più avanzate.",
"openInAdvancedSearch": "Aprire la ricerca avanzata"
},
"profileSettings": {
"showTypeColumn": "Mostra colonna Tipo",
"showTypeColumnDescription": "Mostra la colonna Tipo nelle viste elenco e compatta nell'elenco principale.",
"showCreationDateColumn": "Mostra colonna Data di creazione",
"showCreationDateColumnDescription": "Mostra la colonna Data di creazione nelle viste elenco e compatta nell'elenco principale.",
"promptRightCloseButton": "Mostra il pulsante di chiusura dei prompt a destra",
"promptRightCloseButtonDescription": "Mostra il pulsante di chiusura dei prompt sul lato destro invece che sul sinistro",
"enforcedByAdmin": "L'amministratore ha impedito agli utenti di modificare questa proprietà",
"hideFileExt": "Nascondere determinati file (per estensione)",
"hideFileExtDescription": "Un elenco di estensioni di file separate da spazi. Le estensioni inserite in questo campo verranno nascoste da tutta l'interfaccia utente (visivamente, come nell'elenco o nell'albero dei file) — Rimangono comunque accessibili.",
"preferEditorForMarkdown": "Preferisci editor per file Markdown",
"preferEditorForMarkdownDescription": "Se abilitato, apre i file markdown nell'editor invece che nel visualizzatore.",
"deleteAfterArchive": "Elimina sorgenti dopo creazione/estrazione archivio",
"deleteAfterArchiveDescription": "Se abilitato, eliminerà i file sorgente dopo aver creato o estratto con successo un archivio.",
"defaultViewMode": "Modalità di visualizzazione predefinita",
"defaultViewModeDescription": "Modalità di visualizzazione iniziale su nuovi browser o dispositivi. Non sovrascrive le preferenze di visualizzazione già salvate localmente.",
"defaultGallerySize": "Dimensione predefinita delle miniature",
"defaultGallerySizeDescription": "Scala iniziale delle miniature della galleria o dell'elenco (1–9) sui nuovi browser o dispositivi. Non sovrascrive le dimensioni già salvate localmente.",
"hideFilesInTree": "Nascondi file nell'albero di navigazione",
"hideFilesInTreeDescription": "Se abilitato, solo le cartelle verranno mostrate nella navigazione ad albero laterale.",
"previewFolderDescription": "Mostra l'anteprima delle cartelle usando il primo elemento interno come copertina.",
@@ -546,16 +567,24 @@
"customTheme": "Scegli il tuo tema",
"defaultThemeDescription": "default - Il tema predefinito",
"setDateFormat": "Imposta formato data esatto",
"singleClick": "Basta un clic per aprire i file",
"stickySidebar": "Barra laterale fissa",
"darkMode": "Modalità scura",
"showHiddenFiles": "Mostra file nascosti",
"showHiddenFilesDescription": "Mostra i file che iniziano con un punto o quelli di sistema.",
"showQuickDownload": "Mostra icona download rapido",
"showQuickDownloadDescription": "Aggiunge un'icona su ogni file per scaricarlo istantaneamente con un click.",
"previewDescription": "Mostra le anteprime per {filetype}, mostrerà un'icona generica se disabilitata.",
"previewMotion": "Anteprime animate",
"previewMotionVideosDescription": "L'anteprima animata cicla i fotogrammi del video o le immagini di una cartella al passaggio del mouse.",
"popupPreview": "Visualizzatore pop-up",
"popupPreviewDescription": "Mostra una finestra in sovrapposizione quando si passa sopra elementi con miniature.",
"disableUpdateNotifications": "Disabilita notifiche di aggiornamento",
"disableUpdateNotificationsDescription": "Nasconde il banner degli aggiornamenti disponibile agli amministratori.",
"accountOptions": "Opzioni dell'account",
"showAdvancedProfile": "Mostra le impostazioni avanzate del profilo",
"showAdvancedProfileDescription": "Mostra le impostazioni del profilo suddivise per categorie nella barra laterale, anziché la visualizzazione semplificata del profilo.",
"disableHideSidebar": "Mostra sempre la barra laterale",
"disableHideSidebarDescription": "Impedisce alla barra laterale di nascondersi automaticamente.",
"autoplayMedia": "Riproduzione automatica media",
"autoplayMediaDescription": "Avvia automaticamente i file audio/video all'apertura.",
@@ -566,6 +595,14 @@
"disableSearchOptions": "Disabilita opzioni di ricerca",
"disableSearchOptionsDescription": "Nasconde le opzioni di ricerca nella barra laterale.",
"hideSidebarFileActions": "Nascondi azioni file nella barra laterale",
"showToolsInSidebar": "Mostra gli strumenti nella barra laterale",
"showToolsInSidebarDescription": "Mostrate sempre il link agli strumenti nella barra laterale.",
"showCopyPath": "Mostra Copia percorso nel menu contestuale",
"showCopyPathDescription": "Mostra l'azione Copia percorso nel menu contestuale di file e cartelle.",
"showTypeColumn": "Mostra colonna Tipo",
"showTypeColumnDescription": "Mostra la colonna Tipo nelle viste elenco e compatta nell'elenco principale.",
"showCreationDateColumn": "Mostra colonna Data di creazione",
"showCreationDateColumnDescription": "Mostra la colonna Data di creazione nelle viste elenco e compatta nell'elenco principale.",
"deleteWithoutConfirming": "Elimina file senza conferma",
"deleteWithoutConfirmingDescription": "Elimina i file istantaneamente (usare con cautela).",
"previewOfficeDescription": "Mostra miniature per i file office nell'elenco.",
@@ -580,9 +617,6 @@
"thumbnailOptions": "Opzioni Miniature",
"showThumbnails": "Mostra miniature per elementi supportati",
"showThumbnailsDescription": "Spegni per nascondere tutte le miniature nell'elenco.",
"showCopyPath": "Mostra Copia percorso nel menu contestuale",
"showCopyPathDescription": "Mostra l'azione Copia percorso nel menu contestuale di file e cartelle.",
"previewDescription": "Mostra le anteprime per {filetype}, mostrerà un'icona generica se disabilitata.",
"passkeys": "Passkey",
"passkeysDescription": "Utilizza le passkey per un'autenticazione sicura multi-fattore senza password. Registra una passkey sul tuo dispositivo per utilizzare la verifica biometrica o il PIN invece dei codici OTP.",
"noPasskeys": "Nessuna passkey registrata.",
@@ -594,83 +628,81 @@
"passkeyDeleteFailed": "Impossibile eliminare la passkey",
"created": "Creato",
"lastUsed": "Ultimo utilizzo",
"passkeyDefaultName": "Passkey",
"showToolsInSidebar": "Mostra gli strumenti nella barra laterale",
"showToolsInSidebarDescription": "Mostrate sempre il link agli strumenti nella barra laterale.",
"enforcedByAdmin": "L'amministratore ha impedito agli utenti di modificare questa proprietà",
"singleClick": "Basta un clic per aprire i file",
"stickySidebar": "Barra laterale fissa",
"darkMode": "Modalità scura",
"defaultViewMode": "Modalità di visualizzazione predefinita",
"defaultViewModeDescription": "Modalità di visualizzazione iniziale su nuovi browser o dispositivi. Non sovrascrive le preferenze di visualizzazione già salvate localmente.",
"defaultGallerySize": "Dimensione predefinita delle miniature",
"defaultGallerySizeDescription": "Scala iniziale delle miniature della galleria o dell'elenco (1–9) sui nuovi browser o dispositivi. Non sovrascrive le dimensioni già salvate localmente.",
"disableHideSidebar": "Mostra sempre la barra laterale",
"showAdvancedProfile": "Mostra le impostazioni avanzate del profilo",
"showAdvancedProfileDescription": "Mostra le impostazioni del profilo suddivise per categorie nella barra laterale, anziché la visualizzazione semplificata del profilo.",
"accountOptions": "Opzioni dell'account"
"passkeyDefaultName": "Passkey"
},
"settings": {
"systemAdmin": "Sistema e Admin",
"configViewer": "Visualizzatore Config",
"configViewerShowFull": "Mostra config completa",
"configViewerShowComments": "Mostra commenti",
"configViewerOpen": "Visualizza configurazione",
"configViewerDescription": "Apri una vista in sola lettura della configurazione corrente del server.",
"userDefaults": "Impostazioni predefinite dell'utente",
"userEditPreferences": "Preferenze utente",
"userEditTools": "Accesso agli strumenti",
"userEditToolsDescription": "Configurare a quali strumenti questo utente può accedere",
"userEditSidebarLinks": "Link nella barra laterale",
"userDefaultsDescription": "Configura le impostazioni predefinite applicate ai nuovi utenti e i criteri applicati a tutti gli utenti.",
"editAsYaml": "Modifica come YAML",
"userDefaultFieldLockedFromConfig": "Questa impostazione predefinita è configurata nel file di configurazione e non può essere modificata in questa sede.",
"accountDefaults": "Impostazioni predefinite dell'account",
"disableUserSettings": "Disattiva la pagina delle impostazioni utente",
"analyticsView": "Visualizza le statistiche",
"analyticsViewDescription": "Apri una vista in sola lettura dello snapshot delle analisi di distribuzione raccolto da questo server.",
"analyticsEnabled": "Invia i dati analitici relativi alla distribuzione",
"analyticsEnabledDescription": "L'invio facoltativo delle statistiche di utilizzo invierà informazioni anonime ai server degli sviluppatori di FileBrowser Quantum per contribuire a migliorare l'applicazione.",
"analyticsLoadFailed": "Impossibile caricare le impostazioni di analisi",
"analyticsUpdateFailed": "Impossibile aggiornare le impostazioni di analisi",
"analyticsPreviewFailed": "Impossibile caricare le informazioni diagnostiche relative all'analisi",
"enterPassword": "Inserisci password",
"enterPasswordAgain": "Inserisci di nuovo la password",
"passwordRequirementsMatch": "Le password corrispondono",
"passwordRequirementsMinLength": "Almeno {min} caratteri",
"loginMethod": "Metodo di accesso",
"loginMethods": {
"password": "Password"
},
"modifyOtherUser": "Modifica utente:",
"modifyOtherUserTitle": "Modifica utente: {username}",
"themeColor": "Colore del tema",
"listingOptions": "Opzioni elenco",
"searchOptions": "Opzioni ricerca",
"emptyGlobal": "Al momento non ci sono opzioni di configurazione globale. Puoi modificare il file yaml per le impostazioni di sistema.",
"scopes": "Definisci le sorgenti permesse e l'ambito dell'utente",
"scopes": "Definisci le sorgenti consentite e l'ambito di accesso (scope) dell'utente al loro interno",
"scopePath": "Percorso dell'ambito (scope)",
"userNotAdmin": "L'utente non è un amministratore",
"avoidChanges": "seleziona anche 'Cambia Password' per applicarla",
"lockPassword": "Impedisci all'utente di cambiare la password",
"requirePasswordChange": "Richiedi la modifica della password al prossimo accesso",
"permissions": {
"admin": "Amministratore",
"api": "Gestione Token API a lunga durata",
"realtime": "Abilita connessioni in tempo reale"
},
"permissionsHelp": "Le autorizzazioni globali per l'utente si applicano ovunque, incluse tutte le sorgenti e le condivisioni.",
"sourcePermissionsHelp": "Espandi la riga di una sorgente per impostare le autorizzazioni di visualizzazione, download, modifica, creazione ed eliminazione per quella specifica sorgente.",
"sourceAccessDefaultsHelp": "Permessi predefiniti applicati ai file sorgente e ai nuovi utenti. L'opzione \"Enforce\" blocca un valore per tutti gli utenti non amministratori.",
"settingsUpdated": "Impostazioni aggiornate!",
"shareDeleted": "Condivisione eliminata!",
"userDeleted": "Utente eliminato!",
"userUpdated": "Utente aggiornato!",
"modifyOtherUserTitle": "Modifica utente: {username}",
"configViewerOpen": "Visualizza configurazione",
"configViewerDescription": "Apri una vista in sola lettura della configurazione corrente del server.",
"analyticsView": "Visualizza le statistiche",
"analyticsViewDescription": "Apri una vista in sola lettura dello snapshot delle analisi di distribuzione raccolto da questo server.",
"analyticsEnabled": "Invia i dati analitici relativi alla distribuzione",
"analyticsEnabledDescription": "L'analisi dell'implementazione con consenso esplicito invierà informazioni anonime ai server degli sviluppatori di FileBrowser Quantum per contribuire a migliorare l'applicazione.",
"analyticsLoadFailed": "Impossibile caricare le impostazioni di analisi",
"analyticsUpdateFailed": "Impossibile aggiornare le impostazioni di analisi",
"analyticsPreviewFailed": "Impossibile caricare le informazioni diagnostiche relative all'analisi",
"scopePath": "Percorso di copertura",
"sourcePermissionsHelp": "Espandi una riga di origine per impostare le autorizzazioni di visualizzazione, download, modifica, creazione ed eliminazione per quella origine.",
"permissionsHelp": "Le autorizzazioni globali dell'utente si applicano ovunque, comprese tutte le fonti e le condivisioni.",
"userDefaults": "Impostazioni predefinite dell'utente",
"userDefaultsDescription": "Configurare le impostazioni predefinite da applicare ai nuovi utenti e le politiche da far rispettare a tutti gli utenti.",
"accountDefaults": "Impostazioni predefinite dell'account",
"disableUserSettings": "Disattiva la pagina delle impostazioni utente",
"userDefaultFieldLockedFromConfig": "Questa impostazione predefinita è configurata nel file di configurazione e non può essere modificata in questa sede.",
"sourceAccessDefaultsHelp": "Permessi predefiniti applicati ai file sorgente e ai nuovi utenti. L'opzione \"Enforce\" blocca un valore per tutti gli utenti non amministratori.",
"editAsYaml": "Modifica come YAML",
"userEditPreferences": "Preferenze utente",
"userEditTools": "Accesso agli strumenti",
"userEditToolsDescription": "Configurare a quali strumenti questo utente può accedere",
"userEditSidebarLinks": "Link nella barra laterale",
"passwordRequirementsMatch": "Le password corrispondono",
"passwordRequirementsMinLength": "Almeno {min} caratteri",
"requirePasswordChange": "Richiedi la modifica della password al prossimo accesso"
"userUpdated": "Utente aggiornato!"
},
"time": {
"time": "{prefix}Ora{suffix}",
"minute": "{prefix}minuto{suffix}",
"minutes": "{prefix}Minuti{suffix}",
"hour": "{prefix}ora{suffix}",
"hours": "{prefix}Ore{suffix}",
"day": "{prefix}giorno{suffix}",
"days": "{prefix}Giorni{suffix}",
"months": "{prefix}Mesi{suffix}",
"timeUnit": "Unità di tempo"
},
"sidebar": {
"dividerNamePlaceholder": "Testo divisore (vuoto per una linea)",
"updateIsAvailable": "Un aggiornamento è disponibile!",
"fileActions": "Azioni file",
"switchMode": "Passa dai link all'albero di navigazione e viceversa",
"customizeLinks": "Personalizza Link Barra Laterale",
"customizeLinksDescription": "Personalizza i link mostrati nella barra. Trascina per riordinare.",
"customizeShareLinksDescription": "Personalizza i link per questa condivisione.",
@@ -685,6 +717,10 @@
"usageTextDisplay": "Quale testo di utilizzo visualizzare?",
"usageTextIndexed": "utilizzo come indicizzato",
"usageTextDisk": "utilizzo come riportato dal disco",
"showIndexedUsage": "Mostra l'utilizzo come indicizzato",
"showDiskUsage": "Mostra l'utilizzo in base ai dati riportati dal disco/dalla partizione",
"rootFilesystemOnly": "Limita l'utilizzo del disco al filesystem di origine",
"rootFilesystemOnlyDescription": "Se abilitata, viene segnalato solo il filesystem contenente il percorso di origine. Se disabilitata (impostazione predefinita), il totale del disco include anche i filesystem montati sotto il percorso di origine, con i pool condivisi come i dataset ZFS conteggiati una sola volta.",
"selectSource": "Seleziona Sorgente",
"chooseSource": "Scegli una sorgente",
"selectShare": "Seleziona Condivisione",
@@ -701,16 +737,11 @@
"pickIcon": "Scegli un'Icona",
"pickIconDescription": "Cerca e seleziona un'icona dalla libreria Material Icons",
"sourceNotAccessible": "Questa sorgente non è più accessibile",
"showIndexedUsage": "Mostra l'utilizzo come indicizzato",
"showDiskUsage": "Mostra l'utilizzo in base ai dati riportati dal disco/dalla partizione",
"sidebarLinkDefaults": "Impostazioni predefinite dei link nella barra laterale",
"sidebarLinkDefaultsDescription": "Configurare i collegamenti predefiniti e obbligatori nella barra laterale per tutti gli utenti.",
"sidebarLinkDefaultsHelp": "Questi sono i link predefiniti che verranno aggiunti per tutti i nuovi utenti. Se si imposta un link come obbligatorio, questo verrà aggiunto per tutti gli utenti e non potrà essere rimosso dagli utenti che non sono amministratori.",
"sidebarLinkDefaultSourceHelp": "Si applica solo se l'utente ha accesso a questa fonte.",
"sidebarLinkDefaultsYamlInvalid": "YAML non valido. Controlla il formato e riprova.",
"switchMode": "Passa dai link all'albero di navigazione e viceversa",
"rootFilesystemOnly": "Limita l'utilizzo del disco al filesystem di origine",
"rootFilesystemOnlyDescription": "Se abilitata, viene segnalato solo il filesystem contenente il percorso di origine. Se disabilitata (impostazione predefinita), il totale del disco include anche i filesystem montati sotto il percorso di origine, con i pool condivisi come i dataset ZFS conteggiati una sola volta."
"sidebarLinkDefaultSourceHelp": "Si applica solo se l'utente ha accesso a questa sorgente.",
"sidebarLinkDefaultsYamlInvalid": "YAML non valido. Controlla il formato e riprova."
},
"api": {
"title": "Token API",
@@ -764,8 +795,32 @@
},
"access": {
"access": "Accesso",
"groups": "Gruppi",
"newGroup": "Nuovo gruppo",
"editGroup": "Modifica gruppo",
"groupName": "Nome del gruppo",
"userGroups": "Gruppi",
"userGroupsDescription": "Gruppi di cui fa parte questo utente",
"groupMembers": "Membri",
"membersCount": "Membri: {count}",
"userGroupsCount": "Gruppi: {count}",
"groupNameRequired": "Il nome del gruppo è obbligatorio.",
"groupNameTooShort": "Il nome del gruppo deve contenere almeno {min} caratteri.",
"groupNameTooLong": "Il nome del gruppo deve contenere al massimo {max} caratteri.",
"groupNameInvalid": "Il nome del gruppo non deve contenere caratteri di controllo.",
"groupExists": "Esiste già un gruppo con questo nome.",
"deleteGroupConfirm": "Eliminare il gruppo \"{name}\"? Verrà rimosso anche da tutte le regole di accesso.",
"groupsOidcNote": "I membri dei gruppi sincronizzati tramite SSO (OIDC, LDAP, JWT, proxy) vengono reimpostati dal provider di identità a ogni accesso di tali utenti.",
"selectEntities": "Seleziona",
"selectUser": "Seleziona utente",
"selectUsers": "Seleziona gli utenti",
"selectGroup": "Seleziona un gruppo",
"selectGroups": "Seleziona i gruppi",
"refineSearch": "Visualizzazione dei primi {count} risultati. Affina la ricerca per vederne altri.",
"all": "Nega Tutto",
"rules": "Regole di accesso",
"hasRules": "Regole di accesso configurate",
"manageDescription": "Apri le regole di accesso per questo percorso.",
"deny": "Nega",
"totalDenied": "Negati",
"totalAllowed": "Consentiti",
@@ -775,32 +830,8 @@
"accessManagement": "Gestione Accessi",
"defaultBehavior": "Azione predefinita{suffix}",
"defaultBehaviorDescription": "Cosa succede quando non esistono regole specifiche. 'Consenti' permette l'accesso, 'Nega' blocca tutto tranne gli esplicitamente autorizzati.",
"cascadeDelete": "Eliminazione Ricorsiva",
"cascadeDeleteDescription": "Se abilitato, l'eliminazione di una regola rimuoverà anche quelle nelle sottodirectory.",
"hasRules": "Regole di accesso configurate",
"manageDescription": "Regole di libero accesso per questo percorso.",
"groups": "Gruppi",
"newGroup": "Nuovo gruppo",
"editGroup": "Modifica gruppo",
"groupName": "Nome del gruppo",
"userGroups": "Gruppi",
"groupMembers": "Membri",
"deleteGroupConfirm": "Eliminare il gruppo \"{name}\"? Verrà rimosso anche da tutte le regole di accesso.",
"groupsOidcNote": "I membri dei gruppi sincronizzati tramite OIDC vengono reimpostati dal provider di identità a ogni accesso di tali utenti.",
"userGroupsDescription": "Gruppi di cui fa parte questo utente",
"membersCount": "Membri: {count}",
"userGroupsCount": "Gruppi: {count}",
"groupNameRequired": "Il nome del gruppo è obbligatorio.",
"groupNameTooShort": "Group name must be at least {min} characters.",
"groupNameTooLong": "Il nome del gruppo deve contenere al massimo {max} caratteri.",
"groupNameInvalid": "Il nome del gruppo non deve contenere caratteri di controllo.",
"groupExists": "Esiste già un gruppo con questo nome.",
"selectEntities": "Seleziona",
"selectUser": "Seleziona utente",
"selectUsers": "Seleziona gli utenti",
"selectGroup": "Seleziona un gruppo",
"selectGroups": "Seleziona i gruppi",
"refineSearch": "Vengono visualizzati i primi risultati per \" {count} \". Affina la ricerca per visualizzarne altri."
"cascadeDelete": "Eliminazione ricorsiva",
"cascadeDeleteDescription": "Se abilitato, l'eliminazione di una regola di accesso la rimuoverà anche da tutte le sottocartelle. Ciò consente di rimuovere completamente un utente o un gruppo dall'intero albero delle cartelle."
},
"fileLoading": {
"title": "Caricamenti e Download",
@@ -810,6 +841,8 @@
"uploadChunkSizeMbHelp": "In genere 5-50 MB. Valori bassi sono migliori per reti instabili. 0 disabilita il frazionamento.",
"downloadChunkSizeMb": "Dimensione chunk download in MB",
"downloadChunkSizeMbHelp": "I file più grandi verranno scaricati a pezzi per evitare timeout (es. errori Cloudflare 524). 0 disabilita.",
"uploadStallTimeoutSeconds": "Timeout di stallo del caricamento in secondi",
"uploadStallTimeoutSecondsHelp": "Secondi per cui un caricamento può restare senza alcun progresso prima di essere messo in pausa. Aumentalo con connessioni lente o instabili, o quando i file vengono letti da dischi lenti. I valori inferiori a 1 usano il valore predefinito di 10 secondi.",
"clearAll": "Rimuovi tutto (errori, pause, conflitti)",
"clearAllDescription": "Il pulsante pulisci completati rimuoverà anche gli upload in stato di errore, pausa o conflitto.",
"uploadSettings": "Impostazioni Caricamento"
@@ -891,8 +924,8 @@
"undo": "Annulla",
"redo": "Ripeti",
"moreOptions": "Altre opzioni",
"inlineMath": "Matematica in linea",
"displayMath": "Matematica a blocco",
"inlineMath": "Formula matematica in linea",
"displayMath": "Formula matematica a blocco",
"highlight": "Evidenzia",
"superscript": "Apice",
"subscript": "Pedice",
@@ -907,7 +940,7 @@
"fontColor": "Colore del carattere"
},
"json": {
"formatJSON": "Formato JSON",
"formatJSON": "Formatta JSON",
"formatJSONSuccess": "Formattazione JSON completata con successo",
"invalidJSON": "JSON non valido: {message}"
}
@@ -962,14 +995,14 @@
"title": "Analizzatore Dimensioni File",
"largerThan": "Maggiore di (MB)",
"includeFolders": "Includi Cartelle",
"includeFoldersDescription": "Includi le cartelle nell'analisi",
"totalFiles": "File Totali{suffix}",
"totalSize": "Dimensione Totale{suffix}",
"emptyState": "Inserisci i criteri e clicca Analizza per visualizzare le dimensioni",
"completeResults": "Risultati completi mostrati!",
"incompleteResults": "I risultati potrebbero essere incompleti",
"relativePercentage": "Percentuale Relativa{suffix}",
"percentageOfResults": "{percentage}% dei risultati",
"includeFoldersDescription": "Includi le cartelle nell'analisi"
"percentageOfResults": "{percentage}% dei risultati"
},
"fileTypes": {
"video": "Video",
@@ -988,6 +1021,10 @@
"tools": {
"title": "Strumenti",
"description": "Utility e strumenti di analisi per i tuoi file",
"toolAccessDefaults": "Impostazioni predefinite per l'accesso agli strumenti",
"toolAccessDefaultsDescription": "Configurare a quali strumenti i nuovi utenti possono accedere per impostazione predefinita",
"toolAccessDefaultsHelp": "Abilita gli strumenti per i nuovi utenti e, se lo desideri, rendi l'accesso obbligatorio in modo che gli utenti non amministratori non possano modificarlo.",
"accessDenied": "Non hai accesso a questo strumento.",
"sizeAnalyzer": {
"name": "Analizzatore Dimensioni File",
"description": "Visualizza file e cartelle di grandi dimensioni con una mappa ad albero interattiva"
@@ -996,6 +1033,20 @@
"name": "Ricerca File Duplicati",
"description": "Trova e gestisci i file duplicati per recuperare spazio"
},
"advancedSearch": {
"name": "Ricerca avanzata",
"description": "Ricerca nell'indice con pieno controllo delle fonti, dei filtri e delle opzioni",
"searchTerms": "Termini di ricerca",
"matchAllTerms": "Abbinare tutti i termini",
"matchAllTermsDescription": "Se abilitato, tutti i termini di ricerca devono essere trovati per ogni file.",
"scopeAria": "Limitare la ricerca a una cartella",
"noTerms": "Inserite almeno un termine di ricerca.",
"termTooShort": "Ogni termine di ricerca deve essere composto da almeno {minSearchLength} caratteri.",
"resultsTitle": "Risultati della ricerca",
"invalidSources": "Accendere almeno una sorgente.",
"noResults": "Nessun file o cartella corrispondente.",
"resultLimit": "Numero massimo di risultati di ricerca"
},
"materialIconPicker": {
"name": "Selettore Icone Material",
"description": "Sfoglia e cerca icone Material con anteprima live",
@@ -1015,33 +1066,38 @@
"emptyState": "Scegli un file e clicca play per iniziare il monitoraggio",
"chooseFile": "Scegli un file"
},
"toolNotFound": "Strumento non trovato",
"advancedSearch": {
"name": "Ricerca avanzata",
"description": "Ricerca nell'indice con pieno controllo delle fonti, dei filtri e delle opzioni",
"searchTerms": "Termini di ricerca",
"matchAllTerms": "Abbinare tutti i termini",
"matchAllTermsDescription": "Se abilitato, tutti i termini di ricerca devono essere trovati per ogni file.",
"scopeAria": "Limitare la ricerca a una cartella",
"noTerms": "Inserite almeno un termine di ricerca.",
"termTooShort": "Ogni termine di ricerca deve essere composto da almeno {minSearchLength} caratteri.",
"resultsTitle": "Risultati della ricerca",
"invalidSources": "Accendere almeno una sorgente.",
"noResults": "Nessun file o cartella corrispondente.",
"resultLimit": "Numero massimo di risultati di ricerca"
},
"activityViewer": {
"name": "Visualizzatore attività",
"description": "Visualizza ed esporta l'attività degli utenti relativa a download, modifiche ai file, condivisioni e azioni amministrative",
"scopeAll": "Tutte le attività",
"scopeFiles": "Attività sui file",
"scopeAccess": "Attività di accesso",
"scopeShares": "Condividi l'attività",
"eventBulkDelete": "Eliminazione in blocco",
"eventFileModify": "Modifica file",
"eventShareCreate": "Creazione condivisione",
"eventShareUpdate": "Modifica condivisione",
"eventShareDelete": "Eliminazione condivisione",
"eventUserCreate": "Creazione utente",
"eventUserUpdate": "Aggiornamento utente",
"eventUserDelete": "Eliminazione utente",
"eventAccessCreate": "Creazione regola di accesso",
"eventAccessUpdate": "Modifica regola di accesso",
"eventAccessDelete": "Eliminazione regola di accesso",
"eventTokenCreate": "Creazione di un token",
"eventTokenDelete": "Eliminazione del token",
"webSessionNamed": "Sessione web ({name})",
"activityScope": "Categoria di attività",
"timeRange": "Intervallo di tempo",
"last1h": "Ultima ora",
"last24h": "Ultime 24 ore",
"last7d": "Ultimi 7 giorni",
"last30d": "Ultimi 30 giorni",
"customRange": "Gamma personalizzata",
"customRange": "Intervallo personalizzato",
"eventType": "Tipo di evento",
"eventSummary": "Evento",
"passkeyRegister": "Passkey aggiunto",
"passkeyDelete": "Passkey rimosso",
"interval": "Intervallo di tempo",
"splitBy": "Suddiviso per",
"ungrouped": "Nessun raggruppamento (totali)",
@@ -1053,6 +1109,15 @@
"summaryView": "Riepilogo (barra orizzontale)",
"tableView": "Tabella",
"exportCsv": "Esporta CSV",
"webSession": "Sessione web",
"actorToken": "Token attore",
"affectedToken": "Token interessato",
"activityOverTime": "Andamento nel tempo",
"activityOverTimeBy": "Andamento nel tempo per {subject}",
"activityBy": "Attività di {subject}",
"eventTotals": "Totale eventi",
"activityTotalsBy": "Totale attività per {subject}",
"subjectEventType": "tipo di evento",
"totalEvents": "Numero totale di eventi{suffix}",
"cachedResult": "Risultato memorizzato nella cache",
"showingPage": "Visualizzazione di {shown} di {total} (pagina {page}/{pages})",
@@ -1060,58 +1125,26 @@
"pathFilter": "Filtro percorso",
"pathFilterMode": "Filtra per",
"pathFilterPattern": "Motivo",
"pathGlob": "Modello di percorso (glob)",
"shareFilter": "Condividi filtro",
"passkeyRegister": "Passkey aggiunto",
"passkeyDelete": "Passkey rimosso",
"webSession": "Sessione web",
"actorToken": "Token attore",
"affectedToken": "Token interessato",
"activityOverTime": "Andamento nel tempo",
"activityOverTimeBy": "Andamento nel tempo per {subject}",
"activityBy": "Attività di {subject}",
"eventTotals": "Totali dell'evento",
"activityTotalsBy": "Totali delle attività per {subject}",
"subjectEventType": "tipo di evento",
"pathGlob": "Modello di percorso (glob)",
"viewActivity": "Visualizza attività",
"tableColumns": "Colonne della tabella",
"scopeAll": "Tutte le attività",
"scopeFiles": "Attività sui file",
"scopeShares": "Condividi l'attività",
"eventBulkDelete": "Eliminazione in blocco",
"eventShareCreate": "Condividi Crea",
"eventShareUpdate": "Aggiornamento sulla condivisione",
"eventShareDelete": "Condividi Elimina",
"eventUserCreate": "Creazione utente",
"eventUserUpdate": "Aggiornamento utente",
"eventUserDelete": "Eliminazione utente",
"eventAccessUpdate": "Aggiornamento sull'accesso",
"eventTokenCreate": "Creazione di un token",
"eventTokenDelete": "Eliminazione del token",
"webSessionNamed": "Sessione web ({name})",
"scopeAccess": "Attività di accesso",
"eventAccessCreate": "Accedi - Crea",
"eventAccessDelete": "Accedi - Elimina",
"eventFileModify": "Modifica file"
"tableColumns": "Colonne della tabella"
},
"toolAccessDefaults": "Impostazioni predefinite per l'accesso agli strumenti",
"toolAccessDefaultsDescription": "Configurare a quali strumenti i nuovi utenti possono accedere per impostazione predefinita",
"toolAccessDefaultsHelp": "Abilita gli strumenti per i nuovi utenti e, se lo desideri, rendi l'accesso obbligatorio in modo che gli utenti non amministratori non possano modificarlo.",
"accessDenied": "Non hai accesso a questo strumento."
"toolNotFound": "Strumento non trovato"
},
"notifications": {
"title": "Notifiche",
"description": "Visualizza tutte le notifiche della sessione corrente",
"empty": "Ancora nessuna notifica",
"clearAll": "Pulisci tutto",
"appEnabled": "Notifiche delle app",
"appEnabledDescription": "Mostra le notifiche del browser al termine delle operazioni sui file quando questa scheda è in secondo piano. Richiede l'autorizzazione del browser. Memorizzato solo in questo browser.",
"appUnsupported": "Le notifiche delle app non sono supportate in questo browser.",
"permissionGranted": "ammesso",
"empty": "Nessuna notifica",
"clearAll": "Cancella tutte",
"appEnabled": "Notifiche dell'app",
"appEnabledDescription": "Mostra le notifiche del browser al termine delle operazioni sui file quando questa scheda è in background. Richiede l'autorizzazione del browser. Memorizzato solo in questo browser.",
"appUnsupported": "Le notifiche dell'app non sono supportate in questo browser.",
"permissionGranted": "consentito",
"permissionDeniedHelp": "Le notifiche sono bloccate. Abilitale nelle impostazioni del browser per questo sito.",
"uploadTitle": "Caricamento completato",
"downloadTitle": "Download completato",
"moveTitle": "Operazione completata",
"moveTitle": "Spostamento completato",
"copyTitle": "Copia completata",
"uploadFailedTitle": "Caricamento non riuscito",
"downloadFailedTitle": "Il download non è andato a buon fine",
@@ -1179,36 +1212,5 @@
},
"pwa": {
"install": "Installa l'app"
},
"time": {
"time": "Ora{suffix}",
"minute": "minuto{suffix}",
"minutes": "Verbale{suffix}",
"hour": "ora{suffix}",
"hours": "Orari{suffix}",
"day": "giorno{suffix}",
"days": "Giorni{suffix}",
"months": "Mesi{suffix}",
"timeUnit": "Unità di tempo"
},
"quotas": {
"title": "Quota di archiviazione",
"openDescription": "Apri la finestra di dialogo relativa alla quota di spazio di archiviazione per questa cartella.",
"folderDescription": "Limita lo spazio di archiviazione per questa cartella.",
"shareLimit": "Limita lo spazio di archiviazione su questa condivisione",
"shareLimitDescription": "Conta i caricamenti effettuati tramite questo link. Incide anche sulla quota di origine del proprietario.",
"scopeLimit": "Limite di archiviazione per questa fonte",
"scopeLimitDescription": "Spazio di archiviazione per il compito di questo utente su questa fonte.",
"indexingDisabledMeterHint": "L'indicizzazione è disabilitata per questa fonte. È disponibile solo il monitoraggio dell'utilizzo.",
"meterIndexHint": "Utilizza la dimensione totale dei file indicizzati in questa cartella. Include le modifiche apportate al di fuori di FileBrowser al momento dell'aggiornamento dell'indice.",
"meterAccountedHint": "Conta solo i caricamenti, i salvataggi e le eliminazioni effettuati tramite FileBrowser.",
"meterIndexScope": "Dimensione indicizzata",
"meterAccounted": "Monitoraggio dell'utilizzo",
"usageCounting": "Conta l'utilizzo in base a",
"usageSyncPending": "L'utilizzo è ancora in fase di sincronizzazione con l'indice.",
"accountedFallback": "La dimensione indicizzata non è ancora pronta; fino a quando l’indice non sarà disponibile, verrà utilizzato il monitoraggio dell’utilizzo.",
"errors": {
"exceeded": "Quota di archiviazione superata"
}
}
}
+3 -1
View File
@@ -716,7 +716,9 @@
"uploadChunkSizeMbHelp": "通常5~50MBですが、不安定な接続や制限のある接続の場合は小さい方が良いでしょう。0に設定するとアップロードチャンキングが無効になります。",
"uploadSettings": "設定のアップロード",
"downloadChunkSizeMb": "ダウンロードチャンクサイズ(MB",
"downloadChunkSizeMbHelp": "チャンクダウンロードのチャンクサイズ。このサイズより大きいファイルは、タイムアウトの問題(例えば、Cloudflare 524エラー)を防ぐために、チャンクでダウンロードされます。0に設定すると、チャンクダウンロードが無効になります。"
"downloadChunkSizeMbHelp": "チャンクダウンロードのチャンクサイズ。このサイズより大きいファイルは、タイムアウトの問題(例えば、Cloudflare 524エラー)を防ぐために、チャンクでダウンロードされます。0に設定すると、チャンクダウンロードが無効になります。",
"uploadStallTimeoutSeconds": "アップロード停止タイムアウト(秒)",
"uploadStallTimeoutSecondsHelp": "進捗がないままアップロードが一時停止されるまでの秒数です。低速または不安定な接続や、低速なディスクからファイルを読み込む場合は大きくしてください。1未満の値を指定すると、デフォルトの10秒が使用されます。"
},
"colors": {
"red": "赤",
+3 -1
View File
@@ -716,7 +716,9 @@
"clearAllDescription": "완료 지우기 버튼은 오류, 일시 중지, 충돌 상태를 포함하여 완료된 상태의 모든 업로드를 지웁니다.",
"uploadSettings": "업로드 설정",
"downloadChunkSizeMb": "다운로드 청크 크기(MB)",
"downloadChunkSizeMbHelp": "청크 다운로드를 위한 청크 크기. 이 크기보다 큰 파일은 시간 초과 문제(예: Cloudflare 524 오류)를 방지하기 위해 청크 단위로 다운로드됩니다. 0으로 설정하면 청크 다운로드가 비활성화됩니다."
"downloadChunkSizeMbHelp": "청크 다운로드를 위한 청크 크기. 이 크기보다 큰 파일은 시간 초과 문제(예: Cloudflare 524 오류)를 방지하기 위해 청크 단위로 다운로드됩니다. 0으로 설정하면 청크 다운로드가 비활성화됩니다.",
"uploadStallTimeoutSeconds": "업로드 정체 제한 시간(초)",
"uploadStallTimeoutSecondsHelp": "진행이 없는 상태로 업로드가 일시 중지되기까지 허용되는 시간(초)입니다. 느리거나 불안정한 연결이거나 파일을 느린 디스크에서 읽는 경우 값을 늘리세요. 1보다 작은 값은 기본값인 10초를 사용합니다."
},
"colors": {
"red": "빨간색",
+3 -1
View File
@@ -512,7 +512,9 @@
"clearAllDescription": "De knop Voltooid wissen wist alle uploads in een voltooide status, inclusief fout-, gepauzeerde en conflictstatussen.",
"uploadSettings": "Instellingen uploaden",
"downloadChunkSizeMb": "Grootte downloadbrok in MB",
"downloadChunkSizeMbHelp": "Chunk-grootte voor chunked downloads. Bestanden die groter zijn dan deze grootte worden in chunks gedownload om time-outproblemen te voorkomen (bijv. Cloudflare 524 fouten). Instellen op 0 schakelt chunked downloads uit."
"downloadChunkSizeMbHelp": "Chunk-grootte voor chunked downloads. Bestanden die groter zijn dan deze grootte worden in chunks gedownload om time-outproblemen te voorkomen (bijv. Cloudflare 524 fouten). Instellen op 0 schakelt chunked downloads uit.",
"uploadStallTimeoutSeconds": "Time-out voor vastgelopen uploads in seconden",
"uploadStallTimeoutSecondsHelp": "Aantal seconden dat een upload zonder voortgang kan blijven voordat hij wordt gepauzeerd. Verhoog dit bij trage of onstabiele verbindingen, of wanneer de bestanden van trage schijven worden gelezen. Waarden lager dan 1 gebruiken de standaardwaarde van 10 seconden."
},
"colors": {
"red": "rood",
+2
View File
@@ -841,6 +841,8 @@
"uploadChunkSizeMbHelp": "Gewoonlijk 5 tot 50 MB, kleiner is beter voor onstabiele of beperkte verbindingen. Instellen op 0 schakelt uploads in brokken uit.",
"downloadChunkSizeMb": "Grootte downloadbrok in MB",
"downloadChunkSizeMbHelp": "Brok-grootte voor downloads in brokken. Bestanden die groter zijn dan deze grootte worden in brokken gedownload om time-outproblemen te voorkomen (bijv. Cloudflare 524 fouten). Instellen op 0 schakelt downloads in brokken uit.",
"uploadStallTimeoutSeconds": "Time-out voor vastgelopen uploads in seconden",
"uploadStallTimeoutSecondsHelp": "Aantal seconden dat een upload zonder voortgang kan blijven voordat hij wordt gepauzeerd. Verhoog dit bij trage of onstabiele verbindingen, of wanneer de bestanden van trage schijven worden gelezen. Waarden lager dan 1 gebruiken de standaardwaarde van 10 seconden.",
"clearAll": "Wissen voltooid moet fout, pauze en conflict verhelpen",
"clearAllDescription": "De knop Wissen voltooid wist alle uploads in een voltooide status, inclusief fout-, gepauzeerde en conflictstatussen.",
"uploadSettings": "Instellingen uploaden"
+3 -1
View File
@@ -716,7 +716,9 @@
"clearAllDescription": "Przycisk wyczyść ukończone spowoduje wyczyszczenie wszystkich przesłanych plików w stanie ukończenia, w tym w stanie błędu, wstrzymania i konfliktu.",
"uploadSettings": "Ustawienia przesyłania",
"downloadChunkSizeMb": "Rozmiar pobieranego fragmentu w MB",
"downloadChunkSizeMbHelp": "Rozmiar fragmentu dla pobierania fragmentarycznego. Pliki większe niż ten rozmiar będą pobierane w kawałkach, aby zapobiec problemom z przekroczeniem limitu czasu (np. błędy Cloudflare 524). Ustawienie wartości 0 wyłącza pobieranie fragmentaryczne."
"downloadChunkSizeMbHelp": "Rozmiar fragmentu dla pobierania fragmentarycznego. Pliki większe niż ten rozmiar będą pobierane w kawałkach, aby zapobiec problemom z przekroczeniem limitu czasu (np. błędy Cloudflare 524). Ustawienie wartości 0 wyłącza pobieranie fragmentaryczne.",
"uploadStallTimeoutSeconds": "Limit czasu zablokowanego przesyłania w sekundach",
"uploadStallTimeoutSecondsHelp": "Liczba sekund, przez które przesyłanie może trwać bez postępu, zanim zostanie wstrzymane. Zwiększ ją przy wolnych lub niestabilnych połączeniach albo gdy pliki są odczytywane z wolnych dysków. Wartości mniejsze niż 1 powodują użycie domyślnej wartości 10 sekund."
},
"colors": {
"red": "czerwony",
+3 -1
View File
@@ -716,7 +716,9 @@
"clearAllDescription": "O botão limpar concluído limpará todos os uploads em um estado concluído, inclusive os estados de erro, pausa e conflito.",
"uploadSettings": "Configurações de upload",
"downloadChunkSizeMb": "Tamanho do bloco de download em MB",
"downloadChunkSizeMbHelp": "Tamanho do bloco para downloads em blocos. Arquivos maiores que esse tamanho serão baixados em partes para evitar problemas de tempo limite (por exemplo, erros 524 do Cloudflare). A configuração como 0 desativa os downloads em blocos."
"downloadChunkSizeMbHelp": "Tamanho do bloco para downloads em blocos. Arquivos maiores que esse tamanho serão baixados em partes para evitar problemas de tempo limite (por exemplo, erros 524 do Cloudflare). A configuração como 0 desativa os downloads em blocos.",
"uploadStallTimeoutSeconds": "Tempo limite de travamento do envio em segundos",
"uploadStallTimeoutSecondsHelp": "Segundos que um envio pode ficar sem nenhum progresso antes de ser pausado. Aumente em conexões lentas ou instáveis, ou quando os arquivos são lidos de discos lentos. Valores menores que 1 usam o padrão de 10 segundos."
},
"colors": {
"red": "vermelho",
+3 -1
View File
@@ -512,7 +512,9 @@
"clearAllDescription": "O botão Limpar concluído limpa todos os carregamentos num estado concluído, incluindo os estados de erro, pausa e conflito.",
"uploadSettings": "Carregar definições",
"downloadChunkSizeMb": "Tamanho do bloco de descarregamento em MB",
"downloadChunkSizeMbHelp": "Tamanho do pedaço para downloads em pedaços. Os ficheiros maiores do que este tamanho serão descarregados em blocos para evitar problemas de tempo limite (por exemplo, erros 524 do Cloudflare). Definir como 0 desativa os downloads em pedaços."
"downloadChunkSizeMbHelp": "Tamanho do pedaço para downloads em pedaços. Os ficheiros maiores do que este tamanho serão descarregados em blocos para evitar problemas de tempo limite (por exemplo, erros 524 do Cloudflare). Definir como 0 desativa os downloads em pedaços.",
"uploadStallTimeoutSeconds": "Tempo limite de bloqueio do envio em segundos",
"uploadStallTimeoutSecondsHelp": "Segundos que um envio pode estar sem qualquer progresso antes de ser colocado em pausa. Aumente-o em ligações lentas ou instáveis, ou quando os ficheiros são lidos de discos lentos. Os valores inferiores a 1 usam o valor predefinido de 10 segundos."
},
"colors": {
"red": "vermelho",
+3 -1
View File
@@ -716,7 +716,9 @@
"clearAllDescription": "Butonul șterge finalizat va șterge toate încărcările în stare finalizată, inclusiv stările de eroare, pauză și conflict.",
"uploadSettings": "Setări de încărcare",
"downloadChunkSizeMb": "Dimensiunea bucății de descărcare în MB",
"downloadChunkSizeMbHelp": "Dimensiunea bucăților pentru descărcări fragmentate. Fișierele mai mari decât această dimensiune vor fi descărcate în bucăți pentru a preveni problemele de timeout (de exemplu, erori Cloudflare 524). Setarea la 0 dezactivează descărcările fragmentate."
"downloadChunkSizeMbHelp": "Dimensiunea bucăților pentru descărcări fragmentate. Fișierele mai mari decât această dimensiune vor fi descărcate în bucăți pentru a preveni problemele de timeout (de exemplu, erori Cloudflare 524). Setarea la 0 dezactivează descărcările fragmentate.",
"uploadStallTimeoutSeconds": "Timp limită de blocare a încărcării în secunde",
"uploadStallTimeoutSecondsHelp": "Numărul de secunde în care o încărcare poate rămâne fără progres înainte de a fi pusă pe pauză. Măriți-l pentru conexiuni lente sau instabile sau când fișierele sunt citite de pe discuri lente. Valorile sub 1 folosesc valoarea implicită de 10 secunde."
},
"colors": {
"red": "roșu",
+3 -1
View File
@@ -716,7 +716,9 @@
"clearAllDescription": "Кнопка \"Очистить завершенные\" очистит все загрузки, находящиеся в состоянии завершения, включая состояния ошибки, паузы и конфликта.",
"uploadSettings": "Настройки загрузки",
"downloadChunkSizeMb": "Размер загружаемого фрагмента в МБ",
"downloadChunkSizeMbHelp": "Размер куска для кусковых загрузок. Файлы, превышающие этот размер, будут загружаться кусками, чтобы предотвратить проблемы с таймаутом (например, ошибки Cloudflare 524). Установка значения 0 отключает загрузку кусками."
"downloadChunkSizeMbHelp": "Размер куска для кусковых загрузок. Файлы, превышающие этот размер, будут загружаться кусками, чтобы предотвратить проблемы с таймаутом (например, ошибки Cloudflare 524). Установка значения 0 отключает загрузку кусками.",
"uploadStallTimeoutSeconds": "Тайм-аут зависшей загрузки в секундах",
"uploadStallTimeoutSecondsHelp": "Сколько секунд загрузка может идти без прогресса, прежде чем будет приостановлена. Увеличьте значение при медленном или нестабильном соединении, а также если файлы читаются с медленных дисков. Значения меньше 1 означают значение по умолчанию — 10 секунд."
},
"colors": {
"red": "красный",
+3 -1
View File
@@ -716,7 +716,9 @@
"clearAllDescription": "Tlačidlo Vymazať dokončené vymaže všetky odoslania v stave dokončenia vrátane chybových, pozastavených a konfliktných stavov.",
"uploadSettings": "Nastavenia nahrávania",
"downloadChunkSizeMb": "Veľkosť preberaného úseku v MB",
"downloadChunkSizeMbHelp": "Veľkosť časti pre sťahovanie v častiach. Súbory väčšie ako táto veľkosť sa budú sťahovať po častiach, aby sa zabránilo problémom s časovým limitom (napr. chybám Cloudflare 524). Nastavenie na hodnotu 0 vypne sťahovanie v častiach."
"downloadChunkSizeMbHelp": "Veľkosť časti pre sťahovanie v častiach. Súbory väčšie ako táto veľkosť sa budú sťahovať po častiach, aby sa zabránilo problémom s časovým limitom (napr. chybám Cloudflare 524). Nastavenie na hodnotu 0 vypne sťahovanie v častiach.",
"uploadStallTimeoutSeconds": "Časový limit zaseknutého nahrávania v sekundách",
"uploadStallTimeoutSecondsHelp": "Počet sekúnd, počas ktorých môže nahrávanie prebiehať bez akéhokoľvek postupu, kým sa pozastaví. Zvýšte ho pri pomalých alebo nestabilných pripojeniach alebo ak sa súbory čítajú z pomalých diskov. Hodnoty menšie ako 1 použijú predvolenú hodnotu 10 sekúnd."
},
"colors": {
"red": "červená",
+3 -1
View File
@@ -512,7 +512,9 @@
"clearAllDescription": "Knappen Clear Completed rensar alla uppladdningar som är klara, inklusive fel-, paus- och konflikttillstånd.",
"uploadSettings": "Inställningar för uppladdning",
"downloadChunkSizeMb": "Nedladdningens storlek i MB",
"downloadChunkSizeMbHelp": "Bitstorlek för nedladdningar i bitar. Filer som är större än denna storlek laddas ner i bitar för att förhindra timeout-problem (t.ex. Cloudflare 524-fel). Inställning till 0 inaktiverar nedladdningar i bitar."
"downloadChunkSizeMbHelp": "Bitstorlek för nedladdningar i bitar. Filer som är större än denna storlek laddas ner i bitar för att förhindra timeout-problem (t.ex. Cloudflare 524-fel). Inställning till 0 inaktiverar nedladdningar i bitar.",
"uploadStallTimeoutSeconds": "Tidsgräns för avstannad uppladdning i sekunder",
"uploadStallTimeoutSecondsHelp": "Antal sekunder som en uppladdning får vara utan framsteg innan den pausas. Öka värdet vid långsamma eller instabila anslutningar, eller när filerna läses från långsamma diskar. Värden under 1 använder standardvärdet 10 sekunder."
},
"colors": {
"red": "röd",
+3 -1
View File
@@ -716,7 +716,9 @@
"clearAllDescription": "Tamamlananları temizle düğmesi hata, duraklatılmış ve çakışma durumları da dahil olmak üzere bitmiş durumdaki tüm yüklemeleri temizler.",
"uploadSettings": "Yükleme Ayarları",
"downloadChunkSizeMb": "MB cinsinden indirme yığın boyutu",
"downloadChunkSizeMbHelp": "Parçalı indirmeler için yığın boyutu. Bu boyuttan daha büyük dosyalar, zaman aşımı sorunlarını (örn. Cloudflare 524 hataları) önlemek için parçalar halinde indirilecektir. 0 olarak ayarlamak yığın indirmeleri devre dışı bırakır."
"downloadChunkSizeMbHelp": "Parçalı indirmeler için yığın boyutu. Bu boyuttan daha büyük dosyalar, zaman aşımı sorunlarını (örn. Cloudflare 524 hataları) önlemek için parçalar halinde indirilecektir. 0 olarak ayarlamak yığın indirmeleri devre dışı bırakır.",
"uploadStallTimeoutSeconds": "Yükleme takılma zaman aşımı (saniye)",
"uploadStallTimeoutSecondsHelp": "Bir yüklemenin duraklatılmadan önce hiç ilerleme olmadan geçebileceği saniye sayısı. Yavaş veya kararsız bağlantılarda ya da dosyalar yavaş disklerden okunurken artırın. 1'den küçük değerler varsayılan 10 saniyeyi kullanır."
},
"colors": {
"red": "kırmızı",
+3 -1
View File
@@ -512,7 +512,9 @@
"clearAllDescription": "Кнопка \"Очистити завершені\" очистить всі завантаження в завершеному стані, включаючи помилки, призупинені та конфліктні стани.",
"uploadSettings": "Налаштування завантаження",
"downloadChunkSizeMb": "Розмір завантажуваного фрагмента в МБ",
"downloadChunkSizeMbHelp": "Розмір файлу для завантаження частинами. Файли, більші за цей розмір, буде завантажено частинами, щоб запобігти проблемам з таймаутом (наприклад, помилка Cloudflare 524). Значення 0 вимикає завантаження частинами."
"downloadChunkSizeMbHelp": "Розмір файлу для завантаження частинами. Файли, більші за цей розмір, буде завантажено частинами, щоб запобігти проблемам з таймаутом (наприклад, помилка Cloudflare 524). Значення 0 вимикає завантаження частинами.",
"uploadStallTimeoutSeconds": "Тайм-аут зависання завантаження в секундах",
"uploadStallTimeoutSecondsHelp": "Скільки секунд завантаження може тривати без прогресу, перш ніж його буде призупинено. Збільште значення для повільних або нестабільних з'єднань чи коли файли читаються з повільних дисків. Значення менші за 1 означають типове значення — 10 секунд."
},
"colors": {
"red": "червоний",
+8 -6
View File
@@ -480,7 +480,7 @@
"settingsHover": "用户设置",
"toggleClick": "单击切换",
"toggleDark": "切换黑暗模式",
"toggleSticky": "切换粘贴模式",
"toggleSticky": "切换固定侧边栏",
"noSources": "该用户没有可用的信息源。要么为该用户添加一个信息源,要么配置一个默认为 true 的信息源。",
"unknown": "未知",
"simple": "简单",
@@ -512,15 +512,17 @@
"clearAllDescription": "清除已完成按钮将清除所有处于完成状态的上传,包括错误、暂停和冲突状态。",
"uploadSettings": "上传设置",
"downloadChunkSizeMb": "下载块大小(MB",
"downloadChunkSizeMbHelp": "分块下载的分块大小。大于此大小的文件将分块下载,以防止超时问题(如 Cloudflare 524 错误)。设置为 0 将禁用分块下载。"
"downloadChunkSizeMbHelp": "分块下载的分块大小。大于此大小的文件将分块下载,以防止超时问题(如 Cloudflare 524 错误)。设置为 0 将禁用分块下载。",
"uploadStallTimeoutSeconds": "上传停滞超时(秒)",
"uploadStallTimeoutSecondsHelp": "上传在没有任何进展的情况下可持续多少秒,超时后将被暂停。连接缓慢或不稳定,或从较慢的磁盘读取文件时,请调大该值。小于 1 的值将使用默认的 10 秒。"
},
"colors": {
"red": "红",
"green": "绿化",
"green": "绿",
"blue": "蓝",
"yellow": "黄的",
"violet": "紫色",
"orange": "橘色"
"yellow": "黄",
"violet": "紫",
"orange": "橙"
},
"profileSettings": {
"showTypeColumn": "显示类型列",
+3 -1
View File
@@ -599,7 +599,9 @@
"clearAllDescription": "清除完成按鈕將會清除所有處於完成狀態的上傳,包括錯誤、暫停和衝突狀態。",
"uploadSettings": "上傳設定",
"downloadChunkSizeMb": "下載區塊大小(以 MB 為單位)",
"downloadChunkSizeMbHelp": "分塊下載的分塊大小。大於此大小的檔案會分塊下載,以防止超時問題 (例如 Cloudflare 524 錯誤)。設定為 0 會停用分塊下載。"
"downloadChunkSizeMbHelp": "分塊下載的分塊大小。大於此大小的檔案會分塊下載,以防止超時問題 (例如 Cloudflare 524 錯誤)。設定為 0 會停用分塊下載。",
"uploadStallTimeoutSeconds": "上傳停滯逾時(秒)",
"uploadStallTimeoutSecondsHelp": "上傳在沒有任何進度的情況下可持續多少秒,逾時後將被暫停。連線緩慢或不穩定,或從較慢的磁碟讀取檔案時,請調高此值。小於 1 的值將使用預設的 10 秒。"
},
"colors": {
"red": "紅色",
+5
View File
@@ -561,6 +561,7 @@ export const mutations = {
state.user.fileLoading = {
maxConcurrentUpload: 3,
uploadChunkSizeMb: 5,
uploadStallTimeoutSeconds: 10,
downloadChunkSizeMb: 0,
clearAll: false
};
@@ -572,6 +573,10 @@ export const mutations = {
if (state.user.fileLoading.uploadChunkSizeMb === undefined) {
state.user.fileLoading.uploadChunkSizeMb = 5;
}
// 0 is what the server sends for users saved before this setting existed
if (!state.user.fileLoading.uploadStallTimeoutSeconds) {
state.user.fileLoading.uploadStallTimeoutSeconds = 10;
}
if (state.user.fileLoading.clearAll === undefined) {
state.user.fileLoading.clearAll = false;
}
+1
View File
@@ -97,6 +97,7 @@ export const state: StoreState = reactive({
fileLoading: {
maxConcurrentUpload: 3,
uploadChunkSizeMb: 5,
uploadStallTimeoutSeconds: 10,
clearAll: false
}
},
+1
View File
@@ -247,6 +247,7 @@ export interface UserObject {
fileLoading?: {
maxConcurrentUpload?: number;
uploadChunkSizeMb?: number;
uploadStallTimeoutSeconds?: number;
clearAll?: boolean;
downloadChunkSizeMb?: number;
maxConcurrent?: number;
+60 -10
View File
@@ -107,7 +107,11 @@ class UploadManager {
this.probedDirs = new Set(); // Track directories that were probed/created during conflict check
this.progressTimeouts = new Map(); // Track progress timeouts per upload ID
this.lastUploadActivityTime = null;
this.PROGRESS_TIMEOUT_MS = 10000; // 10 seconds without upload activity = pause
this.PROGRESS_TIMEOUT_MS = 10000; // default: 10 seconds without upload activity = pause (see progressTimeoutMs)
// Once the whole body of a request has been sent there are no more progress events until the server
// answers, and it only answers after saving the data (for the last chunk of a file, the whole file is
// flushed to disk), which can take a long time on slow disks. Wait much longer for that answer.
this.SERVER_RESPONSE_TIMEOUT_MS = 600000; // 10 minutes
this.quotaBatchAborted = false;
}
@@ -450,7 +454,14 @@ class UploadManager {
this.activeUploads < maxConcurrent &&
this.hasPending()
) {
const upload = this.queue.find((item) => item.status === "pending");
// Create directories one at a time (parent first) before any files: the
// backend uses MkdirAll, so a child finishing first makes its parent 409.
if (this.queue.some((item) => item.type === "directory" && item.status === "uploading")) {
break;
}
const upload =
this.queue.find((item) => item.type === "directory" && item.status === "pending") ||
this.queue.find((item) => item.status === "pending");
if (upload) {
if (this.overwriteAll) {
upload.overwrite = true;
@@ -536,6 +547,7 @@ class UploadManager {
const progress = (percent) => {
this.updateProgress(upload, percent);
};
const onSent = () => this.markSentToServer(upload);
// Start progress timeout monitoring
upload.lastProgressTime = Date.now();
@@ -547,18 +559,19 @@ class UploadManager {
promise = resourcesApi.postPublic(state.shareInfo?.hash, upload.path, upload.file, upload.overwrite, progress, {
"X-File-Upload-Session": upload.sessionId,
"X-File-Total-Size": upload.size,
});
}, false, onSent);
} else {
promise = resourcesApi.post(upload.source, upload.path, upload.file, upload.overwrite, progress, {
"X-File-Upload-Session": upload.sessionId,
"X-File-Total-Size": upload.size,
});
}, false, onSent);
}
upload.xhrPromise = promise;
upload.xhr = promise.xhr;
await promise;
upload.awaitingServer = false;
this.recordUploadActivity(upload);
// Clear timeout on successful completion
this.clearProgressTimeout(upload.id);
@@ -567,6 +580,7 @@ class UploadManager {
upload.connectionIssue = false;
notifyUploadComplete(upload);
} catch (err) {
upload.awaitingServer = false;
this.clearProgressTimeout(upload.id);
await this.handleUploadError(upload, err);
} finally {
@@ -600,6 +614,8 @@ class UploadManager {
this.updateProgress(upload, Math.round(progress * 10) / 10);
};
const onSent = () => this.markSentToServer(upload);
try {
let promise;
if (getters.isShare()) {
@@ -613,7 +629,9 @@ class UploadManager {
"X-File-Upload-Session": upload.sessionId,
"X-File-Chunk-Offset": upload.chunkOffset,
"X-File-Total-Size": upload.size,
}
},
false,
onSent
);
} else {
promise = resourcesApi.post(
@@ -626,7 +644,9 @@ class UploadManager {
"X-File-Upload-Session": upload.sessionId,
"X-File-Chunk-Offset": upload.chunkOffset,
"X-File-Total-Size": upload.size,
}
},
false,
onSent
);
}
@@ -637,9 +657,15 @@ class UploadManager {
// Only increment chunkOffset after successful chunk upload.
// This ensures that if we pause/error mid-chunk, we'll retry that chunk on resume.
upload.chunkOffset += chunk.size;
// The server answered: back to the normal limit for the next chunk.
upload.awaitingServer = false;
// A completed chunk is activity even when no progress event was emitted.
this.recordUploadActivity(upload);
if (upload.chunkOffset < upload.size) {
this.startProgressTimeout(upload);
}
} catch (err) {
upload.awaitingServer = false;
this.clearProgressTimeout(upload.id);
await this.handleUploadError(upload, err);
break; // Exit loop on error or pause
@@ -719,6 +745,7 @@ class UploadManager {
}
this.abortUpload(upload);
upload.status = "paused";
upload.awaitingServer = false;
this.clearProgressTimeout(id);
}
@@ -735,7 +762,29 @@ class UploadManager {
this.lastUploadActivityTime = upload.lastProgressTime;
}
startProgressTimeout(upload, delay = this.PROGRESS_TIMEOUT_MS) {
/** Time without upload activity before pausing: the user's setting, or the default when it is unset. */
progressTimeoutMs() {
const seconds = state.user?.fileLoading?.uploadStallTimeoutSeconds;
return seconds > 0 ? seconds * 1000 : this.PROGRESS_TIMEOUT_MS;
}
/** How long an upload may go without activity: longer while the server is saving the data it received. */
stallLimitMs(upload) {
const progressTimeout = this.progressTimeoutMs();
return upload.awaitingServer ? Math.max(this.SERVER_RESPONSE_TIMEOUT_MS, progressTimeout) : progressTimeout;
}
/** The request body was sent in full; the server is saving it and will answer when it is done. */
markSentToServer(upload) {
if (upload.status !== "uploading" || !this.findById(upload.id)) {
return;
}
upload.awaitingServer = true;
this.recordUploadActivity(upload);
this.startProgressTimeout(upload);
}
startProgressTimeout(upload, delay = this.stallLimitMs(upload)) {
// Clear any existing timeout
this.clearProgressTimeout(upload.id);
@@ -748,11 +797,12 @@ class UploadManager {
this.lastUploadActivityTime ?? 0
);
const idleTime = Date.now() - lastActivity;
if (idleTime < this.PROGRESS_TIMEOUT_MS) {
this.startProgressTimeout(upload, this.PROGRESS_TIMEOUT_MS - idleTime);
const limit = this.stallLimitMs(upload);
if (idleTime < limit) {
this.startProgressTimeout(upload, limit - idleTime);
return;
}
console.log(`Upload ${upload.id} stalled - no progress for ${this.PROGRESS_TIMEOUT_MS}ms, pausing`);
console.log(`Upload ${upload.id} stalled - no progress for ${limit}ms, pausing`);
upload.connectionIssue = true;
void this.pause(upload.id);
upload.errorDetails = "Connection stalled - upload paused. Click resume to retry.";
+132
View File
@@ -79,6 +79,27 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe("directory upload ordering", () => {
it("waits for each directory before starting its children", async () => {
// A populated listing avoids the separate top-level directory probe.
state.req.items = [{ name: "unrelated.txt" }];
const parent = deferred();
resourcesApi.post.mockReturnValueOnce(parent.promise).mockResolvedValue("");
await uploadManager.add("/base/", [
{ file: new File(["a"], "a.txt"), relativePath: "folder/child/a.txt" },
]);
expect(resourcesApi.post.mock.calls.map((call) => call[1])).toEqual(["/base/folder/"]);
parent.resolve("");
await vi.advanceTimersByTimeAsync(0);
expect(resourcesApi.post.mock.calls.map((call) => call[1])).toEqual([
"/base/folder/", "/base/folder/child/", "/base/folder/child/a.txt",
]);
state.req.items = [];
});
});
describe("upload stall detection", () => {
it("allows an upload to wait while another progresses, then detects a batch stall", async () => {
const waiting = addUpload();
@@ -438,3 +459,114 @@ describe("hasOngoingUploads", () => {
state.user.fileLoading.clearAll = false;
});
});
describe("upload waiting for the server to save the data", () => {
it("does not pause while the server answers after the whole body was sent", async () => {
const upload = addUpload();
uploadManager.startProgressTimeout(upload);
// the body is sent in full: no more progress events until the server answers
await vi.advanceTimersByTimeAsync(2000);
uploadManager.markSentToServer(upload);
await vi.advanceTimersByTimeAsync(uploadManager.PROGRESS_TIMEOUT_MS * 3);
expect(upload.status).toBe("uploading");
expect(upload.awaitingServer).toBe(true);
});
it("still pauses if the server never answers, after the longer limit", async () => {
const upload = addUpload();
uploadManager.markSentToServer(upload);
await vi.advanceTimersByTimeAsync(uploadManager.SERVER_RESPONSE_TIMEOUT_MS - 1);
expect(upload.status).toBe("uploading");
await vi.advanceTimersByTimeAsync(1);
expect(upload.status).toBe("paused");
expect(upload.awaitingServer).toBe(false);
expect(upload.connectionIssue).toBe(true);
});
it("goes back to the normal limit once the server has answered", async () => {
const upload = addUpload();
uploadManager.markSentToServer(upload);
await vi.advanceTimersByTimeAsync(30000);
// the server answered: the chunk loop clears the flag and restarts the normal timer
upload.awaitingServer = false;
uploadManager.recordUploadActivity(upload);
uploadManager.startProgressTimeout(upload);
await vi.advanceTimersByTimeAsync(uploadManager.PROGRESS_TIMEOUT_MS - 1);
expect(upload.status).toBe("uploading");
await vi.advanceTimersByTimeAsync(1);
expect(upload.status).toBe("paused");
});
it("ignores a late notification for an upload that is not uploading", () => {
const upload = addUpload("paused");
uploadManager.markSentToServer(upload);
expect(upload.awaitingServer).toBeUndefined();
expect(uploadManager.progressTimeouts.size).toBe(0);
});
it("sends the notification when the request body has been sent", async () => {
const instances = [];
const OriginalXhr = globalThis.XMLHttpRequest;
globalThis.XMLHttpRequest = class {
constructor() {
this.upload = {};
instances.push(this);
}
open() {}
setRequestHeader() {}
send() {}
};
try {
const { post } = await import("@/api/resources.js");
const onsent = vi.fn();
void post("test", "/f", new Blob(["a"]), false, undefined, {}, false, onsent);
expect(instances).toHaveLength(1);
expect(onsent).not.toHaveBeenCalled();
instances[0].upload.onload();
expect(onsent).toHaveBeenCalledTimes(1);
} finally {
globalThis.XMLHttpRequest = OriginalXhr;
}
});
});
describe("upload stall timeout setting", () => {
afterEach(() => {
delete state.user.fileLoading.uploadStallTimeoutSeconds;
});
it.each([undefined, 0, -5])("uses the default limit when the setting is %s", (value) => {
state.user.fileLoading.uploadStallTimeoutSeconds = value;
expect(uploadManager.progressTimeoutMs()).toBe(uploadManager.PROGRESS_TIMEOUT_MS);
});
it("pauses after the number of seconds the user chose", async () => {
state.user.fileLoading.uploadStallTimeoutSeconds = 45;
const upload = addUpload();
uploadManager.startProgressTimeout(upload);
await vi.advanceTimersByTimeAsync(44999);
expect(upload.status).toBe("uploading");
await vi.advanceTimersByTimeAsync(1);
expect(upload.status).toBe("paused");
});
it("applies a shorter limit as well", async () => {
state.user.fileLoading.uploadStallTimeoutSeconds = 3;
const upload = addUpload();
uploadManager.startProgressTimeout(upload);
await vi.advanceTimersByTimeAsync(2999);
expect(upload.status).toBe("uploading");
await vi.advanceTimersByTimeAsync(1);
expect(upload.status).toBe("paused");
});
it("keeps the longer limit while the server saves, and honors a setting above it", () => {
const upload = addUpload();
upload.awaitingServer = true;
state.user.fileLoading.uploadStallTimeoutSeconds = 30;
expect(uploadManager.stallLimitMs(upload)).toBe(uploadManager.SERVER_RESPONSE_TIMEOUT_MS);
state.user.fileLoading.uploadStallTimeoutSeconds = 1800;
expect(uploadManager.stallLimitMs(upload)).toBe(1800000);
});
});
@@ -35,6 +35,16 @@
:placeholder="$t('general.number')" />
</div>
</div>
<div class="settings-number-input item">
<div class="no-padding">
<label for="uploadStallTimeoutSeconds">{{ $t("fileLoading.uploadStallTimeoutSeconds") }}</label>
<HelpTooltipIcon :text="$t('fileLoading.uploadStallTimeoutSecondsHelp')" />
</div>
<div class="no-padding">
<input class="sizeInput input" v-model.number="localuser.fileLoading.uploadStallTimeoutSeconds" type="number" min="1"
:placeholder="$t('general.number')" />
</div>
</div>
<ToggleSwitch class="item" v-model="localuser.fileLoading.clearAll" @change="updateSettings"
:name="$t('fileLoading.clearAll')"
:description="$t('fileLoading.clearAllDescription')" />
@@ -85,6 +95,9 @@ export default {
if (this.localuser.fileLoading.downloadChunkSizeMb === undefined || this.localuser.fileLoading.downloadChunkSizeMb === null) {
this.localuser.fileLoading.downloadChunkSizeMb = 0;
}
if (!(this.localuser.fileLoading.uploadStallTimeoutSeconds > 0)) {
this.localuser.fileLoading.uploadStallTimeoutSeconds = 10;
}
},
methods: {
async updateSettings(event) {