Compare commits

...
26 Commits
Author SHA1 Message Date
Graham Steffaniak 19d60a8a24 updated dependencies (#2168) 2026-03-21 15:21:54 -05:00
Graham Steffaniak d583359f4e Update CHANGELOG.md 2026-03-21 13:50:17 -05:00
Graham Steffaniak 2c0f9f26d6 Update CHANGELOG.md 2026-03-21 13:49:57 -05:00
Graham Steffaniak efe10ec6d5 Update CHANGELOG.md 2026-03-21 13:37:57 -05:00
Graham Steffaniak 0f37b8120f Update CHANGELOG.md 2026-03-21 13:37:34 -05:00
Graham Steffaniak c55107e31b Add search filters (#2164) 2026-03-21 11:55:18 -05:00
Kevin B. ee0307d80a add option to prefer editor first and improve markdown viewer (#2160) 2026-03-21 09:40:27 -05:00
Graham Steffaniak e64179d6d2 fix external share banner (#2162) 2026-03-21 09:05:10 -05:00
Graham Steffaniak 68f0f0204e updated with real ip (#2163) 2026-03-21 09:02:00 -05:00
Graham Steffaniak 9e18c99b05 updated with ldap changes (#2161) 2026-03-20 19:08:26 -05:00
Graham Steffaniak cdbc7a35f7 Merge branch 'dev/v1.3.2' of github.com:gtsteffaniak/filebrowser into dev/v1.3.2 2026-03-20 10:00:14 -05:00
Graham Steffaniak a18f268547 updated with maxArchiveSize settings (#2155) 2026-03-19 19:24:35 -05:00
Graham Steffaniak 28edf9ce0e updated with maxArchiveSize settings 2026-03-19 18:42:33 -05:00
Graham Steffaniak e99b22349d Fix put api folders (#2153) 2026-03-19 18:19:00 -05:00
Graham Steffaniak 9775cc03fd updated logging and concurrency (#2152) 2026-03-19 12:58:39 -05:00
Graham Steffaniak 18333e21fd updated rule inheritance (#2151) 2026-03-18 20:07:43 -05:00
Shane Chiu ad3d184811 fix: Prevent root directory deletion and fix bulk delete path bug with added tests (#2146) 2026-03-18 20:06:02 -05:00
Graham Steffaniak d47c2d9c0d pwa changes for public icons (#2150) 2026-03-18 16:46:17 -05:00
Graham Steffaniak 04e4324981 Merge branch 'main' into dev/v1.3.2 2026-03-18 15:21:10 -05:00
Graham Steffaniak 44edfcafa1 Merge branch 'main' into dev/v1.3.2 2026-03-18 14:28:52 -05:00
Kevin B. be2e78f729 Fix delete and fileTree navigation issues (#2142) 2026-03-18 14:27:43 -05:00
Graham Steffaniak c22fc59035 fix user scope, download, and edit issue for onlyoffice (#2145) 2026-03-18 14:27:02 -05:00
Graham Steffaniak ad82de28e8 Merge branch 'main' into dev/v1.3.2 2026-03-17 12:58:40 -05:00
Graham Steffaniak 0481052308 Merge branch 'main' into dev/v1.3.2 2026-03-16 17:47:54 -05:00
Graham Steffaniak 87d241d166 added preview for pdf timeout (#2114) 2026-03-09 19:08:37 -05:00
092c97f9ae Rename prompts functions (#2108)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Gabriel Górski <me@glaeqen.eu>
Co-authored-by: Joel Godfrey <82655889+BaccanoMob@users.noreply.github.com>
2026-03-09 18:54:17 -05:00
137 changed files with 2786 additions and 1430 deletions

No files matched your search

+21 -1
View File
@@ -7,11 +7,31 @@ All notable changes to this project will be documented in this file. For commit
**Security**:
- Patched Username Enumeration via Authentication Timing Side-Channel GHSA-7789-65hx-f26w
**New Features**:
- Option in settings `userDefaults.preferEditorForMarkdown` to prefer editor first for Markdown files (#2136)
- Copy to clipboard button for code blocks in Markdown Viewer (#2160)
- Add "Last modified" filter in search dialog (#2157)
**Notes**:
- docs preview for text and pdf has a 2 second timeout. If it hangs for whatever reason, the maximum time would be 2 seconds. (#2105) (#2114)
- Downloading multiple files streams the archive creation rather than using cacheDir -- thanks @janakoram (#2125) (#2130)
- `server.maxArchiveSizeGB` deprecated since its no longer an issue with archive streaming.
- `server.maxArchiveSizeGB` now defaults to 20 (GB) and only applies to archive/unarchive actions (not downloads).
- browser download progress bar will no longer show for archive downloads. this is the main drawback to the streaming approach.
- should allow for much higher parallel download support and lower cleanup maintenenance.
- [docker] ffmpeg version upgraded to 8.1
- remote ip in logs now prefers `X-Forwarded-For` if it exists, then `X-Real-IP`, then lastly the standard RemoteAddr. Useful when running behind a proxy to log the public IP of each request. (#2110)
- changed loading spinner style to be more compatible with safari browsers.
**BugFixes**:
- Wrong username in share settings (#2147) (#2148)
- [OnlyOffice] Error when saving a file under a user scope #2133
- Cannot edit shared file in OnlyOffice #2143
- PWA install button disappeared (#2086)
- Deleting a root folder was possible #2128
- PUT resource api errors if action against a folder (#2153)
- LDAP authentication issue if a password caontains @ symbol (#2154)
- Share banner seems to be not working for custom urls (#2120)
## v1.3.1-beta
+217
View File
@@ -1,10 +1,12 @@
package files
import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
@@ -409,3 +411,218 @@ func TestOverrideFileToDirectory(t *testing.T) {
t.Error("Directory 'Test Object' should have been added to Folders")
}
}
// TestDeleteFilesRootProtection tests that DeleteFiles refuses to delete
// the source root directory itself, preventing catastrophic data loss.
// This is a regression test for the safety guard added to prevent accidental
// deletion of the entire source directory.
func TestDeleteFilesRootProtection(t *testing.T) {
// Use a temporary directory for cache
tmpDir := t.TempDir()
originalCacheDir := settings.Config.Server.CacheDir
settings.Config.Server.CacheDir = tmpDir
defer func() {
settings.Config.Server.CacheDir = originalCacheDir
}()
// Ensure fileutils permissions are set
if fileutils.PermDir == 0 {
fileutils.SetFsPermissions(0644, 0755)
}
// Initialize the database
if indexing.GetIndexDB() == nil {
db, _, err := dbsql.NewIndexDB("test_root_protection", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
indexing.SetIndexDBForTesting(db)
}
// Create a real temporary directory to use as source root
realTmpDir := t.TempDir()
// Initialize the index with a real path
indexing.Initialize(&settings.Source{
Name: "test_root_protection",
Path: realTmpDir,
}, false, false) // false for mock mode (real path), false for isNewDb
// Wait for scanner to be ready
idx := indexing.GetIndex("test_root_protection")
if idx == nil {
t.Fatal("Failed to get test index")
}
// Wait for scanner to finish
for i := 0; i < 50; i++ {
time.Sleep(10 * time.Millisecond)
status := idx.GetScannerStatus()
if status["status"] == "ready" || status["status"] == "unavailable" {
break
}
}
// Test: Attempting to delete the root directory should return an error
err := DeleteFiles("test_root_protection", realTmpDir, true)
if err == nil {
t.Error("DeleteFiles should return an error when trying to delete root directory")
}
// Verify the error message contains expected text
expectedErrMsg := "refusing to delete source root directory"
if err != nil && !strings.Contains(err.Error(), expectedErrMsg) {
t.Errorf("Expected error message to contain '%s', got: %v", expectedErrMsg, err.Error())
}
// Test with trailing slash - should still be blocked
err = DeleteFiles("test_root_protection", realTmpDir+"/", true)
if err == nil {
t.Error("DeleteFiles should return an error for root directory with trailing slash")
}
// Test with cleaned path variations
err = DeleteFiles("test_root_protection", realTmpDir+"/.", true)
if err == nil {
t.Error("DeleteFiles should return an error for root directory with /. suffix")
}
}
// TestDeleteFilesSubfolderWithRootName tests that deleting a subfolder
// with the same name as the root directory works correctly and doesn't
// accidentally delete the root.
// Regression test for path handling bug where /srv/srv could be mistaken for /srv.
func TestDeleteFilesSubfolderWithRootName(t *testing.T) {
for iter := 1; iter <= raceStressIterations; iter++ {
iter := iter
t.Run(fmt.Sprintf("stress-iter-%d", iter), func(t *testing.T) {
runDeleteFilesSubfolderWithRaceStress(t, iter)
})
}
}
const (
raceStressIterations = 3
raceStatusPollInterval = 5 * time.Millisecond
waitStatusMaxTries = 100
waitStatusSleep = 50 * time.Millisecond
)
func runDeleteFilesSubfolderWithRaceStress(t *testing.T, iter int) {
t.Helper()
start := time.Now()
t.Logf("iteration %d: start", iter)
cacheDir := t.TempDir()
originalCache := settings.Config.Server.CacheDir
settings.Config.Server.CacheDir = cacheDir
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
if fileutils.PermDir == 0 {
fileutils.SetFsPermissions(0644, 0755)
}
if indexing.GetIndexDB() == nil {
db, _, err := dbsql.NewIndexDB("test_subfolder_rootname", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
indexing.SetIndexDBForTesting(db)
}
realTmpDir := t.TempDir()
subfolderName := filepath.Base(realTmpDir)
subfolderPath := filepath.Join(realTmpDir, subfolderName)
if err := os.Mkdir(subfolderPath, 0755); err != nil {
t.Fatalf("Failed to create subfolder: %v", err)
}
testFile := filepath.Join(subfolderPath, "test.txt")
if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
sourceName := fmt.Sprintf("test_subfolder_rootname-%d", iter)
indexing.Initialize(&settings.Source{
Name: sourceName,
Path: realTmpDir,
}, false, false)
idx := indexing.GetIndex(sourceName)
if idx == nil {
t.Fatalf("Failed to get test index for %s", sourceName)
}
stopPolling := startScannerStatusPoll(t, idx, iter)
defer stopPolling()
waitForScannerReady(t, idx)
err := DeleteFiles(sourceName, subfolderPath, true)
stopPolling()
t.Logf("iteration %d: DeleteFiles finished in %s (err=%v)", iter, time.Since(start), err)
if err != nil {
t.Fatalf("DeleteFiles should succeed for subfolder with root-like name, got error: %v", err)
}
if _, err := os.Stat(subfolderPath); !os.IsNotExist(err) {
t.Fatal("Subfolder should have been deleted")
}
if _, err := os.Stat(realTmpDir); os.IsNotExist(err) {
t.Fatal("Root directory should NOT have been deleted")
}
indexing.StopAllScanners()
// Give scanners time to finish their cleanup (defer blocks in tryAcquireAndScan)
time.Sleep(100 * time.Millisecond)
indexing.ClearTestIndices()
t.Logf("iteration %d: cleaned scanners and indices", iter)
}
func waitForScannerReady(t *testing.T, idx *indexing.Index) {
t.Helper()
// Give the scanners a moment to initialize, but don't wait for full "ready" status
// The initial scan can take longer than the test timeout, but DeleteFiles will work
// as long as the scanners are initialized and running
time.Sleep(500 * time.Millisecond)
// Verify scanners exist
status := idx.GetScannerStatus()
if totalScanners, ok := status["totalScanners"].(int); !ok || totalScanners == 0 {
t.Fatal("No scanners were created")
}
t.Log("Scanners initialized, proceeding with delete operation")
}
func startScannerStatusPoll(t *testing.T, idx *indexing.Index, iter int) func() {
t.Helper()
stop := make(chan struct{})
done := make(chan struct{})
var once sync.Once
go func() {
ticker := time.NewTicker(raceStatusPollInterval)
defer ticker.Stop()
for {
select {
case <-stop:
close(done)
return
case <-ticker.C:
idx.GetScannerStatus()
}
}
}()
return func() {
once.Do(func() {
close(stop)
<-done
t.Logf("iteration %d: poll watcher stopped", iter)
})
}
}
+5
View File
@@ -457,6 +457,11 @@ func DeleteFiles(source, absPath string, isDir bool) error {
if index == nil {
return fmt.Errorf("could not get index: %v ", source)
}
// Safety guard: never allow deletion of the source root directory itself
cleanAbs := filepath.Clean(absPath)
if cleanAbs == filepath.Clean(index.Path) {
return fmt.Errorf("refusing to delete source root directory: %s", absPath)
}
if !index.Config.DisableIndexing {
indexPath := index.MakeIndexPath(absPath, isDir)
-56
View File
@@ -373,59 +373,3 @@ func TestJSONAuth_InvalidPasswordHashInitialized(t *testing.T) {
t.Log("✓ InvalidPasswordHash is properly initialized")
}
// TestJSONAuth_ValidUserVsInvalidUserDirectComparison is a simplified direct comparison test
func TestJSONAuth_ValidUserVsInvalidUserDirectComparison(t *testing.T) {
storage := setupTestUsers(t)
auther := auth.JSONAuth{}
iterations := 25
// Measure valid user (exists but wrong password)
var validUserTimes []time.Duration
for i := 0; i < iterations; i++ {
elapsed := measureAuthTime(t, auther, storage, "admin", "wrongpassword")
validUserTimes = append(validUserTimes, elapsed)
}
// Measure invalid user (doesn't exist)
var invalidUserTimes []time.Duration
for i := 0; i < iterations; i++ {
elapsed := measureAuthTime(t, auther, storage, "nonexistent_user_test", "wrongpassword")
invalidUserTimes = append(invalidUserTimes, elapsed)
}
// Calculate medians (more robust than means for timing data)
median := func(times []time.Duration) time.Duration {
sorted := make([]time.Duration, len(times))
copy(sorted, times)
// Simple selection sort for median
for i := range sorted {
for j := i + 1; j < len(sorted); j++ {
if sorted[j] < sorted[i] {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
return sorted[len(sorted)/2]
}
validMedian := median(validUserTimes)
invalidMedian := median(invalidUserTimes)
t.Logf("Valid user (exists) median: %.4fms", float64(validMedian.Microseconds())/1000.0)
t.Logf("Invalid user (not exists) median: %.4fms", float64(invalidMedian.Microseconds())/1000.0)
// The times should be similar (within 20%)
diff := math.Abs(float64(validMedian - invalidMedian))
percentDiff := (diff / float64(validMedian)) * 100.0
t.Logf("Difference: %.2f%%", percentDiff)
if percentDiff > 20.0 {
t.Errorf("Timing difference too large: %.2f%% (threshold: 20%%)", percentDiff)
t.Error("This indicates a potential timing attack vulnerability!")
} else {
t.Logf("✓ Timing difference acceptable: %.2f%% < 20%%", percentDiff)
}
}
+3 -20
View File
@@ -680,6 +680,7 @@ func setDefaults(generate bool) Settings {
SourceMap: map[string]*Source{},
NameToSource: map[string]*Source{},
CacheDir: "tmp",
MaxArchiveSizeGB: 20,
IndexSqlConfig: IndexSqlConfig{
WalMode: false,
BatchSize: 1000,
@@ -710,6 +711,7 @@ func setDefaults(generate bool) Settings {
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
StickySidebar: true,
HideFilesInTree: false,
PreferEditorForMarkdown: false,
LockPassword: false,
ShowHidden: false,
DarkMode: boolPtr(true),
@@ -867,26 +869,7 @@ func setConditionals(config *Source) {
IgnoreAllZeroSizeFolders: false,
IgnoreAllSymlinks: false,
IndexingDisabled: false,
}
// backwards compatibility
if config.Config.Conditionals.Hidden {
logger.Warning("source.conditionals.hidden is deprecated, use source.rules instead")
resolved.IgnoreAllHidden = true
}
// Backwards compatibility: if old format fields are set, treat as global rules
if config.Config.Conditionals.IgnoreHidden {
logger.Warning("source.conditionals.ignoreHidden is deprecated, use source.rules instead")
resolved.IgnoreAllHidden = true
}
if config.Config.DisableIndexing {
logger.Warning("source.disableIndexing is deprecated, use source.rules instead")
resolved.IndexingDisabled = true
}
if config.Config.Conditionals.ZeroSizeFolders {
logger.Warning("source.conditionals.zeroSizeFolders is deprecated, use source.rules instead")
resolved.IgnoreAllZeroSizeFolders = true
NoRules: len(rules) == 0,
}
// Process all rules and infer global flags from root-level rules
+1
View File
@@ -111,6 +111,7 @@ func ApplyUserDefaults(u *users.User) {
u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt
u.FileLoading = Config.UserDefaults.FileLoading
u.DisableOfficePreviewExt = Config.UserDefaults.DisableOfficePreviewExt
u.PreferEditorForMarkdown = Config.UserDefaults.PreferEditorForMarkdown
if len(u.Scopes) == 0 && u.Username != "anonymous" {
for _, source := range Config.Server.Sources {
if source.Config.DefaultEnabled {
+3 -1
View File
@@ -62,7 +62,7 @@ type Server struct {
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
CacheDirCleanup bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // deprecated: no longer a limitation after v1.3.2-beta
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
DisableWebDAV bool `json:"disableWebDAV"` // disable webdav support (default: false)
@@ -261,6 +261,7 @@ type ResolvedRulesConfig struct {
IgnoreAllZeroSizeFolders bool // Excludes all folders with 0 size
IgnoreAllSymlinks bool // Excludes all symbolic links
IndexingDisabled bool // Excludes all files and folders from indexing
NoRules bool // No rules are configured
}
type Frontend struct {
@@ -363,4 +364,5 @@ type UserDefaults struct {
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer.
}
+4
View File
@@ -80,5 +80,9 @@ func SanitizeUserPath(userPath string) (string, error) {
}
}
if clean == "." {
return "", fmt.Errorf("invalid path: path must standard index path")
}
return clean, nil
}
+1
View File
@@ -52,3 +52,4 @@ userDefaults:
customTheme: "" # Name of theme to use chosen from custom themes config.
showSelectMultiple: false # show select multiple files on desktop
debugOffice: false # debug onlyoffice editor
preferEditorForMarkdown: false # prefer editor first for markdown files instead of the Markdown Viewer
+43
View File
@@ -1,6 +1,8 @@
package share
import (
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
@@ -170,3 +172,44 @@ func (l *Link) SourceURL(user *users.User) string {
scopedPath := strings.TrimPrefix(l.Path, userScope)
return "/" + filepath.Join("files", sourceName, scopedPath)
}
func (l *Link) BannerURL() string {
_, _, err := l.GetShareImagePartsHelper(true)
if err == nil {
return fmt.Sprintf("%s%spublic/api/share/image?banner=true&hash=%s", settings.Config.Server.ExternalUrl, settings.Config.Server.BaseURL, l.Hash)
}
return l.Banner
}
func (l *Link) FaviconURL() string {
_, _, err := l.GetShareImagePartsHelper(false)
if err == nil {
return fmt.Sprintf("%s%spublic/api/share/image?favicon=true&hash=%s", settings.Config.Server.ExternalUrl, settings.Config.Server.BaseURL, l.Hash)
}
return l.Favicon
}
func (l *Link) GetShareImagePartsHelper(isBanner bool) (string, string, error) {
// Get the asset query string from share
var assetQueryString string
if isBanner {
assetQueryString = l.Banner
} else {
assetQueryString = l.Favicon
}
if assetQueryString == "" {
return "", "", fmt.Errorf("asset not configured for this share")
}
// Parse the query string to extract source and path
assetParams, err := url.ParseQuery(assetQueryString)
if err != nil {
return "", "", fmt.Errorf("invalid asset configuration: %v", err)
}
sourceName := assetParams.Get("source")
assetPath := assetParams.Get("path")
return sourceName, assetPath, nil
}
+1
View File
@@ -126,6 +126,7 @@ type NonAdminEditable struct {
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
}
type FileLoading struct {
+14 -14
View File
@@ -9,7 +9,7 @@ require (
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/gen2brain/go-fitz v1.24.15
github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.1
github.com/goccy/go-yaml v1.19.2
github.com/golang-jwt/jwt/v4 v4.5.2
@@ -17,22 +17,22 @@ require (
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-logger v1.0.2
github.com/kovidgoyal/imaging v1.8.20
github.com/mattn/go-sqlite3 v1.14.34
github.com/mattn/go-sqlite3 v1.14.37
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.48.0
golang.org/x/image v0.36.0
golang.org/x/mod v0.33.0
golang.org/x/net v0.51.0
golang.org/x/oauth2 v0.35.0
golang.org/x/sys v0.41.0
golang.org/x/time v0.14.0
golang.org/x/tools v0.42.0
golang.org/x/crypto v0.49.0
golang.org/x/image v0.37.0
golang.org/x/mod v0.34.0
golang.org/x/net v0.52.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.42.0
golang.org/x/time v0.15.0
golang.org/x/tools v0.43.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.46.1
modernc.org/sqlite v1.47.0
)
require (
@@ -275,13 +275,13 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.69.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
+30 -30
View File
@@ -299,8 +299,8 @@ github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
@@ -644,8 +644,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgechev/revive v1.14.0 h1:CC2Ulb3kV7JFYt+izwORoS3VT/+Plb8BvslI/l1yZsc=
@@ -928,8 +928,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -948,8 +948,8 @@ golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjO
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -975,8 +975,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1019,16 +1019,16 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1044,8 +1044,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1098,8 +1098,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -1116,13 +1116,13 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -1173,8 +1173,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -1293,8 +1293,8 @@ honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.31.0 h1:/bsaxqdgX3gy/0DboxcvWrc3NpzH+6wpFfI/ZaA/hrg=
modernc.org/ccgo/v4 v4.31.0/go.mod h1:jKe8kPBjIN/VdGTVqARTQ8N1gAziBmiISY8j5HoKwjg=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -1303,8 +1303,8 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.69.0 h1:YQJ5QMSReTgQ3QFmI0dudfjXIjCcYTUxcH8/9P9f0D8=
modernc.org/libc v1.69.0/go.mod h1:YfLLduUEbodNV2xLU5JOnRHBTAHVHsVW3bVYGw0ZCV4=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -1313,8 +1313,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+56 -3
View File
@@ -25,7 +25,7 @@ import (
// POST /resources/archive — server-side only; does not return archive data.
//
// @Summary Create an archive on the server
// @Description Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission.
// @Description Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.
// @Description
// @Description **Request body parameters:**
// @Description - **fromSource** (string, required): Source name where the paths to archive live. Example: `"default"`
@@ -43,6 +43,7 @@ import (
// @Failure 400 {object} map[string]string "Invalid request (e.g. missing required field, invalid path)"
// @Failure 403 {object} map[string]string "Forbidden (create permission or access denied)"
// @Failure 404 {object} map[string]string "Source not found"
// @Failure 413 {object} map[string]string "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources/archive [post]
func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
@@ -155,6 +156,19 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
return http.StatusBadRequest, fmt.Errorf("no paths accessible; add at least one path you have access to")
}
// Check archive size limit if configured
if config.Server.MaxArchiveSizeGB > 0 {
var estimatedSize int64
estimatedSize, err = computeArchiveSize(req.FromSource, itemPaths, d)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to compute archive size: %v", err)
}
maxSizeBytes := config.Server.MaxArchiveSizeGB * 1024 * 1024 * 1024
if estimatedSize > maxSizeBytes {
return http.StatusRequestEntityTooLarge, fmt.Errorf("archive size would exceed the maximum allowed size (maxArchiveSize: %d GB)", config.Server.MaxArchiveSizeGB)
}
}
var createErr error
file, err := os.OpenFile(destRealPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
@@ -204,7 +218,7 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
// unarchiveHandler extracts an archive on the server. POST /resources/unarchive — server-side only.
//
// @Summary Extract an archive on the server
// @Description Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission.
// @Description Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.
// @Description
// @Description **Request body parameters:**
// @Description - **fromSource** (string, required): Source name where the archive file lives. Example: `"default"`
@@ -220,6 +234,7 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
// @Failure 400 {object} map[string]string "Invalid request (e.g. missing required field, unsupported format)"
// @Failure 403 {object} map[string]string "Forbidden (create permission or access denied)"
// @Failure 404 {object} map[string]string "Source or archive file not found"
// @Failure 413 {object} map[string]string "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources/unarchive [post]
func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
@@ -243,7 +258,7 @@ func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
pathClean, err := utils.SanitizeUserPath(req.Path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
return http.StatusBadRequest, err
}
destClean, err := utils.SanitizeUserPath(req.Destination)
if err != nil {
@@ -308,6 +323,14 @@ func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusBadRequest, fmt.Errorf("path is not an archive file: %s", req.Path)
}
// Check archive size limit if configured
if config.Server.MaxArchiveSizeGB > 0 {
maxSizeBytes := config.Server.MaxArchiveSizeGB * 1024 * 1024 * 1024
if info.Size() > maxSizeBytes {
return http.StatusRequestEntityTooLarge, fmt.Errorf("archive size would exceed the maximum allowed size (maxArchiveSize: %d GB)", config.Server.MaxArchiveSizeGB)
}
}
lower := strings.ToLower(archiveReal)
var extractErr error
if strings.HasSuffix(lower, ".zip") {
@@ -730,3 +753,33 @@ func extractTarGz(archivePath, destDir string) error {
}
return nil
}
// computeArchiveSize returns the combined size of the given paths, respecting access rules.
// Paths denied by access are skipped (not counted).
func computeArchiveSize(source string, fileList []string, d *requestContext) (int64, error) {
var estimatedSize int64
idx := indexing.GetIndex(source)
if idx == nil {
return 0, fmt.Errorf("source %s is not available", source)
}
for _, path := range fileList {
if !store.Access.Permitted(idx.Path, path, d.user.Username) {
continue
}
realPath, isDir, err := idx.GetRealPath(path)
if err != nil {
return 0, err
}
indexPath := idx.MakeIndexPath(realPath, isDir)
info, ok := idx.GetReducedMetadata(indexPath, isDir)
if !ok {
info, err = idx.GetFsInfo(indexPath, false, true)
if err != nil {
return 0, fmt.Errorf("failed to get file info for %s : %v", path, err)
}
}
estimatedSize += info.Size
}
return estimatedSize, nil
}
+2 -2
View File
@@ -139,7 +139,7 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
var err error
path, err = utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
return http.StatusBadRequest, err
}
// Parse lines parameter
@@ -275,7 +275,7 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
return http.StatusBadRequest, err
}
path = cleanPath
+6 -6
View File
@@ -24,7 +24,7 @@ func AuthenticateLDAPUser(username, password string) (*users.User, error) {
logger.Debugf("ldap authentication failed: %v", err)
return nil, err
}
// Use the configured UserIdentifier if available, otherwise use the login username
ldapCfg := settings.Config.Auth.Methods.LdapAuth
mappedUsername := username
@@ -36,7 +36,7 @@ func AuthenticateLDAPUser(username, password string) (*users.User, error) {
logger.Warningf("ldap: userIdentifier '%s' not found in LDAP entry for user %s, using login username", ldapCfg.UserIdentifier, username)
}
}
logger.Debugf("ldap authentication successful, getting or creating user %s", mappedUsername)
return getOrCreateLdapUser(mappedUsername, groups)
}
@@ -123,7 +123,7 @@ func authenticateLDAP(username, password string) ([]string, map[string]string, e
}
groups := entry.GetAttributeValues(groupAttr)
// Extract user attributes for identifier mapping
userAttributes := make(map[string]string)
if c.UserIdentifier != "" {
@@ -132,7 +132,7 @@ func authenticateLDAP(username, password string) ([]string, map[string]string, e
userAttributes[c.UserIdentifier] = values[0]
}
}
return groups, userAttributes, nil
}
@@ -187,7 +187,7 @@ func ldapGroupMatchesAdmin(groupDN, adminGroup string) bool {
func getOrCreateLdapUser(username string, groups []string) (*users.User, error) {
logger.Debugf("getting or creating ldap user %s", username)
ldapCfg := config.Auth.Methods.LdapAuth
// Check if user is in required groups (if userGroups is configured)
if len(ldapCfg.UserGroups) > 0 {
userInAllowedGroup := false
@@ -208,7 +208,7 @@ func getOrCreateLdapUser(username string, groups []string) (*users.User, error)
}
logger.Debugf("User %s is in required group, allowing access.", username)
}
isAdmin := false
if ldapCfg.AdminGroup != "" {
for _, g := range groups {
+38 -12
View File
@@ -5,7 +5,9 @@ import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"runtime"
"slices"
"strings"
@@ -339,11 +341,16 @@ func userWithoutOTPhelper(fn handleFunc) handleFunc {
}
}
// No valid admin token - proceed with username/password authentication
username := r.URL.Query().Get("username")
password := r.Header.Get("X-Password")
// Try LDAP first if enabled; on success set d.user and continue to handler
if config.Auth.Methods.LdapAuth.Enabled {
// No valid admin token - proceed with username/password authentication
username := r.URL.Query().Get("username")
password := r.Header.Get("X-Password")
// URL-decode password to support special characters in headers
password, err := url.QueryUnescape(password)
if err != nil {
return 401, fmt.Errorf("invalid password encoding")
}
logger.Debug("ldap auth, calling AuthenticateLDAPUser")
ldapUser, err := AuthenticateLDAPUser(username, password)
if err == nil {
@@ -391,7 +398,7 @@ func withUserHelper(fn handleFunc) handleFunc {
}
return fn(w, r, data)
}
// Check for JWT external auth first (header or query param)
if config.Auth.Methods.JwtAuth.Enabled {
jwtToken := r.Header.Get(config.Auth.Methods.JwtAuth.Header)
@@ -399,12 +406,12 @@ func withUserHelper(fn handleFunc) handleFunc {
// Check query parameter (hardcoded to "jwt")
jwtToken = r.URL.Query().Get("jwt")
}
if jwtToken != "" {
return getJwtUser(w, r, data, fn, jwtToken)
}
}
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
isProxyUser := config.Auth.Methods.ProxyAuth.Enabled && proxyUser != ""
keyFunc := func(token *jwt.Token) (interface{}, error) {
@@ -485,7 +492,7 @@ func getJwtUser(w http.ResponseWriter, r *http.Request, data *requestContext, fn
logger.Debugf("JWT verification failed: %v", err)
return http.StatusForbidden, fmt.Errorf("JWT authentication failed: %w", err)
}
// Setup user based on JWT claims
user, err := setupJwtUser(r, data, username, claims)
if err != nil {
@@ -496,7 +503,7 @@ func getJwtUser(w http.ResponseWriter, r *http.Request, data *requestContext, fn
if data.user.Username == "" {
return http.StatusForbidden, errors.ErrUnauthorized
}
// Generate a FileBrowser session token for JWT users if they don't have one
if data.token == "" {
expires := time.Hour * time.Duration(config.Auth.TokenExpirationHours)
@@ -507,7 +514,7 @@ func getJwtUser(w http.ResponseWriter, r *http.Request, data *requestContext, fn
}
data.token = tokenString
}
// Call the handler function, passing in the context (or return OK if no handler)
if fn == nil {
return http.StatusOK, nil
@@ -758,6 +765,26 @@ func setUserInResponseWriter(w http.ResponseWriter, user *users.User) {
}
}
func getRemoteIP(r *http.Request) string {
// 1. Check X-Forwarded-For
xff := r.Header.Get("X-Forwarded-For")
if xff != "" {
// The first IP is the original client
ips := strings.Split(xff, ",")
return strings.TrimSpace(ips[0])
}
// 2. Check X-Real-IP
xri := r.Header.Get("X-Real-IP")
if xri != "" {
return xri
}
// 3. Fallback to RemoteAddr (strip port if necessary)
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
return ip
}
// LoggingMiddleware logs each request and its status code.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -766,7 +793,6 @@ func LoggingMiddleware(next http.Handler) http.Handler {
if rcv := recover(); rcv != nil {
method := r.Method
url := r.URL.String()
remoteAddr := r.RemoteAddr
username := "unknown" // Default username
// Attempt to get username from ResponseWriterWrapper if it's set
@@ -779,7 +805,7 @@ func LoggingMiddleware(next http.Handler) http.Handler {
stackTrace := string(buf[:n])
logger.Errorf("PANIC RECOVERED: %v\nUser: %s\nMethod: %s\nURL: %s\nRemoteAddr: %s\nGo Stack Trace:\n%s",
rcv, username, method, url, remoteAddr, stackTrace)
rcv, username, method, url, getRemoteIP(r), stackTrace)
// Attempt to send a 500 error response to the client
// This is a best-effort; the connection might be broken or process too unstable.
@@ -822,7 +848,7 @@ func LoggingMiddleware(next http.Handler) http.Handler {
fmt.Sprintf("%-7s | %3d | %-15s | %-12s | %-12s | \"%s\"",
r.Method,
wrappedWriter.StatusCode,
r.RemoteAddr,
getRemoteIP(r),
truncUser,
fmt.Sprintf("%vms", duration.Milliseconds()),
fullURL))
-1
View File
@@ -161,7 +161,6 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
logger.Errorf("failed to exchange token: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to exchange token: %v", err)
}
fmt.Println("token", token)
rawIDToken, ok := token.Extra("id_token").(string)
// accessToken := token.AccessToken // Access token is needed for UserInfo, already in 'token'
+17 -42
View File
@@ -99,7 +99,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(providedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
return http.StatusBadRequest, err
}
providedPath = cleanPath
@@ -280,14 +280,12 @@ func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token strin
}
escapedPath := url.QueryEscape(path)
downloadURL := fmt.Sprintf("%s/api/resources/download?file=%s&auth=%s",
strings.TrimSuffix(baseURL, "/"), escapedPath, token)
downloadURL := fmt.Sprintf("%s/api/resources/download?file=%s&auth=%s&source=%s",
strings.TrimSuffix(baseURL, "/"), escapedPath, token, url.QueryEscape(source))
if hash != "" {
downloadURL = downloadURL + "&hash=" + hash
} else {
downloadURL = downloadURL + "&source=" + url.QueryEscape(source)
downloadURL = fmt.Sprintf("%s/public/api/resources/download?file=%s&auth=%s&hash=%s",
strings.TrimSuffix(baseURL, "/"), escapedPath, token, hash)
}
return downloadURL
}
@@ -354,6 +352,13 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
// Extract clean parameters from query string
source := r.URL.Query().Get("source")
path := r.URL.Query().Get("path")
user := d.user
if d.share != nil {
source = d.share.GetSourceName()
path = d.IndexPath
user = d.shareUser
}
// Validate required parameters
if (path == "" || source == "") && d.fileInfo.Hash == "" {
@@ -364,40 +369,10 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
logger.Errorf("OnlyOffice callback: invalid path: %v", err)
return returnOnlyOfficeError(w, r, 400, "invalid path")
return returnOnlyOfficeError(w, r, 400, err.Error())
}
path = cleanPath
var sourceInfo *settings.Source
var ok bool
if d.fileInfo.Hash != "" {
sourceInfo, ok = settings.Config.Server.SourceMap[d.share.Source]
if !ok {
logger.Error("OnlyOffice: share source not found")
return returnOnlyOfficeError(w, r, 404, "source not found")
}
} else {
sourceInfo, ok = settings.Config.Server.NameToSource[source]
if !ok {
logger.Error("OnlyOffice: source not found")
return returnOnlyOfficeError(w, r, 404, "source not found")
}
}
if d.fileInfo.Hash == "" {
// Regular user request - need to resolve scope
userScope, scopeErr := d.user.GetScopeForSourceName(source)
if scopeErr != nil {
logger.Errorf("OnlyOffice callback: source %s not available for user %s: %v", source, d.user.Username, scopeErr)
return returnOnlyOfficeError(w, r, 403, "source not available")
}
path = utils.JoinPathAsUnix(userScope, path)
} else {
source = sourceInfo.Name
// path is index path, so we build from share path
path = utils.JoinPathAsUnix(d.share.Path, path)
}
// Handle document closure - clean up document key cache
if data.Status == onlyOfficeStatusDocumentClosedWithChanges ||
data.Status == onlyOfficeStatusDocumentClosedWithNoChanges {
@@ -502,14 +477,14 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
}
// Check share permissions first if this is a share request
if d.fileInfo.Hash != "" {
if d.share != nil {
if !d.share.AllowModify {
logger.Errorf("OnlyOffice callback: edit permission not allowed for this share")
return returnOnlyOfficeError(w, r, 403, "edit permission not allowed for this share")
}
} else {
// Verify user has modify permissions
if !d.user.Permissions.Modify {
if !user.Permissions.Modify {
logger.Errorf("OnlyOffice callback: user %s lacks modify permissions for path=%s",
d.user.Username, path)
return returnOnlyOfficeError(w, r, 403, "user lacks modify permissions")
@@ -543,7 +518,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
_, err = files.FileInfoFaster(utils.FileOptions{
Source: source,
Path: path,
}, store.Access, d.user, store.Share)
}, store.Access, user, store.Share)
if err != nil {
logger.Errorf("OnlyOffice callback: original file no longer exists at path=%s: %v",
path, err)
@@ -558,7 +533,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
}
// Get user scope to resolve full index path for write operation
userScope, err := d.user.GetScopeForSourceName(source)
userScope, err := user.GetScopeForSourceName(source)
if err != nil {
return returnOnlyOfficeError(w, r, 403, "user scope not found")
}
+2 -29
View File
@@ -4,14 +4,12 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/go-logger/logger"
@@ -259,7 +257,7 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
return http.StatusBadRequest, err
}
resolvedPath := utils.JoinPathAsUnix(d.share.Path, cleanPath)
@@ -418,7 +416,7 @@ func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (i
return http.StatusNotFound, fmt.Errorf("user for share no longer exists")
}
sourceName, assetPath, err := getShareImagePartsHelper(d.share, isBanner)
sourceName, assetPath, err := d.share.GetShareImagePartsHelper(isBanner)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid asset configuration: %v", err)
}
@@ -464,31 +462,6 @@ func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (i
return status, err
}
func getShareImagePartsHelper(share *share.Link, isBanner bool) (string, string, error) {
// Get the asset query string from share
var assetQueryString string
if isBanner {
assetQueryString = share.Banner
} else {
assetQueryString = share.Favicon
}
if assetQueryString == "" {
return "", "", fmt.Errorf("asset not configured for this share")
}
// Parse the query string to extract source and path
assetParams, err := url.ParseQuery(assetQueryString)
if err != nil {
return "", "", fmt.Errorf("invalid asset configuration: %v", err)
}
sourceName := assetParams.Get("source")
assetPath := assetParams.Get("path")
return sourceName, assetPath, nil
}
// publicItemsGetHandler efficiently returns a basic list of items for a directory in a public share.
// @Summary Get directory items (public share)
// @Description Efficiently returns a basic list of items for the specified path in a public share. Use hash for authentication instead of source. Use 'only' parameter to filter by only files or folders.
+11 -10
View File
@@ -331,7 +331,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
// Get file info
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: idx.MakeIndexPath(item.Path, false),
Path: item.Path,
Source: item.Source,
ShowHidden: true,
}, store.Access, filePermUser, store.Share)
@@ -390,7 +390,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
return http.StatusBadRequest, err
}
path = cleanPath
@@ -589,21 +589,15 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
return http.StatusBadRequest, err
}
path = cleanPath
// Only allow PUT for files.
if strings.HasSuffix(path, "/") {
return http.StatusMethodNotAllowed, nil
}
// Get user scope to resolve full index path for write operation
userScope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
// Check access control for the target path
idx := indexing.GetIndex(source)
if idx == nil {
@@ -614,7 +608,14 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
}
err = files.WriteFile(source, utils.JoinPathAsUnix(userScope, path), r.Body)
// check if destination is a directory
stat, err := os.Stat(filepath.Join(idx.Path + fullIndexPath))
if err == nil && stat.IsDir() {
// if directory return StatusMethodNotAllowed
return http.StatusMethodNotAllowed, fmt.Errorf("path is a directory")
}
err = files.WriteFile(source, fullIndexPath, r.Body)
return errToStatus(err), err
}
+42 -9
View File
@@ -2,8 +2,10 @@ package http
import (
"fmt"
"math"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
@@ -17,7 +19,9 @@ type searchOptions struct {
searchScope string
combinedPath map[string]string // source -> combinedPath
sessionId string
largest bool
largest bool
olderThanUnix int64 // optional; 0 = unset. Modified time must be strictly before this Unix second.
newerThanUnix int64 // optional; 0 = unset. Modified time must be >= this Unix second.
}
// searchHandler handles search requests for files based on the provided query.
@@ -39,6 +43,8 @@ type searchOptions struct {
// - source: Source name (deprecated, use 'sources' instead)
// - sources: Comma-separated list of source names (e.g., "source1,source2")
// - scope: Optional path within user scope to search
// - olderThan: Optional Unix time in seconds; only items modified strictly before this instant
// - newerThan: Optional Unix time in seconds; only items modified on or after this instant
//
// Example request (single source):
//
@@ -73,6 +79,8 @@ type searchOptions struct {
// @Param source query string false "Source name for the desired source (deprecated, use 'sources' instead)"
// @Param sources query string false "Comma-separated list of source names to search across multiple sources. When multiple sources are specified, scope is always the user's scope for each source."
// @Param scope query string false "path within user scope to search, for example '/first/second' to search within the second directory only. Ignored when multiple sources are specified."
// @Param olderThan query int false "Unix seconds; only results modified strictly before this time"
// @Param newerThan query int false "Unix seconds; only results modified on or after this time"
// @Param SessionId header string false "User session ID, add unique value to prevent collisions"
// @Success 200 {array} indexing.SearchResult "List of search results with source field populated"
// @Failure 400 {object} map[string]string "Bad Request"
@@ -96,10 +104,10 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", searchOptions.sources[0])
}
combinedPath := searchOptions.combinedPath[searchOptions.sources[0]]
response = index.Search(searchOptions.query, combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize)
response = index.Search(searchOptions.query, combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix)
} else {
// Multiple sources - use the new SearchMultiSources function
response = indexing.SearchMultiSources(searchOptions.query, searchOptions.sources, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize)
response = indexing.SearchMultiSources(searchOptions.query, searchOptions.sources, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix)
}
// Filter out items that are not permitted according to access rules and trim user scope from paths
@@ -127,6 +135,14 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
sourceParam := r.URL.Query().Get("source") // deprecated, but still supported
scope := r.URL.Query().Get("scope") // Go automatically decodes query params
largest := r.URL.Query().Get("largest") == "true"
olderThanUnix, err := parseOptionalUnixQueryParam("olderThan", r.URL.Query().Get("olderThan"))
if err != nil {
return nil, err
}
newerThanUnix, err := parseOptionalUnixQueryParam("newerThan", r.URL.Query().Get("newerThan"))
if err != nil {
return nil, err
}
cleanScope, err := utils.SanitizeUserPath(scope)
if err != nil {
return nil, fmt.Errorf("invalid scope: %v", err)
@@ -176,11 +192,28 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
}
return &searchOptions{
query: query,
sources: sources,
searchScope: searchScope,
combinedPath: combinedPathMap,
sessionId: sessionId,
largest: largest,
query: query,
sources: sources,
searchScope: searchScope,
combinedPath: combinedPathMap,
sessionId: sessionId,
largest: largest,
olderThanUnix: olderThanUnix,
newerThanUnix: newerThanUnix,
}, nil
}
func parseOptionalUnixQueryParam(name, raw string) (int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, nil
}
v, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid %s: must be a non-negative Unix timestamp in seconds", name)
}
if v > math.MaxInt64 {
return 0, fmt.Errorf("invalid %s: value too large", name)
}
return int64(v), nil
}
+3 -9
View File
@@ -380,7 +380,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(providedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
return http.StatusBadRequest, err
}
body.Path = utils.JoinPathAsUnix(userscope, cleanPath)
@@ -629,14 +629,8 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
commonShare := shareLink.CommonShare
commonShare.ShareURL = getShareURL(r, hash, false, "")
_, _, err = getShareImagePartsHelper(shareLink, true)
if err == nil {
commonShare.BannerUrl = fmt.Sprintf("%spublic/api/share/image?banner=true&hash=%s", config.Server.BaseURL, hash)
}
_, _, err = getShareImagePartsHelper(shareLink, false)
if err == nil {
commonShare.FaviconUrl = fmt.Sprintf("%spublic/api/share/image?favicon=true&hash=%s", config.Server.BaseURL, hash)
}
commonShare.BannerUrl = shareLink.BannerURL()
commonShare.FaviconUrl = shareLink.FaviconURL()
commonShare.Source = ""
commonShare.Path = ""
commonShare.DownloadURL = ""
+6 -20
View File
@@ -119,21 +119,10 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
if d.share.DisableSidebar {
disableSidebar = true
}
if strings.HasPrefix(strings.ToLower(d.share.Banner), "http") {
banner = d.share.Banner
} else {
_, _, err := getShareImagePartsHelper(d.share, true)
if err == nil {
banner = fmt.Sprintf("%s%spublic/api/share/image?banner=true&hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, d.share.Hash)
}
}
if strings.HasPrefix(strings.ToLower(d.share.Favicon), "http") {
favicon = d.share.Favicon
} else {
_, _, err := getShareImagePartsHelper(d.share, false)
if err == nil {
favicon = fmt.Sprintf("%s%spublic/api/share/image?favicon=true&hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, d.share.Hash)
}
banner = d.share.BannerURL()
shareFavicon := d.share.FaviconURL()
if shareFavicon != "" {
favicon = shareFavicon
}
shareHash = d.share.Hash
}
@@ -305,11 +294,8 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", `default-src 'self'; style-src 'unsafe-inline';`)
// Strip baseURL and /static/ prefix to get clean asset path
path := r.URL.Path
path = strings.TrimPrefix(path, config.Server.BaseURL) // Remove /testing/ or other baseURL
path = strings.TrimPrefix(path, "/") // Remove leading slash FIRST
path = strings.TrimPrefix(path, "static/") // Then remove static/ prefix
path := strings.TrimPrefix(r.URL.Path, "/public")
path = strings.TrimPrefix(path, "/static/")
// Handle special routes that need path mapping
var assetPath string
switch path {
+147
View File
@@ -5,6 +5,7 @@ import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
)
// setupConditionalTestIndex creates a test index with conditional rules configured
@@ -623,3 +624,149 @@ func TestIsNeverWatchPath(t *testing.T) {
})
}
}
// TestIsViewable_DoesNotMutateFolderPaths verifies that IsViewable doesn't modify FolderPaths
func TestIsViewable_DoesNotMutateFolderPaths(t *testing.T) {
idx := setupConditionalTestIndex(settings.ConditionalFilter{})
idx.Config.ResolvedRules.FolderNames["private"] = settings.ConditionalRule{
FolderNames: "private",
Viewable: false,
}
if len(idx.Config.ResolvedRules.FolderPaths) != 0 {
t.Fatalf("expected no preloaded folder path rules, got %d", len(idx.Config.ResolvedRules.FolderPaths))
}
if idx.IsViewable(true, "/private/", false, false) {
t.Fatal("expected /private/ to be non-viewable")
}
if len(idx.Config.ResolvedRules.FolderPaths) != 0 {
t.Fatalf("IsViewable should not mutate FolderPaths, got %d entries", len(idx.Config.ResolvedRules.FolderPaths))
}
}
// TestIsViewableWithParentCheck_RecursiveParentWalking tests API request parent checking
func TestIsViewableWithParentCheck_RecursiveParentWalking(t *testing.T) {
idx := setupConditionalTestIndex(settings.ConditionalFilter{})
idx.Config.ResolvedRules.FolderPaths["/private"] = settings.ConditionalRule{
FolderPath: "/private",
Viewable: false,
}
// Enable global hidden folder blocking
idx.Config.ResolvedRules.IgnoreAllHidden = true
tests := []struct {
name string
path string
isDir bool
expected bool
description string
}{
{
name: "Direct non-viewable folder",
path: "/private",
isDir: true,
expected: false,
description: "Folder with explicit non-viewable rule should not be viewable",
},
{
name: "Child of non-viewable folder",
path: "/private/subfolder",
isDir: true,
expected: false,
description: "Child should inherit parent's non-viewable status",
},
{
name: "Deep child of non-viewable folder",
path: "/private/subfolder/deep/nested",
isDir: true,
expected: false,
description: "Deeply nested child should inherit parent's non-viewable status",
},
{
name: "File in non-viewable folder",
path: "/private/secret.txt",
isDir: false,
expected: false,
description: "File should inherit parent folder's non-viewable status",
},
{
name: "Unrelated folder",
path: "/public/documents",
isDir: true,
expected: true,
description: "Unrelated folder should be viewable",
},
{
name: "Hidden folder",
path: "/.hiddenDir",
isDir: true,
expected: false,
description: "Hidden folder should not be viewable when IgnoreAllHidden is true",
},
{
name: "File in hidden folder",
path: "/.hiddenDir/nested.txt",
isDir: false,
expected: false,
description: "File in hidden folder should inherit parent's non-viewable status",
},
{
name: "Nested hidden folder",
path: "/subfolderExclusions/.hiddenDir",
isDir: true,
expected: false,
description: "Nested hidden folder should not be viewable",
},
{
name: "File in nested hidden folder",
path: "/subfolderExclusions/.hiddenDir/nested.txt",
isDir: false,
expected: false,
description: "File in nested hidden folder should not be viewable",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Determine if the path is hidden
realPath := utils.JoinPathAsUnix(idx.Path, tt.path)
isHidden := IsHidden(realPath)
result := idx.IsViewableWithParentCheck(tt.isDir, tt.path, false, isHidden)
if result != tt.expected {
t.Errorf("%s: expected %v, got %v (path=%s, isHidden=%v)",
tt.description, tt.expected, result, tt.path, isHidden)
}
})
}
}
// TestIsViewableWithParentCheck_DoesNotMutate verifies thread safety
func TestIsViewableWithParentCheck_DoesNotMutate(t *testing.T) {
idx := setupConditionalTestIndex(settings.ConditionalFilter{})
idx.Config.ResolvedRules.FolderNames["restricted"] = settings.ConditionalRule{
FolderNames: "restricted",
Viewable: false,
}
initialFolderPathCount := len(idx.Config.ResolvedRules.FolderPaths)
// Call IsViewableWithParentCheck multiple times with various paths
paths := []string{
"/restricted/data/file.txt",
"/projects/restricted/subfolder/doc.pdf",
"/restricted/deep/nested/path/file.csv",
}
for _, path := range paths {
_ = idx.IsViewableWithParentCheck(false, path, false, false)
}
// Verify no mutation occurred
if len(idx.Config.ResolvedRules.FolderPaths) != initialFolderPathCount {
t.Errorf("FolderPaths was mutated: expected %d, got %d",
initialFolderPathCount, len(idx.Config.ResolvedRules.FolderPaths))
}
}
+55 -49
View File
@@ -487,7 +487,14 @@ func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool) (*Pa
// Returns whether the path is viewable and/or indexable
// Does NOT check user permissions (that's handled in the API layer)
func (idx *Index) evaluateIndexRules(ctx *PathContext, isRoutineScan bool) (isViewable bool, isIndexable bool) {
isViewable = idx.IsViewable(ctx.IsDir, ctx.IndexPath, ctx.IsSymlink)
// For scanner (routine scan), only check the item itself
// For API requests, check item and all parent paths
if isRoutineScan {
isViewable = idx.IsViewable(ctx.IsDir, ctx.IndexPath, ctx.IsSymlink, ctx.IsHidden)
} else {
isViewable = idx.IsViewableWithParentCheck(ctx.IsDir, ctx.IndexPath, ctx.IsSymlink, ctx.IsHidden)
}
shouldSkip := idx.ShouldSkip(ctx.IsDir, ctx.IndexPath, ctx.IsHidden, ctx.IsSymlink, isRoutineScan)
isIndexable = !shouldSkip
return isViewable, isIndexable
@@ -503,11 +510,10 @@ func (idx *Index) GetFileInfo(req FileInfoRequest) (*iteminfo.FileInfo, error) {
return nil, err
}
// 2. Apply index rules
isViewable, isIndexable := idx.evaluateIndexRules(ctx, req.IsRoutineScan)
// Path must be either viewable OR indexable
if !isViewable && !isIndexable {
// 2. Apply index rules (non-routine scan)
isViewable, isIndexable := idx.evaluateIndexRules(ctx, false)
// API request: path must be viewable
if !isViewable {
return nil, errors.ErrNotViewable
}
@@ -653,7 +659,7 @@ func (idx *Index) GetFsInfoCore(indexPath string, opts Options) (*iteminfo.FileI
// Check if item is accessible
hidden := IsHidden(realPath)
isSymlink := dirInfo.Mode()&os.ModeSymlink != 0
isViewable := idx.IsViewable(dirInfo.IsDir(), indexPath, isSymlink)
isViewable := idx.IsViewable(dirInfo.IsDir(), indexPath, isSymlink, hidden)
isSkipped := idx.ShouldSkip(dirInfo.IsDir(), indexPath, hidden, isSymlink, false)
// Deny access if not viewable AND skipped
@@ -927,7 +933,7 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
// Check viewable/indexable rules
if !shouldSkip {
isViewable := idx.IsViewable(isDir, fullCombined, isSymlink)
isViewable := idx.IsViewable(isDir, fullCombined, isSymlink, hidden)
isSkipped := idx.ShouldSkip(isDir, fullCombined, hidden, isSymlink, opts.IsRoutineScan)
if opts.CheckViewable {
shouldSkip = !isViewable && isSkipped
@@ -1335,53 +1341,41 @@ func setFilePreviewFlags(fileInfo *iteminfo.ItemInfo, realPath string) {
}
// IsViewable checks if a path is viewable (allows FS access)
func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool) bool {
if indexPath == "/" {
// Only checks the item itself, not parent paths
func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool, isHidden bool) bool {
if indexPath == "/" || idx.Config.ResolvedRules.NoRules {
return true
}
// Check symlinks
if isSymlink && idx.Config.ResolvedRules.IgnoreAllSymlinks {
rules := idx.Config.ResolvedRules
// Check if hidden and should be blocked
if isHidden && rules.IgnoreAllHidden {
return false
}
rules := idx.Config.ResolvedRules
baseName := filepath.Base(indexPath)
if indexPath == "/" {
baseName = filepath.Base(idx.Path)
// Check symlinks
if isSymlink && rules.IgnoreAllSymlinks {
return false
}
baseName := filepath.Base(indexPath)
if isDir {
// Check folder rules - return false if explicitly set to non-viewable
// Check folder rules
if rule, exists := rules.FolderNames[baseName]; exists {
// Cache this match in FolderPaths so children inherit via prefix matching
if _, ok := rules.FolderPaths[indexPath]; !ok {
rules.FolderPaths[indexPath] = rule
}
return rule.Viewable
}
if rule, exists := rules.FolderPaths[indexPath]; exists {
return rule.Viewable
}
for path, rule := range rules.FolderPaths {
if strings.HasPrefix(indexPath, path) {
return rule.Viewable
}
}
for _, rule := range rules.FolderEndsWith {
if strings.HasSuffix(baseName, rule.FolderEndsWith) {
// Cache this match in FolderPaths so children inherit
if _, ok := rules.FolderPaths[indexPath]; !ok {
rules.FolderPaths[indexPath] = rule
}
return rule.Viewable
}
}
for _, rule := range rules.FolderStartsWith {
if strings.HasPrefix(baseName, rule.FolderStartsWith) {
// Cache this match in FolderPaths so children inherit
if _, ok := rules.FolderPaths[indexPath]; !ok {
rules.FolderPaths[indexPath] = rule
}
return rule.Viewable
}
}
@@ -1393,11 +1387,6 @@ func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool) bool
if rule, exists := rules.FilePaths[indexPath]; exists {
return rule.Viewable
}
for path, rule := range rules.FilePaths {
if strings.HasPrefix(indexPath, path) {
return rule.Viewable
}
}
for _, rule := range rules.FileEndsWith {
if strings.HasSuffix(baseName, rule.FileEndsWith) {
return rule.Viewable
@@ -1408,27 +1397,44 @@ func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool) bool
return rule.Viewable
}
}
// Check if file inherits viewable status from parent folder
for path, rule := range rules.FolderPaths {
if strings.HasPrefix(indexPath, path) {
return rule.Viewable
}
}
}
return true // Default: viewable unless explicitly set to false
}
// IsViewableWithParentCheck checks if a path is viewable by walking up the directory tree
// Used for API requests where we need to check inherited rules from parent folders
func (idx *Index) IsViewableWithParentCheck(isDir bool, indexPath string, isSymlink bool, isHidden bool) bool {
if idx.Config.DisableIndexing || idx.Config.ResolvedRules.NoRules {
return true
}
// Check the item itself - if not viewable, stop here
if !idx.IsViewable(isDir, indexPath, isSymlink, isHidden) {
return false
}
// Item is viewable, now check parent recursively
parentDir := utils.GetParentDirectoryPath(indexPath)
if parentDir == "" || parentDir == "/" {
return true // Reached root with no non-viewable rules, default to viewable
}
// Check if parent is hidden
realPath, _, _ := idx.GetRealPath(parentDir)
return idx.IsViewableWithParentCheck(true, parentDir, false, IsHidden(realPath))
}
// ShouldSkip checks if a path should be skipped from indexing
// isRoutineScan: true for scanner (no parent checks), false for API (check parents)
func (idx *Index) ShouldSkip(isDir bool, adjustedPath string, isHidden bool, isSymlink bool, isRoutineScan bool) bool {
rules := idx.Config.ResolvedRules
if adjustedPath == "/" {
return false
}
if idx.Config.DisableIndexing {
return true
}
rules := idx.Config.ResolvedRules
if adjustedPath == "/" || rules.NoRules {
return false
}
if isHidden && rules.IgnoreAllHidden {
return true
}
+64 -15
View File
@@ -24,6 +24,7 @@ type Scanner struct {
lastScanned time.Time
quickScanTime int
fullScanTime int
statsMu sync.RWMutex
scanStartTime int64
isScanning bool // True when scanner is actively scanning or waiting for mutex
@@ -41,6 +42,18 @@ type Scanner struct {
stopOnce sync.Once // Ensures stopChan is only closed once
}
func (s *Scanner) withStatsLock(fn func()) {
s.statsMu.Lock()
defer s.statsMu.Unlock()
fn()
}
func (s *Scanner) withStatsRLock(fn func()) {
s.statsMu.RLock()
defer s.statsMu.RUnlock()
fn()
}
// start begins the scanner's main loop
func (s *Scanner) start() {
// Wait a bit to stagger child scanner initial scans (root goes first)
@@ -126,14 +139,20 @@ func (s *Scanner) runRootScan(quick bool) {
if quick {
s.runQuickScanRoot()
s.statsMu.Lock()
s.quickScanTime = int(time.Since(startTime).Seconds())
s.statsMu.Unlock()
} else {
s.runFullScanRoot()
s.statsMu.Lock()
s.fullScanTime = int(time.Since(startTime).Seconds())
s.statsMu.Unlock()
s.updateComplexity()
}
s.statsMu.Lock()
s.lastScanned = time.Now()
s.statsMu.Unlock()
s.checkForNewChildDirectories()
if err := s.idx.db.ShrinkMemory(); err != nil {
logger.Errorf("Failed to shrink memory: %v", err)
@@ -147,8 +166,10 @@ func (s *Scanner) runFullScanRoot() {
}
// Reset counters and initialize state for full scan
s.numDirs = 0
s.numFiles = 0
s.withStatsLock(func() {
s.numDirs = 0
s.numFiles = 0
})
s.scanStartTime = time.Now().Unix()
s.idx.mu.Lock()
if s.idx.scanSessionStartTime == 0 {
@@ -161,7 +182,9 @@ func (s *Scanner) runFullScanRoot() {
batchSize := s.idx.db.BatchSize
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
s.filesChanged = false
s.withStatsLock(func() {
s.filesChanged = false
})
_, _, err := s.idx.indexDirectory("/", config, s)
if err != nil {
@@ -184,7 +207,9 @@ func (s *Scanner) runFullScanRoot() {
func (s *Scanner) runQuickScanRoot() {
s.scanStartTime = time.Now().Unix()
s.filesChanged = false
s.withStatsLock(func() {
s.filesChanged = false
})
// Get root-level folders from folderSizes map
s.idx.folderSizesMu.RLock()
@@ -228,15 +253,21 @@ func (s *Scanner) runChildScan(quick bool) {
if quick {
// Quick scan: iterate folderSizes, check modtimes only (257x faster!)
s.runQuickScanChild()
s.quickScanTime = int(time.Since(startTime).Seconds())
s.withStatsLock(func() {
s.quickScanTime = int(time.Since(startTime).Seconds())
})
} else {
// Full scan: walk filesystem recursively, read all directory contents
s.runFullScanChild()
s.fullScanTime = int(time.Since(startTime).Seconds())
s.withStatsLock(func() {
s.fullScanTime = int(time.Since(startTime).Seconds())
})
s.updateComplexity()
}
s.lastScanned = time.Now()
s.withStatsLock(func() {
s.lastScanned = time.Now()
})
}
func (s *Scanner) runFullScanChild() {
@@ -246,8 +277,10 @@ func (s *Scanner) runFullScanChild() {
}
// Reset counters and initialize state for full scan
s.numDirs = 0
s.numFiles = 0
s.withStatsLock(func() {
s.numDirs = 0
s.numFiles = 0
})
s.scanStartTime = time.Now().Unix()
s.idx.mu.Lock()
if s.idx.scanSessionStartTime == 0 {
@@ -257,7 +290,9 @@ func (s *Scanner) runFullScanChild() {
s.processedInodes = make(map[uint64]struct{})
s.foundHardLinks = make(map[string]uint64)
s.filesChanged = false
s.withStatsLock(func() {
s.filesChanged = false
})
batchSize := s.idx.db.BatchSize
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
@@ -297,7 +332,9 @@ func (s *Scanner) runFullScanChild() {
func (s *Scanner) runQuickScanChild() {
s.scanStartTime = time.Now().Unix()
s.filesChanged = false
s.withStatsLock(func() {
s.filesChanged = false
})
// Get folders in this scanner's scope from folderSizes map
s.idx.folderSizesMu.RLock()
@@ -451,7 +488,10 @@ func (s *Scanner) getTopLevelDirs() []string {
// calculateSleepTime determines how long to wait before the next scan
func (s *Scanner) calculateSleepTime() time.Duration {
sleepTime := scanSchedule[s.currentSchedule] + s.smartModifier
var sleepTime time.Duration
s.withStatsRLock(func() {
sleepTime = scanSchedule[s.currentSchedule] + s.smartModifier
})
if s.idx.Config.IndexingInterval > 0 {
sleepTime = time.Duration(s.idx.Config.IndexingInterval) * time.Minute
}
@@ -461,6 +501,8 @@ func (s *Scanner) calculateSleepTime() time.Duration {
// updateSchedule adjusts the scanner's schedule based on whether files changed
func (s *Scanner) updateSchedule() {
s.statsMu.Lock()
defer s.statsMu.Unlock()
// Calculate sleep time BEFORE updating schedule (based on current/old schedule)
s.nextSleepTime = scanSchedule[s.currentSchedule] + s.smartModifier
@@ -506,6 +548,9 @@ func (s *Scanner) updateSchedule() {
// updateComplexity calculates the complexity level (1-10) for this scanner's directory
// 0: unknown
func (s *Scanner) updateComplexity() {
s.statsMu.Lock()
defer s.statsMu.Unlock()
s.complexity = calculateComplexity(s.fullScanTime, s.numDirs)
// Set smartModifier based on complexity level
@@ -564,8 +609,10 @@ func (s *Scanner) syncStatsWithDB() {
return
}
s.numDirs = dirs
s.numFiles = files
s.withStatsLock(func() {
s.numDirs = dirs
s.numFiles = files
})
}
// checkFolderModtime checks if a folder's modtime changed since last scan
@@ -590,7 +637,9 @@ func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
if modtimeChanged {
// Modtime changed - read directory contents and update database
s.filesChanged = true
s.withStatsLock(func() {
s.filesChanged = true
})
dir, err := os.Open(realPath)
if err != nil {
+40 -27
View File
@@ -116,7 +116,9 @@ func (idx *Index) markFilesChanged() {
idx.mu.RUnlock()
if exists {
scanner.filesChanged = true
scanner.withStatsLock(func() {
scanner.filesChanged = true
})
}
}
@@ -145,7 +147,9 @@ func (idx *Index) incrementScannerDirsUnlocked() {
}
scanner, exists := idx.scanners[activePath]
if exists {
scanner.numDirs++
scanner.withStatsLock(func() {
scanner.numDirs++
})
}
}
@@ -162,7 +166,9 @@ func (idx *Index) incrementScannerFiles() {
idx.mu.RUnlock()
if exists {
scanner.numFiles++
scanner.withStatsLock(func() {
scanner.numFiles++
})
}
}
@@ -252,13 +258,15 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
rootScanner := idx.createScanner("/")
if persistedScanners != nil {
if rootInfo, ok := persistedScanners["/"]; ok {
rootScanner.complexity = rootInfo.Complexity
rootScanner.currentSchedule = rootInfo.CurrentSchedule
rootScanner.quickScanTime = rootInfo.QuickScanTime
rootScanner.fullScanTime = rootInfo.FullScanTime
rootScanner.numDirs = rootInfo.NumDirs
rootScanner.numFiles = rootInfo.NumFiles
rootScanner.lastScanned = rootInfo.LastScanned
rootScanner.withStatsLock(func() {
rootScanner.complexity = rootInfo.Complexity
rootScanner.currentSchedule = rootInfo.CurrentSchedule
rootScanner.quickScanTime = rootInfo.QuickScanTime
rootScanner.fullScanTime = rootInfo.FullScanTime
rootScanner.numDirs = rootInfo.NumDirs
rootScanner.numFiles = rootInfo.NumFiles
rootScanner.lastScanned = rootInfo.LastScanned
})
}
}
idx.mu.Lock()
@@ -276,13 +284,15 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
// Restore persisted stats for child scanner if available (and DB is not new)
if persistedScanners != nil {
if childInfo, ok := persistedScanners[dirPath]; ok {
childScanner.complexity = childInfo.Complexity
childScanner.currentSchedule = childInfo.CurrentSchedule
childScanner.quickScanTime = childInfo.QuickScanTime
childScanner.fullScanTime = childInfo.FullScanTime
childScanner.numDirs = childInfo.NumDirs
childScanner.numFiles = childInfo.NumFiles
childScanner.lastScanned = childInfo.LastScanned
childScanner.withStatsLock(func() {
childScanner.complexity = childInfo.Complexity
childScanner.currentSchedule = childInfo.CurrentSchedule
childScanner.quickScanTime = childInfo.QuickScanTime
childScanner.fullScanTime = childInfo.FullScanTime
childScanner.numDirs = childInfo.NumDirs
childScanner.numFiles = childInfo.NumFiles
childScanner.lastScanned = childInfo.LastScanned
})
}
}
@@ -318,22 +328,25 @@ func (idx *Index) GetScannerStatus() map[string]interface{} {
activePath := idx.getActiveScannerPathUnlocked()
status["activeScanner"] = activePath
status["isScanning"] = activePath != ""
status["status"] = idx.Status
// Individual scanner stats
scannerStats := make([]map[string]interface{}, 0, len(idx.scanners))
for path, scanner := range idx.scanners {
scannerInfo := map[string]interface{}{
"path": path,
"lastScanned": scanner.lastScanned.Format(time.RFC3339),
"complexity": scanner.complexity,
"currentSchedule": scanner.currentSchedule,
"quickScanTime": scanner.quickScanTime,
"fullScanTime": scanner.fullScanTime,
"numDirs": scanner.numDirs,
"numFiles": scanner.numFiles,
"filesChanged": scanner.filesChanged,
"smartModifier": scanner.smartModifier.String(),
"path": path,
}
scanner.withStatsRLock(func() {
scannerInfo["lastScanned"] = scanner.lastScanned.Format(time.RFC3339)
scannerInfo["complexity"] = scanner.complexity
scannerInfo["currentSchedule"] = scanner.currentSchedule
scannerInfo["quickScanTime"] = scanner.quickScanTime
scannerInfo["fullScanTime"] = scanner.fullScanTime
scannerInfo["numDirs"] = scanner.numDirs
scannerInfo["numFiles"] = scanner.numFiles
scannerInfo["filesChanged"] = scanner.filesChanged
scannerInfo["smartModifier"] = scanner.smartModifier.String()
})
scannerStats = append(scannerStats, scannerInfo)
}
status["scanners"] = scannerStats
+5 -1
View File
@@ -128,7 +128,11 @@ func TestMultiScannerMutex(t *testing.T) {
// Verify markFilesChanged works
idx.markFilesChanged()
if !scanner2.filesChanged {
var changed bool
scanner2.withStatsRLock(func() {
changed = scanner2.filesChanged
})
if !changed {
t.Error("Expected scanner2.filesChanged=true")
}
+7
View File
@@ -375,6 +375,13 @@ func (fi ItemInfo) ContainsSearchTerm(searchTerm string, options SearchOptions)
}
}
if options.ModifiedNewerThan > 0 && fi.ModTime.Unix() < options.ModifiedNewerThan {
return false
}
if options.ModifiedOlderThan > 0 && fi.ModTime.Unix() >= options.ModifiedOlderThan {
return false
}
return true
}
+31 -1
View File
@@ -3,6 +3,7 @@ package iteminfo
import (
"regexp"
"strings"
"time"
)
var typeRegexp = regexp.MustCompile(`type:(\S+)`)
@@ -11,7 +12,11 @@ type SearchOptions struct {
Conditions map[string]bool
LargerThan int
SmallerThan int
Terms []string
// ModifiedOlderThan is Unix seconds (UTC start of day). If > 0, item must have ModTime strictly before this instant.
ModifiedOlderThan int64
// ModifiedNewerThan is Unix seconds (UTC start of day). If > 0, item must have ModTime >= this instant.
ModifiedNewerThan int64
Terms []string
}
func ParseSearch(value string) SearchOptions {
@@ -61,6 +66,18 @@ func ParseSearch(value string) SearchOptions {
size := strings.TrimPrefix(filter, "smallerThan=")
opts.SmallerThan = UpdateSize(size)
}
if strings.HasPrefix(filter, "olderThan=") {
dateStr := strings.TrimPrefix(filter, "olderThan=")
if ts, ok := parseSearchDateUTCStart(dateStr); ok {
opts.ModifiedOlderThan = ts
}
}
if strings.HasPrefix(filter, "newerThan=") {
dateStr := strings.TrimPrefix(filter, "newerThan=")
if ts, ok := parseSearchDateUTCStart(dateStr); ok {
opts.ModifiedNewerThan = ts
}
}
}
if len(types) > 0 {
@@ -85,3 +102,16 @@ func ParseSearch(value string) SearchOptions {
opts.Terms = strings.Split(value, "|")
return opts
}
// parseSearchDateUTCStart parses YYYY-MM-DD as midnight UTC; used for search date filters.
func parseSearchDateUTCStart(s string) (int64, bool) {
s = strings.TrimSpace(s)
if len(s) != 10 {
return 0, false
}
t, err := time.ParseInLocation("2006-01-02", s, time.UTC)
if err != nil {
return 0, false
}
return t.Unix(), true
}
+2
View File
@@ -289,7 +289,9 @@ func (idx *Index) IterateFiles(fn func(path, name string, size, modTime int64))
}
func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, error) {
indexesMutex.Lock()
idx, ok := indexes[sourceName]
indexesMutex.Unlock()
if !ok {
return ReducedIndex{}, fmt.Errorf("index %s not found", sourceName)
}
+28 -4
View File
@@ -28,13 +28,19 @@ type SearchResult struct {
Source string `json:"source"`
}
func (idx *Index) Search(search string, scope string, sourceSession string, largest bool, limit int) []*SearchResult {
func (idx *Index) Search(search string, scope string, sourceSession string, largest bool, limit int, olderThanUnix, newerThanUnix int64) []*SearchResult {
// Ensure scope has consistent trailing slash for directory matching
scope = utils.AddTrailingSlashIfNotExists(scope)
runningHash := utils.InsecureRandomIdentifier(4)
sessionInProgress.Store(sourceSession, runningHash) // Store the value in the sync.Map
searchOptions := iteminfo.ParseSearch(search)
if olderThanUnix > 0 {
searchOptions.ModifiedOlderThan = olderThanUnix
}
if newerThanUnix > 0 {
searchOptions.ModifiedNewerThan = newerThanUnix
}
results := make(map[string]*SearchResult, 0)
count := 0
@@ -102,7 +108,8 @@ func (idx *Index) Search(search string, scope string, sourceSession string, larg
} else {
typeMatches = !hasDirCondition || (hasDirCondition && !dirCondition)
}
matches = sizeMatches && typeMatches
dateMatches := searchDateMatches(item.ModTime.Unix(), searchOptions)
matches = sizeMatches && typeMatches && dateMatches
} else {
matches = item.ContainsSearchTerm(searchTerm, searchOptions)
}
@@ -143,12 +150,22 @@ func (idx *Index) Search(search string, scope string, sourceSession string, larg
return sortedKeys
}
func searchDateMatches(modUnix int64, opts iteminfo.SearchOptions) bool {
if opts.ModifiedNewerThan > 0 && modUnix < opts.ModifiedNewerThan {
return false
}
if opts.ModifiedOlderThan > 0 && modUnix >= opts.ModifiedOlderThan {
return false
}
return true
}
// SearchMultiSources searches across multiple sources in a single database query.
// sources is a list of source names to search.
// sourceScopes is a map from source name to the scope path for that source.
// sourceSession is used for cancellation tracking.
// largest and limit work the same as in Search.
func SearchMultiSources(search string, sources []string, sourceScopes map[string]string, sourceSession string, largest bool, limit int) []*SearchResult {
func SearchMultiSources(search string, sources []string, sourceScopes map[string]string, sourceSession string, largest bool, limit int, olderThanUnix, newerThanUnix int64) []*SearchResult {
if len(sources) == 0 {
return []*SearchResult{}
}
@@ -168,6 +185,12 @@ func SearchMultiSources(search string, sources []string, sourceScopes map[string
runningHash := utils.InsecureRandomIdentifier(4)
sessionInProgress.Store(sourceSession, runningHash)
searchOptions := iteminfo.ParseSearch(search)
if olderThanUnix > 0 {
searchOptions.ModifiedOlderThan = olderThanUnix
}
if newerThanUnix > 0 {
searchOptions.ModifiedNewerThan = newerThanUnix
}
results := make(map[string]*SearchResult, 0)
count := 0
@@ -237,7 +260,8 @@ func SearchMultiSources(search string, sources []string, sourceScopes map[string
} else {
typeMatches = !hasDirCondition || (hasDirCondition && !dirCondition)
}
matches = sizeMatches && typeMatches
dateMatches := searchDateMatches(item.ModTime.Unix(), searchOptions)
matches = sizeMatches && typeMatches && dateMatches
} else {
matches = item.ContainsSearchTerm(searchTerm, searchOptions)
}
+14 -5
View File
@@ -62,7 +62,7 @@ func BenchmarkSearchAllIndexes(b *testing.B) {
for i := 0; i < b.N; i++ {
// Execute the SearchAllIndexes function
for _, term := range searchTerms {
idx.Search(term, "/", "test", false, DefaultSearchResults)
idx.Search(term, "/", "test", false, DefaultSearchResults, 0, 0)
}
}
}
@@ -102,6 +102,15 @@ func TestParseSearch(t *testing.T) {
Terms: []string{"thisfile"},
},
},
{
input: "type:olderThan=2024-06-01 type:newerThan=2024-01-01 findme",
want: iteminfo.SearchOptions{
Conditions: map[string]bool{"exact": false},
Terms: []string{"findme"},
ModifiedOlderThan: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).Unix(),
ModifiedNewerThan: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC).Unix(),
},
},
}
for _, tt := range tests {
@@ -165,7 +174,7 @@ func TestSearchWhileIndexing(t *testing.T) {
wg.Add(1)
go func(searchTerm string) {
defer wg.Done()
idx.Search(searchTerm, "/", "test", false, DefaultSearchResults)
idx.Search(searchTerm, "/", "test", false, DefaultSearchResults, 0, 0)
}(term)
}
@@ -443,7 +452,7 @@ func TestSearchIndexes(t *testing.T) {
for _, tt := range tests {
t.Run(tt.search, func(t *testing.T) {
result := index.Search(tt.search, tt.scope, "", false, DefaultSearchResults)
result := index.Search(tt.search, tt.scope, "", false, DefaultSearchResults, 0, 0)
// Convert results to comparable format (without Modified field)
expected := make([]SearchResult, len(tt.expectedResult))
for i, r := range tt.expectedResult {
@@ -535,7 +544,7 @@ func TestSearchLargestModeExcludesRoot(t *testing.T) {
}
// Test that when largest=true and scope="/", the root directory "/" is NOT included
result := index.Search("", "/", "test-session", true, DefaultSearchResults)
result := index.Search("", "/", "test-session", true, DefaultSearchResults, 0, 0)
// Verify that "/" is NOT in the results
rootFound := false
@@ -631,7 +640,7 @@ func TestSearchLargestModeExcludesScopeDirectory(t *testing.T) {
}
// Test that when largest=true and scope="/test/", the scope directory "/test/" is NOT included
result := index.Search("", "/test/", "test-session", true, DefaultSearchResults)
result := index.Search("", "/test/", "test-session", true, DefaultSearchResults, 0, 0)
// Verify that "/test/" is NOT in the results
scopeDirFound := false
+25 -7
View File
@@ -11,6 +11,7 @@ import (
"os"
"runtime"
"strings"
"time"
"github.com/gen2brain/go-fitz"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
@@ -32,10 +33,14 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
s.docGenMutex.Lock()
defer s.docGenMutex.Unlock()
// 2. Lock the current goroutine to a single OS thread for CGo calls
// Lock the current goroutine to a single OS thread for CGo calls
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// Create a 2-second timeout for document generation after acquiring locks
timeoutCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
docPath := file.RealPath
// copy file to a temporary location if needed
if strings.HasPrefix(file.Type, "text") && !strings.HasSuffix(file.RealPath, ".txt") {
@@ -72,21 +77,29 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
return nil, fmt.Errorf("text snippet: failed to close temporary file '%s': %w", tempFilePath, err)
}
docPath = tempFilePath // Update docPath to point to the new temporary text snippet file
}
docPath = tempFilePath // Update docPath to point to the new temporary text snippet file
}
}
// Check timeout before opening document
if timeoutCtx.Err() != nil {
return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name)
}
doc, err := fitz.New(docPath) // This calls the CGo version
if err != nil {
// The error message you received: "failed to open PDF: fitz: cannot open memory"
return nil, fmt.Errorf("failed to open PDF from memory for file '%s': %w", docPath, err)
}
defer doc.Close()
// Check timeout after opening document
if timeoutCtx.Err() != nil {
return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name)
}
// Get the image from the doc page
// Ensure pageNumber is valid (e.g., >= 0 for 0-indexed or >= 1 for 1-indexed, check go-fitz docs)
// And pageNumber < doc.NumPage()
numPages := doc.NumPage()
if pageNumber < 0 || pageNumber >= numPages { // Assuming 0-indexed for Image()
if pageNumber < 0 || pageNumber >= numPages {
return nil, fmt.Errorf("invalid page number %d for PDF with %d pages ('%s')", pageNumber, numPages, docPath)
}
@@ -95,6 +108,11 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
return nil, fmt.Errorf("failed to get image from page %d of '%s': %w", pageNumber, docPath, err)
}
// Check timeout after rendering image
if timeoutCtx.Err() != nil {
return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name)
}
// Create a new buffer to hold the image bytes
var buf bytes.Buffer
+41 -3
View File
@@ -1728,7 +1728,7 @@ const docTemplate = `{
},
"/api/resources/archive": {
"post": {
"description": "Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the paths to archive live. Example: ` + "`" + `\"default\"` + "`" + `\n- **toSource** (string, optional): Source name where the archive file will be written. Defaults to fromSource if omitted. Example: ` + "`" + `\"backups\"` + "`" + `\n- **paths** (array of strings, required): Paths of files or directories to add to the archive (relative to fromSource). Directories are walked; access-denied entries are skipped. Example: ` + "`" + `[\"/docs/file.txt\", \"/photos\"]` + "`" + `\n- **destination** (string, required): Full path where the archive file will be created (on toSource). Must end with .zip or .tar.gz (or format is inferred). Example: ` + "`" + `\"/backups/my-archive.zip\"` + "`" + `\n- **format** (string, optional): Archive format. One of: ` + "`" + `\"zip\"` + "`" + `, ` + "`" + `\"tar.gz\"` + "`" + `. Default inferred from destination extension. Example: ` + "`" + `\"zip\"` + "`" + `\n- **compression** (integer, optional): Gzip compression level for tar.gz only (0–9). 0 = default. Ignored for zip. Example: ` + "`" + `6` + "`" + `\n- **deleteAfter** (boolean, optional): If true, delete source files/directories after successful creation. Requires delete permission. Example: ` + "`" + `true` + "`" + `",
"description": "Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the paths to archive live. Example: ` + "`" + `\"default\"` + "`" + `\n- **toSource** (string, optional): Source name where the archive file will be written. Defaults to fromSource if omitted. Example: ` + "`" + `\"backups\"` + "`" + `\n- **paths** (array of strings, required): Paths of files or directories to add to the archive (relative to fromSource). Directories are walked; access-denied entries are skipped. Example: ` + "`" + `[\"/docs/file.txt\", \"/photos\"]` + "`" + `\n- **destination** (string, required): Full path where the archive file will be created (on toSource). Must end with .zip or .tar.gz (or format is inferred). Example: ` + "`" + `\"/backups/my-archive.zip\"` + "`" + `\n- **format** (string, optional): Archive format. One of: ` + "`" + `\"zip\"` + "`" + `, ` + "`" + `\"tar.gz\"` + "`" + `. Default inferred from destination extension. Example: ` + "`" + `\"zip\"` + "`" + `\n- **compression** (integer, optional): Gzip compression level for tar.gz only (0–9). 0 = default. Ignored for zip. Example: ` + "`" + `6` + "`" + `\n- **deleteAfter** (boolean, optional): If true, delete source files/directories after successful creation. Requires delete permission. Example: ` + "`" + `true` + "`" + `",
"consumes": [
"application/json"
],
@@ -1787,6 +1787,15 @@ const docTemplate = `{
}
}
},
"413": {
"description": "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
@@ -2114,7 +2123,7 @@ const docTemplate = `{
},
"/api/resources/unarchive": {
"post": {
"description": "Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the archive file lives. Example: ` + "`" + `\"default\"` + "`" + `\n- **toSource** (string, optional): Source name where contents will be extracted. Defaults to fromSource if omitted. Example: ` + "`" + `\"restored\"` + "`" + `\n- **path** (string, required): Path to the archive file (on fromSource). Must be .zip, .tar.gz, or .tgz. Example: ` + "`" + `\"/downloads/data.zip\"` + "`" + `\n- **destination** (string, required): Directory path (on toSource) to extract into. Example: ` + "`" + `\"/projects/imported\"` + "`" + `\n- **deleteAfter** (boolean, optional): If true, delete the archive file after successful extraction. Default: false. Example: ` + "`" + `true` + "`" + `",
"description": "Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the archive file lives. Example: ` + "`" + `\"default\"` + "`" + `\n- **toSource** (string, optional): Source name where contents will be extracted. Defaults to fromSource if omitted. Example: ` + "`" + `\"restored\"` + "`" + `\n- **path** (string, required): Path to the archive file (on fromSource). Must be .zip, .tar.gz, or .tgz. Example: ` + "`" + `\"/downloads/data.zip\"` + "`" + `\n- **destination** (string, required): Directory path (on toSource) to extract into. Example: ` + "`" + `\"/projects/imported\"` + "`" + `\n- **deleteAfter** (boolean, optional): If true, delete the archive file after successful extraction. Default: false. Example: ` + "`" + `true` + "`" + `",
"consumes": [
"application/json"
],
@@ -2173,6 +2182,15 @@ const docTemplate = `{
}
}
},
"413": {
"description": "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
@@ -2833,6 +2851,18 @@ const docTemplate = `{
"name": "scope",
"in": "query"
},
{
"type": "integer",
"description": "Unix seconds; only results modified strictly before this time",
"name": "olderThan",
"in": "query"
},
{
"type": "integer",
"description": "Unix seconds; only results modified on or after this time",
"name": "newerThan",
"in": "query"
},
{
"type": "string",
"description": "User session ID, add unique value to prevent collisions",
@@ -5292,7 +5322,7 @@ const docTemplate = `{
}
},
"maxArchiveSize": {
"description": "deprecated: no longer a limitation after v1.3.2-beta",
"description": "maximum archive/unarchive size in GB. 0 means no limit. (default: 20)",
"type": "integer"
},
"minSearchLength": {
@@ -5548,6 +5578,10 @@ const docTemplate = `{
"permissions": {
"$ref": "#/definitions/settings.UserDefaultsPermissions"
},
"preferEditorForMarkdown": {
"description": "prefer editor first for markdown files instead of the Markdown Viewer.",
"type": "boolean"
},
"preview": {
"$ref": "#/definitions/settings.UserDefaultsPreview"
},
@@ -6458,6 +6492,10 @@ const docTemplate = `{
"permissions": {
"$ref": "#/definitions/users.Permissions"
},
"preferEditorForMarkdown": {
"description": "prefer editor first for markdown files instead of the Markdown Viewer",
"type": "boolean"
},
"preview": {
"$ref": "#/definitions/users.Preview"
},
+41 -3
View File
@@ -1717,7 +1717,7 @@
},
"/api/resources/archive": {
"post": {
"description": "Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the paths to archive live. Example: `\"default\"`\n- **toSource** (string, optional): Source name where the archive file will be written. Defaults to fromSource if omitted. Example: `\"backups\"`\n- **paths** (array of strings, required): Paths of files or directories to add to the archive (relative to fromSource). Directories are walked; access-denied entries are skipped. Example: `[\"/docs/file.txt\", \"/photos\"]`\n- **destination** (string, required): Full path where the archive file will be created (on toSource). Must end with .zip or .tar.gz (or format is inferred). Example: `\"/backups/my-archive.zip\"`\n- **format** (string, optional): Archive format. One of: `\"zip\"`, `\"tar.gz\"`. Default inferred from destination extension. Example: `\"zip\"`\n- **compression** (integer, optional): Gzip compression level for tar.gz only (0–9). 0 = default. Ignored for zip. Example: `6`\n- **deleteAfter** (boolean, optional): If true, delete source files/directories after successful creation. Requires delete permission. Example: `true`",
"description": "Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the paths to archive live. Example: `\"default\"`\n- **toSource** (string, optional): Source name where the archive file will be written. Defaults to fromSource if omitted. Example: `\"backups\"`\n- **paths** (array of strings, required): Paths of files or directories to add to the archive (relative to fromSource). Directories are walked; access-denied entries are skipped. Example: `[\"/docs/file.txt\", \"/photos\"]`\n- **destination** (string, required): Full path where the archive file will be created (on toSource). Must end with .zip or .tar.gz (or format is inferred). Example: `\"/backups/my-archive.zip\"`\n- **format** (string, optional): Archive format. One of: `\"zip\"`, `\"tar.gz\"`. Default inferred from destination extension. Example: `\"zip\"`\n- **compression** (integer, optional): Gzip compression level for tar.gz only (0–9). 0 = default. Ignored for zip. Example: `6`\n- **deleteAfter** (boolean, optional): If true, delete source files/directories after successful creation. Requires delete permission. Example: `true`",
"consumes": [
"application/json"
],
@@ -1776,6 +1776,15 @@
}
}
},
"413": {
"description": "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
@@ -2103,7 +2112,7 @@
},
"/api/resources/unarchive": {
"post": {
"description": "Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the archive file lives. Example: `\"default\"`\n- **toSource** (string, optional): Source name where contents will be extracted. Defaults to fromSource if omitted. Example: `\"restored\"`\n- **path** (string, required): Path to the archive file (on fromSource). Must be .zip, .tar.gz, or .tgz. Example: `\"/downloads/data.zip\"`\n- **destination** (string, required): Directory path (on toSource) to extract into. Example: `\"/projects/imported\"`\n- **deleteAfter** (boolean, optional): If true, delete the archive file after successful extraction. Default: false. Example: `true`",
"description": "Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the archive file lives. Example: `\"default\"`\n- **toSource** (string, optional): Source name where contents will be extracted. Defaults to fromSource if omitted. Example: `\"restored\"`\n- **path** (string, required): Path to the archive file (on fromSource). Must be .zip, .tar.gz, or .tgz. Example: `\"/downloads/data.zip\"`\n- **destination** (string, required): Directory path (on toSource) to extract into. Example: `\"/projects/imported\"`\n- **deleteAfter** (boolean, optional): If true, delete the archive file after successful extraction. Default: false. Example: `true`",
"consumes": [
"application/json"
],
@@ -2162,6 +2171,15 @@
}
}
},
"413": {
"description": "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
@@ -2822,6 +2840,18 @@
"name": "scope",
"in": "query"
},
{
"type": "integer",
"description": "Unix seconds; only results modified strictly before this time",
"name": "olderThan",
"in": "query"
},
{
"type": "integer",
"description": "Unix seconds; only results modified on or after this time",
"name": "newerThan",
"in": "query"
},
{
"type": "string",
"description": "User session ID, add unique value to prevent collisions",
@@ -5281,7 +5311,7 @@
}
},
"maxArchiveSize": {
"description": "deprecated: no longer a limitation after v1.3.2-beta",
"description": "maximum archive/unarchive size in GB. 0 means no limit. (default: 20)",
"type": "integer"
},
"minSearchLength": {
@@ -5537,6 +5567,10 @@
"permissions": {
"$ref": "#/definitions/settings.UserDefaultsPermissions"
},
"preferEditorForMarkdown": {
"description": "prefer editor first for markdown files instead of the Markdown Viewer.",
"type": "boolean"
},
"preview": {
"$ref": "#/definitions/settings.UserDefaultsPreview"
},
@@ -6447,6 +6481,10 @@
"permissions": {
"$ref": "#/definitions/users.Permissions"
},
"preferEditorForMarkdown": {
"description": "prefer editor first for markdown files instead of the Markdown Viewer",
"type": "boolean"
},
"preview": {
"$ref": "#/definitions/users.Preview"
},
+34 -3
View File
@@ -1040,7 +1040,8 @@ definitions:
$ref: '#/definitions/settings.LogConfig'
type: array
maxArchiveSize:
description: 'deprecated: no longer a limitation after v1.3.2-beta'
description: 'maximum archive/unarchive size in GB. 0 means no limit. (default:
20)'
type: integer
minSearchLength:
description: 'minimum length of search query to begin searching (default:
@@ -1238,6 +1239,10 @@ definitions:
type: string
permissions:
$ref: '#/definitions/settings.UserDefaultsPermissions'
preferEditorForMarkdown:
description: prefer editor first for markdown files instead of the Markdown
Viewer.
type: boolean
preview:
$ref: '#/definitions/settings.UserDefaultsPreview'
quickDownload:
@@ -1890,6 +1895,10 @@ definitions:
description: legacy for migration purposes... og filebrowser has perm attribute
permissions:
$ref: '#/definitions/users.Permissions'
preferEditorForMarkdown:
description: prefer editor first for markdown files instead of the Markdown
Viewer
type: boolean
preview:
$ref: '#/definitions/users.Preview'
quickDownload:
@@ -3108,7 +3117,7 @@ paths:
consumes:
- application/json
description: |-
Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission.
Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.
**Request body parameters:**
- **fromSource** (string, required): Source name where the paths to archive live. Example: `"default"`
@@ -3153,6 +3162,13 @@ paths:
additionalProperties:
type: string
type: object
"413":
description: Request Entity Too Large (archive size exceeds maxArchiveSizeGB
limit)
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
@@ -3390,7 +3406,7 @@ paths:
consumes:
- application/json
description: |-
Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission.
Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.
**Request body parameters:**
- **fromSource** (string, required): Source name where the archive file lives. Example: `"default"`
@@ -3434,6 +3450,13 @@ paths:
additionalProperties:
type: string
type: object
"413":
description: Request Entity Too Large (archive size exceeds maxArchiveSizeGB
limit)
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
@@ -3892,6 +3915,14 @@ paths:
in: query
name: scope
type: string
- description: Unix seconds; only results modified strictly before this time
in: query
name: olderThan
type: integer
- description: Unix seconds; only results modified on or after this time
in: query
name: newerThan
type: integer
- description: User session ID, add unique value to prevent collisions
in: header
name: SessionId
+184 -187
View File
@@ -101,9 +101,9 @@
}
},
"node_modules/@babel/parser": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
@@ -116,9 +116,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1165,9 +1165,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.1.tgz",
"integrity": "sha512-xB0b51TB7IfDEzAojXahmr+gfA00uYVInJGgNNkeQG6RPnCPGr7udsylFLTubuIUSRE6FkcI1NElyRt83PP5oQ==",
"cpu": [
"arm"
],
@@ -1179,9 +1179,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.1.tgz",
"integrity": "sha512-XOjPId0qwSDKHaIsdzHJtKCxX0+nH8MhBwvrNsT7tVyKmdTx1jJ4XzN5RZXCdTzMpufLb+B8llTC0D8uCrLhcw==",
"cpu": [
"arm64"
],
@@ -1193,9 +1193,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.1.tgz",
"integrity": "sha512-vQuRd28p0gQpPrS6kppd8IrWmFo42U8Pz1XLRjSZXq5zCqyMDYFABT7/sywL11mO1EL10Qhh7MVPEwkG8GiBeg==",
"cpu": [
"arm64"
],
@@ -1207,9 +1207,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.1.tgz",
"integrity": "sha512-x6VG6U29+Ivlnajrg1IHdzXeAwSoEHBFVO+CtC9Brugx6de712CUJobRUxsIA0KYrQvCmzNrMPFTT1A4CCqNTg==",
"cpu": [
"x64"
],
@@ -1221,9 +1221,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.1.tgz",
"integrity": "sha512-Sgi0Uo6t1YCHJMNO3Y8+bm+SvOanUGkoZKn/VJPwYUe2kp31X5KnXmzKd/NjW8iA3gFcfNZ64zh14uOGrIllCQ==",
"cpu": [
"arm64"
],
@@ -1235,9 +1235,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.1.tgz",
"integrity": "sha512-AM4xnwEZwukdhk7laMWfzWu9JGSVnJd+Fowt6Fd7QW1nrf3h0Hp7Qx5881M4aqrUlKBCybOxz0jofvIIfl7C5g==",
"cpu": [
"x64"
],
@@ -1249,9 +1249,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.1.tgz",
"integrity": "sha512-KUizqxpwaR2AZdAUsMWfL/C94pUu7TKpoPd88c8yFVixJ+l9hejkrwoK5Zj3wiNh65UeyryKnJyxL1b7yNqFQA==",
"cpu": [
"arm"
],
@@ -1263,9 +1263,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.1.tgz",
"integrity": "sha512-MZoQ/am77ckJtZGFAtPucgUuJWiop3m2R3lw7tC0QCcbfl4DRhQUBUkHWCkcrT3pqy5Mzv5QQgY6Dmlba6iTWg==",
"cpu": [
"arm"
],
@@ -1277,9 +1277,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.1.tgz",
"integrity": "sha512-Sez95TP6xGjkWB1608EfhCX1gdGrO5wzyN99VqzRtC17x/1bhw5VU1V0GfKUwbW/Xr1J8mSasoFoJa6Y7aGGSA==",
"cpu": [
"arm64"
],
@@ -1291,9 +1291,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.1.tgz",
"integrity": "sha512-9Cs2Seq98LWNOJzR89EGTZoiP8EkZ9UbQhBlDgfAkM6asVna1xJ04W2CLYWDN/RpUgOjtQvcv8wQVi1t5oQazA==",
"cpu": [
"arm64"
],
@@ -1305,9 +1305,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.1.tgz",
"integrity": "sha512-n9yqttftgFy7IrNEnHy1bOp6B4OSe8mJDiPkT7EqlM9FnKOwUMnCK62ixW0Kd9Clw0/wgvh8+SqaDXMFvw3KqQ==",
"cpu": [
"loong64"
],
@@ -1319,9 +1319,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.1.tgz",
"integrity": "sha512-SfpNXDzVTqs/riak4xXcLpq5gIQWsqGWMhN1AGRQKB4qGSs4r0sEs3ervXPcE1O9RsQ5bm8Muz6zmQpQnPss1g==",
"cpu": [
"loong64"
],
@@ -1333,9 +1333,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.1.tgz",
"integrity": "sha512-LjaChED0wQnjKZU+tsmGbN+9nN1XhaWUkAlSbTdhpEseCS4a15f/Q8xC2BN4GDKRzhhLZpYtJBZr2NZhR0jvNw==",
"cpu": [
"ppc64"
],
@@ -1347,9 +1347,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.1.tgz",
"integrity": "sha512-ojW7iTJSIs4pwB2xV6QXGwNyDctvXOivYllttuPbXguuKDX5vwpqYJsHc6D2LZzjDGHML414Tuj3LvVPe1CT1A==",
"cpu": [
"ppc64"
],
@@ -1361,9 +1361,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.1.tgz",
"integrity": "sha512-FP+Q6WTcxxvsr0wQczhSE+tOZvFPV8A/mUE6mhZYFW9/eea/y/XqAgRoLLMuE9Cz0hfX5bi7p116IWoB+P237A==",
"cpu": [
"riscv64"
],
@@ -1375,9 +1375,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.1.tgz",
"integrity": "sha512-L1uD9b/Ig8Z+rn1KttCJjwhN1FgjRMBKsPaBsDKkfUl7GfFq71pU4vWCnpOsGljycFEbkHWARZLf4lMYg3WOLw==",
"cpu": [
"riscv64"
],
@@ -1389,9 +1389,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.1.tgz",
"integrity": "sha512-EZc9NGTk/oSUzzOD4nYY4gIjteo2M3CiozX6t1IXGCOdgxJTlVu/7EdPeiqeHPSIrxkLhavqpBAUCfvC6vBOug==",
"cpu": [
"s390x"
],
@@ -1403,9 +1403,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.1.tgz",
"integrity": "sha512-NQ9KyU1Anuy59L8+HHOKM++CoUxrQWrZWXRik4BJFm+7i5NP6q/SW43xIBr80zzt+PDBJ7LeNmloQGfa0JGk0w==",
"cpu": [
"x64"
],
@@ -1417,9 +1417,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.1.tgz",
"integrity": "sha512-GZkLk2t6naywsveSFBsEb0PLU+JC9ggVjbndsbG20VPhar6D1gkMfCx4NfP9owpovBXTN+eRdqGSkDGIxPHhmQ==",
"cpu": [
"x64"
],
@@ -1431,9 +1431,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.1.tgz",
"integrity": "sha512-1hjG9Jpl2KDOetr64iQd8AZAEjkDUUK5RbDkYWsViYLC1op1oNzdjMJeFiofcGhqbNTaY2kfgqowE7DILifsrA==",
"cpu": [
"x64"
],
@@ -1445,9 +1445,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.1.tgz",
"integrity": "sha512-ARoKfflk0SiiYm3r1fmF73K/yB+PThmOwfWCk1sr7x/k9dc3uGLWuEE9if+Pw21el8MSpp3TMnG5vLNsJ/MMGQ==",
"cpu": [
"arm64"
],
@@ -1459,9 +1459,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.1.tgz",
"integrity": "sha512-oOST61G6VM45Mz2vdzWMr1s2slI7y9LqxEV5fCoWi2MDONmMvgsJVHSXxce/I2xOSZPTZ47nDPOl1tkwKWSHcw==",
"cpu": [
"arm64"
],
@@ -1473,9 +1473,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.1.tgz",
"integrity": "sha512-x5WgLi5dWpRz7WclKBGEF15LcWTh0ewrHM6Cq4A+WUbkysUMZNeqt05bwPonOQ3ihPS/WMhAZV5zB1DfnI4Sxg==",
"cpu": [
"ia32"
],
@@ -1487,9 +1487,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.1.tgz",
"integrity": "sha512-wS+zHAJRVP5zOL0e+a3V3E/NTEwM2HEvvNKoDy5Xcfs0o8lljxn+EAFPkUsxihBdmDq1JWzXmmB9cbssCPdxxw==",
"cpu": [
"x64"
],
@@ -1501,9 +1501,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.1.tgz",
"integrity": "sha512-rhHyrMeLpErT/C7BxcEsU4COHQUzHyrPYW5tOZUeUhziNtRuYxmDWvqQqzpuUt8xpOgmbKa1btGXfnA/ANVO+g==",
"cpu": [
"x64"
],
@@ -1959,53 +1959,53 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz",
"integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz",
"integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@vue/shared": "3.5.29",
"@vue/shared": "3.5.30",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz",
"integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz",
"integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.29",
"@vue/shared": "3.5.29"
"@vue/compiler-core": "3.5.30",
"@vue/shared": "3.5.30"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz",
"integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@vue/compiler-core": "3.5.29",
"@vue/compiler-dom": "3.5.29",
"@vue/compiler-ssr": "3.5.29",
"@vue/shared": "3.5.29",
"@vue/compiler-core": "3.5.30",
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-ssr": "3.5.30",
"@vue/shared": "3.5.30",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.6",
"postcss": "^8.5.8",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz",
"integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz",
"integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.29",
"@vue/shared": "3.5.29"
"@vue/compiler-dom": "3.5.30",
"@vue/shared": "3.5.30"
}
},
"node_modules/@vue/compiler-vue2": {
@@ -2076,53 +2076,53 @@
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz",
"integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz",
"integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.29"
"@vue/shared": "3.5.30"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz",
"integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz",
"integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.29",
"@vue/shared": "3.5.29"
"@vue/reactivity": "3.5.30",
"@vue/shared": "3.5.30"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz",
"integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz",
"integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.29",
"@vue/runtime-core": "3.5.29",
"@vue/shared": "3.5.29",
"@vue/reactivity": "3.5.30",
"@vue/runtime-core": "3.5.30",
"@vue/shared": "3.5.30",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz",
"integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz",
"integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.29",
"@vue/shared": "3.5.29"
"@vue/compiler-ssr": "3.5.30",
"@vue/shared": "3.5.30"
},
"peerDependencies": {
"vue": "3.5.29"
"vue": "3.5.30"
}
},
"node_modules/@vue/shared": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz",
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz",
"integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==",
"license": "MIT"
},
"node_modules/@xmldom/xmldom": {
@@ -2495,9 +2495,9 @@
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.48.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz",
"integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
@@ -2741,13 +2741,10 @@
}
},
"node_modules/dompurify": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -3489,9 +3486,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
"integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -4330,9 +4327,9 @@
}
},
"node_modules/mammoth": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz",
"integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==",
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz",
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
"license": "BSD-2-Clause",
"dependencies": {
"@xmldom/xmldom": "^0.8.6",
@@ -4481,9 +4478,9 @@
}
},
"node_modules/mlly": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz",
"integrity": "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==",
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5096,9 +5093,9 @@
}
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.1.tgz",
"integrity": "sha512-iZKH8BeoCwTCBTZBZWQQMreekd4mdomwdjIQ40GC1oZm6o+8PnNMIxFOiCsGMWeS8iDJ7KZcl7KwmKk/0HOQpA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5112,31 +5109,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"@rollup/rollup-android-arm-eabi": "4.59.1",
"@rollup/rollup-android-arm64": "4.59.1",
"@rollup/rollup-darwin-arm64": "4.59.1",
"@rollup/rollup-darwin-x64": "4.59.1",
"@rollup/rollup-freebsd-arm64": "4.59.1",
"@rollup/rollup-freebsd-x64": "4.59.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.1",
"@rollup/rollup-linux-arm-musleabihf": "4.59.1",
"@rollup/rollup-linux-arm64-gnu": "4.59.1",
"@rollup/rollup-linux-arm64-musl": "4.59.1",
"@rollup/rollup-linux-loong64-gnu": "4.59.1",
"@rollup/rollup-linux-loong64-musl": "4.59.1",
"@rollup/rollup-linux-ppc64-gnu": "4.59.1",
"@rollup/rollup-linux-ppc64-musl": "4.59.1",
"@rollup/rollup-linux-riscv64-gnu": "4.59.1",
"@rollup/rollup-linux-riscv64-musl": "4.59.1",
"@rollup/rollup-linux-s390x-gnu": "4.59.1",
"@rollup/rollup-linux-x64-gnu": "4.59.1",
"@rollup/rollup-linux-x64-musl": "4.59.1",
"@rollup/rollup-openbsd-x64": "4.59.1",
"@rollup/rollup-openharmony-arm64": "4.59.1",
"@rollup/rollup-win32-arm64-msvc": "4.59.1",
"@rollup/rollup-win32-ia32-msvc": "4.59.1",
"@rollup/rollup-win32-x64-gnu": "4.59.1",
"@rollup/rollup-win32-x64-msvc": "4.59.1",
"fsevents": "~2.3.2"
}
},
@@ -6118,16 +6115,16 @@
"license": "MIT"
},
"node_modules/vue": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.29",
"@vue/runtime-dom": "3.5.29",
"@vue/server-renderer": "3.5.29",
"@vue/shared": "3.5.29"
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",
"@vue/runtime-dom": "3.5.30",
"@vue/server-renderer": "3.5.30",
"@vue/shared": "3.5.30"
},
"peerDependencies": {
"typescript": "*"
@@ -6470,9 +6467,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6518,9 +6515,9 @@
"license": "MIT"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true,
"license": "ISC",
"bin": {
+2
View File
@@ -49,6 +49,7 @@ server:
internalUrl: "" # used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
cacheDir: "tmp" # path to the cache directory, used for thumbnails and other cached files
cacheDirCleanup: false # whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
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
createDirectoryPermission: "755" # Unix permissions like 755, 2755, 1777 (default: 755) validate:required,file_permission
@@ -203,6 +204,7 @@ userDefaults:
customTheme: "" # Name of theme to use chosen from custom themes config.
showSelectMultiple: false # show select multiple files on desktop
debugOffice: false # debug onlyoffice editor
preferEditorForMarkdown: false # prefer editor first for markdown files instead of the Markdown Viewer.
integrations:
office: # validate:omitempty
url: "" # The URL to the OnlyOffice Document Server, needs to be accessible to the user. validate:required
+309 -29
View File
@@ -1,6 +1,89 @@
/* NOTE: This file only contains styles needed by index.html.
All other loader styles (base .loader, size variants, modes, etc.)
are defined in LoadingSpinner.vue as scoped styles.*/
/*
* LoadingSpinner.vue + initial index.html shell.
* Embedded in index.html via the backend; also @import'd from src/css/styles.css for the Vue app.
*
* Spinner glyph: filled SVG path (data URI mask) so ring thickness tapers along the arc — not alpha-only.
* Regenerate: polygon from outer/inner circular arcs (~120° sweep) in viewBox 0 0 100 100; mirror X if spin/orientation needs flipping.
*/
/* -------------------------------------------------------------------------- */
/* Core box */
/* -------------------------------------------------------------------------- */
.loader {
box-sizing: border-box;
margin: 0 auto;
border: none;
overflow: visible;
position: relative;
display: block;
border-radius: 50%;
}
/* -------------------------------------------------------------------------- */
/* Tapered ring: solid fill + SVG mask (true stroke-width taper, not alpha) */
/* Path is mirrored so the thick head leads when rotating clockwise (W3C +angle = CW). */
/* -------------------------------------------------------------------------- */
.loader__comet {
pointer-events: none;
position: absolute;
inset: 0;
display: block;
direction: ltr;
background-color: var(--spinner-color, var(--primaryColor));
/* Mirrored annulus-sector polygon, 120° sweep, viewBox 0 0 100 100 */
--fb-loader-taper-mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20100%20100%22%3E%3Cpath%20fill%3D%22black%22%20d%3D%22M57.47%2C7.65%20L54.99%2C7.29%20L52.50%2C7.07%20L50.00%2C7.00%20L47.50%2C7.07%20L45.01%2C7.29%20L42.53%2C7.65%20L40.08%2C8.16%20L37.67%2C8.81%20L35.29%2C9.59%20L32.97%2C10.52%20L30.70%2C11.57%20L28.50%2C12.76%20L26.37%2C14.07%20L24.32%2C15.51%20L22.36%2C17.06%20L20.49%2C18.72%20L18.72%2C20.49%20L17.06%2C22.36%20L15.51%2C24.32%20L14.07%2C26.37%20L12.76%2C28.50%20L11.57%2C30.70%20L10.52%2C32.97%20L9.59%2C35.29%20L8.81%2C37.67%20L8.16%2C40.08%20L7.65%2C42.53%20L7.29%2C45.01%20L7.07%2C47.50%20L7.00%2C50.00%20L7.07%2C52.50%20L7.29%2C54.99%20L7.65%2C57.47%20L8.16%2C59.92%20L8.81%2C62.33%20L9.59%2C64.71%20L10.16%2C64.50%20L9.56%2C62.11%20L9.12%2C59.69%20L8.81%2C57.26%20L8.65%2C54.83%20L8.63%2C52.41%20L8.75%2C50.00%20L9.01%2C47.61%20L9.41%2C45.26%20L9.94%2C42.94%20L10.61%2C40.66%20L11.40%2C38.44%20L12.32%2C36.28%20L13.36%2C34.19%20L14.51%2C32.18%20L15.77%2C30.24%20L17.14%2C28.39%20L18.60%2C26.63%20L20.16%2C24.96%20L21.81%2C23.40%20L23.53%2C21.95%20L25.33%2C20.60%20L27.20%2C19.37%20L29.12%2C18.26%20L31.10%2C17.26%20L33.12%2C16.39%20L35.18%2C15.64%20L37.27%2C15.02%20L39.38%2C14.52%20L41.50%2C14.15%20L43.64%2C13.91%20L45.77%2C13.79%20L47.89%2C13.79%20L50.00%2C13.92%20L52.09%2C14.18%20L54.14%2C14.55%20L56.16%2C15.04Z%22%2F%3E%3C%2Fsvg%3E");
-webkit-mask-image: var(--fb-loader-taper-mask);
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-image: var(--fb-loader-taper-mask);
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
transform-origin: 50% 50%;
transform-box: fill-box;
animation: fb-spinner-rotation var(--animation-duration, 1s) linear infinite;
}
@keyframes fb-spinner-rotation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* -------------------------------------------------------------------------- */
/* Size variants */
/* -------------------------------------------------------------------------- */
.loader--xsmall {
width: 12px;
height: 12px;
}
.loader--small {
width: 24px;
height: 24px;
}
.loader--medium {
width: 48px;
height: 48px;
}
.loader--large {
width: 96px;
height: 96px;
}
/* -------------------------------------------------------------------------- */
/* Initial full-page shell (index.html) */
/* -------------------------------------------------------------------------- */
body > .loader {
position: fixed;
top: 0;
@@ -11,59 +94,256 @@ body > .loader {
align-items: center;
justify-content: center;
z-index: 9999;
transition: .1s ease opacity;
transition: 0.1s ease opacity;
pointer-events: none;
/* Reset loader styles - spinner will be in pseudo-element */
border: none;
animation: none;
border-radius: 0;
margin: 0;
}
body > .loader::before {
content: '';
width: 48px;
height: 48px;
border-radius: 50%;
border-top: 3px solid var(--spinner-color, var(--primaryColor));
border-right: 3px solid transparent;
border-bottom: 3px solid transparent;
box-sizing: border-box;
animation: spinner-rotation var(--animation-duration, 1s) linear infinite;
body > .loader.loader--xsmall,
body > .loader.loader--small,
body > .loader.loader--medium,
body > .loader.loader--large {
width: 100%;
height: 100%;
}
/* Size variants for body > .loader */
body > .loader.loader--xsmall::before {
body > .loader .loader__comet {
position: relative;
inset: auto;
flex-shrink: 0;
}
body > .loader.loader--xsmall .loader__comet {
width: 12px;
height: 12px;
border-width: 1.5px;
}
body > .loader.loader--small::before {
body > .loader.loader--small .loader__comet {
width: 24px;
height: 24px;
border-width: 2px;
}
body > .loader.loader--medium::before {
body > .loader.loader--medium .loader__comet {
width: 48px;
height: 48px;
border-width: 3px;
}
body > .loader.loader--large::before {
body > .loader.loader--large .loader__comet {
width: 96px;
height: 96px;
border-width: 4px;
}
body > .loader.done {
opacity: 0;
}
@keyframes spinner-rotation {
0% {
transform: rotate(0deg);
}
/* -------------------------------------------------------------------------- */
/* Pulse mode */
/* -------------------------------------------------------------------------- */
.loader--pulse {
border: none;
background: var(--spinner-color, var(--primaryColor));
animation: fb-pulse-scale var(--animation-duration, 0.75s) ease infinite;
}
@keyframes fb-pulse-scale {
0%,
100% {
transform: rotate(360deg);
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(0.95);
opacity: 0.8;
}
}
/* -------------------------------------------------------------------------- */
/* Bounce mode */
/* -------------------------------------------------------------------------- */
.loader--bounce {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3em;
border: none;
background: transparent;
animation: none;
}
.loader--bounce::before,
.loader--bounce::after {
content: "";
width: 0.6em;
height: 0.6em;
background-color: var(--spinner-color, var(--primaryColor));
border-radius: 50%;
display: inline-block;
animation: fb-bounce-scale var(--animation-duration, 0.75s) infinite ease-in-out both;
}
.loader--bounce::before {
animation-delay: -0.32s;
}
.loader--bounce::after {
animation-delay: -0.16s;
}
.loader--bounce .bounce-dot {
width: 0.6em;
height: 0.6em;
background-color: var(--spinner-color, var(--primaryColor));
border-radius: 50%;
display: inline-block;
animation: fb-bounce-scale var(--animation-duration, 0.75s) infinite ease-in-out both;
}
@keyframes fb-bounce-scale {
0%,
80%,
100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.loader--bounce.loader--xsmall::before,
.loader--bounce.loader--xsmall::after,
.loader--bounce.loader--xsmall .bounce-dot {
width: 0.3em;
height: 0.3em;
}
.loader--bounce.loader--small::before,
.loader--bounce.loader--small::after,
.loader--bounce.loader--small .bounce-dot {
width: 0.45em;
height: 0.45em;
}
.loader--bounce.loader--medium::before,
.loader--bounce.loader--medium::after,
.loader--bounce.loader--medium .bounce-dot {
width: 0.6em;
height: 0.6em;
}
.loader--bounce.loader--large::before,
.loader--bounce.loader--large::after,
.loader--bounce.loader--large .bounce-dot {
width: 0.9em;
height: 0.9em;
}
/* -------------------------------------------------------------------------- */
/* Placeholder mode */
/* -------------------------------------------------------------------------- */
.loader--placeholder {
display: flex;
flex-direction: column;
gap: 0.5em;
border: none;
animation: none;
width: auto;
height: auto;
border-radius: 0;
}
.placeholder-item {
background-color: rgba(128, 128, 128, 0.1);
animation: fb-placeholder-ripple 1.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite,
fb-placeholder-bg-fade 2s ease-in-out infinite;
display: block;
border-radius: 0.25em;
margin: 0;
padding: 0;
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
box-shadow 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.placeholder-item-1 {
animation-delay: 0s;
}
.placeholder-item-2 {
animation-delay: 0.36s;
}
.placeholder-item-3 {
animation-delay: 0.72s;
}
@keyframes fb-placeholder-ripple {
0% {
transform: scale(1);
box-shadow: none;
opacity: 0.3;
}
25% {
transform: scale(1.07);
box-shadow: inset 0 -3em 3em rgba(172, 172, 172, 0.211),
0 0 0 2px var(--alt-background), 0 4px 12px rgba(0, 0, 0, 0.15);
opacity: 0.6;
}
50% {
transform: scale(0.97);
box-shadow: none;
opacity: 0.3;
}
100% {
transform: scale(1);
box-shadow: none;
opacity: 0.3;
}
}
@keyframes fb-placeholder-bg-fade {
0% {
background-color: rgba(128, 128, 128, 0.1);
}
50% {
background-color: rgba(128, 128, 128, 0.4);
}
100% {
background-color: rgba(128, 128, 128, 0.1);
}
}
.loader--placeholder.loader--xsmall .placeholder-item {
width: 30px;
height: 7.5px;
min-width: 30px;
min-height: 7.5px;
}
.loader--placeholder.loader--small .placeholder-item {
width: 150px;
height: 12.5px;
min-width: 150px;
min-height: 12.5px;
}
.loader--placeholder.loader--medium .placeholder-item {
width: 200px;
height: 25px;
min-width: 200px;
min-height: 25px;
}
.loader--placeholder.loader--large .placeholder-item {
width: 400px;
height: 50px;
min-width: 400px;
min-height: 50px;
}
+1 -1
View File
@@ -84,7 +84,7 @@
<body>
<div id="app"></div>
<span class="loader loader--medium"></span>
<span class="loader loader--medium" aria-busy="true"><span class="loader__comet" aria-hidden="true"></span></span>
<script type="module" src="/src/main.ts"></script>
+2 -2
View File
@@ -212,10 +212,10 @@ async function downloadChunked(file, shareHash = "") {
downloadManager.setStatus(downloadId, "downloading")
// Show download prompt if not already shown (it should already be shown by downloadFiles, but check to be safe)
const hasDownloadPrompt = state.hovers && state.hovers.some(h => h.name === 'download');
const hasDownloadPrompt = state.prompts && state.prompts.some(h => h.name === 'download');
if (!hasDownloadPrompt) {
mutations.showHover({ name: 'download' })
mutations.showPrompt({ name: 'download' })
}
const params = {
+8 -1
View File
@@ -2,7 +2,7 @@ import { fetchURL } from "./utils";
import { notify } from "@/notify"; // Import notify for error handling
import { getApiPath } from "@/utils/url.js";
export default async function search(base, sources, query, largest = false) {
export default async function search(base, sources, query, largest = false, extraParams = {}) {
try {
// Ensure sources is an array
const sourcesArray = Array.isArray(sources) ? sources : [sources];
@@ -25,6 +25,13 @@ export default async function search(base, sources, query, largest = false) {
params.largest = "true";
}
if (extraParams.olderThan !== undefined && extraParams.olderThan !== "") {
params.olderThan = String(extraParams.olderThan);
}
if (extraParams.newerThan !== undefined && extraParams.newerThan !== "") {
params.newerThan = String(extraParams.newerThan);
}
const apiPath = getApiPath("tools/search", params);
const res = await fetchURL(apiPath);
let data = await res.json();
+9 -1
View File
@@ -3,7 +3,8 @@ import { notify } from "@/notify";
import { getApiPath } from "@/utils/url.js";
// GET /api/tools/search
export async function search(base, sources, query, largest = false) {
// extraParams: optional { olderThan, newerThan } — Unix timestamps in seconds (number or numeric string); modified time must be strictly before olderThan / on or after newerThan
export async function search(base, sources, query, largest = false, extraParams = {}) {
try {
const sourcesArray = Array.isArray(sources) ? sources : [sources];
@@ -24,6 +25,13 @@ export async function search(base, sources, query, largest = false) {
params.largest = "true";
}
if (extraParams.olderThan !== undefined && extraParams.olderThan !== "") {
params.olderThan = String(extraParams.olderThan);
}
if (extraParams.newerThan !== undefined && extraParams.newerThan !== "") {
params.newerThan = String(extraParams.newerThan);
}
const apiPath = getApiPath("tools/search", params);
const res = await fetchURL(apiPath);
let data = await res.json();
+4 -4
View File
@@ -81,15 +81,15 @@ export default {
},
actionMultiIcon() {
if (this.show) {
mutations.closeContextMenus();
mutations.showHover(this.show);
mutations.closeHovers();
mutations.showPrompt(this.show);
}
this.$emit("action");
},
action() {
if (this.show) {
mutations.closeContextMenus();
mutations.showHover(this.show);
mutations.closeHovers();
mutations.showPrompt(this.show);
}
this.$emit("action");
},
+74 -62
View File
@@ -37,25 +37,25 @@
v-if="showCreateActions"
icon="create_new_folder"
:label="$t('files.newFolder')"
@action="showNewDirHover"
@action="showNewDirPrompt"
/>
<action
v-if="showCreateActions"
icon="note_add"
:label="$t('files.newFile')"
@action="showHover('newFile')"
@action="showPrompt('newFile')"
/>
<action
v-if="showCreateActions"
icon="file_upload"
:label="$t('general.upload')"
@action="showUploadHover"
@action="showUploadPrompt"
/>
<action
v-if="showInfo"
icon="info"
:label="$t('general.info')"
@action="showInfoHover"
@action="showInfoPrompt"
/>
<action
@@ -68,31 +68,31 @@
v-if="showArchive"
icon="archive"
:label="$t('prompts.archive')"
@action="showArchiveHover"
@action="showArchivePrompt"
/>
<action
v-if="showUnarchive"
icon="unarchive"
:label="$t('prompts.unarchive')"
@action="showUnarchiveHover"
@action="showUnarchivePrompt"
/>
<action
v-if="showShareAction"
icon="share"
:label="$t('general.share')"
@action="showShareHover"
@action="showSharePrompt"
/>
<action
v-if="showRename"
icon="mode_edit"
:label="$t('general.rename')"
@action="showRenameHover"
@action="showRenamePrompt"
/>
<action
v-if="showCopy"
icon="content_copy"
:label="$t('buttons.copyFile')"
@action="showCopyHover"
@action="showCopyPrompt"
/>
<action
v-if="showOpenParentFolder"
@@ -110,7 +110,7 @@
v-if="showMove"
icon="forward"
:label="$t('buttons.moveFile')"
@action="showMoveHover"
@action="showMovePrompt"
/>
<action
v-if="showSelectAll"
@@ -122,13 +122,13 @@
v-if="showDelete"
icon="delete"
:label="$t('general.delete')"
@action="showDeleteHover"
@action="showDeletePrompt"
/>
<action
v-if="showAccess"
icon="lock"
:label="$t('access.rules')"
@action="showAccessHover"
@action="showAccessPrompt"
/>
<action
v-if="showSelectMultiple"
@@ -155,16 +155,17 @@
class="button no-select floating-window"
:class="{ 'dark-mode': isDarkMode }"
>
<action icon="info" :label="$t('general.info')" @action="showInfoHover"/>
<action icon="info" :label="$t('general.info')" @action="showInfoPrompt"/>
<action v-if="showGoToRaw" icon="open_in_new" :label="$t('general.openFile')" @action="goToRaw()" />
<action v-if="shouldShowParentFolder()" icon="folder" :label="$t('buttons.openParentFolder')" @action="openParentFolder" />
<action v-if="isPreview && permissions.modify" icon="mode_edit" :label="$t('general.rename')" @action="showRenameHoverForPreview" />
<action v-if="isPreview && permissions.modify" icon="mode_edit" :label="$t('general.rename')" @action="showRenamePromptForPreview" />
<action v-if="showWatch" icon="visibility" :label="$t('buttons.watchFile')" @action="watchFile()" />
<action v-if="hasDownload" icon="file_download" :label="$t('general.download')" @action="startDownload" />
<action v-if="showUnarchiveInOverflow" icon="folder_open" :label="$t('prompts.unarchive')" @action="showUnarchiveHoverFromPreview" />
<action v-if="showUnarchiveInOverflow" icon="folder_open" :label="$t('prompts.unarchive')" @action="showUnarchivePromptFromPreview" />
<action v-if="showEdit" icon="edit" :label="$t('general.edit')" @action="edit()" />
<action v-if="markdownPreview" icon="visibility" :label="$t('general.preview')" @action="switchToMarkdown" />
<action v-if="showSave" icon="save" :label="$t('general.save')" @action="save()" />
<action v-if="showDelete" icon="delete" :label="$t('general.delete')" show="delete" />
<action v-if="showDelete" icon="delete" :label="$t('general.delete')" @action="showDeletePrompt" />
</div>
</transition>
</template>
@@ -325,7 +326,7 @@ export default {
return this.showEdit || this.showDelete || this.showSave || this.showGoToRaw || this.hasDownload || this.showUnarchiveInOverflow;
},
showUnarchiveInOverflow() {
if (!this.permissions.archive || getters.isShare()) return false;
if (!this.permissions.create || getters.isShare()) return false;
const req = state.req;
return req && !req.isDir && isArchivePath(req.path || req.name);
},
@@ -402,6 +403,10 @@ export default {
currentPrompt() {
return getters.currentPrompt();
},
markdownPreview() {
if (getters.currentView() !== 'editor') return false;
return state.req.type === 'text/markdown';
},
},
watch: {
hasOverflowItems: {
@@ -431,9 +436,13 @@ export default {
}
},
methods: {
showInfoHover() {
mutations.closeTopHover(); // close top hover to close overflow menu and replace any other open prompt
mutations.showHover({ // from preview we can open the same prompt multiple times, which is undesirable.
showInfoPrompt() {
if (!this.isPreview) {
mutations.closeHovers();
} else {
mutations.closeTopPrompt();
}
mutations.showPrompt({
name: "info",
props: {
item: this.firstSelected,
@@ -465,14 +474,14 @@ export default {
shouldShowParentFolder() {
return this.isPreview && state.req.path != "/";
},
showAccessHover() {
mutations.closeContextMenus();
showAccessPrompt() {
mutations.closeHovers();
let sourceName = this.firstSelected?.source || state.req.source;
let path = this.firstSelected?.path || state.req.path;
if (this.firstSelected && !this.firstSelected.isDir) {
path = url.removeLastDir(path) + '/';
}
mutations.showHover({
mutations.showPrompt({
name: "access",
props: {
sourceName: sourceName,
@@ -548,21 +557,21 @@ export default {
}
this.showCreate = true;
},
showHover(value) {
return mutations.showHover(value);
showPrompt(value) {
return mutations.showPrompt(value);
},
showShareHover() {
mutations.closeContextMenus();
mutations.showHover({
showSharePrompt() {
mutations.closeHovers();
mutations.showPrompt({
name: "share",
props: {
item: this.selectedCount == 1 ? this.firstSelected : state.req
},
});
},
showRenameHover() {
mutations.closeContextMenus();
mutations.showHover({
showRenamePrompt() {
mutations.closeHovers();
mutations.showPrompt({
name: "rename",
props: {
item: this.selectedCount == 1 ? this.firstSelected : state.req,
@@ -570,11 +579,11 @@ export default {
},
});
},
showRenameHoverForPreview() {
mutations.closeTopHover(); // Close the ContextMenu (if it was open from preview)
showRenamePromptForPreview() {
mutations.closeTopPrompt(); // Close the ContextMenu (if it was open from preview)
// Get parent items from the listing
const parentItems = state.navigation.listing || [];
mutations.showHover({
mutations.showPrompt({
name: "rename",
props: {
item: state.req,
@@ -605,22 +614,22 @@ export default {
mutations.closeHovers();
},
startDownload() {
mutations.closeTopHover();
mutations.closeTopPrompt();
const items = this.providedItems;
downloadFiles(items);
},
showDeleteHover() {
mutations.closeTopHover();
mutations.showHover({
showDeletePrompt() {
mutations.closeTopPrompt();
mutations.showPrompt({
name: 'delete',
props: {
items: this.providedItems,
},
});
},
showMoveHover() {
mutations.closeContextMenus();
mutations.showHover({
showMovePrompt() {
mutations.closeHovers();
mutations.showPrompt({
name: 'move',
props: {
items: this.providedItems,
@@ -628,9 +637,9 @@ export default {
},
});
},
showCopyHover() {
mutations.closeContextMenus();
mutations.showHover({
showCopyPrompt() {
mutations.closeHovers();
mutations.showPrompt({
name: 'copy',
props: {
items: this.providedItems,
@@ -638,8 +647,8 @@ export default {
},
});
},
showNewDirHover() {
mutations.closeContextMenus();
showNewDirPrompt() {
mutations.closeHovers();
// If the context menu was triggered on a directory, pass its path as base
const selectedItem = this.firstSelected;
let base = null;
@@ -650,22 +659,22 @@ export default {
source: selectedItem.source,
};
}
mutations.showHover({
mutations.showPrompt({
name: "newDir",
props: {
base: base,
},
});
},
showArchiveHover() {
mutations.closeTopHover();
showArchivePrompt() {
mutations.closeTopPrompt();
const items = this.providedItems.map(item => ({
path: item.path,
name: item.name,
source: item.source || state.req.source,
}));
if (items.length === 0) return;
mutations.showHover({
mutations.showPrompt({
name: "archive",
props: {
items,
@@ -674,14 +683,14 @@ export default {
},
});
},
showUnarchiveHover() {
mutations.closeTopHover();
showUnarchivePrompt() {
mutations.closeTopPrompt();
const item = this.firstSelected;
if (!item) return;
this.openUnarchivePrompt(item);
},
showUnarchiveHoverFromPreview() {
mutations.closeTopHover();
showUnarchivePromptFromPreview() {
mutations.closeTopPrompt();
const req = state.req;
if (!req) return;
this.openUnarchivePrompt({ path: req.path, source: req.source, name: req.name });
@@ -689,7 +698,7 @@ export default {
openUnarchivePrompt(item) {
const path = item.path || item.from;
const source = item.source || state.req.source;
mutations.showHover({
mutations.showPrompt({
name: "unarchive",
props: {
item: { path, source, name: item.name },
@@ -709,10 +718,10 @@ export default {
false
);
window.open(downloadUrl, "_blank");
mutations.closeContextMenus();
mutations.closeHovers();
},
watchFile() {
mutations.closeContextMenus();
mutations.closeHovers();
const source = state.req?.source || state.sources.current || "";
const path = state.req?.path || "/";
this.$router.push({
@@ -724,7 +733,10 @@ export default {
});
},
async edit() {
window.location.hash = "#edit";
this.$router.replace({ hash: '#edit' });
},
async switchToMarkdown() {
this.$router.replace({ hash: '#preview' });
},
async save() {
const button = "save";
@@ -742,10 +754,10 @@ export default {
// Don't show error notification here - API layer already showed it
buttons.done(button);
}
mutations.closeContextMenus();
mutations.closeHovers();
},
showUploadHover() {
mutations.closeContextMenus();
showUploadPrompt() {
mutations.closeHovers();
let targetPath = state.req.path;
let targetSource = state.req.source;
const selectedItem = this.firstSelected;
@@ -753,7 +765,7 @@ export default {
targetPath = selectedItem.path;
targetSource = selectedItem.source;
}
mutations.showHover({
mutations.showPrompt({
name: "upload",
props: {
targetPath: targetPath,
+5 -257
View File
@@ -7,6 +7,11 @@
'--spinner-color': color
}"
>
<span
v-if="mode === 'spinner'"
class="loader__comet"
aria-hidden="true"
></span>
<span v-if="mode === 'bounce'" class="bounce-dot"></span>
<span v-if="mode === 'bounce'" class="bounce-dot"></span>
<div v-if="mode === 'placeholder'" class="placeholder-item placeholder-item-1"></div>
@@ -41,260 +46,3 @@ export default {
},
};
</script>
<style scoped>
.loader {
width: 48px;
height: 48px;
border-radius: 50%;
display: block;
border-top: 3px solid var(--spinner-color, var(--primaryColor));
border-right: 3px solid transparent;
border-bottom: 3px solid transparent;
box-sizing: border-box;
animation: spinner-rotation var(--animation-duration, 1s) linear infinite;
margin: 0 auto;
}
@keyframes spinner-rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* ============================================
SIZE VARIANTS
============================================ */
.loader--xsmall {
width: 12px;
height: 12px;
border-width: 1.5px;
}
.loader--small {
width: 24px;
height: 24px;
border-width: 2px;
}
.loader--medium {
width: 48px;
height: 48px;
border-width: 3px;
}
.loader--large {
width: 96px;
height: 96px;
border-width: 4px;
}
/* ============================================
PULSE MODE
============================================ */
.loader--pulse {
border: none;
background: var(--spinner-color, var(--primaryColor));
animation: pulse-scale var(--animation-duration, 0.75s) ease infinite;
}
@keyframes pulse-scale {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(0.95);
opacity: 0.8;
}
}
/* ============================================
BOUNCE MODE
============================================ */
.loader--bounce {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3em;
border: none;
background: transparent;
animation: none;
}
.loader--bounce::before,
.loader--bounce::after {
content: '';
width: 0.6em;
height: 0.6em;
background-color: var(--spinner-color, var(--primaryColor));
border-radius: 50%;
display: inline-block;
animation: bounce-scale var(--animation-duration, 0.75s) infinite ease-in-out both;
}
.loader--bounce::before {
animation-delay: -0.32s;
}
.loader--bounce::after {
animation-delay: -0.16s;
}
.loader--bounce .bounce-dot {
width: 0.6em;
height: 0.6em;
background-color: var(--spinner-color, var(--primaryColor));
border-radius: 50%;
display: inline-block;
animation: bounce-scale var(--animation-duration, 0.75s) infinite ease-in-out both;
}
@keyframes bounce-scale {
0%, 80%, 100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* Size variants for bounce mode */
.loader--bounce.loader--xsmall::before,
.loader--bounce.loader--xsmall::after,
.loader--bounce.loader--xsmall .bounce-dot {
width: 0.3em;
height: 0.3em;
}
.loader--bounce.loader--small::before,
.loader--bounce.loader--small::after,
.loader--bounce.loader--small .bounce-dot {
width: 0.45em;
height: 0.45em;
}
.loader--bounce.loader--medium::before,
.loader--bounce.loader--medium::after,
.loader--bounce.loader--medium .bounce-dot {
width: 0.6em;
height: 0.6em;
}
.loader--bounce.loader--large::before,
.loader--bounce.loader--large::after,
.loader--bounce.loader--large .bounce-dot {
width: 0.9em;
height: 0.9em;
}
/* ============================================
PLACEHOLDER MODE
============================================ */
.loader--placeholder {
display: flex;
flex-direction: column;
gap: 0.5em;
border: none;
animation: none;
width: auto;
height: auto;
border-radius: 0;
}
.placeholder-item {
background-color: rgba(128, 128, 128, 0.1);
animation: placeholder-ripple 1.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite,
placeholder-bg-fade 2s ease-in-out infinite;
display: block;
border-radius: 0.25em;
margin: 0;
padding: 0;
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
box-shadow 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.placeholder-item-1 {
animation-delay: 0s;
}
.placeholder-item-2 {
animation-delay: 0.36s;
}
.placeholder-item-3 {
animation-delay: 0.72s;
}
@keyframes placeholder-ripple {
0% {
transform: scale(1);
box-shadow: none;
opacity: 0.3;
}
25% {
transform: scale(1.07);
box-shadow: inset 0 -3em 3em rgba(172, 172, 172, 0.211),
0 0 0 2px var(--alt-background),
0 4px 12px rgba(0, 0, 0, 0.15);
opacity: 0.6;
}
50% {
transform: scale(0.97);
box-shadow: none;
opacity: 0.3;
}
100% {
transform: scale(1);
box-shadow: none;
opacity: 0.3;
}
}
@keyframes placeholder-bg-fade {
0% {
background-color: rgba(128, 128, 128, 0.1);
}
50% {
background-color: rgba(128, 128, 128, 0.4);
}
100% {
background-color: rgba(128, 128, 128, 0.1);
}
}
/* Size variants for placeholder mode */
.loader--placeholder.loader--xsmall .placeholder-item {
width: 30px;
height: 7.5px;
min-width: 30px;
min-height: 7.5px;
}
.loader--placeholder.loader--small .placeholder-item {
width: 150px;
height: 12.5px;
min-width: 150px;
min-height: 12.5px;
}
.loader--placeholder.loader--medium .placeholder-item {
width: 200px;
height: 25px;
min-width: 200px;
min-height: 25px;
}
.loader--placeholder.loader--large .placeholder-item {
width: 400px;
height: 50px;
min-width: 400px;
min-height: 50px;
}
</style>
+62 -18
View File
@@ -44,17 +44,19 @@
<ButtonGroup :buttons="typeSelect" @button-clicked="addToTypes" @remove-button-clicked="removeFromTypes"
:isDisabled="isTypeSelectDisabled" />
<!-- Inputs for filtering by file size -->
<div class="sizeConstraints">
<div class="constraints">
<div class="sizeInputWrapper">
<p>{{ $t("search.smallerThan") }}</p>
<input class="sizeInput" v-model="smallerThan" type="number" min="0"
:placeholder="$t('general.number')" />
<p>MB</p> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
placeholder="MB" />
<p>{{ $t("search.largerThan") }}</p>
<input class="sizeInput" v-model="largerThan" type="number" placeholder="MB" />
</div>
<div class="sizeInputWrapper">
<p>{{ $t("search.largerThan") }}</p>
<input class="sizeInput" v-model="largerThan" type="number" :placeholder="$t('general.number')" />
<p>MB</p> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<p>{{ $t("search.olderThanDate") }}</p>
<input class="sizeInput" v-model="modifiedOlderThan" type="date" />
<p>{{ $t("search.newerThanDate") }}</p>
<input class="sizeInput" v-model="modifiedNewerThan" type="date" />
</div>
</div>
<!-- Toggle for showing preview images -->
@@ -131,6 +133,8 @@ export default {
return {
largerThan: "",
smallerThan: "",
modifiedOlderThan: "",
modifiedNewerThan: "",
noneMessage: this.$t("search.typeToSearch", { minSearchLength: globalVars.minSearchLength }),
searchTypes: "",
isTypeSelectDisabled: false,
@@ -157,12 +161,21 @@ export default {
};
},
watch: {
currentSourceState(){
this.selectedSource = state.sources.current;
},
largerThan() {
this.submit();
},
smallerThan() {
this.submit();
},
modifiedOlderThan() {
this.submit();
},
modifiedNewerThan() {
this.submit();
},
searchTypes() {
this.submit();
},
@@ -220,6 +233,9 @@ export default {
}
},
computed: {
currentSourceState() {
return state.sources.current;
},
eventTheme() {
return getters.eventTheme();
},
@@ -302,7 +318,7 @@ export default {
openContext(event) {
event.preventDefault();
event.stopPropagation();
mutations.showHover({
mutations.showPrompt({
name: "ContextMenu",
props: {
posX: event.clientX,
@@ -379,6 +395,23 @@ export default {
humanSize(size) {
return getHumanReadableFilesize(size);
},
/** YYYY-MM-DD from a date input → Unix seconds at 00:00:00 UTC for that calendar day. */
dateToUnixStartOfDayUTC(isoDate) {
if (isoDate === "" || typeof isoDate !== "string") {
return null;
}
const parts = isoDate.split("-");
if (parts.length !== 3) {
return null;
}
const y = Number(parts[0]);
const m = Number(parts[1]);
const d = Number(parts[2]);
if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) {
return null;
}
return Math.floor(Date.UTC(y, m - 1, d) / 1000);
},
basePath(str, isDir) {
let result = url.removeLastDir(str);
if (!isDir) {
@@ -391,15 +424,6 @@ export default {
let part = parts.pop();
return part;
},
open() {
if (!state.isSearchActive) {
mutations.closeHovers();
mutations.closeSidebar();
mutations.resetSelected();
this.resetSearchFilters;
mutations.setSearch(true);
}
},
close(event) {
this.value = "";
event.stopPropagation();
@@ -458,6 +482,15 @@ export default {
if (this.smallerThan != "") {
searchTypesFull = searchTypesFull + "type:smallerThan=" + this.smallerThan + " ";
}
const dateParams = {};
const olderUnix = this.dateToUnixStartOfDayUTC(this.modifiedOlderThan);
if (olderUnix !== null) {
dateParams.olderThan = olderUnix;
}
const newerUnix = this.dateToUnixStartOfDayUTC(this.modifiedNewerThan);
if (newerUnix !== null) {
dateParams.newerThan = newerUnix;
}
this.ongoing = true;
// Determine which sources to search
@@ -473,7 +506,13 @@ export default {
// Only pass scope if searching a single source
const scope = sourcesToSearch.length === 1 ? this.getContext : null;
this.results = await toolsApi.search(scope, sourcesToSearch, searchTypesFull + this.value);
this.results = await toolsApi.search(
scope,
sourcesToSearch,
searchTypesFull + this.value,
false,
dateParams
);
this.ongoing = false;
if (this.results.length == 0) {
@@ -672,6 +711,11 @@ export default {
font-size: 0.95em;
}
#search-input {
width: 100%;
padding-left: 0.5em;
}
#search.active input {
color: inherit;
}
@@ -870,7 +914,7 @@ body.rtl #search .boxes h3 {
/* IE and Edge */
}
.sizeConstraints {
.constraints {
display: flex;
flex-wrap: wrap;
flex-direction: row;
@@ -267,7 +267,7 @@ export default {
});
const moveAction = async (overwrite, rename) => {
mutations.showHover({
mutations.showPrompt({
name: "move",
props: { operationInProgress: true },
});
@@ -291,24 +291,24 @@ export default {
action: buttonAction
}]
});
mutations.closeTopHover();
mutations.closeTopPrompt();
mutations.setReload(true);
} catch (error) {
mutations.closeTopHover();
mutations.closeTopPrompt();
notify.showErrorToast(this.$t("prompts.moveFailed"));
console.log("Move failed", e);
}
};
if (conflict) {
mutations.showHover({
mutations.showPrompt({
name: "replace-rename",
pinned: true,
confirm: (event, option) => {
const overwrite = option === "overwrite";
const rename = option === "rename";
event.preventDefault();
mutations.closeTopHover();
mutations.closeTopPrompt();
moveAction(overwrite, rename);
},
});
+2 -2
View File
@@ -457,7 +457,7 @@ export default {
});
},
createDir: async function () {
mutations.showHover({
mutations.showPrompt({
name: "newDir",
action: null,
confirm: null,
@@ -476,7 +476,7 @@ export default {
this.items = allItems.filter(item => !item.isDirectory && item.type !== 'directory');
},
navigateToItem(item) {
mutations.closeTopHover();
mutations.closeTopPrompt();
mutations.setNavigationTransitioning(true);
url.goToItem(item.source || state.req.source, item.path, undefined);
},
+47 -20
View File
@@ -79,6 +79,7 @@ import { resourcesApi } from '@/api';
import Icon from '@/components/files/Icon.vue';
import LoadingSpinner from '@/components/LoadingSpinner.vue';
import { state, getters, mutations } from '@/store';
import { eventBus } from '@/store/eventBus';
import { goToItem, joinPath } from '@/utils/url';
import { notify } from '@/notify';
@@ -116,6 +117,8 @@ export default {
isRefreshing: false,
unwatchReload: null,
expandTimeouts: new Map(), // For auto-expand when dragging a item to a folder
pendingExpandPath: null,
rootLoaded: false,
};
},
computed: {
@@ -142,14 +145,22 @@ export default {
},
watch: {
currentSource: {
handler(newVal, oldVal) {
if (this.isRootInstance && newVal !== oldVal) this.loadRoot();
async handler(newVal) {
if (this.isRootInstance && newVal) {
await this.loadRoot();
this.rootLoaded = true;
this.tryExpandNodes();
}
},
immediate: true,
},
shareHash: {
handler(newVal, oldVal) {
if (this.isRootInstance && newVal !== oldVal) this.loadRoot();
async handler(newVal) {
if (this.isRootInstance && newVal) {
await this.loadRoot();
this.rootLoaded = true;
this.tryExpandNodes();
}
},
immediate: true,
},
@@ -159,9 +170,10 @@ export default {
},
},
currentPath: {
async handler(newPath, oldPath) {
if (this.isRootInstance && newPath && newPath !== oldPath) {
await this.expandToPath(newPath);
handler(newPath) {
if (newPath !== '/') {
this.pendingExpandPath = newPath;
this.tryExpandNodes();
}
},
immediate: true,
@@ -180,12 +192,13 @@ export default {
() => state.reload,
(newVal) => {
if (newVal === true && this.isRootInstance && !this.isRefreshing) {
console.log('Reload detected, refreshing tree');
this.refresh();
}
},
{ immediate: false, flush: 'sync' }
);
eventBus.on('itemsDeleted', this.refresh);
eventBus.on('itemsRenamed', this.refresh);
window.addEventListener('dragend', this.clearAllDragStates);
},
beforeUnmount() {
@@ -195,11 +208,14 @@ export default {
if (this.unwatchReload) {
this.unwatchReload();
}
eventBus.off('itemsDeleted', this.refresh);
eventBus.off('itemsRenamed', this.refresh);
window.removeEventListener('dragend', this.clearAllDragStates);
},
methods: {
async loadRoot() {
this.error = null;
this.rootLoaded = false;
if ((!this.currentSource && !this.shareHash) || !this.isRootInstance) {
this.rootNodes = [];
return;
@@ -208,6 +224,7 @@ export default {
try {
const items = await this.fetchItems(this.rootPath);
this.rootNodes = items.map(item => this.createNode(item));
this.rootLoaded = true;
} catch (err) {
console.error('Failed to load tree root:', err);
this.error = err.message || 'Failed to load';
@@ -285,13 +302,22 @@ export default {
}
},
async tryExpandNodes() {
if (!this.rootLoaded || !this.pendingExpandPath) return;
await this.expandToPath(this.pendingExpandPath);
this.pendingExpandPath = null;
},
handleNodeClick(node) {
if (node.isDir) {
if (!this.isCurrentItem(node)) {
this.navigateTo(node);
} else {
this.toggleExpand(node, false);
// don't navigate to current item if we're already viewing it
if (this.isCurrentItem(node)) {
if (node.isDir) {
this.toggleExpand(node, false); // If the item is a folder, just expand/collapse instead of navigate again
}
return;
}
if (node.isDir) {
this.navigateTo(node);
} else {
this.navigateTo(node);
}
@@ -303,7 +329,7 @@ export default {
// Build an array with the single node (we don't support multi-select in tree yet, maybe later! But I'm not sure tbh)
const items = [node];
mutations.showHover({
mutations.showPrompt({
name: 'ContextMenu',
props: {
posX: event.clientX,
@@ -315,8 +341,10 @@ export default {
navigateTo(node) {
if (this.isShare) {
mutations.setNavigationTransitioning(true);
goToItem(null, node.path, {}, false, this.shareHash);
} else {
mutations.setNavigationTransitioning(true);
goToItem(this.currentSource, node.path, {});
}
},
@@ -543,7 +571,7 @@ export default {
});
const performAction = async (overwrite, rename) => {
mutations.showHover({
mutations.showPrompt({
name: 'move',
props: { operationInProgress: true },
});
@@ -570,22 +598,22 @@ export default {
}]
};
notify.showSuccess(this.$t('prompts.moveSuccess'), buttonProps);
mutations.closeTopHover();
mutations.closeTopPrompt();
mutations.setReload(true);
} catch (error) {
mutations.closeTopHover();
mutations.closeTopPrompt();
throw error;
}
};
if (conflict) {
mutations.showHover({
mutations.showPrompt({
name: 'replace-rename',
pinned: true,
confirm: async (event, option) => {
const overwrite = option === 'overwrite';
const rename = option === 'rename';
event.preventDefault();
mutations.closeTopHover();
mutations.closeTopPrompt();
await performAction(overwrite, rename);
},
});
@@ -756,7 +784,6 @@ export default {
.tree-error {
padding: 1em;
text-align: center;
color: var(--errorColor);
display: flex;
flex-direction: column;
align-items: center;
@@ -462,7 +462,7 @@ export default {
if (this.disableContextMenu) {
return;
}
mutations.showHover({
mutations.showPrompt({
name: "ContextMenu",
props: {
posX: event.clientX,
@@ -605,7 +605,7 @@ export default {
*/
let action = async (overwrite, rename) => {
// Show move prompt with operation in progress
mutations.showHover({
mutations.showPrompt({
name: "move",
props: {
operationInProgress: true,
@@ -634,17 +634,17 @@ export default {
};
notify.showSuccess(this.$t("prompts.moveSuccess"), buttonProps);
// Close the prompt after successful operation and reload items for reflect the changes
mutations.closeTopHover();
mutations.closeTopPrompt();
mutations.setReload(true);
} catch (error) {
// Close the prompt and let error handling continue
mutations.closeTopHover();
mutations.closeTopPrompt();
throw error;
}
};
if (conflict) {
mutations.showHover({
mutations.showPrompt({
name: "replace-rename",
pinned: true,
/**
@@ -656,7 +656,7 @@ export default {
const rename = option === "rename";
event.preventDefault();
mutations.closeTopHover();
mutations.closeTopPrompt();
await action(overwrite, rename);
},
});
@@ -315,7 +315,7 @@ export default {
// There are unsaved changes - show prompt
return new Promise((resolve) => {
mutations.showHover({
mutations.showPrompt({
name: "SaveBeforeExit",
pinned: true,
confirm: async () => {
@@ -326,7 +326,7 @@ export default {
await saveHandler();
}
// Close the prompt after successful save
mutations.closeTopHover();
mutations.closeTopPrompt();
resolve(true); // Allow navigation
} catch (error) {
// Save failed - keep prompt open by not resolving
@@ -337,13 +337,13 @@ export default {
// Discard changes and proceed
mutations.setEditorDirty(false);
// Close the prompt
mutations.closeTopHover();
mutations.closeTopPrompt();
resolve(true); // Allow navigation
},
cancel: () => {
// Cancel navigation
// Close the prompt
mutations.closeTopHover();
mutations.closeTopPrompt();
resolve(false); // Block navigation
},
});
@@ -964,7 +964,7 @@ export default {
showCurrentListing() {
const currentItems = this.getCurrentListingItems();
mutations.showHover({
mutations.showPrompt({
name: "file-list",
props: {
fileList: currentItems,
@@ -173,9 +173,6 @@ export default {
}
},
methods: {
closeHovers() {
mutations.closeTopHover();
},
startPathEdit() {
this.tempPath = this.currentPath;
this.isEditingPath = true;
@@ -296,9 +293,6 @@ export default {
console.error(e);
}
},
closePrompt() {
mutations.closeTopHover();
},
showTooltip(event, text) {
mutations.showTooltip({
content: text,
@@ -100,9 +100,6 @@ export default {
day: "numeric",
});
},
closeHovers() {
mutations.closeTopHover();
},
async deleteApi() {
// Dummy delete function, to be filled in later
try {
@@ -111,7 +108,7 @@ export default {
setTimeout(() => {
eventBus.emit('apiKeysChanged');
}, 10);
mutations.closeTopHover();
mutations.closeTopPrompt();
notify.showSuccessToast(this.$t("api.apiKeyDeleted"));
} catch (error) {
console.error(error);
+4 -4
View File
@@ -75,7 +75,7 @@
<template v-else>
<button
class="button button--flat button--grey"
@click="closeHovers"
@click="closeTopPrompt"
:aria-label="$t('general.cancel')"
:title="$t('general.cancel')"
>
@@ -159,8 +159,8 @@ export default {
},
},
methods: {
closeHovers() {
mutations.closeTopHover();
closeTopPrompt() {
mutations.closeTopPrompt();
},
updateDestination(pathOrData) {
if (typeof pathOrData === "string") {
@@ -194,7 +194,7 @@ export default {
}
await resourcesApi.createArchive(payload);
mutations.setReload(true);
mutations.closeTopHover();
mutations.closeTopPrompt();
const destSource = this.destSource || this.source;
const archivePath = dest;
@@ -25,7 +25,7 @@
<div class="card-actions">
<button
@click="closeHovers"
@click="closeTopPrompt"
class="button button--flat button--grey"
:aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
@@ -78,12 +78,12 @@ export default {
},
},
methods: {
closeHovers() {
mutations.closeTopHover();
closeTopPrompt() {
mutations.closeTopPrompt();
},
confirm() {
this.onConfirm();
mutations.closeTopHover();
mutations.closeTopPrompt();
},
},
};
@@ -100,9 +100,6 @@ export default {
},
},
methods: {
closeHovers() {
mutations.closeTopHover();
},
async createAPIKey() {
this.creating = true;
try {
@@ -125,7 +122,7 @@ export default {
// Emit event to refresh API keys list
eventBus.emit('apiKeysChanged');
notify.showSuccessToast(this.$t("api.createKeySuccess"));
mutations.closeTopHover();
mutations.closeTopPrompt();
} catch (error) {
notify.showError(this.$t("api.createKeyFailed"));
} finally {
+31 -18
View File
@@ -45,7 +45,7 @@
</div>
</div>
<div class="card-actions">
<button @click="closeHovers" class="button button--flat button--grey" :aria-label="$t('general.cancel')"
<button @click="closeTopPrompt" class="button button--flat button--grey" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
{{ $t("general.cancel") }}
</button>
@@ -163,8 +163,8 @@ export default {
// For custom events (like @select), just return early
return false;
},
closeHovers() {
mutations.closeTopHover();
closeTopPrompt() {
mutations.closeTopPrompt();
},
getItemName(path) {
const parts = path.split("/").filter(p => p);
@@ -250,29 +250,42 @@ export default {
failed: []
});
}
mutations.closeTopHover();
mutations.closeTopPrompt();
if (!this.items) {
mutations.resetSelected();
}
// Smart navigation: if in preview mode with single item deletion, navigate intelligently
// If we delete a item while in a preview
if (getters.isPreviewView() && this.itemsToDelete.length === 1) {
// Try next, then previous, then parent directory
if (state.navigation.nextItem) {
// Use goToItem with undefined to use router.replace (no history entry)
url.goToItem(state.navigation.nextItem.source, state.navigation.nextItem.path, undefined);
} else if (state.navigation.previousItem) {
// Use goToItem with undefined to use router.replace (no history entry)
url.goToItem(state.navigation.previousItem.source, state.navigation.previousItem.path, undefined);
const deletedItem = this.itemsToDelete[0];
const currentItem = state.req;
// Only navigate if we delete the current item, not by deleting a different one (from fileTree)
if (currentItem && deletedItem.source === currentItem.source && deletedItem.path === currentItem.path) {
mutations.setNavigationTransitioning(true);
// Try next, then previous, then parent directory
if (state.navigation.nextItem) {
url.goToItem(state.navigation.nextItem.source, state.navigation.nextItem.path, undefined);
} else if (state.navigation.previousItem) {
url.goToItem(state.navigation.previousItem.source, state.navigation.previousItem.path, undefined);
} else {
// Navigate to parent directory of deleted item
const parentPath = url.removeLastDir(deletedItem.path);
url.goToItem(deletedItem.source, parentPath, {});
}
} else {
// Navigate to parent directory of deleted item
const deletedItemPath = this.itemsToDelete[0].path;
const parentPath = url.removeLastDir(deletedItemPath);
const deletedItemSource = this.itemsToDelete[0].source;
url.goToItem(deletedItemSource, parentPath, {});
mutations.setReload(true);
}
} else {
// If someone deletes the current folder (from fileTree) that they're viewing, navigate to parent directory
if (!getters.isPreviewView() && this.itemsToDelete.length === 1) {
const deletedItem = this.itemsToDelete[0];
if (deletedItem.type === 'directory' && deletedItem.path === state.req.path) {
const parentPath = url.removeLastDir(deletedItem.path);
url.goToItem(deletedItem.source, parentPath, {});
this.deleting = false;
return; // return early to avoid extra reload (and 404)
}
}
// For listing view or multiple deletions, just reload
mutations.setReload(true);
}
+1 -1
View File
@@ -131,7 +131,7 @@ export default {
if (!downloadManager || !downloadManager.hasActive()) {
const prompt = getters.currentPrompt();
if (prompt && prompt.name === 'download') {
mutations.closeTopHover();
mutations.closeTopPrompt();
} else {
prompt?.cancel?.();
}
+63 -17
View File
@@ -1,3 +1,4 @@
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<template>
<div class="card-content help-content">
<p class="help-description">
@@ -12,19 +13,19 @@
<div class="shortcut-section">
<h3 class="section-title">{{ $t("help.functionKeys") }}</h3>
<div class="shortcut-item">
<kbd>F1</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>F1</kbd>
<span>{{ $t("help.f1") }}</span>
</div>
<div class="shortcut-item">
<kbd>F2</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>F2</kbd>
<span>{{ $t("help.f2") }}</span>
</div>
<div class="shortcut-item">
<kbd>DEL</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>DEL</kbd>
<span>{{ $t("help.del") }}</span>
</div>
<div class="shortcut-item">
<kbd>ESC</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>ESC</kbd>
<span>{{ $t("help.esc") }}</span>
</div>
</div>
@@ -33,15 +34,15 @@
<div class="shortcut-section">
<h3 class="section-title">{{ $t("help.mouseActions") }}</h3>
<div class="shortcut-item">
<kbd>Click</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>Click</kbd>
<span>{{ $t("help.click") }}</span>
</div>
<div class="shortcut-item">
<kbd>Click + Drag</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>Click + Drag</kbd>
<span>{{ $t("help.marqueeSelect") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + Click</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + Click</kbd>
<span>{{ $t("help.ctrl.click") }}</span>
</div>
</div>
@@ -50,19 +51,19 @@
<div class="shortcut-section">
<h3 class="section-title">{{ $t("help.fileOperations") }}</h3>
<div class="shortcut-item">
<kbd>CTRL + C</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + C</kbd>
<span>{{ $t("help.ctrl.c") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + X</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + X</kbd>
<span>{{ $t("help.ctrl.x") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + V</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + V</kbd>
<span>{{ $t("help.ctrl.v") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + D</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + D</kbd>
<span>{{ $t("help.ctrl.d") }}</span>
</div>
</div>
@@ -71,23 +72,68 @@
<div class="shortcut-section">
<h3 class="section-title">{{ $t("general.navigation") }}</h3>
<div class="shortcut-item">
<kbd>CTRL + B</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + B</kbd>
<span>{{ $t("help.ctrl.b") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + ,</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>CTRL + ,</kbd>
<span>{{ $t("help.ctrl.comma") }}</span>
</div>
<div class="shortcut-item">
<kbd>/</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<kbd>/</kbd>
<span>{{ $t("help.slash") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + E</kbd>
<span>{{ $t("help.ctrl.e") }}</span>
</div>
</div>
<!-- Media Section -->
<div class="shortcut-section">
<h3 class="section-title">{{ $t("help.media.title") }}</h3>
<div class="shortcut-item">
<kbd>Space / K</kbd>
<span>{{ $t("help.media.playPause") }}</span>
</div>
<div class="shortcut-item">
<kbd>1 to 9</kbd>
<span>{{ $t("help.media.seekPercentage") }}</span>
</div>
<div class="shortcut-item">
<kbd>← / →</kbd>
<span>{{ $t("help.media.seek") }}</span>
</div>
<div class="shortcut-item">
<kbd>↑ / ↓</kbd>
<span>{{ $t("help.media.volume") }}</span>
</div>
<div class="shortcut-item">
<kbd>L</kbd>
<span>{{ $t("help.media.loop") }}</span>
</div>
<div class="shortcut-item">
<kbd>P</kbd>
<span>{{ $t("help.media.playbackModes") }}</span>
</div>
<div class="shortcut-item">
<kbd>F</kbd>
<span>{{ $t("help.media.fullscreen") }}</span>
</div>
<div class="shortcut-item">
<kbd>M</kbd>
<span>{{ $t("help.media.mute") }}</span>
</div>
<div class="shortcut-item">
<kbd>C</kbd>
<span>{{ $t("help.media.captions") }}</span>
</div>
</div>
</div>
</div>
<div class="card-actions">
<button type="submit" @click="closeHovers" class="button button--flat" :aria-label="$t('general.ok')"
<button type="submit" @click="closeTopPrompt" class="button button--flat" :aria-label="$t('general.ok')"
:title="$t('general.ok')">
{{ $t("general.ok") }}
</button>
@@ -100,8 +146,8 @@ import { mutations } from "@/store"; // Import the mutations
export default {
name: "help",
computed: {
closeHovers() {
return mutations.closeTopHover(); // Return the closeHovers mutation
closeTopPrompt() {
return mutations.closeTopPrompt();
},
},
};
@@ -77,7 +77,7 @@
<div class="card-actions">
<button
@click="closeTopHover"
@click="closeTopPrompt"
class="button button--flat button--grey"
:aria-label="$t('general.cancel')"
:title="$t('general.cancel')"
@@ -148,10 +148,10 @@ export default {
getIconClass,
selectIcon(iconName) {
this.onSelect(iconName);
this.closeTopHover();
this.closeTopPrompt();
},
closeTopHover() {
mutations.closeTopHover();
closeTopPrompt() {
mutations.closeTopPrompt();
},
},
};
@@ -268,7 +268,7 @@ export default {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
+1 -4
View File
@@ -87,7 +87,7 @@
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { formatTimestamp } from "@/utils/moment";
import { resourcesApi } from "@/api";
import { state, mutations } from "@/store";
import { state } from "@/store";
import { notify } from "@/notify";
export default {
@@ -106,9 +106,6 @@ export default {
};
},
computed: {
closeHovers() {
return mutations.closeTopHover();
},
humanSize() {
return getHumanReadableFilesize(this.item?.size || 0);
},
+4 -7
View File
@@ -110,9 +110,6 @@ export default {
const perms = getters.permissions();
return !!perms?.create;
},
closeHovers() {
return mutations.closeTopHover();
},
isDirNameValid() {
return this.validateDirName(this.newDirName);
}
@@ -279,7 +276,7 @@ export default {
});
await new Promise((resolve, reject) => {
mutations.showHover({
mutations.showPrompt({
name: "replace-rename",
pinned: true,
props: {
@@ -327,9 +324,9 @@ export default {
// Only close prompts and reload on success (or partial success)
mutations.setReload(true);
mutations.closeTopHover(); // close conflict prompt
mutations.closeTopHover(); // close moveCopy prompt after conflict is resolved
mutations.setSearch(false);
mutations.closeTopPrompt(); // close conflict prompt
mutations.closeTopPrompt(); // close moveCopy prompt after conflict is resolved and file copied/moved
mutations.setSearch(false); // close search if open
// Only show success notification if there were no failures (or partial success was already shown)
if (!hasFailures || hasSuccesses) {
+8 -8
View File
@@ -13,7 +13,7 @@
</div>
<div class="card-actions">
<button class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('general.cancel')"
<button class="button button--flat button--grey" @click="closeTopPrompt" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
{{ $t("general.cancel") }}
</button>
@@ -80,8 +80,8 @@ export default {
},
},
methods: {
closeHovers() {
return mutations.closeTopHover();
closeTopPrompt() {
return mutations.closeTopPrompt();
},
async submit(event) {
try {
@@ -103,13 +103,13 @@ export default {
if (getters.isShare()) {
await resourcesApi.postPublic(state.shareInfo?.hash, newPath, "", overwrite, undefined, {}, true);
mutations.setReload(true);
mutations.closeTopHover();
mutations.closeTopPrompt();
this.creating = false;
return;
}
await resourcesApi.post(source, newPath, "", overwrite, undefined, {}, true);
mutations.setReload(true);
mutations.closeTopHover();
mutations.closeTopPrompt();
// Show success notification with "go to item" button
const buttonAction = () => {
@@ -128,7 +128,7 @@ export default {
} catch (error) {
if (error.message === "conflict") {
// Show replace-rename prompt for file/folder conflicts
mutations.showHover({
mutations.showPrompt({
name: "replace-rename",
pinned: true,
confirm: async (event, option) => {
@@ -151,13 +151,13 @@ export default {
if (getters.isShare()) {
await resourcesApi.postPublic(state.shareInfo?.hash, newPath, "", false, undefined, {}, true);
mutations.setReload(true);
mutations.closeTopHover();
mutations.closeTopPrompt();
success = true;
return;
}
await resourcesApi.post(source, newPath, "", false, undefined, {}, true);
mutations.setReload(true);
mutations.closeTopHover();
mutations.closeTopPrompt();
success = true;
// Show success notification with "go to item" button
+8 -8
View File
@@ -13,7 +13,7 @@
</div>
<div class="card-actions">
<button class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('general.cancel')"
<button class="button button--flat button--grey" @click="closeTopPrompt" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
{{ $t("general.cancel") }}
</button>
@@ -54,8 +54,8 @@ export default {
isListing() {
return getters.isListing();
},
closeHovers() {
return mutations.closeTopHover();
closeTopPrompt() {
return mutations.closeTopPrompt();
},
// Determine parent path and source based on prop
parentInfo() {
@@ -100,13 +100,13 @@ export default {
if (getters.isShare()) {
await resourcesApi.postPublic(state.shareInfo?.hash, newPath, "", overwrite);
mutations.setReload(true);
mutations.closeTopHover();
mutations.closeTopPrompt();
this.creating = false;
return;
}
await resourcesApi.post(source, newPath, "", overwrite);
mutations.setReload(true);
mutations.closeTopHover();
mutations.closeTopPrompt();
// Show success notification with "go to item" button
const buttonAction = () => {
@@ -125,7 +125,7 @@ export default {
} catch (error) {
if (error.message === "conflict") {
// Show replace-rename prompt for file/folder conflicts
mutations.showHover({
mutations.showPrompt({
name: "replace-rename",
pinned: true,
confirm: async (event, option) => {
@@ -148,13 +148,13 @@ export default {
if (getters.isShare()) {
await resourcesApi.postPublic(state.shareInfo?.hash, newPath, "", false);
mutations.setReload(true);
mutations.closeTopHover();
mutations.closeTopPrompt();
success = true;
return;
}
await resourcesApi.post(source, newPath, "", false);
mutations.setReload(true);
mutations.closeTopHover();
mutations.closeTopPrompt();
success = true;
// Show success notification with "go to item" button
+1 -1
View File
@@ -53,7 +53,7 @@ export default {
},
methods: {
submit() {
mutations.closeTopHover();
mutations.closeTopPrompt();
this.submitCallback(this.password);
},
},
@@ -5,7 +5,7 @@
</div>
<div class="card-actions">
<button class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('general.cancel')"
<button class="button button--flat button--grey" @click="closeTopPrompt" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
{{ $t("general.cancel") }}
</button>
@@ -54,8 +54,8 @@ export default {
this.selectedSource = this.currentSource || "";
},
computed: {
closeHovers() {
return mutations.closeTopHover();
closeTopPrompt() {
return mutations.closeTopPrompt();
},
},
methods: {
@@ -74,8 +74,8 @@ export default {
path: this.selectedPath,
source: this.selectedSource
});
// Close the modal
mutations.closeTopHover();
// Close the prompt
mutations.closeTopPrompt();
},
},
};
+11 -12
View File
@@ -33,7 +33,7 @@
:aria-label="$t('general.close')"
:title="$t('general.close')"
:disabled="isBlocked(prompt)"
@click.stop="closePrompt(prompt.id)"
@click.stop="closeTopPrompt(prompt.id)"
@mousedown.stop
@touchstart.stop
>
@@ -324,10 +324,10 @@ export default {
return promptName;
}
},
closePrompt(id) {
closeTopPrompt(id) {
// Find the prompt we're trying to close
const promptToClose = state.prompts.find(p => p.id === id);
if (!promptToClose) return;
// Check if it's the upload prompt with active uploads
if (promptToClose?.name === "upload") {
const hasActiveUploads = state.upload.isUploading;
@@ -335,30 +335,29 @@ export default {
if (hasActiveUploads && !hasWarningPrompt) {
// Show warning prompt instead of closing
mutations.showHover({
mutations.showPrompt({
name: "CloseWithActiveUploads",
pinned: true,
confirm: () => {
// User confirmed to close anyway - close the upload prompt
mutations.closePromptById(id);
mutations.closeTopPrompt(id);
// Clean up state for this prompt
delete this.dragOffsets[id];
delete this.dragStarts[id];
delete this.touchIds[id];
this.draggingIds.delete(id);
delete this.sizes[id];
this.cleanupDragState(id);
},
cancel: () => {
// User cancelled - just close the warning prompt
mutations.closeTopHover();
mutations.closeTopPrompt();
},
});
return;
}
}
// Normal close behavior
mutations.closePromptById(id);
mutations.closeTopPrompt(id);
// Clean up state for this prompt
this.cleanupDragState(id);
},
cleanupDragState(id) {
delete this.dragOffsets[id];
delete this.dragStarts[id];
delete this.touchIds[id];
+6 -4
View File
@@ -27,7 +27,7 @@
</div>
<div class="card-actions">
<button class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('general.cancel')"
<button class="button button--flat button--grey" @click="closeTopPrompt" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
{{ $t("general.cancel") }}
</button>
@@ -40,6 +40,7 @@
<script>
import { resourcesApi } from "@/api";
import { mutations, state, getters } from "@/store";
import { eventBus } from '@/store/eventBus';
import { notify } from "@/notify";
import { getFileExtension, removePrefix } from '@/utils/files.js';
import { url } from "@/utils";
@@ -80,8 +81,8 @@ export default {
};
},
computed: {
closeHovers() {
return mutations.closeTopHover();
closeTopPrompt() {
return mutations.closeTopPrompt();
},
validation() {
return this.validateFileName(this.name);
@@ -211,7 +212,8 @@ export default {
await resourcesApi.moveCopy(items, "move");
}
notify.showSuccessToast(this.$t("prompts.renameSuccess"));
mutations.closeTopHover();
eventBus.emit('itemsRenamed');
mutations.closeTopPrompt();
if (this.isPreviewView) {
url.goToItem(this.item.source, newPath, undefined); // When undefined will not create browser history
+3 -3
View File
@@ -4,7 +4,7 @@
</div>
<div class="card-actions">
<button class="button button--flat button--grey" @click="closeTopHover" :aria-label="$t('general.cancel')"
<button class="button button--flat button--grey" @click="closeTopPrompt" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
{{ $t("general.cancel") }}
</button>
@@ -25,8 +25,8 @@ export default {
},
},
methods: {
closeTopHover() {
mutations.closeTopHover();
closeTopPrompt() {
mutations.closeTopPrompt();
},
},
};
@@ -56,14 +56,14 @@ export default {
if (this.currentPrompt && this.currentPrompt.cancel) {
this.currentPrompt.cancel();
}
mutations.closeTopHover();
mutations.closeTopPrompt();
},
async saveAndExit() {
if (this.currentPrompt && this.currentPrompt.confirm) {
try {
await this.currentPrompt.confirm();
// Only close prompt if save succeeded
mutations.closeTopHover();
mutations.closeTopPrompt();
} catch (error) {
// If save fails, keep the prompt open so user can try again or choose another option
console.error("Save failed:", error);
@@ -75,7 +75,7 @@ export default {
if (this.currentPrompt && this.currentPrompt.discard) {
this.currentPrompt.discard();
}
mutations.closeTopHover();
mutations.closeTopPrompt();
},
},
};
+4 -7
View File
@@ -323,7 +323,7 @@
{{ $t("general.share") }}
</button>
</div>
<div v-if="isPickingFile" class="card-action">
<div v-if="isPickingFile" class="card-actions">
<button class="button button--flat" @click="cancelFilePicker" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
{{ $t("general.cancel") }}
@@ -452,9 +452,6 @@ export default {
availableThemes() {
return globalVars.userSelectableThemes || {};
},
closeHovers() {
return mutations.closeTopHover();
},
req() {
return /** @type {FilebrowserRequest} */ (/** @type {unknown} */ (state.req));
},
@@ -730,7 +727,7 @@ export default {
} else {
// emit event to reload shares in settings view
eventBus.emit('sharesChanged');
mutations.closeTopHover();
mutations.closeTopPrompt();
}
this.time = "";
@@ -830,7 +827,7 @@ export default {
switchListing() {
if (this.links.length === 0 && !this.listing) {
// Access the store directly if needed
mutations.closeTopHover();
mutations.closeTopPrompt();
}
this.listing = !this.listing;
@@ -924,7 +921,7 @@ export default {
sidebarLinks: this.sidebarLinks,
};
mutations.showHover({
mutations.showPrompt({
name: 'sidebarLinks',
props: {
context: 'share',
@@ -35,7 +35,7 @@
<script>
import { resourcesApi } from "@/api";
import { state, getters, mutations } from "@/store";
import { state, getters } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import QrcodeVue from "qrcode.vue";
@@ -85,9 +85,6 @@ export default {
getShareLink() {
return state.shareInfo.shareURL;
},
close() {
mutations.closeTopHover();
},
},
};
</script>
@@ -381,12 +381,8 @@ export default {
}
return '/';
},
closePrompt() {
// Close only this prompt (SidebarLinks), returning to the previous one (Share)
mutations.closeTopHover();
},
openIconPicker() {
mutations.showHover({
mutations.showPrompt({
name: "IconPicker",
props: {
onSelect: this.handleIconSelect,
@@ -798,7 +794,7 @@ export default {
}
// Close only this prompt, returning to the previous one (if any)
mutations.closeTopHover();
mutations.closeTopPrompt();
} catch (error) {
notify.showError(this.$t("sidebar.linksUpdateFailed"));
}
+1 -7
View File
@@ -95,19 +95,13 @@ export default {
this.succeeded = true
this.error = "";
notify.showSuccessToast(this.$t("otp.verificationSucceed"));
mutations.closeTopHover();
mutations.closeTopPrompt();
} catch (error) {
this.error = this.$t("otp.verificationFailed");
console.log("error", error);
return;
}
},
closeHovers() {
if (!this.succeeded) {
mutations.updateCurrentUser({ otpEnabled: false });
}
mutations.closeTopHover();
},
},
};
</script>
@@ -55,7 +55,7 @@
<template v-else>
<button
class="button button--flat button--grey"
@click="closeHovers"
@click="closeTopPrompt"
:aria-label="$t('general.cancel')"
:title="$t('general.cancel')"
>
@@ -129,8 +129,8 @@ export default {
},
},
methods: {
closeHovers() {
mutations.closeTopHover();
closeTopPrompt() {
mutations.closeTopPrompt();
},
updateDestination(pathOrData) {
if (typeof pathOrData === "string") {
@@ -154,7 +154,7 @@ export default {
deleteAfter: this.deleteAfter,
});
mutations.setReload(true);
mutations.closeTopHover();
mutations.closeTopPrompt();
const destPath = this.destPath;
const destSource = toSource;
+8 -8
View File
@@ -232,7 +232,7 @@ export default {
const handleConflict = (resolver) => {
conflictResolver = resolver;
mutations.showHover({
mutations.showPrompt({
name: "replace-rename",
pinned: true,
confirm: (event, option) => {
@@ -251,11 +251,11 @@ export default {
if (conflictResolver) {
conflictResolver(overwrite);
}
mutations.closeTopHover(); // Only close the conflict prompt, return to upload prompt
mutations.closeTopPrompt(); // Only close the conflict prompt, return to upload prompt
};
const showRenamePrompt = () => {
mutations.closeTopHover(); // Only close the replace-rename prompt, keep upload prompt open
mutations.closeTopPrompt(); // Only close the replace-rename prompt, keep upload prompt open
// Get the conflicting folder name from the upload queue
const conflictingFolder = uploadManager.getConflictingFolder();
if (!conflictingFolder) {
@@ -263,7 +263,7 @@ export default {
return;
}
mutations.showHover({
mutations.showPrompt({
name: "rename",
confirm: (newName) => {
renameUploadFolder(conflictingFolder, newName);
@@ -288,7 +288,7 @@ export default {
if (conflictResolver) {
conflictResolver({ rename: newName });
}
mutations.closeTopHover(); // Only close the rename prompt, return to upload prompt
mutations.closeTopPrompt(); // Only close the rename prompt, return to upload prompt
} catch (error) {
console.error(error);
}
@@ -356,7 +356,7 @@ export default {
);
const close = () => {
mutations.closeTopHover();
mutations.closeTopPrompt();
};
const clearCompleted = () => {
@@ -522,12 +522,12 @@ export default {
};
const handleConflictAction = (file) => {
mutations.showHover({
mutations.showPrompt({
name: "replace",
pinned: true,
confirm: () => {
uploadManager.retry(file.id, true);
mutations.closeTopHover();
mutations.closeTopPrompt();
},
});
};
+10 -10
View File
@@ -107,7 +107,7 @@
</div>
<div class="card-actions">
<button class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('general.cancel')"
<button class="button button--flat button--grey" @click="closeTopPrompt" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
{{ $t("general.cancel") }}
</button>
@@ -236,8 +236,8 @@ export default {
},
},
methods: {
closeHovers() {
mutations.closeTopHover();
closeTopPrompt() {
mutations.closeTopPrompt();
},
async fetchData() {
mutations.setLoading("users", true);
@@ -324,7 +324,7 @@ export default {
}
},
deletePrompt() {
mutations.showHover({
mutations.showPrompt({
name: "generic",
pinned: true,
props: {
@@ -338,8 +338,8 @@ export default {
await usersApi.remove(this.user.id);
notify.showSuccessToast(this.$t("settings.userDeleted"));
eventBus.emit('usersChanged');
mutations.closeTopHover(); // close delete user prompt
mutations.closeTopHover(); // close user prompt since user doens't exist anymore
mutations.closeTopPrompt(); // close delete user prompt confirmation
mutations.closeTopPrompt(); // close user prompt since user doens't exist anymore
} catch (e) {
console.error(e);
notify.showErrorToast(this.$t("settings.userDeleteFailed"));
@@ -369,8 +369,8 @@ export default {
await usersApi.create({ ...this.user, scopes: scopesToSend });
// Emit event to refresh user list
eventBus.emit('usersChanged');
// Close the modal
mutations.closeTopHover();
// Close the prompt
mutations.closeTopPrompt();
} else {
await usersApi.update({ ...this.user, scopes: scopesToSend }, fields);
// Only emit usersChanged for admin user management, not profile updates
@@ -378,14 +378,14 @@ export default {
eventBus.emit('usersChanged');
}
notify.showSuccessToast(this.$t("settings.userUpdated"));
mutations.closeTopHover();
mutations.closeTopPrompt();
}
} catch (e) {
console.error(e);
}
},
newOTP() {
mutations.showHover({
mutations.showPrompt({
name: "totp",
props: {
generate: true,
+38 -14
View File
@@ -1,24 +1,25 @@
<template>
<div class="card headline-card">
<div v-if="isDataLoaded && shouldShowLogin" class="card-wrapper user-card">
<div v-if="settingsAllowed" class="inner-card">
<div v-if="settingsAllowed" class="inner-card user-card__profile">
<a href="/settings#profile-main" class="person-button action button"
@click.prevent="navigateTo('/settings', '#profile-main')"
@mouseenter="showTooltip($event, $t('index.settingsHover'))" @mouseleave="hideTooltip">
<i class="material-icons">person</i>
{{ user.username }}
<i aria-label="settings" class="material-icons">settings</i>
<i class="material-icons person-button__icon">person</i>
<span class="person-button__name">{{ user.username }}</span>
<i aria-label="settings" class="material-icons person-button__icon">settings</i>
</a>
</div>
<div v-else-if="user.username === 'anonymous' && shouldShowLogin" @click="navigateToLogin" class="inner-card">
<div v-else-if="user.username === 'anonymous' && shouldShowLogin" @click="navigateToLogin"
class="inner-card user-card__profile">
<button class="person-button action button">
<i class="material-symbols-outlined">login</i> {{ $t("general.login") }}
</button>
</div>
<div v-else-if="user.username !== 'anonymous'" class="inner-card">
<button class="person-button action button">
<i class="material-icons">person</i>
{{ user.username }}
<div v-else-if="user.username !== 'anonymous'" class="inner-card user-card__profile">
<button class="person-button action button" type="button">
<i class="material-icons person-button__icon">person</i>
<span class="person-button__name">{{ user.username }}</span>
</button>
</div>
@@ -148,7 +149,7 @@ export default {
methods: {
openContextMenu() {
mutations.resetSelected();
mutations.showHover({
mutations.showPrompt({
name: "ContextMenu",
props: {
showCentered: true,
@@ -205,14 +206,14 @@ export default {
path: state.req.path,
});
this.$router.push({ path: path, hash: hash });
mutations.closeTopHover();
mutations.closeTopPrompt();
},
navigateToLogin() {
this.$router.push({ path: "/login", query: { redirect: this.$route.path } });
},
// Show the help overlay
help() {
mutations.showHover("help");
mutations.showPrompt("help");
},
// Logout the user
@@ -325,13 +326,21 @@ export default {
</script>
<style>
.user-card {
flex-direction: row !important;
justify-content: space-between !important;
align-items: center;
width: 100%;
color: var(--textPrimary);
margin-bottom: 0.5em;
}
.user-card > .user-card__profile {
flex: 1 1 0;
min-width: 0;
}
.quick-toggles {
display: flex;
justify-content: space-evenly;
@@ -412,11 +421,26 @@ button.action {
}
.person-button {
max-width: 13em;
min-width: 0;
width: 100%;
box-sizing: border-box;
display: flex;
padding-right: 1em !important;
justify-content: center;
justify-content: flex-start;
align-items: center;
gap: 0.35em;
}
.person-button__icon {
flex-shrink: 0;
}
.person-button__name {
min-width: 0;
flex: 1 1 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
a.person-button {
+7 -7
View File
@@ -428,7 +428,7 @@ export default {
handleLinkClick(link) {
// Handle special share actions
if (link.category === 'shareInfo') {
mutations.showHover({ name: "ShareInfo" });
mutations.showPrompt({ name: "ShareInfo" });
return;
}
if (link.category === 'download') {
@@ -463,17 +463,17 @@ export default {
} else {
this.$router.push(link.target);
}
mutations.closeTopHover();
mutations.closeTopPrompt();
},
goToDownload() {
// Check if we're in a directory with multiple items
const hasMultipleItems = state.req.items && state.req.items.length > 1;
if (hasMultipleItems) {
// Show format selector for directories with multiple items
mutations.showHover({
mutations.showPrompt({
name: "download",
confirm: (format) => {
mutations.closeTopHover();
mutations.closeTopPrompt();
const downloadLink = resourcesApi.getDownloadURLPublic({
path: "/",
hash: state.shareInfo.hash,
@@ -501,13 +501,13 @@ export default {
path: state.req?.path,
});
this.$router.push({ path: path, hash: hash });
mutations.closeTopHover();
mutations.closeTopPrompt();
},
openSidebarLinksPrompt() {
if (!this.isLoggedIn) {
return;
}
mutations.showHover({
mutations.showPrompt({
name: "SidebarLinks",
});
},
@@ -581,7 +581,7 @@ export default {
hash: shareHash,
};
mutations.showHover({
mutations.showPrompt({
name: "share",
props: {
editing: true,
@@ -45,6 +45,7 @@ export default {
active: (view) => state.activeSettingsView === view,
setView(view) {
mutations.closeHovers();
mutations.closeTopPrompt();
if (state.route.path != "/settings") {
router.push({ path: "/settings", hash: "#" + view }, () => {});
} else {
+1 -1
View File
@@ -165,7 +165,7 @@ export default {
},
// Show the help overlay
help() {
mutations.showHover("help");
mutations.showPrompt("help");
},
setSeenUpdate() {
mutations.setSeenUpdate(globalVars.updateAvailable);
+1 -1
View File
@@ -58,7 +58,7 @@ body.rtl .listing-items {
}
.listing-item p {
margin: 0.2em;
margin: 0;
}
.listing-item .size,
+2
View File
@@ -10,6 +10,8 @@
@import "./dashboard.css";
@import "./mobile.css";
@import "./halloween.css";
/* Loader / skeleton: single source in public/css/loadingSpinners.css (also inlined in index.html for first paint). */
@import "../../public/css/loadingSpinners.css";
.link {
color: var(--blue);
+21 -3
View File
@@ -1,5 +1,6 @@
{
"buttons": {
"copySuccess": "تم النسخ إلى الحافظة!",
"copyFile": "نسخ الملف",
"copyToClipboard": "نسخ الى الحافظة",
"moveFile": "نقل الملف",
@@ -79,7 +80,8 @@
"comma": "الانتقال إلى الإعدادات",
"c": "نسخ العناصر المحددة",
"v": "لصق العناصر المنسوخة/المقطوعة",
"x": "قطع العناصر المختارة"
"x": "قطع العناصر المختارة",
"e": "التبديل بين المحرر وعارض Markdown (في ملفات .md)"
},
"del": "حذف البيانات المحددة",
"esc": "مسح التحديد وإغلاق النافذة المنبثقة",
@@ -88,7 +90,19 @@
"description": "يمكنك عرض خيارات التنقل الأساسية أدناه. للحصول على معلومات إضافية، يرجى زيارة",
"slash": "تفعيل البحث",
"wiki": "مستندات FileBrowser Quantum الرسمية",
"marqueeSelect": "تحديد ملفات أو أدلة متعددة عن طريق السحب على مساحة فارغة"
"marqueeSelect": "تحديد ملفات أو أدلة متعددة عن طريق السحب على مساحة فارغة",
"media": {
"title": "مشغل الوسائط",
"playPause": "تشغيل/إيقاف التشغيل",
"seekPercentage": "التقديم من 10% إلى 90%",
"seek": "التقديم 10 ثوانٍ، للخلف أو للأمام",
"volume": "رفع أو خفض مستوى الصوت",
"mute": "كتم/إلغاء كتم الصوت",
"loop": "التبديل بين وضع التكرار ووضع التشغيل الفردي",
"playbackModes": "التبديل بين أوضاع التشغيل (باستثناء التكرار والفردي)",
"fullscreen": "التبديل إلى وضع ملء الشاشة في مقاطع الفيديو",
"captions": "تشغيل/إيقاف الترجمة"
}
},
"languages": {
"he": "עברית",
@@ -241,7 +255,9 @@
"notEnoughCharacters": "أحرف غير كافية للبحث (الحد الأدنى {minSearchLength})",
"typeToSearch": "ابدأ الكتابة {minSearchLength} أو أكثر لبدء البحث.",
"showPreviewImages": "عرض صور المعاينة",
"showPreviewImagesDescription": "عرض معاينات الصور المصغرة لنتائج البحث عند توفرها، وإظهار أيقونات أكبر لجميع النتائج."
"showPreviewImagesDescription": "عرض معاينات الصور المصغرة لنتائج البحث عند توفرها، وإظهار أيقونات أكبر لجميع النتائج.",
"olderThanDate": "أقدم من:",
"newerThanDate": "أحدث من:"
},
"settings": {
"administrator": "Administrator",
@@ -688,6 +704,8 @@
"downloadChunkSizeMbHelp": "حجم القطع للتحميلات المقطعة. سيتم تنزيل الملفات الأكبر من هذا الحجم على شكل أجزاء لمنع حدوث مشكلات في المهلة (على سبيل المثال، أخطاء Cloudflare 524). يؤدي الإعداد إلى 0 إلى تعطيل التنزيلات المقطعة."
},
"profileSettings": {
"preferEditorForMarkdown": "تفضيل المحرر أولاً لملفات Markdown",
"preferEditorForMarkdownDescription": "عند التمكين، يتم فتح ملفات Markdown في المحرر أولاً بدلاً من عارض Markdown.",
"deleteAfterArchive": "حذف الملفات المصدر بعد إنشاء/استخراج الأرشيفات",
"deleteAfterArchiveDescription": "عند التمكين، سيتم حذف الملفات المصدر بعد إنشاء/استخراج الأرشيفات بنجاح",
"hideFilesInTree": "إخفاء الملفات في شجرة التنقل",
+21 -3
View File
@@ -1,5 +1,6 @@
{
"buttons": {
"copySuccess": "Zkopírováno do schránky!",
"generateNewOtp": "Resetovat a vygenerovat nový kód dvoufázového ověření",
"copyFile": "Kopírovat soubor",
"copyToClipboard": "Kopírovat do schránky",
@@ -82,7 +83,8 @@
"comma": "přejít do nastavení",
"c": "kopírování vybraných položek",
"v": "vložit zkopírované/vyjmuté položky",
"x": "řezání vybraných položek"
"x": "řezání vybraných položek",
"e": "Přepínání mezi editorem a prohlížečem Markdown (u souborů .md)"
},
"del": "smazat označené položky",
"esc": "zrušit označení a/nebo zavřít dialog",
@@ -91,7 +93,19 @@
"description": "Základní možnosti navigace si můžete prohlédnout níže. Další informace naleznete na adrese",
"slash": "aktivovat vyhledávání",
"wiki": "Oficiální dokumenty k aplikaci FileBrowser Quantum",
"marqueeSelect": "vybrat více souborů nebo adresářů přetažením na prázdné místo."
"marqueeSelect": "vybrat více souborů nebo adresářů přetažením na prázdné místo.",
"media": {
"title": "Přehrávač médií",
"playPause": "Spustit/pozastavit přehrávání",
"seekPercentage": "Přesun od 10 % do 90 %",
"seek": "Přesun o 10 s vzad nebo vpřed",
"volume": "Zvýšit nebo snížit hlasitost",
"mute": "Ztlumit/zapnout zvuk",
"loop": "Přepínání mezi smyčkou a jedním přehráním",
"playbackModes": "Přepínání režimů přehrávání (kromě smyčky a jednoho přehrání)",
"fullscreen": "Přepnout režim celé obrazovky u videí",
"captions": "Zapnout/vypnout titulky"
}
},
"languages": {
"he": "עברית",
@@ -319,9 +333,13 @@
"notEnoughCharacters": "Nedostatek znaků pro vyhledávání (min. {minSearchLength})",
"typeToSearch": "Pro zahájení vyhledávání začněte psát {minSearchLength} nebo více znaků.",
"showPreviewImages": "Zobrazit náhledové obrázky",
"showPreviewImagesDescription": "Zobrazení miniaturních náhledů výsledků vyhledávání, pokud jsou k dispozici, a zobrazení větších ikon u všech výsledků."
"showPreviewImagesDescription": "Zobrazení miniaturních náhledů výsledků vyhledávání, pokud jsou k dispozici, a zobrazení větších ikon u všech výsledků.",
"olderThanDate": "Starší než:",
"newerThanDate": "Novější než:"
},
"profileSettings": {
"preferEditorForMarkdown": "Upřednostnit editor pro Markdown soubory",
"preferEditorForMarkdownDescription": "Pokud je povoleno, otevřou se Markdown soubory nejprve v editoru místo v prohlížeči Markdown.",
"deleteAfterArchive": "Smazat zdrojové soubory po vytvoření/extrahování archivů",
"deleteAfterArchiveDescription": "Pokud je povoleno, po úspěšném vytvoření/extrahování archivů dojde ke smazání zdrojových souborů",
"hideFilesInTree": "Skrýt soubory ve stromové navigaci",
+21 -3
View File
@@ -1,5 +1,6 @@
{
"buttons": {
"copySuccess": "In die Zwischenablage kopiert!",
"copyFile": "Datei kopieren",
"copyToClipboard": "In Zwischenablage kopieren",
"moveFile": "Datei verschieben",
@@ -79,7 +80,8 @@
"comma": "zu den Einstellungen gehen",
"c": "ausgewählte Elemente kopieren",
"v": "Kopierte/ausgeschnittene Elemente einfügen",
"x": "ausgewählte Artikel ausschneiden"
"x": "ausgewählte Artikel ausschneiden",
"e": "Zwischen Editor und Markdown-Betrachter umschalten (bei .md-Dateien)"
},
"del": "Löscht die ausgewählten Elemente",
"esc": "Auswahl zurücksetzen und/oder Dialog schließen",
@@ -88,7 +90,19 @@
"description": "Nachstehend finden Sie die grundlegenden Navigationsoptionen. Für zusätzliche Informationen besuchen Sie bitte das",
"slash": "Suche aktivieren",
"wiki": "FileBrowser Quantum-Wiki",
"marqueeSelect": "mehrere Dateien oder Verzeichnisse durch Ziehen auf leeren Raum auswählen"
"marqueeSelect": "mehrere Dateien oder Verzeichnisse durch Ziehen auf leeren Raum auswählen",
"media": {
"title": "Media Player",
"playPause": "Wiedergabe starten/pausieren",
"seekPercentage": "Springe von 10 % bis 90 %",
"seek": "10 Sekunden vor- oder zurückspringen",
"volume": "Lautstärke erhöhen oder verringern",
"mute": "Ton stummschalten/aktivieren",
"loop": "Zwischen Schleife und Einzelmodus wechseln",
"playbackModes": "Zwischen Wiedergabemodi wechseln (außer Schleife und Einzelmodus)",
"fullscreen": "Vollbildmodus bei Videos umschalten",
"captions": "Untertitel ein-/ausschalten"
}
},
"languages": {
"he": "עברית",
@@ -241,7 +255,9 @@
"notEnoughCharacters": "Nicht genügend Zeichen für die Suche (min {minSearchLength})",
"typeToSearch": "Beginnen Sie mit der Eingabe von {minSearchLength} oder mehr Zeichen, um die Suche zu starten.",
"showPreviewImages": "Vorschaubilder anzeigen",
"showPreviewImagesDescription": "Zeigen Sie eine Miniaturvorschau für Suchergebnisse an, wenn verfügbar, und zeigen Sie größere Symbole für alle Ergebnisse an."
"showPreviewImagesDescription": "Zeigen Sie eine Miniaturvorschau für Suchergebnisse an, wenn verfügbar, und zeigen Sie größere Symbole für alle Ergebnisse an.",
"olderThanDate": "Älter als:",
"newerThanDate": "Neuere als:"
},
"settings": {
"administrator": "Administrator",
@@ -696,6 +712,8 @@
"orange": "orange"
},
"profileSettings": {
"preferEditorForMarkdown": "Editor für Markdown-Dateien bevorzugen",
"preferEditorForMarkdownDescription": "Wenn aktiviert, werden Markdown-Dateien zuerst im Editor geöffnet, anstatt im Markdown-Betrachter.",
"deleteAfterArchive": "Quelldateien nach Erstellung/Extraktion von Archiven löschen",
"deleteAfterArchiveDescription": "Wenn aktiviert, werden die Quelldateien nach erfolgreicher Erstellung/Extraktion von Archiven gelöscht",
"hideFilesInTree": "Dateien in der Baumnavigation ausblenden",
+21 -3
View File
@@ -1,5 +1,6 @@
{
"buttons": {
"copySuccess": "Αντιγράφηκε στο πρόχειρο!",
"copyFile": "Αντιγραφή αρχείου",
"copyToClipboard": "Αντιγραφή στο πρόχειρο",
"copyDownloadLinkToClipboard": "Αντιγραφή συνδέσμου λήψης στο πρόχειρο",
@@ -82,7 +83,8 @@
"comma": "μεταβείτε στις ρυθμίσεις",
"c": "αντιγραφή επιλεγμένων στοιχείων",
"v": "επικόλληση αντιγραμμένων/κομμένων στοιχείων",
"x": "κόψτε επιλεγμένα αντικείμενα"
"x": "κόψτε επιλεγμένα αντικείμενα",
"e": "Εναλλαγή μεταξύ επεξεργαστή και προβολέα Markdown (σε αρχεία .md)"
},
"del": "διαγραφή επιλεγμένων στοιχείων",
"esc": "καθαρίζει την επιλογή ή/και κλείνει το παράθυρο",
@@ -91,7 +93,19 @@
"description": "Μπορείτε να δείτε τις βασικές επιλογές πλοήγησης παρακάτω. Για πρόσθετες πληροφορίες, επισκεφθείτε τη διεύθυνση",
"slash": "ενεργοποιήστε την αναζήτηση",
"wiki": "Επίσημα έγγραφα του FileBrowser Quantum",
"marqueeSelect": "επιλογή πολλαπλών αρχείων ή καταλόγων με σύρσιμο σε κενό χώρο"
"marqueeSelect": "επιλογή πολλαπλών αρχείων ή καταλόγων με σύρσιμο σε κενό χώρο",
"media": {
"title": "Media Player",
"playPause": "Έναρξη/Παύση αναπαραγωγής",
"seekPercentage": "Μετάβαση από 10% έως 90%",
"seek": "Μετάβαση 10 δευτ., πίσω ή μπροστά",
"volume": "Αύξηση ή μείωση της έντασης",
"mute": "Σίγαση/Επαναφορά ήχου",
"loop": "Εναλλαγή μεταξύ λειτουργίας επανάληψης και μονής αναπαραγωγής",
"playbackModes": "Εναλλαγή λειτουργιών αναπαραγωγής (εκτός από επανάληψη και μονή)",
"fullscreen": "Εναλλαγή λειτουργίας πλήρους οθόνης σε βίντεο",
"captions": "Εναλλαγή υποτίτλων ενεργό/ανενεργό"
}
},
"languages": {
"he": "עברית",
@@ -244,7 +258,9 @@
"notEnoughCharacters": "Δεν υπάρχουν αρκετοί χαρακτήρες για αναζήτηση (min {minSearchLength})",
"typeToSearch": "Αρχίστε να πληκτρολογείτε {minSearchLength} ή περισσότερους χαρακτήρες για να ξεκινήσετε την αναζήτηση.",
"showPreviewImages": "Εμφάνιση εικόνων προεπισκόπησης",
"showPreviewImagesDescription": "Εμφάνιση προεπισκοπήσεων μικρογραφιών για τα αποτελέσματα αναζήτησης, όταν είναι διαθέσιμα, και εμφάνιση μεγαλύτερων εικονιδίων για όλα τα αποτελέσματα."
"showPreviewImagesDescription": "Εμφάνιση προεπισκοπήσεων μικρογραφιών για τα αποτελέσματα αναζήτησης, όταν είναι διαθέσιμα, και εμφάνιση μεγαλύτερων εικονιδίων για όλα τα αποτελέσματα.",
"olderThanDate": "Μεγαλύτερη από:",
"newerThanDate": "Νεότερο από:"
},
"settings": {
"administrator": "Διαχειριστής",
@@ -696,6 +712,8 @@
"orange": "πορτοκαλί"
},
"profileSettings": {
"preferEditorForMarkdown": "Προτίμηση επεξεργαστή πρώτα για αρχεία Markdown",
"preferEditorForMarkdownDescription": "Όταν ενεργοποιηθεί, τα αρχεία Markdown ανοίγουν πρώτα στον επεξεργαστή αντί στον προβολέα Markdown.",
"deleteAfterArchive": "Διαγραφή αρχείων προέλευσης μετά τη δημιουργία/εξαγωγή αρχείων",
"deleteAfterArchiveDescription": "Όταν ενεργοποιηθεί, θα διαγραφούν τα αρχεία προέλευσης μετά την επιτυχή δημιουργία/εξαγωγή αρχείων",
"hideFilesInTree": "Απόκρυψη αρχείων στην πλοήγηση δέντρου",
+20 -2
View File
@@ -131,6 +131,7 @@
"latency": "Latency{suffix}"
},
"buttons": {
"copySuccess": "Copied to clipboard!",
"generateNewOtp": "Reset and generate new two-factor code",
"copyFile": "Copy file",
"copyToClipboard": "Copy to clipboard",
@@ -214,7 +215,8 @@
"x": "cut selected items",
"d": "download selected items",
"b": "toggle sidebar",
"comma": "go to settings"
"comma": "go to settings",
"e": "Toggle between editor and markdown viewer (on .md files)"
},
"del": "delete selected items",
"esc": "clear selection and/or close the prompt",
@@ -222,7 +224,19 @@
"f1": "show help prompt",
"slash": "activate search",
"description": "You can view the basic naviagation options below. For additional information, please visit",
"wiki": "FileBrowser Quantum Official Docs"
"wiki": "FileBrowser Quantum Official Docs",
"media": {
"title": "Media Player",
"playPause": "Play/Pause playback",
"seekPercentage": "Seek from 10% to 90%",
"seek": "Seek 10s, back or forward",
"volume": "Turn the volume up or down",
"mute": "Mute/Unmute the audio playing",
"loop": "Toggle between loop and single mode",
"playbackModes": "Toggle between playback modes (excluding loop and single)",
"fullscreen": "Toggle fullscreen mode in videos",
"captions": "Toggle captions on/off"
}
},
"languages": {
"he": "עברית",
@@ -447,12 +461,16 @@
"noResults": "No results found in indexed search.",
"smallerThan": "Smaller than:",
"largerThan": "Larger than:",
"olderThanDate": "Older than:",
"newerThanDate": "Newer than:",
"showPreviewImages": "Show preview images",
"showPreviewImagesDescription": "Display thumbnail previews for search results when available, and show larger icons for all results.",
"helpText1": "Search occurs on each character you type (3 character minimum for search terms).",
"helpText2": "The index: Search utilizes the index which automatically gets updated on the configured interval (default: 5 minutes). Searching when the program has just started may result in incomplete results."
},
"profileSettings": {
"preferEditorForMarkdown": "Prefer editor first for Markdown files",
"preferEditorForMarkdownDescription": "When enabled, open markdown files in the editor first instead of the Markdown Viewer.",
"deleteAfterArchive": "Delete source files after creation/extraction of archives",
"deleteAfterArchiveDescription": "When enabled, will delete the source files after successful creation/extraction of archives",
"hideFilesInTree": "Hide files in tree navigation",
+21 -3
View File
@@ -1,5 +1,6 @@
{
"buttons": {
"copySuccess": "¡Copiado al portapapeles!",
"copyFile": "Copiar archivo",
"copyToClipboard": "Copiar al portapapeles",
"moveFile": "Mover archivo",
@@ -79,7 +80,8 @@
"comma": "Ir a configuración",
"c": "Copiar elementos seleccionados",
"v": "Pegar elementos copiados o cortados",
"x": "Cortar elementos seleccionados"
"x": "Cortar elementos seleccionados",
"e": "Cambiar entre editor y Visor Markdown (en archivos .md)"
},
"del": "Eliminar elementos seleccionados",
"esc": "Cancela la selección o cierra la ventana actual",
@@ -88,7 +90,19 @@
"description": "Aquí puedes ver todos los atajos de teclado básicos. Para saber más, visita la",
"slash": "Abrir panel de búsqueda",
"wiki": "documentación oficial de FileBrowser Quantum",
"marqueeSelect": "Selecciona archivos o carpetas con el mouse arrastrando en un espacio vacio"
"marqueeSelect": "Selecciona archivos o carpetas con el mouse arrastrando en un espacio vacio",
"media": {
"title": "Reproductor multimedia",
"playPause": "Reproducir/Pausar",
"seekPercentage": "Saltar del 10% al 90%",
"seek": "Saltar 10s, atrás o adelante",
"volume": "Subir o bajar el volumen",
"mute": "Silenciar/Activar el sonido",
"loop": "Activar/Desactivar reproducción en bucle",
"playbackModes": "Cambiar entre modos de reproducción (excepto bucle)",
"fullscreen": "Cambiar a pantalla completa en vídeos",
"captions": "Activar/Desactivar subtítulos"
}
},
"languages": {
"he": "עברית",
@@ -241,7 +255,9 @@
"notEnoughCharacters": "Escribe al menos {minSearchLength} letras para empezar a buscar",
"typeToSearch": "Escribe {minSearchLength} letras o más para iniciar la búsqueda",
"showPreviewImages": "Mostrar imágenes de previsualización",
"showPreviewImagesDescription": "Mostrar vistas previas en miniatura de los resultados de búsqueda cuando estén disponibles, y mostrar iconos más grandes para todos los resultados."
"showPreviewImagesDescription": "Mostrar vistas previas en miniatura de los resultados de búsqueda cuando estén disponibles, y mostrar iconos más grandes para todos los resultados.",
"olderThanDate": "Mayor que:",
"newerThanDate": "Más nuevo que:"
},
"settings": {
"scopes": "Fuentes y carpetas que el usuario tendrá acceso",
@@ -696,6 +712,8 @@
"orange": "Naranja"
},
"profileSettings": {
"preferEditorForMarkdown": "Preferir editor primero para archivos Markdown",
"preferEditorForMarkdownDescription": "Cuando está activado, los archivos markdown abrirán el editor en lugar del visor de Markdown",
"deleteAfterArchive": "Eliminar archivos originales después de la creación/extracción de archivos",
"deleteAfterArchiveDescription": "Cuando está activado, eliminará los archivos originales después de la creación o extracción de archivos",
"hideFilesInTree": "Ocultar archivos en la navegación de árbol",
+21 -3
View File
@@ -1,5 +1,6 @@
{
"buttons": {
"copySuccess": "Copié dans le presse-papiers !",
"copyFile": "Copier le fichier",
"copyToClipboard": "Copier dans le presse-papier",
"moveFile": "Déplacer le fichier",
@@ -79,7 +80,8 @@
"comma": "accéder aux paramètres",
"c": "copier les éléments sélectionnés",
"v": "coller les éléments copiés/coupés",
"x": "couper les articles sélectionnés"
"x": "couper les articles sélectionnés",
"e": "Basculer entre l'éditeur et le visualiseur Markdown (pour les fichiers .md)"
},
"del": "supprimer les éléments sélectionnés",
"esc": "désélectionner et/ou fermer la boîte de dialogue",
@@ -88,7 +90,19 @@
"description": "Vous pouvez consulter les options de navigation de base ci-dessous. Pour plus d'informations, veuillez consulter le site",
"slash": "activer la recherche",
"wiki": "Documents officiels de FileBrowser Quantum",
"marqueeSelect": "sélectionner plusieurs fichiers ou répertoires en les faisant glisser sur l'espace vide"
"marqueeSelect": "sélectionner plusieurs fichiers ou répertoires en les faisant glisser sur l'espace vide",
"media": {
"title": "Lecteur multimédia",
"playPause": "Lire/Mettre en pause",
"seekPercentage": "Avancer de 10 % à 90 %",
"seek": "Avancer/Reculer de 10 s",
"volume": "Augmenter ou diminuer le volume",
"mute": "Activer/Désactiver le son",
"loop": "Basculer entre mode répétition et mode unique",
"playbackModes": "Basculer entre les modes de lecture (hors répétition et unique)",
"fullscreen": "Basculer en plein écran pour les vidéos",
"captions": "Activer/Désactiver les sous-titres"
}
},
"languages": {
"he": "עברית",
@@ -241,7 +255,9 @@
"notEnoughCharacters": "Pas assez de caractères pour effectuer une recherche (min {minSearchLength})",
"typeToSearch": "Commencez à taper {minSearchLength} ou plus de caractères pour lancer la recherche.",
"showPreviewImages": "Afficher les images de prévisualisation",
"showPreviewImagesDescription": "Afficher les vignettes des résultats de la recherche lorsqu'elles sont disponibles, et afficher des icônes plus grandes pour tous les résultats."
"showPreviewImagesDescription": "Afficher les vignettes des résultats de la recherche lorsqu'elles sont disponibles, et afficher des icônes plus grandes pour tous les résultats.",
"olderThanDate": "Plus vieux que :",
"newerThanDate": "Plus récent que :"
},
"settings": {
"scopes": "Définir les sources autorisées et la portée de l'utilisateur au sein de celles-ci",
@@ -696,6 +712,8 @@
"orange": "orange"
},
"profileSettings": {
"preferEditorForMarkdown": "Privilégier d'abord l'éditeur pour les fichiers Markdown",
"preferEditorForMarkdownDescription": "Lorsque activé, ouvre les fichiers Markdown dans l'éditeur plutôt que dans le visualiseur Markdown.",
"deleteAfterArchive": "Supprimer les fichiers source après la création/extraction d'archives",
"deleteAfterArchiveDescription": "Lorsque activé, supprimera les fichiers source après la création/extraction réussie d'archives",
"hideFilesInTree": "Masquer les fichiers dans l'arborescence",
+21 -3
View File
@@ -1,5 +1,6 @@
{
"buttons": {
"copySuccess": "הועתק ללוח!",
"copyFile": "העתק קובץ",
"copyToClipboard": "העתק ללוח",
"moveFile": "העבר קובץ",
@@ -79,7 +80,8 @@
"comma": "עבור להגדרות",
"c": "העתק פריטים נבחרים",
"v": "הדבק פריטים שהועתקו/נחתכו",
"x": "חתוך פריטים נבחרים"
"x": "חתוך פריטים נבחרים",
"e": "החלף בין עורך לצופה Markdown (בקבצי .md)"
},
"del": "מחק את מה שנבחר",
"esc": "נקה את הבחירה ו/או סגור את השדה",
@@ -88,7 +90,19 @@
"description": "ניתן לצפות באפשרויות הניווט הבסיסיות להלן. למידע נוסף, בקרו בכתובת",
"slash": "הפעל חיפוש",
"wiki": "מסמכים רשמיים של FileBrowser Quantum",
"marqueeSelect": "בחר מספר קבצים או ספריות על ידי גרירה על שטח ריק"
"marqueeSelect": "בחר מספר קבצים או ספריות על ידי גרירה על שטח ריק",
"media": {
"title": "נגן מדיה",
"playPause": "הפעל/השהה נגינה",
"seekPercentage": "דילוג מ-10% ל-90%",
"seek": "דילוג 10 שניות, אחורה או קדימה",
"volume": "הגבר או הנמך עוצמת קול",
"mute": "השתק/בטל השתקת שמע",
"loop": "החלף בין מצב לולאה למצב יחיד",
"playbackModes": "החלף בין מצבי נגינה (למעט לולאה ויחיד)",
"fullscreen": "החלף מצב מסך מלא בסרטונים",
"captions": "הפעל/כבה כתוביות"
}
},
"languages": {
"he": "עברית",
@@ -241,7 +255,9 @@
"notEnoughCharacters": "אין מספיק תווים לחיפוש (מינימום {minSearchLength})",
"typeToSearch": "התחל להקליד {minSearchLength} או יותר תווים כדי להתחיל בחיפוש.",
"showPreviewImages": "הצג תמונות מקדימות",
"showPreviewImagesDescription": "הצג תצוגה מקדימה של תמונות ממוזערות עבור תוצאות החיפוש, כאשר הן זמינות, והצג סמלים גדולים יותר עבור כל התוצאות."
"showPreviewImagesDescription": "הצג תצוגה מקדימה של תמונות ממוזערות עבור תוצאות החיפוש, כאשר הן זמינות, והצג סמלים גדולים יותר עבור כל התוצאות.",
"olderThanDate": "גדול מ:",
"newerThanDate": "מאוחר יותר מ:"
},
"settings": {
"administrator": "מנהל ראשי",
@@ -696,6 +712,8 @@
"orange": "כתום"
},
"profileSettings": {
"preferEditorForMarkdown": "העדף עורך תחילה עבור קבצי Markdown",
"preferEditorForMarkdownDescription": "כאשר מופעל, פתח קבצי Markdown בעורך תחילה במקום בצופה Markdown.",
"deleteAfterArchive": "מחק קבצי מקור לאחר יצירה/חילוץ של ארכיונים",
"deleteAfterArchiveDescription": "כאשר מופעל, ימחק את קבצי המקור לאחר יצירה/חילוץ מוצלחים של ארכיונים",
"hideFilesInTree": "הסתר קבצים בניווט עץ",
+21 -3
View File
@@ -1,5 +1,6 @@
{
"buttons": {
"copySuccess": "Vágólapra másolva!",
"copyFile": "Fájl másolása",
"copyToClipboard": "Másolás vágólapra",
"moveFile": "Fájl mozgatása",
@@ -79,7 +80,8 @@
"comma": "menj a beállításokhoz",
"c": "kiválasztott elemek másolása",
"v": "másolt/kivágott elemek beillesztése",
"x": "kiválasztott tételek kivágása"
"x": "kiválasztott tételek kivágása",
"e": "Váltás szerkesztő és Markdown néző között (.md fájloknál)"
},
"del": "kijelölt elemek törlése",
"esc": "kijelölés törlése és/vagy parancssor bezárása",
@@ -88,7 +90,19 @@
"description": "Az alábbiakban megtekintheti az alapvető navigációs lehetőségeket. További információkért kérjük, látogasson el a következő weboldalra",
"slash": "keresés aktiválása",
"wiki": "FileBrowser Quantum hivatalos dokumentumok",
"marqueeSelect": "több fájl vagy könyvtár kiválasztása üres helyre történő húzással"
"marqueeSelect": "több fájl vagy könyvtár kiválasztása üres helyre történő húzással",
"media": {
"title": "Médialejátszó",
"playPause": "Lejátszás/Szünet",
"seekPercentage": "Ugrás 10%-tól 90%-ig",
"seek": "Ugrás 10 másodpercet, hátra vagy előre",
"volume": "Hangerő növelése vagy csökkentése",
"mute": "Némítás/Behangzás",
"loop": "Váltás ismétlés és egyszeri mód között",
"playbackModes": "Váltás lejátszási módok között (az ismétlés és egyszeri kivételével)",
"fullscreen": "Teljes képernyős mód váltása videóknál",
"captions": "Feliratok be/ki"
}
},
"languages": {
"he": "עברית",
@@ -241,7 +255,9 @@
"notEnoughCharacters": "Nincs elég karakter a kereséshez (min {minSearchLength})",
"typeToSearch": "A keresés megkezdéséhez írja be a {minSearchLength} vagy több karaktert.",
"showPreviewImages": "Előnézeti képek megjelenítése",
"showPreviewImagesDescription": "A keresési találatok miniatűr előnézeteinek megjelenítése, ha rendelkezésre állnak, és nagyobb ikonok megjelenítése az összes találathoz."
"showPreviewImagesDescription": "A keresési találatok miniatűr előnézeteinek megjelenítése, ha rendelkezésre állnak, és nagyobb ikonok megjelenítése az összes találathoz.",
"olderThanDate": "Idősebb, mint:",
"newerThanDate": "Újabb, mint:"
},
"settings": {
"administrator": "Adminisztrátor",
@@ -696,6 +712,8 @@
"orange": "narancs"
},
"profileSettings": {
"preferEditorForMarkdown": "Szerkesztő előnyben részesítése Markdown fájlokhoz",
"preferEditorForMarkdownDescription": "Ha engedélyezve van, a Markdown fájlok először a szerkesztőben nyílnak meg a Markdown néző helyett.",
"deleteAfterArchive": "Forrásfájlok törlése archívumok létrehozása/kibontása után",
"deleteAfterArchiveDescription": "Ha engedélyezve van, törli a forrásfájlokat az archívumok sikeres létrehozása/kibontása után",
"hideFilesInTree": "Fájlok elrejtése a fa navigációban",
+18 -2
View File
@@ -1,5 +1,6 @@
{
"buttons": {
"copySuccess": "Afritað á klippiborð!",
"generateNewOtp": "Endurstilla og búa til nýjan tveggja-þrepa kóða",
"cancel": "Hætta við",
"verify": "Staðfesta",
@@ -101,7 +102,8 @@
"click": "velja skrá eða möppu",
"ctrl": {
"click": "velja margar skrár eða möppur",
"d": "sækja valin atriði"
"d": "sækja valin atriði",
"e": "Víxla á milli ritils og Markdown skoðara (í .md skrám)"
},
"del": "eyða völdum atriðum",
"esc": "hreinsa val og/eða loka sprettiglugga",
@@ -109,7 +111,19 @@
"f1": "sýna aðstoðarsprettiglugga",
"help": "Hjálp",
"description": "Þú getur skoðað grunnstýringarvalkostina hér fyrir neðan. Fyrir frekari upplýsingar, farðu á",
"wiki": "FileBrowser Quantum Wiki"
"wiki": "FileBrowser Quantum Wiki",
"media": {
"title": "Miðlunaspilari",
"playPause": "Spila/Gera hlé",
"seekPercentage": "Leita frá 10% til 90%",
"seek": "Leita 10 sek., aftur eða áfram",
"volume": "Hækka eða lækka hljóðstyrk",
"mute": "Þagga/Virka hljóð",
"loop": "Víxla á milli endurtekningar og einstaklingshams",
"playbackModes": "Víxla á milli spilunarhama (fyrir utan endurtekningu og einstaklingsham)",
"fullscreen": "Víxla í skjáfylliham í myndskeiðum",
"captions": "Víxla texta á/af"
}
},
"languages": {
"he": "עברית",
@@ -296,6 +310,8 @@
"helpText2": "Vísirinn: Leit nýtir vísinn sem uppfærast sjálfkrafa á stilluðu millibili (sjálfgefið: 5 mínútur). Leit þegar forritið er nýrætt getur leitt til ófullnægjandi niðurstaðna."
},
"profileSettings": {
"preferEditorForMarkdown": "Kjósa ritil fyrst fyrir Markdown skrár",
"preferEditorForMarkdownDescription": "Þegar virkt, opna Markdown skrár fyrst í ritlinum í stað Markdown skoðara.",
"deleteAfterArchive": "Eyða upprunaskrám eftir stofnun/útdrátt safnskráa",
"deleteAfterArchiveDescription": "Þegar virkt, mun eyða upprunaskrám eftir árangursríka stofnun/útdrátt safnskráa",
"hideFilesInTree": "Fela skrár í trjáflakki",
+21 -3
View File
@@ -1,5 +1,6 @@
{
"buttons": {
"copySuccess": "Copiato negli appunti!",
"copyFile": "Copia file",
"copyToClipboard": "Copia negli appunti",
"moveFile": "Sposta file",
@@ -79,7 +80,8 @@
"comma": "vai alle impostazioni",
"c": "copiare gli elementi selezionati",
"v": "incolla gli elementi copiati/tagliati",
"x": "tagliare gli articoli selezionati"
"x": "tagliare gli articoli selezionati",
"e": "Alterna tra editor e visualizzatore Markdown (nei file .md)"
},
"del": "elimina gli elementi selezionati",
"esc": "annulla la selezione e/o chiude la finestra aperta",
@@ -88,7 +90,19 @@
"description": "Le opzioni di navigazione di base sono riportate di seguito. Per ulteriori informazioni, visitate il sito",
"slash": "attivare la ricerca",
"wiki": "Documenti ufficiali di FileBrowser Quantum",
"marqueeSelect": "selezionare più file o directory trascinando sullo spazio vuoto"
"marqueeSelect": "selezionare più file o directory trascinando sullo spazio vuoto",
"media": {
"title": "Lettore multimediale",
"playPause": "Avvia/Metti in pausa",
"seekPercentage": "Passa dal 10% al 90%",
"seek": "Salta 10 secondi, indietro o avanti",
"volume": "Alza o abbassa il volume",
"mute": "Attiva/Disattiva l'audio",
"loop": "Alterna tra modalità loop e singola",
"playbackModes": "Alterna tra modalità di riproduzione (escluse loop e singola)",
"fullscreen": "Attiva/disattiva schermo intero nei video",
"captions": "Attiva/disattiva sottotitoli"
}
},
"languages": {
"he": "עברית",
@@ -241,7 +255,9 @@
"notEnoughCharacters": "Non ci sono abbastanza caratteri per la ricerca (min {minSearchLength})",
"typeToSearch": "Iniziare a digitare {minSearchLength} o più caratteri per avviare la ricerca.",
"showPreviewImages": "Mostra le immagini di anteprima",
"showPreviewImagesDescription": "Visualizzare le anteprime dei risultati della ricerca, se disponibili, e mostrare icone più grandi per tutti i risultati."
"showPreviewImagesDescription": "Visualizzare le anteprime dei risultati della ricerca, se disponibili, e mostrare icone più grandi per tutti i risultati.",
"olderThanDate": "Più vecchio di:",
"newerThanDate": "Più recente di:"
},
"settings": {
"administrator": "Amministratore",
@@ -696,6 +712,8 @@
"orange": "arancione"
},
"profileSettings": {
"preferEditorForMarkdown": "Preferisci prima l'editor per i file Markdown",
"preferEditorForMarkdownDescription": "Quando abilitato, apre i file Markdown nell'editor anziché nel visualizzatore Markdown.",
"deleteAfterArchive": "Elimina i file sorgente dopo la creazione/estrazione di archivi",
"deleteAfterArchiveDescription": "Quando abilitato, eliminerà i file sorgente dopo la creazione/estrazione riuscita di archivi",
"hideFilesInTree": "Nascondi file nella navigazione ad albero",
+21 -3
View File
@@ -1,5 +1,6 @@
{
"buttons": {
"copySuccess": "クリップボードにコピーしました!",
"copyFile": "ファイルをコピー",
"copyToClipboard": "クリップボードにコピー",
"moveFile": "ファイルを移動",
@@ -79,7 +80,8 @@
"comma": "設定に進む",
"c": "選択した項目をコピーする",
"v": "コピー/カットした項目を貼り付ける",
"x": "選択された項目をカット"
"x": "選択された項目をカット",
"e": "エディタとMarkdownビューアを切り替え(.mdファイル)"
},
"del": "選択した項目を削除",
"esc": "選択をクリアーまたはプロンプトを閉じる",
@@ -88,7 +90,19 @@
"description": "基本的なナビゲーションオプションは以下の通りです。その他の情報については",
"slash": "検索をアクティブにする",
"wiki": "FileBrowser Quantum 公式ドキュメント",
"marqueeSelect": "空いているスペースにドラッグして、複数のファイルやディレクトリを選択する。"
"marqueeSelect": "空いているスペースにドラッグして、複数のファイルやディレクトリを選択する。",
"media": {
"title": "メディアプレーヤー",
"playPause": "再生/一時停止",
"seekPercentage": "10%から90%までシーク",
"seek": "10秒戻る/進む",
"volume": "音量を上げる/下げる",
"mute": "ミュート/ミュート解除",
"loop": "ループモードとシングルモードを切り替え",
"playbackModes": "再生モードを切り替え(ループとシングルを除く)",
"fullscreen": "動画の全画面モードを切り替え",
"captions": "字幕のオン/オフを切り替え"
}
},
"languages": {
"he": "עברית",
@@ -241,7 +255,9 @@
"notEnoughCharacters": "検索する文字数が不足しています(最小:{minSearchLength}文字)",
"typeToSearch": "検索を開始するには、{minSearchLength}文字以上を入力してください。",
"showPreviewImages": "プレビュー画像を表示する",
"showPreviewImagesDescription": "検索結果のサムネイルプレビューを表示し、すべての検索結果に対して大きなアイコンを表示します。"
"showPreviewImagesDescription": "検索結果のサムネイルプレビューを表示し、すべての検索結果に対して大きなアイコンを表示します。",
"olderThanDate": "より古い:",
"newerThanDate": "より新しい:"
},
"settings": {
"administrator": "管理者",
@@ -696,6 +712,8 @@
"orange": "オレンジ"
},
"profileSettings": {
"preferEditorForMarkdown": "Markdownファイルではエディタを優先する",
"preferEditorForMarkdownDescription": "有効にすると、MarkdownファイルをMarkdownビューアではなくエディタで開きます。",
"deleteAfterArchive": "アーカイブの作成/展開後にソースファイルを削除",
"deleteAfterArchiveDescription": "有効にすると、アーカイブの作成/展開が成功した後にソースファイルを削除します",
"hideFilesInTree": "ツリーナビゲーションでファイルを非表示",
+21 -3
View File
@@ -1,5 +1,6 @@
{
"buttons": {
"copySuccess": "클립보드에 복사되었습니다!",
"copyFile": "파일 복사",
"copyToClipboard": "클립보드 복사",
"moveFile": "파일 이동",
@@ -79,7 +80,8 @@
"comma": "설정으로 이동",
"c": "선택한 항목 복사",
"v": "복사/잘라낸 항목 붙여넣기",
"x": "선택한 항목 잘라내기"
"x": "선택한 항목 잘라내기",
"e": "편집기와 Markdown 뷰어 간 전환 (.md 파일)"
},
"del": "선택된 파일 삭제",
"esc": "선택 취소/프롬프트 닫기",
@@ -88,7 +90,19 @@
"description": "아래에서 기본 탐색 옵션을 확인할 수 있습니다. 자세한 내용은 다음을 참조하세요.",
"slash": "검색 활성화",
"wiki": "FileBrowser 퀀텀 공식 문서",
"marqueeSelect": "빈 공간에 드래그하여 여러 파일 또는 디렉토리를 선택합니다."
"marqueeSelect": "빈 공간에 드래그하여 여러 파일 또는 디렉토리를 선택합니다.",
"media": {
"title": "미디어 플레이어",
"playPause": "재생/일시정지",
"seekPercentage": "10%에서 90%까지 탐색",
"seek": "10초 뒤로/앞으로 탐색",
"volume": "볼륨 높이기/낮추기",
"mute": "음소거 켜기/끄기",
"loop": "반복 모드와 단일 모드 전환",
"playbackModes": "재생 모드 전환 (반복 및 단일 제외)",
"fullscreen": "비디오 전체 화면 모드 전환",
"captions": "자막 켜기/끄기"
}
},
"languages": {
"he": "עברית",
@@ -241,7 +255,9 @@
"notEnoughCharacters": "검색할 문자가 부족합니다(최소 {minSearchLength})",
"typeToSearch": "검색을 시작하려면 {minSearchLength} 을 입력하세요.",
"showPreviewImages": "미리 보기 이미지 표시",
"showPreviewImagesDescription": "가능한 경우 검색 결과의 썸네일 미리 보기를 표시하고 모든 결과에 대해 더 큰 아이콘을 표시합니다."
"showPreviewImagesDescription": "가능한 경우 검색 결과의 썸네일 미리 보기를 표시하고 모든 결과에 대해 더 큰 아이콘을 표시합니다.",
"olderThanDate": "이전보다 오래되었습니다:",
"newerThanDate": "보다 최신:"
},
"settings": {
"administrator": "관리자",
@@ -696,6 +712,8 @@
"orange": "오렌지"
},
"profileSettings": {
"preferEditorForMarkdown": "Markdown 파일에 대해 편집기 우선 사용",
"preferEditorForMarkdownDescription": "활성화하면 Markdown 파일을 Markdown 뷰어 대신 편집기에서 먼저 엽니다.",
"deleteAfterArchive": "아카이브 생성/추출 후 소스 파일 삭제",
"deleteAfterArchiveDescription": "활성화되면 아카이브 생성/추출 성공 후 소스 파일을 삭제합니다",
"hideFilesInTree": "트리 탐색에서 파일 숨기기",
+21 -3
View File
@@ -1,5 +1,6 @@
{
"buttons": {
"copySuccess": "Gekopieerd naar klembord!",
"copyFile": "Bestand kopiëren",
"copyToClipboard": "Kopiëren naar klembord",
"moveFile": "Verplaats bestand",
@@ -79,7 +80,8 @@
"comma": "ga naar instellingen",
"c": "geselecteerde items kopiëren",
"v": "gekopieerde/afgesneden items plakken",
"x": "geselecteerde items knippen"
"x": "geselecteerde items knippen",
"e": "Schakel tussen editor en Markdown-viewer (bij .md-bestanden)"
},
"del": "verwijder geselecteerde items",
"esc": "Wis selectie en/of sluit de prompt",
@@ -88,7 +90,19 @@
"description": "Je kunt de basisnavigatieopties hieronder bekijken. Ga voor aanvullende informatie naar",
"slash": "Zoeken activeren",
"wiki": "FileBrowser Quantum Officiële documenten",
"marqueeSelect": "meerdere bestanden of mappen selecteren door ze over lege ruimte te slepen"
"marqueeSelect": "meerdere bestanden of mappen selecteren door ze over lege ruimte te slepen",
"media": {
"title": "Mediaspeler",
"playPause": "Afspelen/Pauzeren",
"seekPercentage": "Zoeken van 10% tot 90%",
"seek": "10 seconden vooruit of achteruit",
"volume": "Volume omhoog of omlaag",
"mute": "Geluid dempen/inschakelen",
"loop": "Schakel tussen herhalen en enkelvoudige modus",
"playbackModes": "Schakel tussen afspeelmodi (behalve herhalen en enkelvoudig)",
"fullscreen": "Volledig scherm modus voor video's",
"captions": "Ondertiteling aan/uit"
}
},
"languages": {
"he": "עברית",
@@ -241,7 +255,9 @@
"notEnoughCharacters": "Niet genoeg tekens om te zoeken (min {minSearchLength})",
"typeToSearch": "Begin met het typen van {minSearchLength} of meer tekens om te beginnen met zoeken.",
"showPreviewImages": "Voorbeeldafbeeldingen tonen",
"showPreviewImagesDescription": "Toon miniatuurvoorbeelden voor zoekresultaten indien beschikbaar, en toon grotere pictogrammen voor alle resultaten."
"showPreviewImagesDescription": "Toon miniatuurvoorbeelden voor zoekresultaten indien beschikbaar, en toon grotere pictogrammen voor alle resultaten.",
"olderThanDate": "Ouder dan:",
"newerThanDate": "Nieuwer dan:"
},
"settings": {
"administrator": "Administrator",
@@ -550,6 +566,8 @@
"orange": "oranje"
},
"profileSettings": {
"preferEditorForMarkdown": "Geef de voorkeur aan editor voor Markdown-bestanden",
"preferEditorForMarkdownDescription": "Indien ingeschakeld, open Markdown-bestanden eerst in de editor in plaats van de Markdown-viewer.",
"deleteAfterArchive": "Bronbestanden verwijderen na aanmaken/uitpakken van archieven",
"deleteAfterArchiveDescription": "Indien ingeschakeld, worden de bronbestanden verwijderd na succesvol aanmaken/uitpakken van archieven",
"hideFilesInTree": "Bestanden verbergen in boomnavigatie",
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more