Compare commits

...
12 Commits
124 changed files with 5668 additions and 2047 deletions

No files matched your search

+36
View File
@@ -2,6 +2,42 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v0.8.5-beta
**New Features**:
- backend media metadata processing
- significantly improved performance
- album artwork shows in listing view as preview thumbnail Album art thumbnail: display embedded covers of mp3/flac files #925
- folders can show album art if enabled with indexing `server.sources.config.indexAlbumArt:true`
- removed need for frontend dependancies.
- optional thumbnail support for folders -- uses first child item with thumbnail as cover. Useful in combination with album metadata support to show album covers for folders with music.
- Remember folder view type (and sorting) for each folder #966
- heic is supported in viewer (via ffmpeg conversion) when on non-safari browser. This can be enabled via `integrations.ffmpeg.convert.impagePreview.heic: true` #1191
- OnlyOffice: Add option to open documents in viewer mode (`integrations.office.viewOnly`) #1193
- Exclude folders and file names 'starting with' or wildcard option #1054
- added better info for failed uploads #1050
- upload prompt "clear completed" can be changed to "clear all" in uploads & downloads settings to clear error and conflict states as well. #1128
- share 'default view mode' option #1212
- Universal Next/Previous buttons
- available for all listing items (including office/markdown etc)
- remembers sort order from parent directory
- new "Quick Jump" prompt if you drag the next/previous icons. This shows other files available to "jump" directly to quickly.
**Notes**:
- Added more tests to ensure new features work in future.
- caching for preview images is md5 based. Moved, renamed, or duplicate images don't get re-generated. Same album artwork shares cache.
- Hide @eaDir folder by default #1212
- defaults to hide "@eadir" folders (common for synology) #1212
- After move/copy, ability to move to the destination folder #999
**BugFixes**:
- Access Management: issue with access settings #1195
- fix shutdown panic related to sse connection
- Custom theming not working in 0.8.4 beta #1204
- Config Viewer not working #1189
- "Path not found" when trying to share a file or folder inside a sub-directory #1139
- files containing "+" in share issue #1089
## v0.8.4-beta
**New Features**:
+3 -3
View File
@@ -9,14 +9,14 @@ Thank you for your interest in contributing to FileBrowser Quantum! This guide w
- **Docker** (optional, for containerized development)
- **Git**
### Required Tools
### Optional Tools
- **ffmpeg**: Required for video thumbnail generation
- **ffmpeg**: For media features (subtitles, thumbnails, heic)
- Ubuntu/Debian: `sudo apt-get install ffmpeg`
- macOS: `brew install ffmpeg`
- Windows: Download from [ffmpeg.org](https://ffmpeg.org/download.html)
- **mupdf-tools**: Required for PDF preview generation
- **mupdf-tools**: For PDF preview generation
- Ubuntu/Debian: `sudo apt-get install mupdf-tools`
- macOS: `brew install mupdf-tools`
- Windows: Download from [mupdf.com](https://mupdf.com/downloads/)
+1
View File
@@ -10,6 +10,7 @@ COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
+5
View File
@@ -15,6 +15,11 @@ server:
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
styling:
customThemes:
no-rounded:
description: "No rounded corners" # The description of the theme to display in the UI.
css: "no-rounded.css" # The css file path and filename to use for the theme.
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
+74 -40
View File
@@ -1,13 +1,8 @@
package files
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"encoding/base64"
"fmt"
"hash"
"io"
"unicode"
"unicode/utf8"
@@ -18,6 +13,7 @@ import (
"strings"
"time"
"github.com/dhowden/tag"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
@@ -34,7 +30,6 @@ func FileInfoFaster(opts iteminfo.FileOptions) (*iteminfo.ExtendedFileInfo, erro
if index == nil {
return response, fmt.Errorf("could not get index: %v ", opts.Source)
}
realPath, isDir, err := index.GetRealPath(opts.Path)
if err != nil {
return response, fmt.Errorf("could not get real path for requested path: %v", opts.Path)
@@ -42,26 +37,39 @@ func FileInfoFaster(opts iteminfo.FileOptions) (*iteminfo.ExtendedFileInfo, erro
opts.IsDir = isDir
var info *iteminfo.FileInfo
var exists bool
err = index.RefreshFileInfo(opts)
if err != nil {
if err == errors.ErrNotIndexed && index.Config.DisableIndexing {
info, err = index.GetFsDirInfo(opts.Path)
if err != nil {
return response, err
var useFsDirInfo bool
if isDir {
err = index.RefreshFileInfo(opts)
if err != nil {
if err == errors.ErrNotIndexed && index.Config.DisableIndexing {
useFsDirInfo = true
} else if err == errors.ErrNotIndexed {
return response, fmt.Errorf("could not refresh file info: %v", err)
}
} else if err == errors.ErrNotIndexed {
return response, fmt.Errorf("could not refresh file info: %v", err)
}
}
if useFsDirInfo {
info, err = index.GetFsDirInfo(opts.Path)
if err != nil {
return response, err
}
} else {
info, exists = index.GetReducedMetadata(opts.Path, opts.IsDir)
if !exists {
return response, fmt.Errorf("could not get metadata for path: %v", opts.Path)
err = index.RefreshFileInfo(opts)
if err != nil {
return response, fmt.Errorf("could not refresh file info: %v", err)
}
info, exists = index.GetReducedMetadata(opts.Path, opts.IsDir)
if !exists {
return response, fmt.Errorf("could not get metadata for path: %v", opts.Path)
}
}
}
response.FileInfo = *info
response.RealPath = realPath
response.Source = opts.Source
if opts.Access != nil && !opts.Access.Permitted(index.Path, opts.Path, opts.Username) {
// check if any subpath is permitted
// keep track of permitted paths and only show them at the end
@@ -100,6 +108,12 @@ func FileInfoFaster(opts iteminfo.FileOptions) (*iteminfo.ExtendedFileInfo, erro
func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index) {
isVideo := strings.HasPrefix(info.Type, "video")
isAudio := strings.HasPrefix(info.Type, "audio")
isFolder := info.Type == "directory"
if isFolder {
return
}
if isVideo {
parentInfo, exists := idx.GetReducedMetadata(filepath.Dir(info.Path), true)
if exists {
@@ -111,6 +125,18 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index) {
}
return
}
if isAudio {
err := extractAudioMetadata(info)
if err != nil {
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
} else {
info.HasPreview = info.AudioMeta.AlbumArt != ""
}
return
}
// Process text content for non-video, non-audio files
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
content, err := getContent(info.RealPath)
if err != nil {
@@ -134,34 +160,42 @@ func generateOfficeId(realPath string) string {
return key
}
// Checksum checksums a given File for a given User, using a specific
// algorithm. The checksums data is saved on File object.
func GetChecksum(fullPath, algo string) (map[string]string, error) {
subs := map[string]string{}
reader, err := os.Open(fullPath)
// extractAudioMetadata extracts metadata from an audio file using dhowden/tag
func extractAudioMetadata(item *iteminfo.ExtendedFileInfo) error {
file, err := os.Open(item.RealPath)
if err != nil {
return subs, err
return err
}
defer reader.Close()
defer file.Close()
hashFuncs := map[string]hash.Hash{
"md5": md5.New(),
"sha1": sha1.New(),
"sha256": sha256.New(),
"sha512": sha512.New(),
}
h, ok := hashFuncs[algo]
if !ok {
return subs, errors.ErrInvalidOption
}
_, err = io.Copy(h, reader)
m, err := tag.ReadFrom(file)
if err != nil {
return subs, err
return err
}
subs[algo] = hex.EncodeToString(h.Sum(nil))
return subs, nil
item.AudioMeta = &iteminfo.AudioMetadata{
Title: m.Title(),
Artist: m.Artist(),
Album: m.Album(),
Year: m.Year(),
Genre: m.Genre(),
}
// Extract track number
track, _ := m.Track()
item.AudioMeta.Track = track
// Extract album art and encode as base64
if picture := m.Picture(); picture != nil && picture.Data != nil {
// Limit album art size to prevent excessive response sizes (max 1MB)
if len(picture.Data) <= 1024*1024 {
item.AudioMeta.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
} else {
logger.Debugf("Skipping album art for %s: too large (%d bytes)", item.RealPath, len(picture.Data))
}
}
return nil
}
func DeleteFiles(source, absPath string, absDirPath string) error {
+7 -1
View File
@@ -349,11 +349,17 @@ func createConfig(configpath string) {
}
func generateYaml() {
if os.Getenv("FILEBROWSER_GENERATE_CONFIG") != "" {
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
devMode := os.Getenv("FILEBROWSER_DEVMODE") == "true"
if generateConfig || devMode {
logger.Info("Generating config.yaml")
settings.GenerateYaml()
}
if generateConfig {
os.Exit(0)
}
}
func SplitByMultiple(str string) []string {
+10
View File
@@ -77,12 +77,22 @@ func StartFilebrowser() {
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
}
// Dev mode enables development features like template hot-reloading
devMode := os.Getenv("FILEBROWSER_DEVMODE") == "true"
_, err := os.Stat("http/dist")
// In dev mode, always use filesystem assets. Otherwise, check if http/dist exists
if !devMode {
settings.Config.Server.EmbeddedFs = os.IsNotExist(err)
}
sourceList := []string{}
for path, source := range settings.Config.Server.SourceMap {
sourceList = append(sourceList, fmt.Sprintf("%v: %v", source.Name, path))
}
logger.Infof("Initializing FileBrowser Quantum (%v)", version.Version)
logger.Infof("Using Config file : %v", configPath)
logger.Infof("Using Embedded FS : %v", settings.Config.Server.EmbeddedFs)
logger.Infof("Auth Methods : %v", settings.Config.Auth.AuthMethods)
logger.Info(database)
logger.Infof("Sources : %v", sourceList)
+10 -2
View File
@@ -69,7 +69,9 @@ func setupFrontend(generate bool) {
Config.Frontend.Styling.DarkBackground = FallbackColor(Config.Frontend.Styling.DarkBackground, "#141D24")
Config.Frontend.Styling.CustomCSSRaw = readCustomCSS(Config.Frontend.Styling.CustomCSS)
Config.Frontend.Styling.CustomThemeOptions = map[string]CustomTheme{}
Config.Frontend.Styling.CustomThemes = map[string]CustomTheme{}
if Config.Frontend.Styling.CustomThemes == nil {
Config.Frontend.Styling.CustomThemes = map[string]CustomTheme{}
}
for name, theme := range Config.Frontend.Styling.CustomThemes {
addCustomTheme(name, theme.Description, theme.CSS)
}
@@ -362,7 +364,7 @@ func setDefaults(generate bool) Settings {
if database == "" {
database = "database.db"
}
return Settings{
s := Settings{
Server: Server{
Port: 80,
NumImageProcessors: numCpus,
@@ -410,6 +412,12 @@ func setDefaults(generate bool) Settings {
},
},
}
// set default image preview types
s.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]bool)
for _, t := range AllImagePreviewTypes {
s.Integrations.Media.Convert.ImagePreview[t] = (t == HEICImagePreview) // default is heic
}
return s
}
func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, error) {
+360 -9
View File
@@ -116,6 +116,267 @@ func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, De
return comments, secrets, deprecated, nil
}
// PathToTypeField represents a mapping from YAML path to Go type and field info
type PathToTypeField struct {
TypeName string
FieldName string
}
// buildStructPathMap builds a dynamic mapping from YAML paths to Go struct types/fields using reflection
func buildStructPathMap(config *Settings) map[string]PathToTypeField {
pathMap := make(map[string]PathToTypeField)
// Start with the root Settings struct
buildPathMapRecursive(reflect.TypeOf(config).Elem(), reflect.ValueOf(config).Elem(), "", "Settings", pathMap)
return pathMap
}
// buildPathMapRecursive recursively builds the path mapping using reflection
func buildPathMapRecursive(structType reflect.Type, structValue reflect.Value, currentPath string, typeName string, pathMap map[string]PathToTypeField) {
for i := 0; i < structType.NumField(); i++ {
field := structType.Field(i)
// Skip unexported fields
if field.PkgPath != "" {
continue
}
// Skip fields marked to ignore
if yamlTag := field.Tag.Get("yaml"); yamlTag == "-" {
continue
}
if jsonTag := field.Tag.Get("json"); jsonTag == "-" {
continue
}
// Determine the YAML field name from struct tags
yamlFieldName := getYamlFieldName(field)
// Build the full path
var fullPath string
if currentPath == "" {
fullPath = yamlFieldName
} else {
fullPath = currentPath + "." + yamlFieldName
}
// Add this field to the path map
pathMap[fullPath] = PathToTypeField{
TypeName: typeName,
FieldName: field.Name,
}
// If this field is a struct, recurse into it
fieldValue := structValue.Field(i)
fieldType := field.Type
// Handle pointers
if fieldType.Kind() == reflect.Ptr {
if fieldValue.IsNil() {
// Skip nil pointers
continue
}
fieldValue = fieldValue.Elem()
fieldType = fieldType.Elem()
}
// Recurse into structs (but not slices of structs for now - those are handled differently)
if fieldType.Kind() == reflect.Struct {
// Skip certain types that shouldn't be recursed into
if isSkippableStructType(fieldType) {
continue
}
buildPathMapRecursive(fieldType, fieldValue, fullPath, fieldType.Name(), pathMap)
} else if fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.Struct {
// For slices of structs, we map to the element type
elemType := fieldType.Elem()
if !isSkippableStructType(elemType) {
// Create a zero value of the element type to recurse into
zeroValue := reflect.Zero(elemType)
buildPathMapRecursive(elemType, zeroValue, fullPath, elemType.Name(), pathMap)
}
}
}
}
// getYamlFieldName determines the YAML field name from struct tags
func getYamlFieldName(field reflect.StructField) string {
// Check yaml tag first
if yamlTag := field.Tag.Get("yaml"); yamlTag != "" && yamlTag != "-" {
return strings.Split(yamlTag, ",")[0]
}
// Check json tag second
if jsonTag := field.Tag.Get("json"); jsonTag != "" && jsonTag != "-" {
return strings.Split(jsonTag, ",")[0]
}
// Default to field name
return field.Name
}
// isSkippableStructType checks if a struct type should be skipped during recursion
func isSkippableStructType(t reflect.Type) bool {
// Skip types from other packages that we don't want to recurse into
pkgPath := t.PkgPath()
// Skip external packages
if pkgPath != "" && !strings.Contains(pkgPath, "filebrowser/backend") {
return true
}
return false
}
// CollectCommentsFromEmbeddedYaml parses comments from embedded YAML content and returns CommentsMap, SecretFieldsMap, and DeprecatedFieldsMap
func CollectCommentsFromEmbeddedYaml(yamlContent string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
comments := make(CommentsMap)
secrets := make(SecretFieldsMap)
deprecated := make(DeprecatedFieldsMap)
// Build the dynamic path mapping from the actual Config struct
pathMap := buildStructPathMap(&Config)
lines := strings.Split(yamlContent, "\n")
// Track the current path in the YAML structure
var currentPath []string
for _, line := range lines {
// Skip empty lines and lines that are just comments
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
// Find the key and comment in the line
if !strings.Contains(line, ":") {
continue
}
// Split on the first colon to get key part
colonIndex := strings.Index(line, ":")
keyPart := line[:colonIndex]
restPart := line[colonIndex+1:]
// Calculate indentation
indent := 0
for i, char := range keyPart {
if char == ' ' {
indent++
} else {
keyPart = keyPart[i:]
break
}
}
depth := indent / 2
// Get the key name
key := strings.TrimSpace(keyPart)
if key == "" {
continue
}
// Adjust the current path based on depth
if depth < len(currentPath) {
currentPath = currentPath[:depth]
}
// Add current key to path
if depth < len(currentPath) {
currentPath[depth] = key
currentPath = currentPath[:depth+1]
} else {
currentPath = append(currentPath, key)
}
// Look for comment in the rest of the line
var comment string
if strings.Contains(restPart, "#") {
// Find the comment part (not inside quotes)
inQuotes := false
for i, char := range restPart {
if char == '"' && (i == 0 || restPart[i-1] != '\\') {
inQuotes = !inQuotes
} else if char == '#' && !inQuotes {
comment = strings.TrimSpace(restPart[i+1:])
break
}
}
}
// Store comment if it exists
if comment != "" {
// Build the full path string
fullPath := strings.Join(currentPath, ".")
// Look up the type and field name from our dynamic mapping
if pathInfo, exists := pathMap[fullPath]; exists {
typeName := pathInfo.TypeName
fieldName := pathInfo.FieldName
// Initialize maps if they don't exist
if comments[typeName] == nil {
comments[typeName] = make(map[string]string)
}
if secrets[typeName] == nil {
secrets[typeName] = make(map[string]bool)
}
if deprecated[typeName] == nil {
deprecated[typeName] = make(map[string]bool)
}
comments[typeName][fieldName] = comment
// Check for secret and deprecated markers
commentLower := strings.ToLower(comment)
if strings.Contains(commentLower, "secret:") {
secrets[typeName][fieldName] = true
}
if strings.Contains(commentLower, "deprecated:") {
deprecated[typeName][fieldName] = true
}
}
}
}
return comments, secrets, deprecated, nil
}
// parseDefaultsFromEmbeddedYaml creates a default config by parsing the embedded YAML template
func parseDefaultsFromEmbeddedYaml(embeddedYaml string) (*Settings, error) {
// Start with baseline defaults from setDefaults to ensure all fields are initialized
defaultConfig := setDefaults(true)
defaultConfig.Server.Sources = []Source{{Path: "."}}
// Parse the embedded YAML to overlay the documented defaults
if err := yaml.Unmarshal([]byte(embeddedYaml), &defaultConfig); err != nil {
return nil, fmt.Errorf("failed to unmarshal embedded YAML into config: %v", err)
}
return &defaultConfig, nil
}
// readEmbeddedYaml attempts to read the embedded config.generated.yaml file
func readEmbeddedYaml() (string, error) {
// Try to read from the relative path that should work when the binary includes embedded files
embeddedContent, err := os.ReadFile("http/dist/config.generated.yaml")
if err != nil {
// Try alternative path
embeddedContent, err = os.ReadFile("backend/http/dist/config.generated.yaml")
if err != nil {
// Try current directory path
embeddedContent, err = os.ReadFile("dist/config.generated.yaml")
if err != nil {
return "", fmt.Errorf("could not read embedded YAML from any path: %v", err)
}
}
}
return string(embeddedContent), nil
}
// BuildNode constructs a yaml.Node for any Go value, injecting comments on struct fields.
func BuildNode(v reflect.Value, comm CommentsMap) (*yaml.Node, error) {
return buildNodeWithDefaults(v, comm, reflect.Value{}, SecretFieldsMap{}, DeprecatedFieldsMap{})
@@ -255,7 +516,10 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
if err != nil {
return nil, err
}
seq.Content = append(seq.Content, n)
// Only add placeholder if it's not empty
if n.Kind == yaml.MappingNode && len(n.Content) > 0 {
seq.Content = append(seq.Content, n)
}
return seq, nil
}
for i := 0; i < v.Len(); i++ {
@@ -272,6 +536,10 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
return seq, nil
case reflect.Map:
if v.Len() == 0 {
// Return an empty string node for empty maps, which renders as a blank value.
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ""}, nil
}
mapNode := &yaml.Node{Kind: yaml.MappingNode}
for _, key := range v.MapKeys() {
@@ -361,6 +629,71 @@ func GenerateConfigYaml(config *Settings, showComments bool, showFull bool, filt
return GenerateConfigYamlWithEmptyMaps(config, showFull)
}
// GenerateConfigYamlWithEmbedded generates YAML from a given config using embedded YAML content as comment source
func GenerateConfigYamlWithEmbedded(config *Settings, showComments bool, showFull bool, filterDeprecated bool, embeddedYaml string) (string, error) {
var comm CommentsMap
var secrets SecretFieldsMap
var deprecated DeprecatedFieldsMap
var err error
if showComments && embeddedYaml != "" {
// Parse comments from the embedded YAML content
comm, secrets, deprecated, err = CollectCommentsFromEmbeddedYaml(embeddedYaml)
if err != nil {
return "", fmt.Errorf("error parsing embedded YAML comments: %w", err)
}
} else {
// Create empty maps
comm = make(CommentsMap)
secrets = make(SecretFieldsMap)
deprecated = make(DeprecatedFieldsMap)
}
// If not filtering deprecated fields, clear the deprecated map
if !filterDeprecated {
deprecated = make(DeprecatedFieldsMap)
}
var node *yaml.Node
if showFull {
// Show the full current config
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.Value{}, secrets, deprecated)
} else {
// Show only non-default values by comparing with defaults from embedded YAML
defaultConfig, parseErr := parseDefaultsFromEmbeddedYaml(embeddedYaml)
if parseErr != nil {
return "", fmt.Errorf("failed to parse defaults from embedded YAML: %v", parseErr)
}
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.ValueOf(defaultConfig), secrets, deprecated)
}
if err != nil {
return "", err
}
// Create document
doc := &yaml.Node{Kind: yaml.DocumentNode}
doc.Content = []*yaml.Node{node}
// Encode to YAML
var rawBuf bytes.Buffer
enc := yaml.NewEncoder(&rawBuf)
enc.SetIndent(2)
if err := enc.Encode(doc); err != nil {
return "", err
}
// Apply comment alignment if comments are enabled
yamlOutput := rawBuf.String()
if showComments {
yamlOutput = AlignComments(yamlOutput)
}
return yamlOutput, nil
}
// GenerateConfigYamlWithEmptyMaps generates YAML without comment parsing when source files are unavailable
func GenerateConfigYamlWithEmptyMaps(config *Settings, showFull bool) (string, error) {
// Create empty maps
@@ -376,10 +709,20 @@ func GenerateConfigYamlWithEmptyMaps(config *Settings, showFull bool) (string, e
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.Value{}, secrets, deprecated)
} else {
// Show only non-default values by comparing with defaults during node building
defaultConfig := setDefaults(true)
defaultConfig.Server.Sources = []Source{{Path: "."}}
// Try to use embedded YAML defaults for consistency, fallback to setDefaults
var defaultConfig *Settings
embeddedYaml, readErr := readEmbeddedYaml()
if readErr == nil {
defaultConfig, err = parseDefaultsFromEmbeddedYaml(embeddedYaml)
}
if err != nil || readErr != nil {
// Fallback to setDefaults
defaultConfigValue := setDefaults(true)
defaultConfigValue.Server.Sources = []Source{{Path: "."}}
defaultConfig = &defaultConfigValue
}
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.ValueOf(&defaultConfig), secrets, deprecated)
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.ValueOf(defaultConfig), secrets, deprecated)
}
if err != nil {
@@ -435,12 +778,20 @@ func GenerateConfigYamlWithSource(config *Settings, showComments bool, showFull
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.Value{}, secrets, deprecated)
} else {
// Show only non-default values by comparing with defaults during node building
// Create a clean default config (no file loading, just pure defaults)
defaultConfig := setDefaults(true)
// Apply same setup as a fresh instance would have
defaultConfig.Server.Sources = []Source{{Path: "."}}
// Try to use embedded YAML defaults for consistency, fallback to setDefaults
var defaultConfig *Settings
embeddedYaml, readErr := readEmbeddedYaml()
if readErr == nil {
defaultConfig, err = parseDefaultsFromEmbeddedYaml(embeddedYaml)
}
if err != nil || readErr != nil {
// Fallback to setDefaults
defaultConfigValue := setDefaults(true)
defaultConfigValue.Server.Sources = []Source{{Path: "."}}
defaultConfig = &defaultConfigValue
}
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.ValueOf(&defaultConfig), secrets, deprecated)
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.ValueOf(defaultConfig), secrets, deprecated)
}
if err != nil {
+52 -27
View File
@@ -21,18 +21,18 @@ type Settings struct {
}
type Server struct {
MinSearchLength int `json:"minSearchLength"` // minimum length of search query to begin searching (default: 3)
DisableUpdateCheck bool `json:"disableUpdateCheck"` // disables backend update check service
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
Socket string `json:"socket"` // socket to listen on
TLSKey string `json:"tlsKey"` // path to TLS key
TLSCert string `json:"tlsCert"` // path to TLS cert
DisablePreviews bool `json:"disablePreviews"` // disable all previews thumbnails, simple icons will be used
DisableResize bool `json:"disablePreviewResize"` // disable resizing of previews for faster loading over slow connections
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
Port int `json:"port"` // port to listen on
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
Logging []LogConfig `json:"logging"`
MinSearchLength int `json:"minSearchLength" yaml:"minSearchLength"` // minimum length of search query to begin searching (default: 3)
DisableUpdateCheck bool `json:"disableUpdateCheck"` // disables backend update check service
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
Socket string `json:"socket"` // socket to listen on
TLSKey string `json:"tlsKey"` // path to TLS key
TLSCert string `json:"tlsCert"` // path to TLS cert
DisablePreviews bool `json:"disablePreviews"` // disable all previews thumbnails, simple icons will be used
DisableResize bool `json:"disablePreviewResize"` // disable resizing of previews for faster loading over slow connections
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
Port int `json:"port"` // port to listen on
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
Logging []LogConfig `json:"logging" yaml:"logging"`
DebugMedia bool `json:"debugMedia"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
Database string `json:"database"` // path to the database file
Sources []Source `json:"sources" validate:"required,dive"`
@@ -44,6 +44,7 @@ type Server struct {
SourceMap map[string]Source `json:"-" validate:"omitempty"` // uses realpath as key
NameToSource map[string]Source `json:"-" validate:"omitempty"` // uses name as key
MuPdfAvailable bool `json:"-"` // used internally if compiled with mupdf support
EmbeddedFs bool `json:"-"` // used internally if compiled with embedded fs support
}
type Integrations struct {
@@ -57,19 +58,40 @@ type OnlyOffice struct {
Url string `json:"url" validate:"required"` // The URL to the OnlyOffice Document Server, needs to be accessible to the user.
InternalUrl string `json:"internalUrl"` // An optional internal address that the filebrowser server can use to communicate with the OnlyOffice Document Server, could be useful to bypass proxy.
Secret string `json:"secret" validate:"required"` // secret: authentication key for OnlyOffice integration
ViewOnly bool `json:"viewOnly"` // view only mode for OnlyOffice
}
type Media struct {
FfmpegPath string `json:"ffmpegPath"` // path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
FfmpegPath string `json:"ffmpegPath"` // path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
Convert FfmpegConvert `json:"convert"` // config for ffmpeg conversion settings
}
type FfmpegConvert struct {
ImagePreview map[ImagePreviewType]bool `json:"imagePreview"` // supported image preview formats. default is heic
}
type ImagePreviewType string
const (
HEICImagePreview ImagePreviewType = "heic"
)
func (i ImagePreviewType) String() string {
return string(i)
}
// AllImagePreviewTypes contains all supported image preview types.
var AllImagePreviewTypes = []ImagePreviewType{
HEICImagePreview,
}
type LogConfig struct {
Levels string `json:"levels"` // separated list of log levels to enable. (eg. "info|warning|error|debug")
ApiLevels string `json:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
Output string `json:"output"` // output location. (eg. "stdout" or "path/to/file.log")
NoColors bool `json:"noColors"` // disable colors in the output
Json bool `json:"json"` // output in json format, currently not supported
Utc bool `json:"utc"` // use UTC time in the output instead of local time
Levels string `json:"levels" yaml:"levels"` // separated list of log levels to enable. (eg. "info|warning|error|debug")
ApiLevels string `json:"apiLevels" yaml:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
Output string `json:"output" yaml:"output"` // output location. (eg. "stdout" or "path/to/file.log")
NoColors bool `json:"noColors" yaml:"noColors"` // disable colors in the output
Json bool `json:"json" yaml:"json"` // output in json format, currently not supported
Utc bool `json:"utc" yaml:"utc"` // use UTC time in the output instead of local time
}
type Source struct {
@@ -91,6 +113,7 @@ type SourceConfig struct {
DefaultUserScope string `json:"defaultUserScope"` // default "/" should match folders under path
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user
IndexAlbumArt bool `json:"indexAlbumArt"` // check for album art in audio files and remember it for folder previews. Can slow down indexing.
}
type IncludeIndexFilter struct {
@@ -99,14 +122,16 @@ type IncludeIndexFilter struct {
}
type ExcludeIndexFilter struct {
Hidden bool `json:"hidden"` // exclude hidden files and folders.
ZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // ignore folders with 0 size
FilePaths []string `json:"filePaths"` // list of filepaths Eg. "folder1" or "file1.txt" or "folder1/file1.txt" (without source path prefix)
FolderPaths []string `json:"folderPaths"` // (filepath) list of folder names to include/exclude. Eg. "folder1" or "folder1/subfolder" (do not include source path, just the subpaths from the source path)
FileNames []string `json:"fileNames"` // (global) list of file names to include/exclude. Eg. "a.jpg"
FolderNames []string `json:"folderNames"` // (global) list of folder names to include/exclude. Eg. "@eadir" or ".thumbnails"
FileEndsWith []string `json:"fileEndsWith"` // (global) exclude files that end with these suffixes. Eg. ".jpg" or ".txt"
FolderEndsWith []string `json:"folderEndsWith"` // (global) exclude folders that end with these suffixes. Eg. ".thumbnails" or ".git"
Hidden bool `json:"hidden"` // exclude hidden files and folders.
ZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // ignore folders with 0 size
FilePaths []string `json:"filePaths"` // list of filepaths Eg. "folder1" or "file1.txt" or "folder1/file1.txt" (without source path prefix)
FolderPaths []string `json:"folderPaths"` // (filepath) list of folder names to include/exclude. Eg. "folder1" or "folder1/subfolder" (do not include source path, just the subpaths from the source path)
FileNames []string `json:"fileNames"` // (global) list of file names to include/exclude. Eg. "a.jpg"
FolderNames []string `json:"folderNames"` // (global) list of folder names to include/exclude. Eg. "@eadir" or ".thumbnails"
FileEndsWith []string `json:"fileEndsWith"` // (global) exclude files that end with these suffixes. Eg. ".jpg" or ".txt"
FolderEndsWith []string `json:"folderEndsWith"` // (global) exclude folders that end with these suffixes. Eg. ".thumbnails" or ".git"
FileStartsWith []string `json:"fileStartsWith"` // (global) exclude files that start with these prefixes. Eg. "archive-" or "backup-"
FolderStartsWith []string `json:"folderStartsWith"` // (global) exclude folders that start with these prefixes. Eg. "archive-" or "backup-"
}
type Frontend struct {
+43
View File
@@ -0,0 +1,43 @@
package utils
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"hash"
"io"
"os"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
// GetChecksum calculates the checksum of a file using the specified algorithm.
// Returns the checksum as a hex-encoded string.
func GetChecksum(fullPath, algo string) (string, error) {
reader, err := os.Open(fullPath)
if err != nil {
return "", err
}
defer reader.Close()
hashFuncs := map[string]hash.Hash{
"md5": md5.New(),
"sha1": sha1.New(),
"sha256": sha256.New(),
"sha512": sha512.New(),
}
h, ok := hashFuncs[algo]
if !ok {
return "", errors.ErrInvalidOption
}
_, err = io.Copy(h, reader)
if err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
+71 -13
View File
@@ -273,32 +273,91 @@ func (s *Storage) Permitted(sourcePath, indexPath, username string) bool {
}
func (s *Storage) computePermitted(sourcePath, indexPath, username string) bool {
var rulesFound []*AccessRule
// Walk up the path hierarchy and collect all relevant rules
currentPath := indexPath
for {
permitted, found := s.permittedAtExactPath(sourcePath, indexPath, username)
rule, found := s.getRuleAtExactPath(sourcePath, currentPath)
if found {
return permitted
rulesFound = append(rulesFound, rule)
}
indexPath = utils.GetParentDirectoryPath(indexPath)
if indexPath == "" {
if currentPath == "/" || currentPath == "." || currentPath == "" {
break
}
currentPath = utils.GetParentDirectoryPath(currentPath)
}
// Now evaluate the rules, starting from the most specific (indexPath) to the least specific (root)
for _, rule := range rulesFound {
permitted, hasSpecificRule := s.evaluateRuleForUser(rule, username)
if hasSpecificRule {
return permitted
}
}
// No rules found anywhere in the path hierarchy
// Check if the source has DenyByDefault configured (acts like a root-level denyAll rule)
// No specific user or group rule found in the hierarchy.
// Check for any DenyAll rule in the path.
for _, rule := range rulesFound {
if rule.DenyAll {
return false
}
}
// No specific rules found anywhere in the path hierarchy.
// Fallback to the source's DenyByDefault setting.
sourceInfo, ok := settings.Config.Server.SourceMap[sourcePath]
if !ok {
logger.Errorf("source %s not found in config during access check", sourcePath)
return false
}
if sourceInfo.Config.DenyByDefault {
return false
}
return true
return !sourceInfo.Config.DenyByDefault
}
// getRuleAtExactPath is a helper to get a rule without the recursive logic.
func (s *Storage) getRuleAtExactPath(sourcePath, indexPath string) (*AccessRule, bool) {
s.mux.RLock()
defer s.mux.RUnlock()
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return nil, false
}
rule, ok := rulesBySource[indexPath]
return rule, ok
}
// evaluateRuleForUser evaluates a single rule for a user and returns if a specific rule was found.
func (s *Storage) evaluateRuleForUser(rule *AccessRule, username string) (permitted bool, hasSpecificRule bool) {
// Check user deny first
if _, found := rule.Deny.Users[username]; found {
return false, true
}
// Check group deny
for group := range rule.Deny.Groups {
if s.isUserInGroup(username, group) {
return false, true
}
}
// Check user allow
if _, found := rule.Allow.Users[username]; found {
return true, true
}
// Check group allow
for group := range rule.Allow.Groups {
if s.isUserInGroup(username, group) {
return true, true
}
}
// No specific rule for this user in this rule set.
return false, false
}
/*
// permittedAtExactPath checks if a rule exists at the given path and evaluates it if so.
func (s *Storage) permittedAtExactPath(sourcePath, indexPath, username string) (bool, bool) {
s.mux.RLock()
@@ -371,6 +430,7 @@ func (s *Storage) permittedRule(rule *AccessRule, username string, sourcePath st
return true
}
*/
// isUserInGroup checks if a username is in a group.
func (s *Storage) isUserInGroup(username, group string) bool {
@@ -421,8 +481,6 @@ func (s *Storage) GetFrontendRules(sourcePath, indexPath string) (FrontendAccess
frontendRules.Deny.Groups = utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups)))
frontendRules.Allow.Users = utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users)))
frontendRules.Allow.Groups = utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups)))
logger.Debugf("Returning frontend rules for source: %s and index: %s", sourcePath, indexPath)
return frontendRules, ok
}
+74
View File
@@ -405,3 +405,77 @@ func TestPermitted_DenyByDefaultWithDenyAll(t *testing.T) {
t.Error("bob should not be permitted (DenyByDefault is true and no allow rule)")
}
}
func TestPermitted_DenyByDefault_AdminRootAccess(t *testing.T) {
// 1. Setup
access.ClearCache()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "admin")
createTestUser(t, userStore, "graham")
createTestUser(t, userStore, "bob")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
// 2. Configure source with DenyByDefault: true
originalSourceMap := settings.Config.Server.SourceMap
defer func() {
settings.Config.Server.SourceMap = originalSourceMap
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]settings.Source{
"mnt/secure": {
Path: "mnt/secure",
Name: "secure",
Config: settings.SourceConfig{
DenyByDefault: true,
},
},
}
// 3. Set up access rules
if err := s.AllowUser("mnt/secure", "/", "admin"); err != nil {
t.Fatalf("AllowUser for admin failed: %v", err)
}
if err := s.AllowUser("mnt/secure", "/test", "graham"); err != nil {
t.Fatalf("AllowUser for graham failed: %v", err)
}
// 4. Assertions
// Admin checks
if !s.Permitted("mnt/secure", "/", "admin") {
t.Error("admin should be permitted for /")
}
if !s.Permitted("mnt/secure", "/test", "admin") {
t.Error("admin should be permitted for /test")
}
if !s.Permitted("mnt/secure", "/test/sub", "admin") {
t.Error("admin should be permitted for /test/sub (this is the bug)")
}
// Graham checks
if !s.Permitted("mnt/secure", "/test", "graham") {
t.Error("graham should be permitted for /test")
}
if !s.Permitted("mnt/secure", "/test/sub", "graham") {
t.Error("graham should be permitted for /test/sub")
}
if s.Permitted("mnt/secure", "/", "graham") {
t.Error("graham should NOT be permitted for /")
}
if s.Permitted("mnt/secure", "/anotherfolder", "graham") {
t.Error("graham should NOT be permitted for /anotherfolder")
}
// Bob checks (should have no access)
if s.Permitted("mnt/secure", "/", "bob") {
t.Error("bob should NOT be permitted for /")
}
if s.Permitted("mnt/secure", "/test", "bob") {
t.Error("bob should NOT be permitted for /test")
}
if s.Permitted("mnt/secure", "/test/sub", "bob") {
t.Error("bob should NOT be permitted for /test/sub")
}
}
+1 -1
View File
@@ -21,12 +21,12 @@ type CommonShare struct {
QuickDownload bool `json:"quickDownload,omitempty"`
HideNavButtons bool `json:"hideNavButtons,omitempty"`
DisableSidebar bool `json:"disableSidebar"`
ViewMode string `json:"viewMode,omitempty"`
Source string `json:"source,omitempty"` // backend source is path to maintain between name changes
Path string `json:"path,omitempty"`
DownloadURL string `json:"downloadURL,omitempty"`
DisableShareCard bool `json:"disableShareCard,omitempty"`
EnforceDarkLightMode string `json:"enforceDarkLightMode,omitempty"` // "dark" or "light"
ViewMode string `json:"viewMode,omitempty"` // default view mode for anonymous users: "list", "compact", "normal", "gallery"
EnableOnlyOffice bool `json:"enableOnlyOffice,omitempty"`
EnableOnlyOfficeEditing bool `json:"enableOnlyOfficeEditing,omitempty"`
}
+10 -8
View File
@@ -42,14 +42,15 @@ type Sorting struct {
type Preview struct {
DisableHideSidebar bool `json:"disableHideSidebar"` // disable the hide sidebar preview for previews and editors
HighQuality bool `json:"highQuality"` // generate high quality preview images
Image bool `json:"image"` // show real image as icon instead of generic photo icon
Video bool `json:"video"` // show preview image for video files
MotionVideoPreview bool `json:"motionVideoPreview"` // show multiple frames for videos in preview when hovering
Office bool `json:"office"` // show preview image for office files
PopUp bool `json:"popup"` // show larger popup preview when hovering
HighQuality bool `json:"highQuality"` // generate high quality thumbnail preview images
Image bool `json:"image"` // show thumbnail preview image for image files
Video bool `json:"video"` // show thumbnail preview image for video files
MotionVideoPreview bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
Office bool `json:"office"` // show thumbnail preview image for office files
PopUp bool `json:"popup"` // show larger popup preview when hovering over thumbnail
AutoplayMedia bool `json:"autoplayMedia"` // autoplay media files in preview
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable html5 media player and use the default media player
Folder bool `json:"folder"` // show thumbnail preview image for folder files
}
// User describes a user.
@@ -108,8 +109,9 @@ type NonAdminEditable struct {
}
type FileLoading struct {
MaxConcurrent int `json:"maxConcurrentUpload"`
ChunkSize int `json:"uploadChunkSizeMb"`
MaxConcurrent int `json:"maxConcurrentUpload"`
ChunkSize int `json:"uploadChunkSizeMb"`
ClearAll bool `json:"clearAll"`
}
func CleanUsername(s string) string {
+18 -6
View File
@@ -75,7 +75,12 @@ func Register(username string, sources []string) chan EventMessage {
func Unregister(username string, ch chan EventMessage) {
userClientsMu.Lock()
conns := userClients[username]
defer userClientsMu.Unlock()
conns, ok := userClients[username]
if !ok {
// User already cleaned up by Shutdown, nothing to do.
return
}
for i, c := range conns {
if c == ch {
userClients[username] = append(conns[:i], conns[i+1:]...)
@@ -85,18 +90,15 @@ func Unregister(username string, ch chan EventMessage) {
if len(userClients[username]) == 0 {
delete(userClients, username)
}
userClientsMu.Unlock()
sourceClientsMu.Lock()
defer sourceClientsMu.Unlock()
for source, clients := range sourceClients {
delete(clients, ch)
if len(clients) == 0 {
delete(sourceClients, source)
}
}
sourceClientsMu.Unlock()
close(ch)
}
func SendToUsers(eventType, message string, users []string) {
@@ -142,10 +144,20 @@ func handleSourceUpdates() {
func Shutdown() {
userClientsMu.Lock()
defer userClientsMu.Unlock()
sourceClientsMu.Lock()
defer sourceClientsMu.Unlock()
for _, clientChannels := range userClients {
for username, clientChannels := range userClients {
for _, ch := range clientChannels {
// Clean up source clients
for source, clients := range sourceClients {
delete(clients, ch)
if len(clients) == 0 {
delete(sourceClients, source)
}
}
close(ch)
}
delete(userClients, username)
}
}
+370
View File
@@ -0,0 +1,370 @@
package ffmpeg
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
// ImageService handles image operations with ffmpeg
type ImageService struct {
ffmpegPath string
ffprobePath string
debug bool
cacheDir string
}
// NewImageService creates a new image service instance
func NewImageService(ffmpegPath, ffprobePath string, debug bool, cacheDir string) *ImageService {
return &ImageService{
ffmpegPath: ffmpegPath,
ffprobePath: ffprobePath,
debug: debug,
cacheDir: cacheDir,
}
}
// GetImageOrientation extracts the EXIF orientation from an image file using exiftool
func (s *ImageService) GetImageOrientation(imagePath string) (string, error) {
// Use exiftool to get orientation information
cmd := exec.Command("exiftool", "-Orientation", "-s3", imagePath)
var out bytes.Buffer
var errOut bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errOut
if err := cmd.Run(); err != nil {
return "Horizontal (normal)", nil // Default to normal orientation
}
orientation := strings.TrimSpace(out.String())
if orientation == "" {
orientation = "Horizontal (normal)" // Default if no orientation found
}
return orientation, nil
}
// GetOrientationFilter converts EXIF orientation to FFmpeg filter
func (s *ImageService) GetOrientationFilter(orientation string) string {
switch orientation {
// Text-based EXIF orientation values (from exiftool)
case "Rotate 90 CW", "Right-top":
return ",transpose=1" // 90° clockwise
case "Rotate 180", "Bottom-right":
return ",transpose=1,transpose=1" // 180° (two 90° rotations)
case "Rotate 270 CW", "Left-bottom":
return ",transpose=2" // 90° counter-clockwise
case "Mirror horizontal", "Top-right":
return ",hflip" // Horizontal flip
case "Mirror vertical", "Bottom-left":
return ",vflip" // Vertical flip (upside down)
case "Mirror horizontal and rotate 270 CW", "Right-bottom":
return ",transpose=0" // 90° counter-clockwise + horizontal flip
case "Mirror horizontal and rotate 90 CW", "Left-top":
return ",transpose=3" // 90° clockwise + horizontal flip
case "Horizontal (normal)", "Top-left":
return "" // No rotation needed
// Numeric EXIF orientation values (standard EXIF specification)
case "1":
return "" // Top-left (normal)
case "2":
return ",hflip" // Top-right (flip horizontal)
case "3":
return ",transpose=1,transpose=1" // Bottom-right (rotate 180)
case "4":
return ",vflip" // Bottom-left (flip vertical)
case "5":
return ",transpose=3" // Left-top (transpose: 90° CW + horizontal flip)
case "6":
return ",transpose=1" // Right-top (rotate 90 CW)
case "7":
return ",transpose=0" // Right-bottom (transverse: 90° CCW + horizontal flip)
case "8":
return ",transpose=2" // Left-bottom (rotate 270 CW / 90° CCW)
default:
return "" // Default to no rotation for unknown orientations
}
}
// GetImageDimensions extracts the dimensions of an image file using ffprobe
func (s *ImageService) GetImageDimensions(imagePath string) (width, height int, err error) {
// Get HEIC dimensions (fallback to individual stream dimensions for nowe re
probeCmd := exec.Command(
s.ffprobePath,
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "csv=p=0:s=x",
imagePath,
)
var probeOut bytes.Buffer
var probeErr bytes.Buffer
probeCmd.Stdout = &probeOut
probeCmd.Stderr = &probeErr
if err = probeCmd.Run(); err != nil {
return 0, 0, fmt.Errorf("ffprobe command failed on image file '%s': %w", imagePath, err)
}
dimensions := strings.TrimSpace(probeOut.String())
// Handle cases where ffprobe returns multiple lines or extra characters
// Take the first line and clean it up
lines := strings.Split(dimensions, "\n")
cleanDimensions := strings.TrimSpace(lines[0])
// Remove any trailing 'x' characters that might appear
cleanDimensions = strings.TrimSuffix(cleanDimensions, "x")
parts := strings.Split(cleanDimensions, "x")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid dimensions format: %s", cleanDimensions)
}
width, err = strconv.Atoi(parts[0])
if err != nil {
return 0, 0, fmt.Errorf("invalid width: %w", err)
}
height, err = strconv.Atoi(parts[1])
if err != nil {
return 0, 0, fmt.Errorf("invalid height: %w", err)
}
return width, height, nil
}
// ConvertHEICToJPEGDirect converts a HEIC file to JPEG using direct FFmpeg conversion (fast method)
func (s *ImageService) ConvertHEICToJPEGDirect(heicPath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
// Get EXIF orientation and create appropriate filter
orientation, err := s.GetImageOrientation(heicPath)
if err != nil {
orientation = "Horizontal (normal)"
}
orientationFilter := s.GetOrientationFilter(orientation)
// Create temporary output file
outputDir := s.cacheDir
err = os.MkdirAll(outputDir, 0755)
if err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
outputFile := filepath.Join(outputDir, fmt.Sprintf("heic_direct_%d.jpg", os.Getpid()))
defer os.Remove(outputFile)
// Build FFmpeg command for direct conversion
args := []string{"-i", heicPath}
// Build video filter chain with scaling and orientation
var filterChain string
if targetWidth > 0 && targetHeight > 0 {
filterChain = fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease%s", targetWidth, targetHeight, orientationFilter)
} else {
// For original size, only apply orientation if needed
if orientationFilter != "" {
filterChain = orientationFilter[1:] // Remove leading comma
}
}
// Add video filter if we have one
if filterChain != "" {
args = append(args, "-vf", filterChain)
}
// Add quality and output settings
args = append(args, "-q:v", quality, "-pix_fmt", "yuvj420p", "-y", outputFile)
cmd := exec.Command(s.ffmpegPath, args...)
var cmdOut bytes.Buffer
var cmdErr bytes.Buffer
cmd.Stdout = &cmdOut
cmd.Stderr = &cmdErr
if err = cmd.Run(); err != nil {
return nil, fmt.Errorf("direct HEIC conversion failed: %w", err)
}
// Read the converted file
jpegBytes, err := os.ReadFile(outputFile)
if err != nil {
return nil, fmt.Errorf("failed to read converted JPEG: %w", err)
}
return jpegBytes, nil
}
// ConvertHEICToJPEG converts a HEIC file to JPEG with specified dimensions and quality using proper tile extraction
func (s *ImageService) ConvertHEICToJPEG(heicPath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
// Create temporary directory for tile processing
outputDir := s.cacheDir
tempDir := filepath.Join(outputDir, fmt.Sprintf("heic_tiles_%d", os.Getpid()))
err := os.MkdirAll(tempDir, 0755)
if err != nil {
return nil, fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tempDir)
// Step 1: Get grid info using trace output
// Use ffprobe with trace level to get grid information (with time limit for speed)
gridCmd := exec.Command(s.ffmpegPath, "-loglevel", "trace", "-i", heicPath, "-f", "null", "-", "-t", "0.1")
var gridOut bytes.Buffer
var gridErr bytes.Buffer
gridCmd.Stdout = &gridOut
gridCmd.Stderr = &gridErr
_ = gridCmd.Run() // Don't check error, we're just extracting info
traceOutput := gridErr.String()
// Parse grid dimensions from trace output
var gridCols, gridRows = 8, 6 // Default fallback
var val int
// Look for grid_row and grid_col in trace output
lines := strings.Split(traceOutput, "\n")
for _, line := range lines {
if strings.Contains(line, "grid_row") {
// Try to extract grid_row value
if parts := strings.Split(line, "grid_row"); len(parts) > 1 {
if matches := strings.Fields(parts[1]); len(matches) > 0 {
if val, err = strconv.Atoi(strings.Trim(matches[0], " :=")); err == nil {
gridRows = val + 1 // HEIC grid_row is 0-based
}
}
}
}
if strings.Contains(line, "grid_col") {
// Try to extract grid_col value
if parts := strings.Split(line, "grid_col"); len(parts) > 1 {
if matches := strings.Fields(parts[1]); len(matches) > 0 {
if val, err = strconv.Atoi(strings.Trim(matches[0], " :=")); err == nil {
gridCols = val + 1 // HEIC grid_col is 0-based
}
}
}
}
}
// Get EXIF orientation and create appropriate filter
orientation, err := s.GetImageOrientation(heicPath)
if err != nil {
orientation = "Horizontal (normal)"
}
orientationFilter := s.GetOrientationFilter(orientation)
// Step 2: Extract tile streams (skip stream 0 which is compatibility image)
// Extract using JPEG for faster I/O (PNG was unnecessarily slow and large)
// Skip the first output (stream 0) since it's the compatibility image
tilesPattern := filepath.Join(tempDir, "output_%d.jpg")
extractCmd := exec.Command(
s.ffmpegPath,
"-i", heicPath,
"-map", "0", // Map all streams
"-q:v", "3", // Medium quality for intermediate tiles
"-y",
tilesPattern,
)
var extractOut bytes.Buffer
var extractErr bytes.Buffer
extractCmd.Stdout = &extractOut
extractCmd.Stderr = &extractErr
if err = extractCmd.Run(); err != nil {
return nil, fmt.Errorf("tile extraction failed: %w", err)
}
// Step 3: Filter tiles (skip output_0.png which is compatibility image)
files, err := filepath.Glob(filepath.Join(tempDir, "output_*.jpg"))
if err != nil || len(files) == 0 {
return nil, fmt.Errorf("no tiles extracted, found %d files", len(files))
}
// Create filtered tiles, skipping output_0.jpg (compatibility image)
// and rename them to sequential tile_001.jpg, tile_002.jpg, etc.
tileIndex := 1
expectedTiles := gridCols * gridRows
for i := 1; i <= len(files); i++ { // Start from 1 to skip output_0.jpg
sourceFile := filepath.Join(tempDir, fmt.Sprintf("output_%d.jpg", i))
var info os.FileInfo
if info, err = os.Stat(sourceFile); err == nil && info.Size() > 500 {
// This is a valid tile, rename it to sequential format
targetFile := filepath.Join(tempDir, fmt.Sprintf("tile_%03d.jpg", tileIndex))
if err = os.Rename(sourceFile, targetFile); err != nil {
continue
}
tileIndex++
// Stop if we have enough tiles
if tileIndex > expectedTiles {
break
}
}
}
actualTiles := tileIndex - 1
// Step 4: Merge tiles back into final image with proper quality and rotation
outputFile := filepath.Join(tempDir, "final_output.jpg")
// Build the video filter chain
inputPattern := filepath.Join(tempDir, "tile_%03d.jpg")
// Create filter chain: tile assembly + proper scaling
var filterChain string
// For original size (targetWidth=0, targetHeight=0), don't scale
if targetWidth == 0 && targetHeight == 0 {
// This is original size - no scaling, maximum quality
// Use padding=0:margin=0 to eliminate black borders between tiles
filterChain = fmt.Sprintf("tile=%dx%d:nb_frames=%d:padding=0:margin=0%s",
gridCols, gridRows, actualTiles, orientationFilter)
} else {
// This is a resize request - apply scaling with proper aspect handling
filterChain = fmt.Sprintf("tile=%dx%d:nb_frames=%d:padding=0:margin=0%s,scale=%d:%d:force_original_aspect_ratio=decrease:eval=frame",
gridCols, gridRows, actualTiles, orientationFilter, targetWidth, targetHeight)
}
mergeCmd := exec.Command(
s.ffmpegPath,
"-i", inputPattern,
"-vf", filterChain,
"-frames:v", "1",
"-q:v", quality, // Use the quality setting passed in
"-pix_fmt", "yuvj420p", // Use full-range YUV for better JPEG compatibility
"-y",
outputFile,
)
var mergeOut bytes.Buffer
var mergeErr bytes.Buffer
mergeCmd.Stdout = &mergeOut
mergeCmd.Stderr = &mergeErr
if err = mergeCmd.Run(); err != nil {
return nil, fmt.Errorf("tile merge failed: %w", err)
}
// Step 5: Read final output
jpegBytes, err := os.ReadFile(outputFile)
if err != nil {
return nil, fmt.Errorf("failed to read merged JPEG: %w", err)
}
return jpegBytes, nil
}
+290
View File
@@ -0,0 +1,290 @@
package ffmpeg
import (
"os"
"path/filepath"
"testing"
)
func TestGetOrientationFilter(t *testing.T) {
service := &ImageService{}
tests := []struct {
name string
orientation string
expected string
}{
{
name: "Normal orientation",
orientation: "Horizontal (normal)",
expected: "",
},
{
name: "Normal orientation alternative",
orientation: "Top-left",
expected: "",
},
{
name: "Rotate 90 CW",
orientation: "Rotate 90 CW",
expected: ",transpose=1",
},
{
name: "Rotate 90 CW alternative",
orientation: "Right-top",
expected: ",transpose=1",
},
{
name: "Rotate 180",
orientation: "Rotate 180",
expected: ",transpose=1,transpose=1",
},
{
name: "Rotate 180 alternative",
orientation: "Bottom-right",
expected: ",transpose=1,transpose=1",
},
{
name: "Rotate 270 CW",
orientation: "Rotate 270 CW",
expected: ",transpose=2",
},
{
name: "Rotate 270 CW alternative",
orientation: "Left-bottom",
expected: ",transpose=2",
},
{
name: "Mirror horizontal",
orientation: "Mirror horizontal",
expected: ",hflip",
},
{
name: "Mirror horizontal alternative",
orientation: "Top-right",
expected: ",hflip",
},
{
name: "Mirror vertical",
orientation: "Mirror vertical",
expected: ",vflip",
},
{
name: "Mirror vertical alternative",
orientation: "Bottom-left",
expected: ",vflip",
},
{
name: "Mirror horizontal and rotate 270 CW",
orientation: "Mirror horizontal and rotate 270 CW",
expected: ",transpose=0",
},
{
name: "Mirror horizontal and rotate 270 CW alternative",
orientation: "Right-bottom",
expected: ",transpose=0",
},
{
name: "Mirror horizontal and rotate 90 CW",
orientation: "Mirror horizontal and rotate 90 CW",
expected: ",transpose=3",
},
{
name: "Mirror horizontal and rotate 90 CW alternative",
orientation: "Left-top",
expected: ",transpose=3",
},
{
name: "Unknown orientation",
orientation: "Unknown Orientation",
expected: "",
},
{
name: "Empty orientation",
orientation: "",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := service.GetOrientationFilter(tt.orientation)
if result != tt.expected {
t.Errorf("GetOrientationFilter(%q) = %q, want %q", tt.orientation, result, tt.expected)
}
})
}
}
func TestGetImageOrientation(t *testing.T) {
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "ffmpeg_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
service := NewImageService("ffmpeg", "ffprobe", false, tempDir)
t.Run("Non-existent file", func(t *testing.T) {
nonExistentFile := filepath.Join(tempDir, "non_existent.heic")
orientation, err := service.GetImageOrientation(nonExistentFile)
// Should return default orientation when exiftool fails
if err != nil {
t.Errorf("Expected no error for non-existent file, got: %v", err)
}
if orientation != "Horizontal (normal)" {
t.Errorf("Expected default orientation 'Horizontal (normal)', got: %q", orientation)
}
})
t.Run("Empty file path", func(t *testing.T) {
orientation, err := service.GetImageOrientation("")
// Should return default orientation when exiftool fails
if err != nil {
t.Errorf("Expected no error for empty file path, got: %v", err)
}
if orientation != "Horizontal (normal)" {
t.Errorf("Expected default orientation 'Horizontal (normal)', got: %q", orientation)
}
})
}
func TestNewImageService(t *testing.T) {
ffmpegPath := "/usr/bin/ffmpeg"
ffprobePath := "/usr/bin/ffprobe"
debug := true
cacheDir := "/tmp/test_cache"
service := NewImageService(ffmpegPath, ffprobePath, debug, cacheDir)
if service.ffmpegPath != ffmpegPath {
t.Errorf("Expected ffmpegPath %q, got %q", ffmpegPath, service.ffmpegPath)
}
if service.ffprobePath != ffprobePath {
t.Errorf("Expected ffprobePath %q, got %q", ffprobePath, service.ffprobePath)
}
if service.debug != debug {
t.Errorf("Expected debug %v, got %v", debug, service.debug)
}
if service.cacheDir != cacheDir {
t.Errorf("Expected cacheDir %q, got %q", cacheDir, service.cacheDir)
}
}
// Integration test for orientation handling in direct conversion
func TestConvertHEICToJPEGDirect_OrientationHandling(t *testing.T) {
// Skip this test if ffmpeg is not available
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "ffmpeg_integration_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
service := NewImageService("ffmpeg", "ffprobe", true, tempDir)
t.Run("Direct conversion with orientation detection", func(t *testing.T) {
// This test requires actual HEIC files to work properly
// For CI/CD environments, we can mock this or skip if files don't exist
heicTestFiles := []string{
"/Users/steffag/Downloads/heic/IMG_6660.heic",
"/Users/steffag/Downloads/heic/IMG_2919.HEIC",
"/Users/steffag/Downloads/heic/shelf-christmas-decoration.heic",
}
for _, testFile := range heicTestFiles {
// Check if test file exists
if _, err := os.Stat(testFile); os.IsNotExist(err) {
t.Logf("Skipping test for %s (file not found)", filepath.Base(testFile))
continue
}
t.Logf("Testing orientation handling for %s", filepath.Base(testFile))
// Test orientation detection
orientation, err := service.GetImageOrientation(testFile)
if err != nil {
t.Errorf("Failed to get orientation for %s: %v", filepath.Base(testFile), err)
continue
}
t.Logf("Detected orientation: %s", orientation)
// Test filter generation
filter := service.GetOrientationFilter(orientation)
t.Logf("Generated filter: %s", filter)
// Test actual conversion (small size for speed)
jpegBytes, err := service.ConvertHEICToJPEGDirect(testFile, 100, 100, "5")
if err != nil {
t.Errorf("Failed to convert %s: %v", filepath.Base(testFile), err)
continue
}
if len(jpegBytes) < 400 {
t.Errorf("Converted image too small (%d bytes), likely conversion failed", len(jpegBytes))
}
t.Logf("Successfully converted %s to %d bytes", filepath.Base(testFile), len(jpegBytes))
}
})
}
// Benchmark the orientation filter generation
func BenchmarkGetOrientationFilter(b *testing.B) {
service := &ImageService{}
orientations := []string{
"Horizontal (normal)",
"Rotate 90 CW",
"Rotate 180",
"Mirror vertical",
"Unknown Orientation",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
orientation := orientations[i%len(orientations)]
_ = service.GetOrientationFilter(orientation)
}
}
// Test table for all EXIF orientation values
func TestGetOrientationFilter_AllEXIFValues(t *testing.T) {
service := &ImageService{}
// Test all possible EXIF orientation values according to EXIF spec
exifTests := []struct {
exifValue string
description string
expectsTransformation bool
}{
{"1", "Top-left (normal)", false},
{"2", "Top-right (flip horizontal)", true},
{"3", "Bottom-right (rotate 180)", true},
{"4", "Bottom-left (flip vertical)", true},
{"5", "Left-top (transpose)", true},
{"6", "Right-top (rotate 90 CW)", true},
{"7", "Right-bottom (transverse)", true},
{"8", "Left-bottom (rotate 270 CW)", true},
}
for _, tt := range exifTests {
t.Run(tt.description, func(t *testing.T) {
filter := service.GetOrientationFilter(tt.exifValue)
if tt.expectsTransformation && filter == "" {
t.Errorf("Expected transformation for EXIF value %s (%s), but got empty filter", tt.exifValue, tt.description)
}
if !tt.expectsTransformation && filter != "" {
t.Errorf("Expected no transformation for EXIF value %s (%s), but got filter: %s", tt.exifValue, tt.description, filter)
}
})
}
}
+33 -21
View File
@@ -5,6 +5,7 @@ go 1.25.0
require (
github.com/asdine/storm/v3 v3.2.1
github.com/coreos/go-oidc/v3 v3.15.0
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-playground/validator/v10 v10.27.0
@@ -15,16 +16,16 @@ require (
github.com/gtsteffaniak/go-logger v0.1.2
github.com/kovidgoyal/imaging v1.6.4
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.14.0
github.com/stretchr/testify v1.10.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.41.0
golang.org/x/image v0.30.0
golang.org/x/mod v0.27.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sys v0.35.0
golang.org/x/time v0.12.0
golang.org/x/crypto v0.42.0
golang.org/x/image v0.31.0
golang.org/x/mod v0.28.0
golang.org/x/oauth2 v0.31.0
golang.org/x/sys v0.36.0
golang.org/x/time v0.13.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -46,7 +47,7 @@ require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.3.1 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.62.0 // indirect
github.com/air-verse/air v1.63.0 // indirect
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
@@ -101,10 +102,21 @@ require (
github.com/go-critic/go-critic v0.13.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
github.com/go-openapi/jsonpointer v0.21.2 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/jsonpointer v0.22.0 // indirect
github.com/go-openapi/jsonreference v0.21.1 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
github.com/go-openapi/swag v0.24.1 // indirect
github.com/go-openapi/swag/cmdutils v0.24.0 // indirect
github.com/go-openapi/swag/conv v0.24.0 // indirect
github.com/go-openapi/swag/fileutils v0.24.0 // indirect
github.com/go-openapi/swag/jsonname v0.24.0 // indirect
github.com/go-openapi/swag/jsonutils v0.24.0 // indirect
github.com/go-openapi/swag/loading v0.24.0 // indirect
github.com/go-openapi/swag/mangling v0.24.0 // indirect
github.com/go-openapi/swag/netutils v0.24.0 // indirect
github.com/go-openapi/swag/stringutils v0.24.0 // indirect
github.com/go-openapi/swag/typeutils v0.24.0 // indirect
github.com/go-openapi/swag/yamlutils v0.24.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
@@ -118,8 +130,8 @@ require (
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/gohugoio/hugo v0.147.6 // indirect
github.com/golang/geo v0.0.0-20250825151631-54d70cc7cb31 // indirect
github.com/gohugoio/hugo v0.149.1 // indirect
github.com/golang/geo v0.0.0-20250912065020-b504328d3ef3 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.0 // indirect
@@ -211,7 +223,7 @@ require (
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.4.0 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
github.com/spf13/cast v1.8.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.7 // indirect
@@ -222,7 +234,7 @@ require (
github.com/subosito/gotenv v1.4.1 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/tdakkota/asciicheck v0.4.1 // indirect
github.com/tdewolff/parse/v2 v2.8.1 // indirect
github.com/tdewolff/parse/v2 v2.8.3 // indirect
github.com/tetafro/godot v1.5.1 // indirect
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect
github.com/timonwong/loggercheck v0.11.0 // indirect
@@ -248,11 +260,11 @@ require (
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.36.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/tools v0.37.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.6.1 // indirect
+98 -60
View File
@@ -73,8 +73,8 @@ github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsu
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/air-verse/air v1.62.0 h1:6CoXL4MAX9dc4xAzLfjMcDfbBoGmW5VjuuTV/1+bI+M=
github.com/air-verse/air v1.62.0/go.mod h1:EO+jWuetL10tS9raffwg8WEV0t0KUeucRRaf9ii86dA=
github.com/air-verse/air v1.63.0 h1:fwcdHpwaUe4/+q349PxptzAIn8gVE6Yke8TgW0LsDxQ=
github.com/air-verse/air v1.63.0/go.mod h1:RyCQVx2+3Zz2BzoqkukYiGmWkWXNKMf0x5ubIFcUB8Q=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
@@ -108,6 +108,8 @@ github.com/ashanbrown/makezero/v2 v2.0.1 h1:r8GtKetWOgoJ4sLyUx97UTwyt2dO7WkGFHiz
github.com/ashanbrown/makezero/v2 v2.0.1/go.mod h1:kKU4IMxmYW1M4fiEHMb2vc5SFoPzXvgbMR9gIp5pjSw=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -116,8 +118,8 @@ github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bep/gitmap v1.6.0 h1:sDuQMm9HoTL0LtlrfxjbjgAg2wHQd4nkMup2FInYzhA=
github.com/bep/gitmap v1.6.0/go.mod h1:n+3W1f/rot2hynsqEGxGMErPRgT41n9CkGuzPvz9cIw=
github.com/bep/gitmap v1.9.0 h1:2pyb1ex+cdwF6c4tsrhEgEKfyNfxE34d5K+s2sa9byc=
github.com/bep/gitmap v1.9.0/go.mod h1:Juq6e1qqCRvc1W7nzgadPGI9IGV13ZncEebg5atj4Vo=
github.com/bep/goat v0.5.0 h1:S8jLXHCVy/EHIoCY+btKkmcxcXFd34a0Q63/0D4TKeA=
github.com/bep/goat v0.5.0/go.mod h1:Md9x7gRxiWKs85yHlVTvHQw9rg86Bm+Y4SuYE8CTH7c=
github.com/bep/godartsass/v2 v2.5.0 h1:tKRvwVdyjCIr48qgtLa4gHEdtRkPF8H1OeEhJAEv7xg=
@@ -128,6 +130,8 @@ github.com/bep/goportabletext v0.1.0 h1:8dqym2So1cEqVZiBa4ZnMM1R9l/DnC1h4ONg4J5k
github.com/bep/goportabletext v0.1.0/go.mod h1:6lzSTsSue75bbcyvVc0zqd1CdApuT+xkZQ6Re5DzZFg=
github.com/bep/gowebp v0.4.0 h1:QihuVnvIKbRoeBNQkN0JPMM8ClLmD6V2jMftTFwSK3Q=
github.com/bep/gowebp v0.4.0/go.mod h1:95gtYkAA8iIn1t3HkAPurRCVGV/6NhgaHJ1urz0iIwc=
github.com/bep/helpers v0.6.0 h1:qtqMCK8XPFNM9hp5Ztu9piPjxNNkk8PIyUVjg6v8Bsw=
github.com/bep/helpers v0.6.0/go.mod h1:IOZlgx5PM/R/2wgyCatfsgg5qQ6rNZJNDpWGXqDR044=
github.com/bep/imagemeta v0.12.0 h1:ARf+igs5B7pf079LrqRnwzQ/wEB8Q9v4NSDRZO1/F5k=
github.com/bep/imagemeta v0.12.0/go.mod h1:23AF6O+4fUi9avjiydpKLStUNtJr5hJB4rarG18JpN8=
github.com/bep/lazycache v0.8.0 h1:lE5frnRjxaOFbkPZ1YL6nijzOPPz6zeXasJq8WpG4L8=
@@ -207,6 +211,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8=
github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
@@ -236,8 +242,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q=
github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A=
github.com/evanw/esbuild v0.25.3 h1:4JKyUsm/nHDhpxis4IyWXAi8GiyTwG1WdEp6OhGVE8U=
github.com/evanw/esbuild v0.25.3/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/evanw/esbuild v0.25.9 h1:aU7GVC4lxJGC1AyaPwySWjSIaNLAdVEEuq3chD0Khxs=
github.com/evanw/esbuild v0.25.9/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
@@ -255,8 +261,8 @@ github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIp
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gen2brain/go-fitz v1.24.15 h1:sJNB1MOWkqnzzENPHggFpgxTwW0+S5WF/rM5wUBpJWo=
github.com/gen2brain/go-fitz v1.24.15/go.mod h1:SftkiVbTHqF141DuiLwBBM65zP7ig6AVDQpf2WlHamo=
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/ghostiam/protogetter v0.3.15 h1:1KF5sXel0HE48zh1/vn0Loiw25A9ApyseLzQuif1mLY=
@@ -282,14 +288,36 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.21.2 h1:AqQaNADVwq/VnkCmQg6ogE+M3FOsKTytwges0JdwVuA=
github.com/go-openapi/jsonpointer v0.21.2/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM=
github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU=
github.com/go-openapi/jsonreference v0.21.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA=
github.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/go-openapi/swag v0.24.1 h1:DPdYTZKo6AQCRqzwr/kGkxJzHhpKxZ9i/oX0zag+MF8=
github.com/go-openapi/swag v0.24.1/go.mod h1:sm8I3lCPlspsBBwUm1t5oZeWZS0s7m/A+Psg0ooRU0A=
github.com/go-openapi/swag/cmdutils v0.24.0 h1:KlRCffHwXFI6E5MV9n8o8zBRElpY4uK4yWyAMWETo9I=
github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8=
github.com/go-openapi/swag/conv v0.24.0 h1:ejB9+7yogkWly6pnruRX45D1/6J+ZxRu92YFivx54ik=
github.com/go-openapi/swag/conv v0.24.0/go.mod h1:jbn140mZd7EW2g8a8Y5bwm8/Wy1slLySQQ0ND6DPc2c=
github.com/go-openapi/swag/fileutils v0.24.0 h1:U9pCpqp4RUytnD689Ek/N1d2N/a//XCeqoH508H5oak=
github.com/go-openapi/swag/fileutils v0.24.0/go.mod h1:3SCrCSBHyP1/N+3oErQ1gP+OX1GV2QYFSnrTbzwli90=
github.com/go-openapi/swag/jsonname v0.24.0 h1:2wKS9bgRV/xB8c62Qg16w4AUiIrqqiniJFtZGi3dg5k=
github.com/go-openapi/swag/jsonname v0.24.0/go.mod h1:GXqrPzGJe611P7LG4QB9JKPtUZ7flE4DOVechNaDd7Q=
github.com/go-openapi/swag/jsonutils v0.24.0 h1:F1vE1q4pg1xtO3HTyJYRmEuJ4jmIp2iZ30bzW5XgZts=
github.com/go-openapi/swag/jsonutils v0.24.0/go.mod h1:vBowZtF5Z4DDApIoxcIVfR8v0l9oq5PpYRUuteVu6f0=
github.com/go-openapi/swag/loading v0.24.0 h1:ln/fWTwJp2Zkj5DdaX4JPiddFC5CHQpvaBKycOlceYc=
github.com/go-openapi/swag/loading v0.24.0/go.mod h1:gShCN4woKZYIxPxbfbyHgjXAhO61m88tmjy0lp/LkJk=
github.com/go-openapi/swag/mangling v0.24.0 h1:PGOQpViCOUroIeak/Uj/sjGAq9LADS3mOyjznmHy2pk=
github.com/go-openapi/swag/mangling v0.24.0/go.mod h1:Jm5Go9LHkycsz0wfoaBDkdc4CkpuSnIEf62brzyCbhc=
github.com/go-openapi/swag/netutils v0.24.0 h1:Bz02HRjYv8046Ycg/w80q3g9QCWeIqTvlyOjQPDjD8w=
github.com/go-openapi/swag/netutils v0.24.0/go.mod h1:WRgiHcYTnx+IqfMCtu0hy9oOaPR0HnPbmArSRN1SkZM=
github.com/go-openapi/swag/stringutils v0.24.0 h1:i4Z/Jawf9EvXOLUbT97O0HbPUja18VdBxeadyAqS1FM=
github.com/go-openapi/swag/stringutils v0.24.0/go.mod h1:5nUXB4xA0kw2df5PRipZDslPJgJut+NjL7D25zPZ/4w=
github.com/go-openapi/swag/typeutils v0.24.0 h1:d3szEGzGDf4L2y1gYOSSLeK6h46F+zibnEas2Jm/wIw=
github.com/go-openapi/swag/typeutils v0.24.0/go.mod h1:q8C3Kmk/vh2VhpCLaoR2MVWOGP8y7Jc8l82qCTd1DYI=
github.com/go-openapi/swag/yamlutils v0.24.0 h1:bhw4894A7Iw6ne+639hsBNRHg9iZg/ISrOVr+sJGp4c=
github.com/go-openapi/swag/yamlutils v0.24.0/go.mod h1:DpKv5aYuaGm/sULePoeiG8uwMpZSfReo1HR3Ik0yaG8=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -341,10 +369,10 @@ github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp4
github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
github.com/gohugoio/httpcache v0.7.0 h1:ukPnn04Rgvx48JIinZvZetBfHaWE7I01JR2Q2RrQ3Vs=
github.com/gohugoio/httpcache v0.7.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
github.com/gohugoio/hugo v0.147.6 h1:rL4rnus/5qzj4+FoA+JMzsVvFJ2YZdVIH6pbuCB2P84=
github.com/gohugoio/hugo v0.147.6/go.mod h1:Sb2COQPDPYG+tRSpePtzKytiuVDqkBivEhgIew1QbNo=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.3.0 h1:gj49kTR5Z4Hnm0ZaQrgPVazL3DUkppw+x6XhHCmh+Wk=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.3.0/go.mod h1:IMMj7xiUbLt1YNJ6m7AM4cnsX4cFnnfkleO/lBHGzUg=
github.com/gohugoio/hugo v0.149.1 h1:uWOc8Ve4h4e48FyYhBquRoHCJviyxA5yGrFJLT48yio=
github.com/gohugoio/hugo v0.149.1/go.mod h1:HS6BP6e8FGxungP4CHC3zeLDvhBLnTJIjHJZWTZjs7o=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0 h1:dco+7YiOryRoPOMXwwaf+kktZSCtlFtreNdiJbETvYE=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0/go.mod h1:CRrxQTKeM3imw+UoS4EHKyrqB7Zp6sAJiqHit+aMGTE=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1 h1:nUzXfRTszLliZuN0JTKeunXTRaiFX6ksaWP0puLLYAY=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1/go.mod h1:Wy8ThAA8p2/w1DY05vEzq6EIeI2mzDjvHsu7ULBVwog=
github.com/gohugoio/locales v0.14.0 h1:Q0gpsZwfv7ATHMbcTNepFd59H7GoykzWJIxi113XGDc=
@@ -356,8 +384,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20250825151631-54d70cc7cb31 h1:226lwSa0uZO7sd7QU88n43nDIqGoc+bOh0vSO3Q/byU=
github.com/golang/geo v0.0.0-20250825151631-54d70cc7cb31/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw=
github.com/golang/geo v0.0.0-20250912065020-b504328d3ef3 h1:vLpszxHK60Falbg9wHLAFI5LPqXlxnbvQRGxCJJHvLY=
github.com/golang/geo v0.0.0-20250912065020-b504328d3ef3/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -442,6 +470,8 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s=
github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk=
github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc=
github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado=
@@ -579,6 +609,8 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0j
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgechev/revive v1.11.0 h1:b/gLLpBE427o+Xmd8G58gSA+KtBwxWinH/A565Awh0w=
github.com/mgechev/revive v1.11.0/go.mod h1:tI0oLF/2uj+InHCBLrrqfTKfjtFTBCFFfG05auyzgdw=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE=
@@ -600,8 +632,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
github.com/niklasfasching/go-org v1.7.0 h1:vyMdcMWWTe/XmANk19F4k8XGBYg0GQ/gJGMimOjGMek=
github.com/niklasfasching/go-org v1.7.0/go.mod h1:WuVm4d45oePiE0eX25GqTDQIt/qPW1T9DGkRscqLW5o=
github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg=
github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
@@ -612,8 +644,12 @@ github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//J
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
@@ -718,10 +754,10 @@ github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o=
github.com/sonatard/noctx v0.4.0/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0=
github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
@@ -744,8 +780,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
@@ -756,10 +792,10 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tdakkota/asciicheck v0.4.1 h1:bm0tbcmi0jezRA2b5kg4ozmMuGAFotKI3RZfrhfovg8=
github.com/tdakkota/asciicheck v0.4.1/go.mod h1:0k7M3rCfRXb0Z6bwgvkEIMleKH3kXNz9UqJ9Xuqopr8=
github.com/tdewolff/minify/v2 v2.23.5 h1:/P548KcpTkIOUvNg22zN83/GiaYSOIrbqtoue4I7kYM=
github.com/tdewolff/minify/v2 v2.23.5/go.mod h1:2RI9tiIrzJU1Z5EasXEPaI1MqobRyxKHOOgrRkq5oEw=
github.com/tdewolff/parse/v2 v2.8.1 h1:J5GSHru6o3jF1uLlEKVXkDxxcVx6yzOlIVIotK4w2po=
github.com/tdewolff/parse/v2 v2.8.1/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/minify/v2 v2.24.2 h1:vnY3nTulEAbCAAlxTxPPDkzG24rsq31SOzp63yT+7mo=
github.com/tdewolff/minify/v2 v2.24.2/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE=
github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I=
github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA=
@@ -790,6 +826,8 @@ github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU=
github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM=
github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
@@ -807,8 +845,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.11 h1:ZCxLyDMtz0nT2HFfsYG8WZ47Trip2+JyLysKcMYE5bo=
github.com/yuin/goldmark v1.7.11/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo=
@@ -848,8 +886,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.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
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=
@@ -860,16 +898,16 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b h1:QoALfVG9rhQ/M7vYDScfPdWjGL9dlsVVM5VGh7aKoAA=
golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b h1:KdrhdYPDUvJTvrDK9gdjfFd6JTk8vA1WJoldYSi0kHo=
golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b/go.mod h1:LKZHyeOpPuZcMgxeHjJp4p5yvxrCX1xDvH10zYHhjjQ=
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.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
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=
@@ -895,8 +933,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.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
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=
@@ -939,16 +977,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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
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.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
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=
@@ -964,8 +1002,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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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=
@@ -1019,8 +1057,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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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=
@@ -1037,13 +1075,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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
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.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
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=
@@ -1098,8 +1136,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.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
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=
@@ -1185,8 +1223,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+4 -13
View File
@@ -44,20 +44,9 @@ var (
func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete chan struct{}) {
store = storage
config = &settings.Config
// Dev mode enables development features like template hot-reloading
devMode := os.Getenv("FILEBROWSER_DEVMODE") == "true"
_, err := os.Stat("http/dist")
// In dev mode, always use filesystem assets. Otherwise, check if http/dist exists
var embeddedFS bool
if devMode {
embeddedFS = false
} else {
embeddedFS = os.IsNotExist(err)
}
var err error
// --- START: ADD THIS DECRYPTION LOGIC ---
if embeddedFS {
if settings.Config.Server.EmbeddedFs {
// Embedded mode: Serve files from the embedded assets
assetFs, err = fs.Sub(assets, "embed")
if err != nil {
@@ -75,6 +64,8 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
return string(a), err
},
})
devMode := os.Getenv("FILEBROWSER_DEVMODE") == "true"
if !devMode {
templates = template.Must(templates.ParseFS(assetFs, "public/index.html"))
}
+11 -3
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"runtime"
"slices"
"strings"
@@ -48,7 +49,13 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContex
func withHashFileHelper(fn handleFunc) handleFunc {
return withOrWithoutUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
hash := r.URL.Query().Get("hash")
path := r.URL.Query().Get("path")
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
path, err := url.PathUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
if err != nil {
@@ -81,7 +88,8 @@ func withHashFileHelper(fn handleFunc) handleFunc {
}
// Get file information with options
getContent := r.URL.Query().Get("content") == "true"
if link.DisableFileViewer || link.Downloads >= link.DownloadsLimit {
reachedDownloadsLimit := link.Downloads >= link.DownloadsLimit && link.DownloadsLimit > 0
if link.DisableFileViewer || reachedDownloadsLimit {
getContent = false
}
file, err := FileInfoFasterFunc(iteminfo.FileOptions{
@@ -98,7 +106,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
file.Token = link.Token
file.Source = ""
file.Hash = link.Hash
if !link.EnableOnlyOffice || !link.DisableFileViewer {
if !link.EnableOnlyOffice || !link.DisableFileViewer || reachedDownloadsLimit {
file.OnlyOfficeId = ""
}
if getContent && file.Content != "" {
+40 -37
View File
@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
@@ -49,7 +50,21 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
return http.StatusBadRequest, errors.New("missing required parameters: path + source/hash are required")
}
themeMode := utils.Ternary(d.user.DarkMode, "dark", "light")
var sourceInfo settings.Source
var ok bool
if d.fileInfo.Hash != "" {
sourceInfo, ok = settings.Config.Server.SourceMap[source]
if !ok {
logger.Error("OnlyOffice: source not found")
return http.StatusInternalServerError, fmt.Errorf("source not found")
}
} else {
sourceInfo, ok = settings.Config.Server.NameToSource[source]
if !ok {
logger.Error("OnlyOffice: source not found")
return http.StatusInternalServerError, fmt.Errorf("source not found")
}
}
if d.fileInfo.Hash == "" {
// Build file info based on whether this is a share or regular request
// Regular user request - need to resolve scope
@@ -58,27 +73,20 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
logger.Errorf("OnlyOffice: source %s not available for user %s: %v", source, d.user.Username, scopeErr)
return http.StatusForbidden, fmt.Errorf("source %s is not available", source)
}
resolvedPath := utils.JoinPathAsUnix(userScope, path)
logger.Debugf("OnlyOffice user request: resolved path=%s", resolvedPath)
indexPath := utils.JoinPathAsUnix(userScope, path)
logger.Debugf("OnlyOffice user request: resolved path=%s", indexPath)
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: resolvedPath,
Path: indexPath,
Modify: d.user.Permissions.Modify,
Source: source,
Expand: false,
})
if err != nil {
logger.Errorf("OnlyOffice: failed to get file info for source=%s, path=%s: %v", source, resolvedPath, err)
logger.Errorf("OnlyOffice: failed to get file info for source=%s, path=%s: %v", source, indexPath, err)
return errToStatus(err), err
}
d.fileInfo = *fileInfo
} else {
// is a share, use the file info from the share middleware
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
if !ok {
logger.Error("OnlyOffice: source from share not found")
return http.StatusInternalServerError, fmt.Errorf("source not found for share")
}
source = sourceInfo.Name
// path is index path, so we build from share path
path = utils.JoinPathAsUnix(d.share.Path, path)
@@ -92,7 +100,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
}
// Determine file type and editing permissions
fileType := getFileExtension(d.fileInfo.Name)
fileType := strings.TrimPrefix(filepath.Ext(d.fileInfo.Name), ".")
canEdit := iteminfo.CanEditOnlyOffice(d.user.Permissions.Modify, fileType)
canEditMode := utils.Ternary(canEdit, "edit", "view")
if d.fileInfo.Hash != "" {
@@ -111,7 +119,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
}
// Generate document ID for OnlyOffice
documentId, err := getOnlyOfficeId(source, path)
documentId, err := getOnlyOfficeId(d.fileInfo.RealPath)
if err != nil {
logger.Errorf("OnlyOffice: failed to generate document ID for source=%s, path=%s: %v", source, path, err)
return http.StatusNotFound, fmt.Errorf("failed to generate document ID: %v", err)
@@ -131,7 +139,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
"title": d.fileInfo.Name,
"url": downloadURL,
"permissions": map[string]interface{}{
"edit": canEditMode,
"edit": utils.Ternary(settings.Config.Integrations.OnlyOffice.ViewOnly, "view", canEditMode),
"download": true,
"print": true,
},
@@ -148,7 +156,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
"uiTheme": themeMode,
},
"lang": d.user.Locale,
"mode": canEditMode,
"mode": utils.Ternary(settings.Config.Integrations.OnlyOffice.ViewOnly, "view", canEditMode),
},
}
@@ -166,15 +174,6 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
return renderJSON(w, r, clientConfig)
}
// getFileExtension extracts file extension from filename
func getFileExtension(filename string) string {
parts := strings.Split(filename, ".")
if len(parts) < 2 {
return ""
}
return parts[len(parts)-1]
}
// buildOnlyOfficeDownloadURL constructs the download URL that OnlyOffice server will use to fetch the file
func buildOnlyOfficeDownloadURL(source, path, hash, token string) string {
// Determine base URL (internal URL takes priority for OnlyOffice server communication)
@@ -254,6 +253,21 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
logger.Errorf("OnlyOffice callback missing required parameters: source=%s, path=%s", source, path)
return http.StatusBadRequest, errors.New("missing required parameters: path + source/hash are required")
}
var sourceInfo settings.Source
var ok bool
if d.fileInfo.Hash != "" {
sourceInfo, ok = settings.Config.Server.SourceMap[source]
if !ok {
logger.Error("OnlyOffice: source not found")
return http.StatusInternalServerError, fmt.Errorf("source not found")
}
} else {
sourceInfo, ok = settings.Config.Server.NameToSource[source]
if !ok {
logger.Error("OnlyOffice: source not found")
return http.StatusInternalServerError, fmt.Errorf("source not found")
}
}
if d.fileInfo.Hash == "" {
// Regular user request - need to resolve scope
@@ -264,12 +278,6 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
}
path = utils.JoinPathAsUnix(userScope, path)
} else {
// is a share, use the file info from the share middleware
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
if !ok {
logger.Error("OnlyOffice: source from share not found")
return http.StatusInternalServerError, fmt.Errorf("source not found for share")
}
source = sourceInfo.Name
// path is index path, so we build from share path
path = utils.JoinPathAsUnix(d.share.Path, path)
@@ -339,12 +347,7 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
return renderJSON(w, r, resp)
}
func getOnlyOfficeId(source, path string) (string, error) {
idx := indexing.GetIndex(source)
if idx == nil {
return "", fmt.Errorf("source not found")
}
realpath, _, _ := idx.GetRealPath(path)
func getOnlyOfficeId(realpath string) (string, error) {
// error is intentionally ignored in order treat errors
// the same as a cache-miss
cachedDocumentKey, ok := utils.OnlyOfficeCache.Get(realpath).(string)
+36 -6
View File
@@ -48,7 +48,7 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
source := r.URL.Query().Get("source")
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
source, err = url.PathUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
@@ -63,9 +63,8 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
Access: store.Access,
Username: d.user.Username,
Path: utils.JoinPathAsUnix(userscope, path),
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
Content: true,
})
if err != nil {
return errToStatus(err), err
@@ -98,15 +97,46 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
if !(previewSize == "large" || previewSize == "original") {
previewSize = "small"
}
if d.fileInfo.Type == "directory" {
return http.StatusBadRequest, fmt.Errorf("can't create preview for directory")
if d.fileInfo.Type == "directory" && !d.fileInfo.HasPreview {
return http.StatusBadRequest, fmt.Errorf("this folder does not have a preview")
}
if d.fileInfo.Type == "directory" {
// get extended file info of first previewable item in directory
for _, item := range d.fileInfo.Files {
if item.HasPreview {
source := d.fileInfo.Source
path := utils.JoinPathAsUnix(d.fileInfo.Path, item.Name)
if d.share != nil {
// Get the actual source name from the share's source mapping
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
if !ok {
return http.StatusInternalServerError, fmt.Errorf("source not found for share")
}
source = sourceInfo.Name
path = utils.JoinPathAsUnix(d.share.Path, path)
}
fileInfo, err := files.FileInfoFaster(
iteminfo.FileOptions{
Path: path,
Source: source,
Content: true,
})
if err != nil {
return http.StatusInternalServerError, err
}
d.fileInfo = *fileInfo
break
}
}
}
setContentDisposition(w, r, d.fileInfo.Name)
isImage := strings.HasPrefix(d.fileInfo.Type, "image")
if config.Server.DisableResize && isImage {
return rawFileHandler(w, r, d.fileInfo)
}
if !preview.AvailablePreview(d.fileInfo) {
if !d.fileInfo.HasPreview {
if isImage {
return rawFileHandler(w, r, d.fileInfo)
}
+2 -2
View File
@@ -116,13 +116,13 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
realPath, _, _ := idx.GetRealPath(userscope, path)
checksums, err := files.GetChecksum(realPath, algo)
checksum, err := utils.GetChecksum(realPath, algo)
if err == errors.ErrInvalidOption {
return http.StatusBadRequest, nil
} else if err != nil {
return http.StatusInternalServerError, err
}
fileInfo.Checksums = checksums
fileInfo.Checksums[algo] = checksum
}
return renderJSON(w, r, fileInfo)
+18 -6
View File
@@ -1,6 +1,7 @@
package http
import (
"fmt"
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
@@ -54,16 +55,27 @@ func settingsConfigHandler(w http.ResponseWriter, r *http.Request, d *requestCon
showFull := fullParam == "true"
showComments := commentsParam == "true"
// Generate YAML using the existing generator function (don't filter deprecated fields in API)
yamlOutput, err := settings.GenerateConfigYaml(config, showComments, showFull, false)
if err != nil {
return http.StatusInternalServerError, err
var err error
var yamlConfig string
if config.Server.EmbeddedFs {
embeddedYaml, readErr := assets.ReadFile("embed/config.generated.yaml")
if readErr != nil {
return http.StatusInternalServerError, fmt.Errorf("error reading embedded YAML: %v", readErr)
}
yamlConfig, err = settings.GenerateConfigYamlWithEmbedded(config, showComments, showFull, false, string(embeddedYaml))
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error generating YAML: %v", err)
}
} else {
yamlConfig, err = settings.GenerateConfigYaml(config, showComments, showFull, false)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error generating YAML: %v", err)
}
}
// Set content type and write response
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if _, err := w.Write([]byte(yamlOutput)); err != nil {
if _, err := w.Write([]byte(yamlConfig)); err != nil {
return http.StatusInternalServerError, err
}
+1 -1
View File
@@ -89,7 +89,7 @@ func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
encodedPath := r.URL.Query().Get("path")
sourceName := r.URL.Query().Get("source")
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
path, err := url.PathUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
+38 -25
View File
@@ -56,7 +56,9 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
defaultThemeColor := "#455a64"
staticURL := config.Server.BaseURL + "static"
publicStaticURL := config.Server.BaseURL + "public/static"
if d.share != nil {
staticURL = config.Server.BaseURL + "public/static"
}
// Use custom favicon if configured and validated, otherwise fall back to default
var favicon string
@@ -66,20 +68,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
// Default favicon
favicon = staticURL + "/img/icons/favicon-256x256.png"
}
data := map[string]interface{}{
"title": config.Frontend.Name,
"customCSS": config.Frontend.Styling.CustomCSSRaw,
"userSelectedTheme": userSelectedTheme,
"lightBackground": config.Frontend.Styling.LightBackground,
"darkBackground": config.Frontend.Styling.DarkBackground,
"staticURL": staticURL,
"baseURL": config.Server.BaseURL,
"favicon": favicon,
"color": defaultThemeColor,
"winIcon": staticURL + "/img/icons/mstile-144x144.png",
"appIcon": staticURL + "/img/icons/android-chrome-256x256.png",
"description": config.Frontend.Description,
}
data := make(map[string]interface{})
shareProps := map[string]interface{}{
"isShare": false,
"isValid": false,
@@ -117,13 +106,14 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
shareProps["isPasswordProtected"] = d.share.PasswordHash != ""
shareProps["downloadURL"] = getDownloadURL(r, d.share.Hash)
shareProps["enforceDarkLightMode"] = d.share.EnforceDarkLightMode
shareProps["viewMode"] = d.share.ViewMode
shareProps["enableOnlyOffice"] = d.share.EnableOnlyOffice
shareProps["enableOnlyOfficeEditing"] = d.share.EnableOnlyOfficeEditing
if d.share.Favicon != "" {
if strings.HasPrefix(d.share.Favicon, "http") {
data["favicon"] = d.share.Favicon
} else {
data["favicon"] = publicStaticURL + "/" + d.share.Favicon
data["favicon"] = staticURL + "/" + d.share.Favicon
}
}
if d.share.Description != "" {
@@ -135,15 +125,29 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
}
// base url could be different for routes behind proxy
data["staticURL"] = publicStaticURL
data["staticURL"] = staticURL
// Use custom favicon for shares too if configured
if config.Frontend.Favicon != "" {
data["favicon"] = publicStaticURL + "/favicon"
data["favicon"] = staticURL + "/favicon"
} else {
data["favicon"] = publicStaticURL + "/img/icons/favicon-256x256.png"
data["favicon"] = staticURL + "/img/icons/favicon-256x256.png"
}
data["winIcon"] = publicStaticURL + "/img/icons/mstile-144x144.png"
data["appIcon"] = publicStaticURL + "/img/icons/android-chrome-256x256.png"
data["winIcon"] = staticURL + "/img/icons/mstile-144x144.png"
data["appIcon"] = staticURL + "/img/icons/android-chrome-256x256.png"
}
data["htmlVars"] = map[string]interface{}{
"title": config.Frontend.Name,
"customCSS": config.Frontend.Styling.CustomCSSRaw,
"userSelectedTheme": userSelectedTheme,
"lightBackground": config.Frontend.Styling.LightBackground,
"darkBackground": config.Frontend.Styling.DarkBackground,
"staticURL": staticURL,
"baseURL": config.Server.BaseURL,
"favicon": favicon,
"color": defaultThemeColor,
"winIcon": staticURL + "/img/icons/mstile-144x144.png",
"appIcon": staticURL + "/img/icons/android-chrome-256x256.png",
"description": config.Frontend.Description,
}
// variables consumed by frontend as json
data["globalVars"] = map[string]interface{}{
@@ -169,13 +173,22 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"updateAvailable": utils.GetUpdateAvailableUrl(),
"disableNavButtons": disableNavButtons,
"userSelectableThemes": config.Frontend.Styling.CustomThemeOptions,
"share": shareProps,
"enableHeicConversion": config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview],
}
jsonVars, err := json.Marshal(data["globalVars"])
// Marshal each variable to JSON strings for direct template usage
globalVarsJSON, err := json.Marshal(data["globalVars"])
if err != nil {
return http.StatusInternalServerError, err
return http.StatusInternalServerError, fmt.Errorf("error marshaling globalVars: %w", err)
}
data["globalVars"] = strings.ReplaceAll(string(jsonVars), `'`, `\'`)
shareVarsJSON, err := json.Marshal(shareProps)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error marshaling shareVars: %w", err)
}
// Replace with JSON strings for direct template usage
data["globalVars"] = string(globalVarsJSON)
data["shareVars"] = string(shareVarsJSON)
// Render the template with global variables
if err := templateRenderer.Render(w, file, data); err != nil {
+71 -7
View File
@@ -65,6 +65,9 @@ const (
UNAVAILABLE IndexStatus = "unavailable"
)
// omitList contains directory names to skip during indexing
var omitList = []string{"$RECYCLE.BIN", "System Volume Information", "@eaDir"}
func init() {
indexes = make(map[string]*Index)
}
@@ -194,7 +197,7 @@ func (idx *Index) GetFsDirInfo(adjustedPath string) (*iteminfo.FileInfo, error)
ModTime: dirInfo.ModTime(),
},
}
fileInfo.DetectType(adjustedPath, false)
fileInfo.DetectType(realPath, false)
return &fileInfo, nil
}
combinedPath := adjustedPath + "/"
@@ -238,6 +241,13 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
var totalSize int64
fileInfos := []iteminfo.ItemInfo{}
dirInfos := []iteminfo.ItemInfo{}
hasPreview := false
if !recursive {
realDirInfo, exists := idx.GetMetadataInfo(adjustedPath, true)
if exists {
hasPreview = realDirInfo.HasPreview
}
}
// Process each file and directory in the current directory
for _, file := range files {
@@ -271,7 +281,7 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
}
}
// skip non-indexable dirs.
if file.Name() == "$RECYCLE.BIN" || file.Name() == "System Volume Information" {
if slices.Contains(omitList, file.Name()) {
continue
}
@@ -288,6 +298,7 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
realDirInfo, exists := idx.GetMetadataInfo(dirPath, true)
if exists {
itemInfo.Size = realDirInfo.Size
itemInfo.HasPreview = realDirInfo.HasPreview
}
totalSize += itemInfo.Size
itemInfo.Type = "directory"
@@ -297,7 +308,35 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
}
} else {
size, shouldCountSize := idx.handleFile(file, fullCombined)
itemInfo.DetectType(fullCombined, false)
itemInfo.DetectType(realPath+"/"+file.Name(), false)
simpleType := strings.Split(itemInfo.Type, "/")[0]
if simpleType == "audio" {
if recursive && !quick && idx.Config.IndexAlbumArt {
itemInfo.HasPreview = iteminfo.HasAlbumArt(realPath+"/"+file.Name(), filepath.Ext(file.Name()))
}
if !recursive && !itemInfo.HasPreview {
info, exists := idx.GetReducedMetadata(fullCombined, false)
if exists {
itemInfo.HasPreview = info.HasPreview
}
}
}
ext := strings.ToLower(filepath.Ext(file.Name()))
switch ext {
case ".jpg", ".jpeg", ".png", ".bmp", ".tiff":
itemInfo.HasPreview = true
case ".heic", ".heif":
if settings.Config.Integrations.Media.FfmpegPath != "" {
itemInfo.HasPreview = true
}
}
if simpleType == "image" {
itemInfo.HasPreview = true
}
if settings.Config.Integrations.Media.FfmpegPath != "" && simpleType == "video" {
itemInfo.HasPreview = true
}
itemInfo.Size = int64(size)
fileInfos = append(fileInfos, *itemInfo)
if shouldCountSize {
@@ -306,6 +345,16 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
if recursive {
idx.NumFiles++
}
if itemInfo.HasPreview {
hasPreview = true
}
// these don't create preview for parent folders
if settings.Config.Integrations.OnlyOffice.Secret != "" && iteminfo.IsOnlyOffice(file.Name()) {
itemInfo.HasPreview = true
}
if iteminfo.HasDocConvertableExtension(itemInfo.Name, itemInfo.Type) {
itemInfo.HasPreview = true
}
}
}
@@ -326,10 +375,11 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
Folders: dirInfos,
}
dirFileInfo.ItemInfo = iteminfo.ItemInfo{
Name: filepath.Base(dirInfo.Name()),
Type: "directory",
Size: totalSize,
ModTime: stat.ModTime(),
Name: filepath.Base(dirInfo.Name()),
Type: "directory",
Size: totalSize,
ModTime: stat.ModTime(),
HasPreview: hasPreview,
}
dirFileInfo.SortItems()
return dirFileInfo, nil
@@ -488,6 +538,13 @@ func (idx *Index) shouldSkip(isDir bool, isHidden bool, fullCombined, baseName s
}
}
}
if len(rules.FolderStartsWith) > 0 {
for _, start := range rules.FolderStartsWith {
if strings.HasPrefix(baseName, start) {
return true
}
}
}
} else {
if len(rules.FilePaths) > 0 {
for _, p := range rules.FilePaths {
@@ -506,6 +563,13 @@ func (idx *Index) shouldSkip(isDir bool, isHidden bool, fullCombined, baseName s
}
}
}
if len(rules.FileStartsWith) > 0 {
for _, start := range rules.FileStartsWith {
if strings.HasPrefix(baseName, start) {
return true
}
}
}
// Exclude if parent directory matches FolderPaths
if len(rules.FolderPaths) > 0 {
parent := filepath.Dir(fullCombined)
+77 -22
View File
@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"github.com/dhowden/tag"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
@@ -32,21 +33,21 @@ var SubtitleExts = []string{
".smi",
}
var MuPdfConvertable = []string{
".pdf", // PDF
".xps", // XPS
".epub", // EPUB
".mobi", // MOBI
".fb2", // FB2
".cbz", // CBZ
".svg", // SVG
".txt", // TXT
".docx", // DOCX
".pptx", // PPTX
".xlsx", // XLSX
".hwp", // HWP
".hwp", // HWPX
".md", // Markdown
var MuPdfConvertable = map[string]bool{
".pdf": true, // PDF
".xps": true, // XPS
".epub": true, // EPUB
".mobi": true, // MOBI
".fb2": true, // FB2
".cbz": true, // CBZ
".svg": true, // SVG
".txt": true, // TXT
".docx": true, // DOCX
".pptx": true, // PPTX
".xlsx": true, // XLSX
".hwp": true, // HWP
".hwpx": true, // HWPX
".md": true, // Markdown
}
// Known bundle-style extensions that are technically directories but treated as files
@@ -184,6 +185,26 @@ var compressedFile = []string{
".zstd",
}
var audioMetadataTypes = []string{
".mp3",
".flac",
".ogg",
".m4a",
".mp4",
".wav",
".ape",
".wv",
}
func CouldHaveAlbumArt(extension string) bool {
for _, typefile := range audioMetadataTypes {
if extension == typefile {
return true
}
}
return false
}
func ExtendedMimeTypeCheck(extension string) string {
if IsDoc(extension) {
return "application/document"
@@ -267,14 +288,19 @@ func IsMatchingType(extension string, matchType string) bool {
// DetectType detects the MIME type of a file and updates the ItemInfo struct.
func (i *ItemInfo) DetectType(realPath string, saveContent bool) {
name := i.Name
ext := filepath.Ext(name)
ext := strings.ToLower(filepath.Ext(name))
// Attempt MIME detection by file extension
if ext == ".md" {
switch ext {
case ".md":
i.Type = "text/markdown"
return
case ".heic", ".heif":
i.Type = "image/heic"
return
}
i.Type = strings.Split(mime.TypeByExtension(ext), ";")[0]
if i.Type == "" {
i.Type = ExtendedMimeTypeCheck(ext)
}
@@ -285,10 +311,40 @@ func (i *ItemInfo) DetectType(realPath string, saveContent bool) {
i.Type = DetectTypeByHeader(realPath)
return
}
if i.Type == "blob" {
if i.Type == "blob" || i.Type == "" {
i.Type = DetectTypeByHeader(realPath)
}
}
if i.Type == "" || i.Type == "application/octet-stream" {
i.Type = "blob"
}
}
// hasAlbumArt efficiently checks if an audio file contains embedded album art.
// It returns true if album art is found, and false otherwise.
func HasAlbumArt(filePath string, extension string) bool {
if !CouldHaveAlbumArt(extension) {
return false
}
file, err := os.Open(filePath)
if err != nil {
return false
}
defer file.Close()
// Read metadata from the file.
m, err := tag.ReadFrom(file)
if err != nil {
// If the error is simply that no tags were found, it's not a processing error.
// It just means there's no album art.
if err == tag.ErrNoTagsFound {
return false
}
// For any other error (e.g., corrupted file), return the error.
return false
}
// m.Picture() returns the first picture found. If it's not nil, album art exists.
return m.Picture() != nil
}
// DetectTypeByHeader detects the MIME type of a file based on its header.
@@ -406,10 +462,9 @@ func HasDocConvertableExtension(name, mimetype string) bool {
return true
}
ext := strings.ToLower(filepath.Ext(name))
for _, e := range MuPdfConvertable {
if ext == e {
return true
}
val, ok := MuPdfConvertable[ext]
if ok {
return val
}
return false
}
+19 -5
View File
@@ -9,11 +9,12 @@ import (
)
type ItemInfo struct {
Name string `json:"name"` // name of the file
Size int64 `json:"size"` // length in bytes for regular files
ModTime time.Time `json:"modified"` // modification time
Type string `json:"type"` // type of the file, either "directory" or a file mimetype
Hidden bool `json:"hidden"` // whether the file is hidden
Name string `json:"name"` // name of the file
Size int64 `json:"size"` // length in bytes for regular files
ModTime time.Time `json:"modified"` // modification time
Type string `json:"type"` // type of the file, either "directory" or a file mimetype
Hidden bool `json:"hidden"` // whether the file is hidden
HasPreview bool `json:"hasPreview"` // whether the file has a thumbnail preview
}
// FileInfo describes a file.
@@ -25,12 +26,25 @@ type FileInfo struct {
Path string `json:"path"` // path scoped to the associated index
}
// AudioMetadata contains metadata extracted from audio files
type AudioMetadata struct {
Title string `json:"title,omitempty"` // track title
Artist string `json:"artist,omitempty"` // track artist
Album string `json:"album,omitempty"` // album name
Year int `json:"year,omitempty"` // release year
Genre string `json:"genre,omitempty"` // music genre
Track int `json:"track,omitempty"` // track number
Duration int `json:"duration,omitempty"` // duration in seconds
AlbumArt string `json:"albumArt,omitempty"` // base64 encoded album art
}
// for efficiency, a response will be a pointer to the data
// extra calculated fields can be added here
type ExtendedFileInfo struct {
FileInfo
Content string `json:"content,omitempty"` // text content of a file, if requested
Subtitles []ffmpeg.SubtitleTrack `json:"subtitles,omitempty"` // subtitles for video files
AudioMeta *AudioMetadata `json:"audioMeta,omitempty"` // audio metadata for audio files
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
Token string `json:"token,omitempty"` // token for the file -- used for sharing
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
+36
View File
@@ -0,0 +1,36 @@
package preview
import (
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
)
// convertHEICToJPEGWithFFmpeg converts a HEIC file to JPEG format using FFmpeg
// This function handles all FFmpeg-related logic and parameters
func (s *Service) convertHEICToJPEGWithFFmpeg(filePath string, previewSize string) ([]byte, error) {
// Create FFmpeg image service
imageService := ffmpeg.NewImageService(s.ffmpegPath, s.ffprobePath, s.debug, filepath.Join(settings.Config.Server.CacheDir, "heic", filepath.Base(filePath)))
// Determine target dimensions and quality based on preview size
var width, height int
var quality string
switch previewSize {
case "large":
width, height = 640, 640
quality = "2" // High quality for FFmpeg -q:v
case "original":
// For original size - no scaling, maximum quality
width, height = 0, 0 // Signal to not apply scaling
quality = "1" // Maximum quality for original
default:
width, height = 256, 256
quality = "5" // Medium quality
}
// Use tile-based conversion for correct full-resolution image reconstruction
result, err := imageService.ConvertHEICToJPEG(filePath, width, height, quality)
if err != nil {
return nil, err
}
return result, nil
}
+23 -4
View File
@@ -1,4 +1,3 @@
//go:generate go-enum --sql --marshal --file $GOFILE
package preview
import (
@@ -8,9 +7,9 @@ import (
"image"
"io"
"github.com/kovidgoyal/imaging"
exif "github.com/dsoprea/go-exif/v3"
exifcommon "github.com/dsoprea/go-exif/v3/common"
"github.com/kovidgoyal/imaging"
)
// Format is an image file format.
@@ -21,6 +20,7 @@ png
gif
tiff
bmp
heic
)
*/
type Format int
@@ -37,6 +37,8 @@ func (x Format) toImaging() imaging.Format {
return imaging.TIFF
case FormatBmp:
return imaging.BMP
case FormatHeic:
return imaging.JPEG
default:
return imaging.JPEG
}
@@ -73,6 +75,12 @@ fill
type ResizeMode int
func (s *Service) FormatFromExtension(ext string) (Format, error) {
// heic is not supported by imaging, so we return FormatHeic
switch ext {
case ".heic", ".heif":
return FormatHeic, nil
}
format, err := imaging.FormatFromExtension(ext)
if err != nil {
return -1, ErrUnsupportedFormat
@@ -139,6 +147,10 @@ func (s *Service) Resize(in io.Reader, width, height int, out io.Writer, options
option(&config)
}
if format == FormatHeic {
config.format = FormatJpeg
}
if config.quality == QualityLow && format == FormatJpeg {
thm, newWrappedReader, errThm := getEmbeddedThumbnail(wrappedReader)
wrappedReader = newWrappedReader
@@ -150,16 +162,19 @@ func (s *Service) Resize(in io.Reader, width, height int, out io.Writer, options
}
}
img, err := imaging.Decode(wrappedReader, imaging.AutoOrientation(true))
// For HEIC files, try without AutoOrientation first since it might not work properly
img, err := imaging.Decode(wrappedReader)
if err != nil {
return err
}
// Note: For HEIC files processed via FFmpeg, orientation is handled automatically
switch config.resizeMode {
case ResizeModeFill:
img = imaging.Fill(img, width, height, imaging.Center, config.quality.resampleFilter())
case ResizeModeFit:
fallthrough
img = imaging.Fit(img, width, height, config.quality.resampleFilter())
default:
img = imaging.Fit(img, width, height, config.quality.resampleFilter())
}
@@ -176,6 +191,10 @@ func (s *Service) detectFormat(in io.Reader) (Format, io.Reader, error) {
return 0, nil, fmt.Errorf("%s: %w", err.Error(), ErrUnsupportedFormat)
}
if imgFormat == "heif" {
imgFormat = "heic"
}
format, err := ParseFormat(imgFormat)
if err != nil {
return 0, nil, ErrUnsupportedFormat
+15 -11
View File
@@ -19,16 +19,19 @@ const (
FormatTiff
// FormatBmp is a Format of type Bmp
FormatBmp
// FormatHeic is a Format of type Heic
FormatHeic
)
const _FormatName = "jpegpnggiftiffbmp"
const _FormatName = "jpegpnggiftiffbmpheic"
var _FormatMap = map[Format]string{
0: _FormatName[0:4],
1: _FormatName[4:7],
2: _FormatName[7:10],
3: _FormatName[10:14],
4: _FormatName[14:17],
0: _FormatName[0:4], // jpeg
1: _FormatName[4:7], // png
2: _FormatName[7:10], // gif
3: _FormatName[10:14], // tiff
4: _FormatName[14:17], // bmp
5: _FormatName[17:21], // heic
}
// String implements the Stringer interface.
@@ -40,11 +43,12 @@ func (x Format) String() string {
}
var _FormatValue = map[string]Format{
_FormatName[0:4]: 0,
_FormatName[4:7]: 1,
_FormatName[7:10]: 2,
_FormatName[10:14]: 3,
_FormatName[14:17]: 4,
_FormatName[0:4]: 0, // jpeg
_FormatName[4:7]: 1, // png
_FormatName[7:10]: 2, // gif
_FormatName[10:14]: 3, // tiff
_FormatName[14:17]: 4, // bmp
_FormatName[17:21]: 5, // heic
}
// ParseFormat attempts to convert a string to a Format
+56 -29
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
@@ -13,10 +14,10 @@ import (
"runtime"
"strings"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/diskcache"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -61,6 +62,10 @@ func NewPreviewGenerator(concurrencyLimit int, ffmpegPath string, cacheDir strin
if err != nil {
logger.Error(err)
}
err = os.MkdirAll(filepath.Join(settings.Config.Server.CacheDir, "heic"), 0755)
if err != nil {
logger.Error(err)
}
ffmpegMainPath, err := CheckValidFFmpeg(ffmpegPath)
if err != nil && ffmpegPath != "" {
logger.Fatalf("the configured ffmpeg path does not contain a valid ffmpeg binary %s, err: %v", ffmpegPath, err)
@@ -70,11 +75,11 @@ func NewPreviewGenerator(concurrencyLimit int, ffmpegPath string, cacheDir strin
logger.Fatalf("the configured ffmpeg path is not a valid ffprobe binary %s, err: %v", ffmpegPath, err)
}
if errprobe == nil && err == nil {
logger.Infof("Media Enabled : %v", errprobe == nil)
settings.Config.Integrations.Media.FfmpegPath = filepath.Base(ffmpegMainPath)
}
logger.Debugf("Media Enabled : %v", ffmpegMainPath != "" && ffprobePath != "")
settings.Config.Server.MuPdfAvailable = docEnabled()
logger.Infof("MuPDF Enabled : %v", settings.Config.Server.MuPdfAvailable)
logger.Debugf("MuPDF Enabled : %v", settings.Config.Server.MuPdfAvailable)
return &Service{
sem: make(chan struct{}, concurrencyLimit),
ffmpegPath: ffmpegMainPath,
@@ -90,16 +95,31 @@ func StartPreviewGenerator(concurrencyLimit int, ffmpegPath, cacheDir string) er
}
func GetPreviewForFile(file iteminfo.ExtendedFileInfo, previewSize, url string, seekPercentage int) ([]byte, error) {
if !AvailablePreview(file) {
if !file.HasPreview {
return nil, ErrUnsupportedMedia
}
cacheKey := CacheKey(file.RealPath, previewSize, file.ModTime, seekPercentage)
var thisMd5 string
if file.AudioMeta != nil && file.AudioMeta.AlbumArt != "" {
// md5 is based on album art
// md5 file.AlbumArt
hasher := md5.New() //nolint:gosec
_, _ = hasher.Write([]byte(file.AudioMeta.AlbumArt))
thisMd5 = hex.EncodeToString(hasher.Sum(nil))
file.Checksums = make(map[string]string)
file.Checksums["md5"] = thisMd5
} else {
var err error
thisMd5, err = utils.GetChecksum(file.RealPath, "md5")
if err != nil {
return nil, fmt.Errorf("failed to get checksum: %w", err)
}
}
cacheKey := CacheKey(thisMd5, previewSize, seekPercentage)
if data, found, err := service.fileCache.Load(context.Background(), cacheKey); err != nil {
return nil, fmt.Errorf("failed to load from cache: %w", err)
} else if found {
return data, nil
}
return GeneratePreview(file, previewSize, url, seekPercentage)
}
@@ -109,9 +129,10 @@ func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl stri
err error
imageBytes []byte
)
// Generate thumbnail image from video
hasher := md5.New() //nolint:gosec
_, _ = hasher.Write([]byte(CacheKey(file.RealPath, previewSize, file.ModTime, seekPercentage)))
_, _ = hasher.Write([]byte(CacheKey(file.Checksums["md5"], previewSize, seekPercentage)))
hash := hex.EncodeToString(hasher.Sum(nil))
// Generate an image from office document
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
@@ -125,8 +146,19 @@ func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl stri
if err != nil {
return nil, fmt.Errorf("failed to create image for office file: %w", err)
}
} else if strings.HasPrefix(file.Type, "image/heic") {
// HEIC files need FFmpeg conversion to JPEG with proper size/quality handling
imageBytes, err = service.convertHEICToJPEGWithFFmpeg(file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to process HEIC image file: %w", err)
}
// For HEIC files, we've already done the resize/conversion, so cache and return directly
cacheKey := CacheKey(file.Checksums["md5"], previewSize, seekPercentage)
if err = service.fileCache.Store(context.Background(), cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache HEIC image: %v", err)
}
return imageBytes, nil
} else if strings.HasPrefix(file.Type, "image") {
// get image bytes from file
imageBytes, err = os.ReadFile(file.RealPath)
if err != nil {
return nil, fmt.Errorf("failed to read image file: %w", err)
@@ -141,6 +173,16 @@ func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl stri
if err != nil {
return nil, fmt.Errorf("failed to create image for video file: %w", err)
}
} else if strings.HasPrefix(file.Type, "audio") {
// Extract album artwork from audio files
if file.AudioMeta != nil && file.AudioMeta.AlbumArt != "" {
imageBytes, err = base64.StdEncoding.DecodeString(file.AudioMeta.AlbumArt)
if err != nil {
return nil, fmt.Errorf("failed to decode album artwork: %w", err)
}
} else {
return nil, fmt.Errorf("no album artwork available for audio file: %s", file.Name)
}
} else {
return nil, fmt.Errorf("unsupported media type: %s", ext)
}
@@ -155,13 +197,13 @@ func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl stri
return nil, fmt.Errorf("failed to resize preview image: %w", err)
}
// Cache and return
cacheKey := CacheKey(file.RealPath, previewSize, file.ModTime, seekPercentage)
cacheKey := CacheKey(file.Checksums["md5"], previewSize, seekPercentage)
if err := service.fileCache.Store(context.Background(), cacheKey, resizedBytes); err != nil {
logger.Errorf("failed to cache resized image: %v", err)
}
return resizedBytes, nil
} else {
cacheKey := CacheKey(file.RealPath, previewSize, file.ModTime, seekPercentage)
cacheKey := CacheKey(file.Checksums["md5"], previewSize, seekPercentage)
if err := service.fileCache.Store(context.Background(), cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache resized image: %v", err)
}
@@ -198,35 +240,20 @@ func (s *Service) CreatePreview(data []byte, previewSize string) ([]byte, error)
return output.Bytes(), nil
}
func CacheKey(realPath, previewSize string, modTime time.Time, percentage int) string {
return fmt.Sprintf("%x%x%x%x", realPath, modTime.Unix(), previewSize, percentage)
func CacheKey(md5, previewSize string, percentage int) string {
return fmt.Sprintf("%x%x%x", md5, previewSize, percentage)
}
func DelThumbs(ctx context.Context, file iteminfo.ExtendedFileInfo) {
errSmall := service.fileCache.Delete(ctx, CacheKey(file.RealPath, "small", file.ModTime, 0))
errSmall := service.fileCache.Delete(ctx, CacheKey(file.Checksums["md5"], "small", 0))
if errSmall != nil {
errLarge := service.fileCache.Delete(ctx, CacheKey(file.RealPath, "large", file.ModTime, 0))
errLarge := service.fileCache.Delete(ctx, CacheKey(file.Checksums["md5"], "large", 0))
if errLarge != nil {
logger.Debugf("Could not delete thumbnail: %v", file.Name)
}
}
}
func AvailablePreview(file iteminfo.ExtendedFileInfo) bool {
if strings.HasPrefix(file.Type, "video") && service.ffmpegPath != "" {
return true
}
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
return true
}
ext := strings.ToLower(filepath.Ext(file.Name))
switch ext {
case ".jpg", ".jpeg", ".png", ".bmp", ".tiff":
return true
}
return file.OnlyOfficeId != ""
}
// CheckValidFFmpeg checks for a valid ffmpeg executable.
// If a path is provided, it looks there. Otherwise, it searches the system's PATH.
func CheckValidFFmpeg(path string) (string, error) {
+65 -6
View File
@@ -2549,6 +2549,10 @@ const docTemplate = `{
"$ref": "#/definitions/iteminfo.ItemInfo"
}
},
"hasPreview": {
"description": "whether the file has a thumbnail preview",
"type": "boolean"
},
"hidden": {
"description": "whether the file is hidden",
"type": "boolean"
@@ -2578,6 +2582,10 @@ const docTemplate = `{
"iteminfo.ItemInfo": {
"type": "object",
"properties": {
"hasPreview": {
"description": "whether the file has a thumbnail preview",
"type": "boolean"
},
"hidden": {
"description": "whether the file is hidden",
"type": "boolean"
@@ -2665,6 +2673,13 @@ const docTemplate = `{
"type": "string"
}
},
"fileStartsWith": {
"description": "(global) exclude files that start with these prefixes. Eg. \"archive-\" or \"backup-\"",
"type": "array",
"items": {
"type": "string"
}
},
"folderEndsWith": {
"description": "(global) exclude folders that end with these suffixes. Eg. \".thumbnails\" or \".git\"",
"type": "array",
@@ -2686,6 +2701,13 @@ const docTemplate = `{
"type": "string"
}
},
"folderStartsWith": {
"description": "(global) exclude folders that start with these prefixes. Eg. \"archive-\" or \"backup-\"",
"type": "array",
"items": {
"type": "string"
}
},
"hidden": {
"description": "exclude hidden files and folders.",
"type": "boolean"
@@ -2717,6 +2739,18 @@ const docTemplate = `{
}
}
},
"settings.FfmpegConvert": {
"type": "object",
"properties": {
"imagePreview": {
"description": "supported image preview formats. default is heic",
"type": "object",
"additionalProperties": {
"type": "boolean"
}
}
}
},
"settings.Frontend": {
"type": "object",
"properties": {
@@ -2835,6 +2869,14 @@ const docTemplate = `{
"settings.Media": {
"type": "object",
"properties": {
"convert": {
"description": "config for ffmpeg conversion settings",
"allOf": [
{
"$ref": "#/definitions/settings.FfmpegConvert"
}
]
},
"ffmpegPath": {
"description": "path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)",
"type": "string"
@@ -2908,6 +2950,10 @@ const docTemplate = `{
"url": {
"description": "The URL to the OnlyOffice Document Server, needs to be accessible to the user.",
"type": "string"
},
"viewOnly": {
"description": "view only mode for OnlyOffice",
"type": "boolean"
}
}
},
@@ -3148,6 +3194,10 @@ const docTemplate = `{
}
]
},
"indexAlbumArt": {
"description": "check for album art in audio files and remember it for folder previews. Can slow down indexing.",
"type": "boolean"
},
"indexingIntervalMinutes": {
"description": "optional manual overide interval in minutes to re-index the source",
"type": "integer"
@@ -3410,6 +3460,7 @@ const docTemplate = `{
"type": "string"
},
"viewMode": {
"description": "default view mode for anonymous users: \"list\", \"compact\", \"normal\", \"gallery\"",
"type": "string"
}
}
@@ -3515,6 +3566,7 @@ const docTemplate = `{
"type": "integer"
},
"viewMode": {
"description": "default view mode for anonymous users: \"list\", \"compact\", \"normal\", \"gallery\"",
"type": "string"
}
}
@@ -3545,6 +3597,9 @@ const docTemplate = `{
"users.FileLoading": {
"type": "object",
"properties": {
"clearAll": {
"type": "boolean"
},
"maxConcurrentUpload": {
"type": "integer"
},
@@ -3601,28 +3656,32 @@ const docTemplate = `{
"description": "disable the hide sidebar preview for previews and editors",
"type": "boolean"
},
"folder": {
"description": "show thumbnail preview image for folder files",
"type": "boolean"
},
"highQuality": {
"description": "generate high quality preview images",
"description": "generate high quality thumbnail preview images",
"type": "boolean"
},
"image": {
"description": "show real image as icon instead of generic photo icon",
"description": "show thumbnail preview image for image files",
"type": "boolean"
},
"motionVideoPreview": {
"description": "show multiple frames for videos in preview when hovering",
"description": "show multiple frames for videos in thumbnail preview when hovering",
"type": "boolean"
},
"office": {
"description": "show preview image for office files",
"description": "show thumbnail preview image for office files",
"type": "boolean"
},
"popup": {
"description": "show larger popup preview when hovering",
"description": "show larger popup preview when hovering over thumbnail",
"type": "boolean"
},
"video": {
"description": "show preview image for video files",
"description": "show thumbnail preview image for video files",
"type": "boolean"
}
}
+65 -6
View File
@@ -2538,6 +2538,10 @@
"$ref": "#/definitions/iteminfo.ItemInfo"
}
},
"hasPreview": {
"description": "whether the file has a thumbnail preview",
"type": "boolean"
},
"hidden": {
"description": "whether the file is hidden",
"type": "boolean"
@@ -2567,6 +2571,10 @@
"iteminfo.ItemInfo": {
"type": "object",
"properties": {
"hasPreview": {
"description": "whether the file has a thumbnail preview",
"type": "boolean"
},
"hidden": {
"description": "whether the file is hidden",
"type": "boolean"
@@ -2654,6 +2662,13 @@
"type": "string"
}
},
"fileStartsWith": {
"description": "(global) exclude files that start with these prefixes. Eg. \"archive-\" or \"backup-\"",
"type": "array",
"items": {
"type": "string"
}
},
"folderEndsWith": {
"description": "(global) exclude folders that end with these suffixes. Eg. \".thumbnails\" or \".git\"",
"type": "array",
@@ -2675,6 +2690,13 @@
"type": "string"
}
},
"folderStartsWith": {
"description": "(global) exclude folders that start with these prefixes. Eg. \"archive-\" or \"backup-\"",
"type": "array",
"items": {
"type": "string"
}
},
"hidden": {
"description": "exclude hidden files and folders.",
"type": "boolean"
@@ -2706,6 +2728,18 @@
}
}
},
"settings.FfmpegConvert": {
"type": "object",
"properties": {
"imagePreview": {
"description": "supported image preview formats. default is heic",
"type": "object",
"additionalProperties": {
"type": "boolean"
}
}
}
},
"settings.Frontend": {
"type": "object",
"properties": {
@@ -2824,6 +2858,14 @@
"settings.Media": {
"type": "object",
"properties": {
"convert": {
"description": "config for ffmpeg conversion settings",
"allOf": [
{
"$ref": "#/definitions/settings.FfmpegConvert"
}
]
},
"ffmpegPath": {
"description": "path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)",
"type": "string"
@@ -2897,6 +2939,10 @@
"url": {
"description": "The URL to the OnlyOffice Document Server, needs to be accessible to the user.",
"type": "string"
},
"viewOnly": {
"description": "view only mode for OnlyOffice",
"type": "boolean"
}
}
},
@@ -3137,6 +3183,10 @@
}
]
},
"indexAlbumArt": {
"description": "check for album art in audio files and remember it for folder previews. Can slow down indexing.",
"type": "boolean"
},
"indexingIntervalMinutes": {
"description": "optional manual overide interval in minutes to re-index the source",
"type": "integer"
@@ -3399,6 +3449,7 @@
"type": "string"
},
"viewMode": {
"description": "default view mode for anonymous users: \"list\", \"compact\", \"normal\", \"gallery\"",
"type": "string"
}
}
@@ -3504,6 +3555,7 @@
"type": "integer"
},
"viewMode": {
"description": "default view mode for anonymous users: \"list\", \"compact\", \"normal\", \"gallery\"",
"type": "string"
}
}
@@ -3534,6 +3586,9 @@
"users.FileLoading": {
"type": "object",
"properties": {
"clearAll": {
"type": "boolean"
},
"maxConcurrentUpload": {
"type": "integer"
},
@@ -3590,28 +3645,32 @@
"description": "disable the hide sidebar preview for previews and editors",
"type": "boolean"
},
"folder": {
"description": "show thumbnail preview image for folder files",
"type": "boolean"
},
"highQuality": {
"description": "generate high quality preview images",
"description": "generate high quality thumbnail preview images",
"type": "boolean"
},
"image": {
"description": "show real image as icon instead of generic photo icon",
"description": "show thumbnail preview image for image files",
"type": "boolean"
},
"motionVideoPreview": {
"description": "show multiple frames for videos in preview when hovering",
"description": "show multiple frames for videos in thumbnail preview when hovering",
"type": "boolean"
},
"office": {
"description": "show preview image for office files",
"description": "show thumbnail preview image for office files",
"type": "boolean"
},
"popup": {
"description": "show larger popup preview when hovering",
"description": "show larger popup preview when hovering over thumbnail",
"type": "boolean"
},
"video": {
"description": "show preview image for video files",
"description": "show thumbnail preview image for video files",
"type": "boolean"
}
}
+52 -6
View File
@@ -100,6 +100,9 @@ definitions:
items:
$ref: '#/definitions/iteminfo.ItemInfo'
type: array
hasPreview:
description: whether the file has a thumbnail preview
type: boolean
hidden:
description: whether the file is hidden
type: boolean
@@ -121,6 +124,9 @@ definitions:
type: object
iteminfo.ItemInfo:
properties:
hasPreview:
description: whether the file has a thumbnail preview
type: boolean
hidden:
description: whether the file is hidden
type: boolean
@@ -189,6 +195,12 @@ definitions:
items:
type: string
type: array
fileStartsWith:
description: (global) exclude files that start with these prefixes. Eg. "archive-"
or "backup-"
items:
type: string
type: array
folderEndsWith:
description: (global) exclude folders that end with these suffixes. Eg. ".thumbnails"
or ".git"
@@ -208,6 +220,12 @@ definitions:
items:
type: string
type: array
folderStartsWith:
description: (global) exclude folders that start with these prefixes. Eg.
"archive-" or "backup-"
items:
type: string
type: array
hidden:
description: exclude hidden files and folders.
type: boolean
@@ -230,6 +248,14 @@ definitions:
- text
- url
type: object
settings.FfmpegConvert:
properties:
imagePreview:
additionalProperties:
type: boolean
description: supported image preview formats. default is heic
type: object
type: object
settings.Frontend:
properties:
description:
@@ -315,6 +341,10 @@ definitions:
type: object
settings.Media:
properties:
convert:
allOf:
- $ref: '#/definitions/settings.FfmpegConvert'
description: config for ffmpeg conversion settings
ffmpegPath:
description: path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
type: string
@@ -372,6 +402,9 @@ definitions:
description: The URL to the OnlyOffice Document Server, needs to be accessible
to the user.
type: string
viewOnly:
description: view only mode for OnlyOffice
type: boolean
required:
- secret
- url
@@ -550,6 +583,10 @@ definitions:
allOf:
- $ref: '#/definitions/settings.IncludeIndexFilter'
description: include files and folders from indexing, if exclude is not set
indexAlbumArt:
description: check for album art in audio files and remember it for folder
previews. Can slow down indexing.
type: boolean
indexingIntervalMinutes:
description: optional manual overide interval in minutes to re-index the source
type: integer
@@ -747,6 +784,8 @@ definitions:
unit:
type: string
viewMode:
description: 'default view mode for anonymous users: "list", "compact", "normal",
"gallery"'
type: string
type: object
share.Link:
@@ -821,6 +860,8 @@ definitions:
userID:
type: integer
viewMode:
description: 'default view mode for anonymous users: "list", "compact", "normal",
"gallery"'
type: string
type: object
users.AuthToken:
@@ -840,6 +881,8 @@ definitions:
type: object
users.FileLoading:
properties:
clearAll:
type: boolean
maxConcurrentUpload:
type: integer
uploadChunkSizeMb:
@@ -879,23 +922,26 @@ definitions:
disableHideSidebar:
description: disable the hide sidebar preview for previews and editors
type: boolean
folder:
description: show thumbnail preview image for folder files
type: boolean
highQuality:
description: generate high quality preview images
description: generate high quality thumbnail preview images
type: boolean
image:
description: show real image as icon instead of generic photo icon
description: show thumbnail preview image for image files
type: boolean
motionVideoPreview:
description: show multiple frames for videos in preview when hovering
description: show multiple frames for videos in thumbnail preview when hovering
type: boolean
office:
description: show preview image for office files
description: show thumbnail preview image for office files
type: boolean
popup:
description: show larger popup preview when hovering
description: show larger popup preview when hovering over thumbnail
type: boolean
video:
description: show preview image for video files
description: show thumbnail preview image for video files
type: boolean
type: object
users.Sorting:
-2
View File
@@ -55,8 +55,6 @@
"shallowOnly": true
}
]
// no-undef is already included in
// @vue/eslint-config-typescript
},
"parserOptions": {
"ecmaVersion": "latest",
+1 -1
View File
@@ -31,7 +31,6 @@
"file-loader": "^6.2.0",
"glob": "^9.3.5",
"highlight.js": "^11.11.1",
"jsmediatags": "github:Kurami32/jsmediatags",
"mammoth": "^1.9.1",
"marked": "^15.0.6",
"normalize.css": "^8.0.1",
@@ -56,6 +55,7 @@
"fs-extra": "^11.3.0",
"jsdom": "^25.0.1",
"vite": "^6.2.0",
"vite-plugin-checker": "^0.10.3",
"vite-plugin-compression2": "^1.0.0",
"vitest": "^3.0.7",
"vue-tsc": "^2.0.7"
+9
View File
@@ -39,12 +39,15 @@ server:
folderNames: []
fileEndsWith: []
folderEndsWith: []
fileStartsWith: []
folderStartsWith: []
include: # include files and folders from indexing, if exclude is not set
rootFolders: []
rootFiles: []
defaultUserScope: "/" # default "/" should match folders under path
defaultEnabled: false # should be added as a default source for new users?
createUserDir: false # create a user directory for each user
indexAlbumArt: false # check for album art in audio files and remember it for folder previews. Can slow down indexing.
externalUrl: "" # used by share links if set (eg. http://mydomain.com)
internalUrl: "" # used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
cacheDir: "tmp" # path to the cache directory, used for thumbnails and other cached files
@@ -137,6 +140,7 @@ userDefaults:
popup: false
autoplayMedia: false
defaultMediaPlayer: false
folder: false
permissions:
api: false
admin: false
@@ -149,6 +153,7 @@ userDefaults:
fileLoading: # upload and download settings
maxConcurrentUpload: 10
uploadChunkSizeMb: 10
clearAll: false
disableOnlyOfficeExt: "" # list of file extensions to disable onlyoffice editor for
customTheme: "" # Name of theme to use chosen from custom themes config.
showSelectMultiple: false # show select multiple files on desktop
@@ -158,5 +163,9 @@ integrations:
url: "" # The URL to the OnlyOffice Document Server, needs to be accessible to the user. validate:required
internalUrl: "" # An optional internal address that the filebrowser server can use to communicate with the OnlyOffice Document Server, could be useful to bypass proxy.
secret: "**hidden**" # secret: authentication key for OnlyOffice integration validate:required
viewOnly: false # view only mode for OnlyOffice
media: # validate:omitempty
ffmpegPath: "" # path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
convert: # config for ffmpeg conversion settings
imagePreview: # supported image preview formats. default is heic
heic: true
+18 -17
View File
@@ -7,49 +7,50 @@
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>{{ .title }}</title>
<link rel="icon" type="image/png" sizes="256x256" href="{{ .favicon }}">
<link rel="icon" type="image/png" sizes="256x256" href="{{ .htmlVars.favicon }}">
<!--<link href="https://fonts.googleapis.com/css?family=Material+Icons|Material+Symbols+Outlined" rel="stylesheet">-->
<!-- Add to home screen for Android and modern mobile browsers -->
<link rel="manifest" id="manifestPlaceholder" crossorigin="use-credentials">
<meta name="theme-color" content="{{ .color }}">
<meta name="theme-color" content="{{ .htmlVars.color }}">
<!-- Add to home screen for Safari on iOS/iPadOS -->
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="assets">
<link rel="apple-touch-icon" sizes="180x180" href="{{ .appIcon }}">
<link rel="apple-touch-icon" sizes="180x180" href="{{ .htmlVars.appIcon }}">
<!-- Add to home screen for Windows -->
<meta name="msapplication-TileImage" content="{{ .winIcon }}">
<meta name="msapplication-TileColor" content="{{ .color }}">
<meta name="msapplication-TileImage" content="{{ .htmlVars.winIcon }}">
<meta name="msapplication-TileColor" content="{{ .htmlVars.color }}">
<meta name="description" content="{{ .description }}">
<meta name="description" content="{{ .htmlVars.description }}">
<link rel="canonical" href="https://github.com/gtsteffaniak/filebrowser">
<link rel="canonical" href="https://github.com/gtsteffaniak">
<link rel="canonical" href="https://github.com/quantumx-apps">
<!-- Inject Some Variables and generate the manifest json -->
<script>
window.filebrowser = JSON.parse('{{ .globalVars }}');
window.globalVars = {{ .globalVars }};
window.shareVars = {{ .shareVars }};
var dynamicManifest = {
"name": window.filebrowser.name || 'FileBrowser Quantum',
"name": window.globalVars.name || 'FileBrowser Quantum',
"icons": [
{
"src": window.location.origin + "{{ .staticURL }}/img/icons/android-chrome-256x256.png",
"src": window.location.origin + "{{ .htmlVars.staticURL }}/img/icons/android-chrome-256x256.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": window.location.origin + "{{ .staticURL }}/img/icons/android-chrome-192x192.png",
"src": window.location.origin + "{{ .htmlVars.staticURL }}/img/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
],
"start_url": window.location.origin + "{{ .baseURL }}",
"start_url": window.location.origin + "{{ .htmlVars.baseURL }}",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "{{ .color }}"
"theme_color": "{{ .htmlVars.color }}"
}
const stringManifest = JSON.stringify(dynamicManifest);
@@ -61,7 +62,7 @@
<style>
/* Basic Styles */
:root {
--background: {{ .lightBackground }};
--background: {{ .htmlVars.lightBackground }};
--alt-background: #ddd;
--surfacePrimary: gray;
--surfaceSecondary: lightgray;
@@ -71,7 +72,7 @@
--activeWhiteIcon: gray;
}
.dark-mode {
--background: {{ .darkBackground }};
--background: {{ .htmlVars.darkBackground }};
--alt-background: #283136;
--surfacePrimary: #20292F;
--surfaceSecondary: #3A4147;
@@ -155,15 +156,15 @@
transform: scale(1.0);
}
}
{{ .customCSS }}
{{ .userSelectedTheme }}
{{ .htmlVars.customCSS }}
{{ .htmlVars.userSelectedTheme }}
</style>
</head>
<body>
<div id="app"></div>
{{ if .darkMode -}}
{{ if .htmlVars.darkMode -}}
<div id="loading" class="dark-mode">
<div class="spinner">
<div class="bounce1"></div>
+2 -2
View File
@@ -1,10 +1,10 @@
import { baseURL } from "@/utils/constants";
import { globalVars } from "@/utils/constants";
const ssl = window.location.protocol === "https:";
const protocol = ssl ? "wss:" : "ws:";
export default function command(url, command, onmessage, onclose) {
url = `${protocol}//${window.location.host}${baseURL}api/command${url}`;
url = `${protocol}//${window.location.host}${globalVars.baseURL}api/command${url}`;
let conn = new window.WebSocket(url);
conn.onopen = () => conn.send(command);
conn.onmessage = onmessage;
+3 -3
View File
@@ -2,7 +2,7 @@ import { fetchURL, adjustedData } from './utils'
import { getApiPath, doubleEncode, getPublicApiPath } from '@/utils/url.js'
import { mutations } from '@/store'
import { notify } from '@/notify'
import { externalUrl } from '@/utils/constants'
import { globalVars } from '@/utils/constants'
// Notify if errors occur
export async function fetchFiles(source, path, content = false) {
@@ -229,8 +229,8 @@ export function getDownloadURL(source, path, inline, useExternal) {
...(inline && { inline: 'true' })
}
const apiPath = getApiPath('api/raw', params)
if (externalUrl && useExternal) {
return externalUrl + apiPath
if (globalVars.externalUrl && useExternal) {
return globalVars.externalUrl + apiPath
}
return window.origin + apiPath
} catch (err) {
+6 -5
View File
@@ -1,7 +1,7 @@
import { adjustedData } from "./utils";
import { notify } from "@/notify";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { externalUrl } from "@/utils/constants";
import { getApiPath, getPublicApiPath, encodedPath } from "@/utils/url.js";
import { globalVars } from "@/utils/constants";
import { state } from "@/store";
// ============================================================================
@@ -17,8 +17,9 @@ import { state } from "@/store";
* @returns {Promise<any>}
*/
export async function fetchPub(path, hash, password = "", content = false) {
path = encodedPath(path);
const params = {
path,
path: path,
hash,
...(content && { content: 'true' }),
...(state.share.token && { token: state.share.token })
@@ -97,9 +98,9 @@ export function getPreviewURL(path,size="small") {
* @returns {string}
*/
export function getShareURL(share) {
if (externalUrl) {
if (globalVars.externalUrl) {
const apiPath = getApiPath(`public/share/${share.hash}`)
return externalUrl + apiPath;
return globalVars.externalUrl + apiPath;
}
return window.origin + getApiPath(`public/share/${share.hash}`);
}
+43 -5
View File
@@ -1,12 +1,50 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('@/utils/constants', () => {
return {
globalVars: {
baseURL: "unit-testing",
name: "Test App",
mediaAvailable: true,
muPdfAvailable: true,
onlyOfficeUrl: "",
recaptcha: false,
recaptchaKey: "",
darkMode: false,
oidcAvailable: false,
passwordAvailable: true,
externalUrl: "",
minSearchLength: 1,
disableNavButtons: false,
userSelectableThemes: {},
enableThumbs: true,
noAuth: false,
loginPage: true,
signup: false,
version: "test",
commitSHA: "test",
disableExternal: false,
externalLinks: [],
updateAvailable: "",
},
shareInfo: {
isShare: false,
disableThumbnails: false,
hash: "",
enforceDarkLightMode: "",
disableSidebar: false,
isValid: true,
},
serverHasMultipleSources: false,
logoURL: "test-logo.png",
origin: "http://localhost",
settings: [],
};
});
import { adjustedData } from './utils.js';
describe('adjustedData', () => {
vi.doMock('@/utils/constants', () => {
return {
baseURL: "unit-testing",
};
});
it('should append the URL and process directory data correctly', () => {
const input = {
type: "directory",
+2 -10
View File
@@ -146,7 +146,7 @@
import downloadFiles from "@/utils/download";
import { state, getters, mutations } from "@/store";
import Action from "@/components/Action.vue";
import { onlyOfficeUrl } from "@/utils/constants.js";
import { globalVars } from "@/utils/constants.js";
import buttons from "@/utils/buttons";
import { notify } from "@/notify";
import { eventBus } from "@/store/eventBus";
@@ -246,7 +246,7 @@ export default {
return getters.currentPromptName() == "ContextMenu";
},
onlyofficeEnabled() {
return onlyOfficeUrl !== "";
return globalVars.onlyOfficeUrl !== "";
},
isSearchActive() {
return state.isSearchActive;
@@ -281,14 +281,6 @@ export default {
},
},
watch: {
currentPrompt: {
handler(prompt) {
if (prompt && prompt.name === "ContextMenu") {
this.setPositions();
}
},
deep: true,
},
hasOverflowItems: {
handler(hasItems) {
mutations.setContextMenuHasItems(hasItems);
+39
View File
@@ -18,12 +18,21 @@ https://raw.githubusercontent.com/dzwillia/vue-simple-progress/master/src/compon
v-if="textPosition == 'inside'"
>
{{ displayed_text }}
<i
v-if="helpText && status === 'error'"
class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip"
@mouseleave="hideTooltip"
>
help
</i>
</div>
</div>
</template>
<script>
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
import { mutations } from "@/store";
// We're leaving this untouched as you can read in the beginning
var isNumber = function (n) {
@@ -92,6 +101,10 @@ export default {
type: String,
default: 'default',
},
"help-text": {
type: String,
default: "",
},
},
computed: {
isValNumeric() {
@@ -244,6 +257,20 @@ export default {
return style;
},
},
methods: {
showTooltip(event) {
if (this.helpText) {
mutations.showTooltip({
content: this.helpText,
x: event.clientX,
y: event.clientY,
});
}
},
hideTooltip() {
mutations.hideTooltip();
},
},
};
</script>
@@ -263,4 +290,16 @@ export default {
.vue-simple-progress-text {
color: black;
}
.tooltip-info-icon {
font-size: 1rem;
cursor: pointer;
margin-left: 0.3em;
vertical-align: middle;
opacity: 0.7;
}
.tooltip-info-icon:hover {
opacity: 1;
}
</style>
+7 -8
View File
@@ -147,7 +147,7 @@ import { getters, mutations, state } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { url } from "@/utils/";
import Icon from "@/components/files/Icon.vue";
import { serverHasMultipleSources, baseURL, minSearchLength } from "@/utils/constants";
import { globalVars, serverHasMultipleSources } from "@/utils/constants";
var boxes = {
folder: { label: "folders", icon: "folder" },
@@ -169,7 +169,7 @@ export default {
return {
largerThan: "",
smallerThan: "",
noneMessage: this.$t("search.typeToSearch", { minSearchLength: minSearchLength }),
noneMessage: this.$t("search.typeToSearch", { minSearchLength: globalVars.minSearchLength }),
searchTypes: "",
isTypeSelectDisabled: false,
showHelp: false,
@@ -294,7 +294,7 @@ export default {
if (this.ongoing) {
return "";
}
return this.$t("search.typeToSearch", { minSearchLength: minSearchLength })
return this.$t("search.typeToSearch", { minSearchLength: globalVars.minSearchLength })
},
isRunning() {
return this.ongoing;
@@ -395,9 +395,9 @@ export default {
const encodedPath = encodeURIComponent(context + "/" + path).replaceAll("%2F", "/");
let fullpath = encodedPath;
if (serverHasMultipleSources) {
fullpath = baseURL+"files/" + this.selectedSource + encodedPath;
fullpath = globalVars.baseURL+"files/" + this.selectedSource + encodedPath;
} else {
fullpath = baseURL+"files" + encodedPath;
fullpath = globalVars.baseURL+"files" + encodedPath;
}
return fullpath;
},
@@ -483,9 +483,8 @@ export default {
if (event != undefined) {
event.preventDefault();
}
if (this.value === "" || this.value.length < minSearchLength) {
this.ongoing = false;
this.noneMessage = this.$t("search.notEnoughCharacters", { minSearchLength: minSearchLength });
if (this.value === "" || this.value.length < globalVars.minSearchLength) {
this.ongoing = false; this.noneMessage = this.$t("search.notEnoughCharacters", { minSearchLength: globalVars.minSearchLength });
return;
}
let searchTypesFull = this.searchTypes;
+24 -48
View File
@@ -24,19 +24,13 @@
</template>
<script>
import {
onlyOfficeUrl,
mediaAvailable,
muPdfAvailable,
baseURL,
} from "@/utils/constants";
import { globalVars, shareInfo } from "@/utils/constants";
import { getTypeInfo } from "@/utils/mimetype";
import { mutations, state } from "@/store";
import { shareInfo } from "@/utils/constants";
import { mutations, state, getters } from "@/store";
// NEW: Define placeholder and error image URLs for easy configuration
const PLACEHOLDER_URL = baseURL + "static/img/placeholder.png"; // A generic loading placeholder
const ERROR_URL = baseURL + "static/img/placeholder.png";
const PLACEHOLDER_URL = globalVars.baseURL + "static/img/placeholder.png"; // A generic loading placeholder
const ERROR_URL = globalVars.baseURL + "static/img/placeholder.png";
export default {
name: "Icon",
@@ -49,6 +43,10 @@ export default {
type: String,
required: true,
},
hasPreview: {
type: Boolean,
default: false,
},
active: {
type: Boolean,
},
@@ -79,7 +77,16 @@ export default {
if (shareInfo.disableThumbnails) {
return false;
}
return this.isPreviewImg && this.imageState !== 'error' && !this.disablePreviewExt && !this.officeFileDisabled
if (this.thumbnailUrl == "") {
return false;
}
if (!this.hasPreview) {
return false;
}
if (!state.user.preview.folder && this.mimetype == "directory") {
return false;
}
return this.imageState !== 'error' && !this.disablePreviewExt && !this.officeFileDisabled
},
disablePreviewExt() {
const ext = "." + (this.filename.split(".").pop() || "").toLowerCase(); // Ensure lowercase and dot
@@ -92,7 +99,7 @@ export default {
return state.user?.disablePreviewExt?.includes(ext);
},
pdfConvertable() {
if (!muPdfAvailable) {
if (!globalVars.muPdfAvailable) {
return false; // If muPDF is not available
}
const ext = "." + (this.filename.split(".").pop() || "").toLowerCase(); // Ensure lowercase and dot
@@ -126,16 +133,16 @@ export default {
return this.imageTargetSrc;
},
showLargeIcon() {
return state.user.viewMode === "gallery" && state.user.preview.highQuality;
return getters.viewMode() === "gallery" && state.user.preview.highQuality;
},
showLarger() {
return state.user.viewMode === "gallery" || state.user.viewMode === "normal";
return getters.viewMode() === "gallery" || getters.viewMode() === "normal";
},
hasMotion() {
return (
this.getIconForType().simpleType === "video" &&
state.user.preview?.video &&
mediaAvailable &&
globalVars.mediaAvailable &&
// @ts-ignore
state.user.preview.motionVideoPreview
);
@@ -143,37 +150,6 @@ export default {
isMaterialIcon() {
return this.materialIcon !== "";
},
isPreviewImg() {
if (this.thumbnailUrl == "") {
return false;
}
if (this.mimetype == "text/csv") {
return false;
}
// @ts-ignore
if (this.pdfConvertable && state.user.preview?.office) {
return true;
}
if (this.getIconForType().simpleType === "image" && state.user.preview?.image) {
return true;
}
if (
this.getIconForType().simpleType === "video" &&
state.user.preview?.video &&
mediaAvailable
) {
return true;
}
if (
this.getIconForType().simpleType === "document" &&
// @ts-ignore
state.user.preview?.office &&
onlyOfficeUrl != ""
) {
return true;
}
return false;
},
},
methods: {
// NEW: Centralized method to load any image and handle its state
@@ -276,11 +252,11 @@ export default {
showLargeIcon() {
this.updateImageTargetSrc();
},
// UPDATED: Added a check for isPreviewImg
// UPDATED: Added a check for hasPreviewImage
imageTargetSrc: {
handler(newSrc) {
// ONLY trigger the image loader if the component is meant to show a preview.
if (this.isPreviewImg) {
if (this.hasPreviewImage) {
this.loadImage(newSrc);
}
},
+10 -8
View File
@@ -34,6 +34,7 @@
:active="isSelected"
:thumbnailUrl="isThumbnailInView ? thumbnailUrl : ''"
:filename="name"
:hasPreview="hasPreview"
/>
</div>
@@ -54,6 +55,7 @@
@click.stop="downloadFile"
v-if="quickDownloadEnabled"
:filename="name"
:hasPreview="hasPreview"
mimetype="file_download"
style="padding-right: 0.5em"
class="download-icon"
@@ -66,7 +68,7 @@
</template>
<script>
import { enableThumbs } from "@/utils/constants";
import { globalVars, serverHasMultipleSources, shareInfo } from "@/utils/constants";
import downloadFiles from "@/utils/download";
import { getHumanReadableFilesize } from "@/utils/filesizes";
@@ -75,7 +77,6 @@ import * as upload from "@/utils/upload";
import { state, getters, mutations } from "@/store"; // Import your custom store
import { url } from "@/utils";
import Icon from "@/components/files/Icon.vue";
import { baseURL, serverHasMultipleSources, shareInfo } from "@/utils/constants";
export default {
name: "item",
@@ -106,10 +107,11 @@ export default {
"path",
"reducedOpacity",
"hash",
"hasPreview",
],
computed: {
galleryView() {
return state.user.viewMode === "gallery";
return getters.viewMode() === "gallery";
},
quickDownloadEnabled() {
// @ts-ignore
@@ -164,7 +166,7 @@ export default {
return true;
},
thumbnailUrl() {
if (!enableThumbs) {
if (!globalVars.enableThumbs) {
return "";
}
const previewPath = url.removeTrailingSlash(state.req.path) + "/" + this.name;
@@ -175,7 +177,7 @@ export default {
return filesApi.getPreviewURL(state.req.source, previewPath, this.modified);
},
isThumbsEnabled() {
return enableThumbs;
return globalVars.enableThumbs;
},
},
mounted() {
@@ -232,12 +234,12 @@ export default {
},
getUrl() {
if (this.hash) {
return baseURL + "public/share/" + this.hash + "/" + url.encodedPath(this.path);
return globalVars.baseURL + "public/share/" + this.hash + "/" + url.encodedPath(this.path);
}
if (serverHasMultipleSources) {
return baseURL + "files/" + encodeURIComponent(this.source) + url.encodedPath(this.path);
return globalVars.baseURL + "files/" + encodeURIComponent(this.source) + url.encodedPath(this.path);
}
return baseURL + "files" + url.encodedPath(this.path);
return globalVars.baseURL + "files" + url.encodedPath(this.path);
},
/** @param {MouseEvent} event */
onRightClick(event) {
@@ -0,0 +1,882 @@
<template>
<!-- Left edge detection zone -->
<div
v-if="enabled && hasPrevious"
class="nav-zone nav-zone-left"
:class="{ moveWithSidebar: moveWithSidebar }"
@mousemove="toggleNavigation"
@touchstart="(e) => { handleTouchStart(e); toggleNavigation(e); }"
@touchmove="handleTouchMove"
@click="handleClick"
></div>
<!-- Right edge detection zone -->
<div
v-if="enabled && hasNext"
class="nav-zone nav-zone-right"
@mousemove="toggleNavigation"
@touchstart="(e) => { handleTouchStart(e); toggleNavigation(e); }"
@touchmove="handleTouchMove"
@click="handleClick"
></div>
<!-- Previous button -->
<button
v-if="enabled && hasPrevious"
@click.stop="handlePrevClick"
@mousedown="startDrag($event, 'previous')"
@touchstart="handleTouchStart($event, 'previous')"
@touchmove="handleButtonTouchMove"
@touchend="handleTouchEnd"
@mouseover="setHoverNav(true)"
@mouseleave="setHoverNav(false)"
class="nav-button nav-previous"
:class="{
moveWithSidebar: moveWithSidebar,
hidden: !showNav,
disabled: !hasPrevious,
dragging: dragState.type === 'previous',
active: dragState.atFullExtent && dragState.type === 'previous',
'dark-mode': isDarkMode,
}"
:style="dragState.type === 'previous' ? { transform: `translateY(-50%) translate(${dragState.deltaX}px, 0)` } : {}"
:aria-label="$t('buttons.previous')"
:title="$t('buttons.previous')"
>
<i class="material-icons">
{{ dragState.type === 'previous' && dragState.atFullExtent ? 'list_alt' : 'chevron_left' }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</i>
</button>
<!-- Next button -->
<button
v-if="enabled && hasNext"
@click.stop="handleNextClick"
@mousedown="startDrag($event, 'next')"
@touchstart="handleTouchStart($event, 'next')"
@touchmove="handleButtonTouchMove"
@touchend="handleTouchEnd"
@mouseover="setHoverNav(true)"
@mouseleave="setHoverNav(false)"
class="nav-button nav-next"
:class="{ hidden: !showNav, dragging: dragState.type === 'next', active: dragState.atFullExtent && dragState.type === 'next','dark-mode': isDarkMode}"
:style="dragState.type === 'next' ? { transform: `translateY(-50%) translate(${dragState.deltaX}px, 0)` } : {}"
:aria-label="$t('buttons.next')"
:title="$t('buttons.next')"
>
<i class="material-icons">
{{ dragState.type === 'next' && dragState.atFullExtent ? 'list_alt' : 'chevron_right' }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</i>
</button>
<!-- Prefetch links for better performance -->
<link v-if="previousRaw" rel="prefetch" :href="previousRaw" />
<link v-if="nextRaw" rel="prefetch" :href="nextRaw" />
</template>
<script>
import { state, getters, mutations } from "@/store";
import throttle from "@/utils/throttle";
import { previewViews } from "@/utils/constants";
import { url } from "@/utils";
import { filesApi, publicApi } from "@/api";
export default {
name: "NextPrevious",
data() {
return {
hoverNav: false,
dragState: {
isDragging: false,
type: null, // 'previous' or 'next'
startX: 0,
startY: 0,
deltaX: 0,
deltaY: 0,
threshold: 0, // Will be calculated as 10em in pixels
atFullExtent: false,
triggered: false,
},
// State tracking
navigationTimeout: null,
isSwipe: false,
touchStartX: 0,
touchStartY: 0,
// Button touch handling
touchState: {
isButtonTouch: false,
buttonType: null,
startTime: 0,
hasMoved: false,
tapTimeout: null,
triggered: false
},
};
},
computed: {
isDarkMode() { return getters.isDarkMode(); },
moveWithSidebar() {
return getters.isSidebarVisible() && getters.isStickySidebar();
},
enabled() {
return state.navigation.enabled;
},
showNav() {
const shouldShow = state.navigation.show || this.hoverNav;
return shouldShow;
},
hasPrevious() {
const has = state.navigation.previousLink !== "";
return has;
},
hasNext() {
const has = state.navigation.nextLink !== "";
return has;
},
previousRaw() {
return state.navigation.previousRaw;
},
nextRaw() {
return state.navigation.nextRaw;
},
currentView() {
const view = getters.currentView();
return view;
}
},
watch: {
currentView() {
this.updateNavigationEnabled();
// Also trigger navigation setup if we're now in a preview view
this.$nextTick(() => {
if (this.enabled && state.req) {
this.setupNavigationForCurrentItem();
}
});
},
'state.req': {
handler() {
this.updateNavigationEnabled();
// Auto-setup navigation when request changes and we're enabled
if (this.enabled) {
this.$nextTick(() => {
this.setupNavigationForCurrentItem();
});
}
},
deep: true,
immediate: false
},
enabled(newEnabled) {
if (newEnabled && state.req) {
this.$nextTick(() => {
this.setupNavigationForCurrentItem();
});
}
},
'$route'() {
// Give time for state.req to be updated, then setup navigation
setTimeout(() => {
this.$nextTick(() => {
if (this.enabled && state.req) {
this.setupNavigationForCurrentItem();
}
});
}, 100);
},
// Watch for when navigation links are set up
'state.navigation.previousLink'() {
this.showInitialNavigation();
},
'state.navigation.nextLink'() {
this.showInitialNavigation();
},
},
mounted() {
window.addEventListener("keydown", this.keyEvent);
window.addEventListener("mousemove", this.handleDrag);
window.addEventListener("mouseup", this.endDrag);
window.addEventListener("touchmove", this.handleDrag, { passive: false });
window.addEventListener("touchend", this.endDrag);
// Calculate 10em threshold in pixels
const emSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
this.dragState.threshold = 10 * emSize;
this.updateNavigationEnabled();
// Setup navigation if enabled and we have a current item
if (this.enabled && state.req) {
this.$nextTick(() => {
this.setupNavigationForCurrentItem();
});
} else {
this.$nextTick(() => {
this.showInitialNavigation();
});
}
},
beforeUnmount() {
window.removeEventListener("keydown", this.keyEvent);
window.removeEventListener("mousemove", this.handleDrag);
window.removeEventListener("mouseup", this.endDrag);
window.removeEventListener("touchmove", this.handleDrag);
window.removeEventListener("touchend", this.endDrag);
// Clear our local timeout
if (this.navigationTimeout) {
clearTimeout(this.navigationTimeout);
this.navigationTimeout = null;
}
// Clean up touch state
this.resetTouchState();
mutations.clearNavigation();
},
methods: {
updateNavigationEnabled() {
const shouldEnable = previewViews.includes(this.currentView);
mutations.setNavigationEnabled(shouldEnable);
},
async setupNavigationForCurrentItem() {
if (!this.enabled || !state.req || state.req.type === 'directory') {
// Clear navigation when not applicable
mutations.clearNavigation();
return;
}
const directoryPath = url.removeLastDir(state.req.path);
let listing = null;
// Try to get listing from current request first
if (state.req.items) {
listing = state.req.items;
} else {
// Fetch the directory listing
try {
let res;
if (getters.isShare()) {
res = await publicApi.fetchPub(directoryPath, state.share.hash);
} else {
res = await filesApi.fetchFiles(state.req.source, directoryPath);
}
listing = res.items;
} catch (error) {
listing = [state.req]; // Fallback to current item only
}
}
mutations.setupNavigation({
listing: listing,
currentItem: state.req,
directoryPath: directoryPath
});
},
showInitialNavigation() {
// Show navigation initially for 3 seconds when navigation is set up
if (this.enabled && (this.hasPrevious || this.hasNext)) {
mutations.setNavigationShow(true);
// Clear any existing timeout
if (this.navigationTimeout) {
clearTimeout(this.navigationTimeout);
this.navigationTimeout = null;
}
this.navigationTimeout = setTimeout(() => {
if (!this.hoverNav) {
mutations.setNavigationShow(false);
}
this.navigationTimeout = null;
}, 3000);
}
},
prev() {
if (this.hasPrevious) {
this.hoverNav = false;
this.$router.replace({ path: state.navigation.previousLink });
}
},
next() {
if (this.hasNext) {
this.hoverNav = false;
this.$router.replace({ path: state.navigation.nextLink });
}
},
keyEvent(event) {
// Only handle navigation if enabled and no prompt is active
if (!this.enabled || state.prompts.length > 0) {
return;
}
const { key } = event;
switch (key) {
case "ArrowRight":
if (this.hasNext) {
event.preventDefault();
this.next();
}
break;
case "ArrowLeft":
if (this.hasPrevious) {
event.preventDefault();
this.prev();
}
break;
}
},
handleClick() {
// Don't show navigation if this is part of a swipe gesture
if (this.isSwipe) {
return;
}
// Simplified: clicking anywhere in the CSS zones shows navigation
if (!this.enabled || (!this.hasPrevious && !this.hasNext)) {
return;
}
this.showNavigation();
},
showNavigation() {
mutations.setNavigationShow(true);
mutations.clearNavigationTimeout();
// Clear our local timeout too
if (this.navigationTimeout) {
clearTimeout(this.navigationTimeout);
this.navigationTimeout = null;
}
this.navigationTimeout = setTimeout(() => {
if (!this.hoverNav) {
mutations.setNavigationShow(false);
}
mutations.clearNavigationTimeout();
this.navigationTimeout = null;
}, 3000); // Show for 3 seconds
mutations.setNavigationTimeout(this.navigationTimeout);
},
toggleNavigation: throttle(function () {
if (!this.enabled) {
return;
}
if (this.isSwipe) {
return;
}
this.showNavigation();
}, 100),
setHoverNav(value) {
this.hoverNav = value;
mutations.setNavigationHover(value);
},
// Touch handling and swipe detection (similar to ListingItem.vue)
handleTouchStart(event, buttonType = null) {
if (event.touches && event.touches.length > 0) {
const touch = event.touches[0];
this.touchStartX = touch.clientX;
this.touchStartY = touch.clientY;
this.isSwipe = false;
// Handle button-specific touch
if (buttonType) {
this.touchState = {
isButtonTouch: true,
buttonType: buttonType,
startTime: Date.now(),
hasMoved: false,
tapTimeout: null,
triggered: false
};
// Set a longer timeout to allow for drag intent detection
this.touchState.tapTimeout = setTimeout(() => {
// If we haven't moved significantly and not dragging, treat as tap
if (!this.touchState.hasMoved && !this.dragState.isDragging) {
this.handleButtonTap(buttonType);
}
}, 300); // Increased to 300ms to allow for drag detection
}
}
},
handleTouchMove(event) {
if (!event.touches || event.touches.length === 0) return;
const touch = event.touches[0];
const deltaX = Math.abs(touch.clientX - this.touchStartX);
const deltaY = Math.abs(touch.clientY - this.touchStartY);
const movementThreshold = 10;
if (deltaX > movementThreshold || deltaY > movementThreshold) {
this.isSwipe = true;
this.cancelNavigationTimeout();
}
},
// Handle touch movement specifically for navigation buttons
handleButtonTouchMove(event) {
if (!event.touches || event.touches.length === 0) return;
if (!this.touchState.isButtonTouch) return;
event.preventDefault(); // Prevent scrolling while dragging
const touch = event.touches[0];
const deltaX = Math.abs(touch.clientX - this.touchStartX);
const deltaY = Math.abs(touch.clientY - this.touchStartY);
const movementThreshold = 10;
// Check if user has moved enough to start dragging
if (deltaX > movementThreshold || deltaY > movementThreshold) {
this.touchState.hasMoved = true;
// Cancel tap timeout since user is dragging
if (this.touchState.tapTimeout) {
clearTimeout(this.touchState.tapTimeout);
this.touchState.tapTimeout = null;
}
// Initialize drag state if not already dragging
if (!this.dragState.isDragging) {
// Calculate 10em threshold in pixels if not set
if (!this.dragState.threshold) {
const emSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
this.dragState.threshold = 10 * emSize;
}
this.dragState = {
isDragging: true,
type: this.touchState.buttonType,
startX: this.touchStartX,
startY: this.touchStartY,
deltaX: 0,
deltaY: 0,
threshold: this.dragState.threshold,
atFullExtent: false,
triggered: false,
};
}
// Update drag position - implement drag logic directly
if (this.dragState.isDragging) {
let dragDeltaX = touch.clientX - this.dragState.startX;
const maxDrag = this.dragState.threshold; // 10em
// Constrain drag to correct direction and max distance
if (this.dragState.type === 'previous') {
// Left button: only allow rightward drag (positive deltaX)
dragDeltaX = Math.max(0, Math.min(maxDrag, dragDeltaX));
} else if (this.dragState.type === 'next') {
// Right button: only allow leftward drag (negative deltaX)
dragDeltaX = Math.min(0, Math.max(-maxDrag, dragDeltaX));
}
this.dragState.deltaX = dragDeltaX;
// Check if we've reached the full extent
const atFullExtent = Math.abs(dragDeltaX) >= maxDrag;
this.dragState.atFullExtent = atFullExtent;
}
}
},
handleTouchEnd() {
// Handle touch end for buttons
if (this.touchState.isButtonTouch) {
const touchDuration = Date.now() - this.touchState.startTime;
// If it was a short touch without movement, and we haven't already navigated, treat as tap
if (!this.touchState.hasMoved && touchDuration < 300 && this.touchState.tapTimeout) {
clearTimeout(this.touchState.tapTimeout);
this.touchState.tapTimeout = null;
this.handleButtonTap(this.touchState.buttonType);
}
// Reset touch state
this.resetTouchState();
}
// Reset navigation swipe state
this.isSwipe = false;
// Let endDrag handle the drag cleanup
if (this.dragState.isDragging) {
this.endDrag();
}
},
cancelNavigationTimeout() {
if (this.navigationTimeout) {
clearTimeout(this.navigationTimeout);
this.navigationTimeout = null;
}
},
// Handle immediate button tap (mobile-friendly)
handleButtonTap(buttonType) {
// Prevent double navigation if already triggered
if (this.touchState.triggered) {
return;
}
// Clear any pending timeouts
if (this.touchState.tapTimeout) {
clearTimeout(this.touchState.tapTimeout);
this.touchState.tapTimeout = null;
}
// Mark as triggered to prevent double navigation
this.touchState.triggered = true;
// Navigate immediately on tap
if (buttonType === 'previous' && this.hasPrevious) {
this.prev();
} else if (buttonType === 'next' && this.hasNext) {
this.next();
}
// Reset touch state
this.resetTouchState();
},
resetTouchState() {
if (this.touchState.tapTimeout) {
clearTimeout(this.touchState.tapTimeout);
}
this.touchState = {
isButtonTouch: false,
buttonType: null,
startTime: 0,
hasMoved: false,
tapTimeout: null,
triggered: false
};
},
// Drag functionality for navigation buttons
startDrag(event, type) {
event.preventDefault();
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
const clientY = event.touches ? event.touches[0].clientY : event.clientY;
this.dragState = {
isDragging: true,
type: type,
startX: clientX,
startY: clientY,
deltaX: 0,
deltaY: 0,
threshold: this.dragState.threshold,
atFullExtent: false,
triggered: false,
};
},
handleDrag(event) {
if (!this.dragState.isDragging) return;
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
let deltaX = clientX - this.dragState.startX;
// Constrain drag to correct direction and max distance
const maxDrag = this.dragState.threshold; // 10em
if (this.dragState.type === 'previous') {
// Left button: only allow rightward drag (positive deltaX)
deltaX = Math.max(0, Math.min(maxDrag, deltaX));
} else if (this.dragState.type === 'next') {
// Right button: only allow leftward drag (negative deltaX)
deltaX = Math.min(0, Math.max(-maxDrag, deltaX));
}
this.dragState.deltaX = deltaX;
// Check if we've reached the full extent
const atFullExtent = Math.abs(deltaX) >= maxDrag;
this.dragState.atFullExtent = atFullExtent;
// Prevent default to avoid text selection during drag
event.preventDefault();
},
endDrag() {
if (!this.dragState.isDragging && !this.touchState.isButtonTouch) return;
// Only show file list if user released at full extent
if (this.dragState.atFullExtent) {
this.showFileList(this.dragState.type);
}
this.resetDragState();
this.resetTouchState();
},
resetDragState() {
this.dragState = {
isDragging: false,
type: null,
startX: 0,
startY: 0,
deltaX: 0,
deltaY: 0,
threshold: this.dragState.threshold,
atFullExtent: false,
triggered: false,
};
},
handlePrevClick() {
// Only navigate if this wasn't a drag
if (!this.dragState.triggered) {
this.prev();
}
this.resetDragState();
},
handleNextClick() {
// Only navigate if this wasn't a drag
if (!this.dragState.triggered) {
this.next();
}
this.resetDragState();
},
showFileList(type) {
// Hide navigation buttons when showing file list
mutations.setNavigationShow(false);
// Determine what list to show based on drag type
if (type === 'previous') {
// Show parent directories for navigating up
this.showParentDirectories();
} else if (type === 'next') {
// Show current listing items for quick jumping
this.showCurrentListing();
}
},
showParentDirectories() {
// Show files in the current directory (same directory as the previewed file)
const currentItems = this.getCurrentListingItems();
mutations.showHover({
name: "file-list",
props: {
fileList: currentItems,
mode: "navigate-siblings",
title: this.$t("prompts.quickJump")
}
});
},
showCurrentListing() {
const currentItems = this.getCurrentListingItems();
mutations.showHover({
name: "file-list",
props: {
fileList: currentItems,
mode: "quick-jump",
title: this.$t("prompts.quickJump")
}
});
},
getParentDirectories() {
// Build array of parent directories from current path
const currentPath = state.req.path || "/";
const pathParts = currentPath.split("/").filter(part => part);
const parentDirs = [];
// Add root
parentDirs.push({
name: "/",
path: "/",
source: state.req.source,
isDirectory: true
});
// Add each level up to current
let buildPath = "";
for (let i = 0; i < pathParts.length; i++) {
buildPath += "/" + pathParts[i];
parentDirs.push({
name: pathParts[i],
path: buildPath,
source: state.req.source,
isDirectory: true
});
}
return parentDirs.reverse(); // Show deepest first
},
getCurrentListingItems() {
// Get items from the current navigation listing (files in same directory)
const listing = state.navigation.listing || [];
return listing.map(item => ({
name: item.name, // Keep original names without emojis
path: item.path,
source: item.source || state.req.source,
type: item.type,
isDirectory: item.type === 'directory',
originalItem: item
}));
}
},
};
</script>
<style scoped>
/* Thin edge detection zones - minimal interference with content */
.nav-zone {
position: fixed;
top: 25%; /* Start at 25% from top */
bottom: 25%; /* End at 25% from bottom (so middle 50%) */
width: 3em; /* Reduced width to minimize content interference */
pointer-events: auto;
z-index: 5; /* Lower z-index so content can appear above */
background: transparent; /* Invisible zones for mouse/touch detection */
}
.nav-zone-left {
left: 0;
}
.nav-zone-right {
right: 0;
}
/* Removed navigation-buttons container to prevent content interaction blocking */
.nav-button {
position: fixed;
top: 50%;
transform: translateY(-50%);
width: 50px;
height: 50px;
border: none;
border-radius: 50%;
background: var(--background);
color: var(--textPrimary);
cursor: pointer;
transition: opacity 0.4s ease, transform 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease;
pointer-events: auto;
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
opacity: 1;
}
.nav-button.dark-mode {
background: var(--surfacePrimary);
color: var(--textPrimary);
}
.nav-button:hover,
.nav-button.active {
background: var(--primaryColor);
transform: translateY(-50%) scale(1.1);
box-shadow:
inset 0 -3em 3em rgba(217, 217, 217, 0.211),
0 0 0 2px var(--alt-background),
0 4px 20px rgba(0, 0, 0, 0.4);
color: white;
opacity: 1;
}
.nav-previous {
left: 20px;
}
.nav-next {
right: 20px;
}
.nav-button.hidden {
opacity: 0;
transform: translateY(-50%) scale(0.9);
pointer-events: none !important; /* Ensure no interaction when hidden */
z-index: -1; /* Move behind content when hidden */
}
/* Smooth show animation for better UX */
.nav-button:not(.hidden) {
animation: nav-button-show 0.4s ease-out;
}
@keyframes nav-button-show {
0% {
opacity: 0;
transform: translateY(-50%) scale(0.8);
}
100% {
opacity: 1;
transform: translateY(-50%) scale(1);
}
}
.nav-button.dragging {
z-index: 1002;
cursor: grabbing;
transition: none; /* Disable transitions during drag for immediate response */
}
.nav-button i.material-icons {
font-size: 24px;
line-height: 1;
transition: transform 0.2s ease;
}
.nav-button:hover i.material-icons,
.nav-button.active i.material-icons {
transform: scale(1.1);
}
/* Mobile styles */
@media (max-width: 768px) {
.nav-button {
width: 44px;
height: 44px;
}
.nav-previous {
left: 10px;
}
.nav-next {
right: 10px;
}
.nav-button i.material-icons {
font-size: 20px;
}
/* Reduce animation intensity on mobile for better performance */
.nav-button:not(.hidden) {
animation-duration: 0.3s;
}
}
/* Ensure buttons don't interfere with scrollbars */
@media (max-width: 480px) {
.nav-previous {
left: 8px;
}
.nav-next {
right: 8px;
}
}
.moveWithSidebar {
margin-left: 20em;
}
</style>
+13 -12
View File
@@ -5,14 +5,6 @@
<div class="card-content">
<div v-if="isEditingPath">
<file-list @update:selected="updateTempPath" :browse-source="sourceName"></file-list>
<div style="margin-top: 1em; text-align: right;">
<button class="button button--flat" @click="cancelPathChange">
{{ $t("buttons.cancel") }}
</button>
<button class="button button--flat" @click="confirmPathChange">
{{ $t("buttons.ok") }}
</button>
</div>
</div>
<div v-else>
<p>{{ $t("prompts.source", { suffix: ":" }) }} {{ currentSource }}</p>
@@ -77,10 +69,19 @@
</div>
</div>
<div class="card-action">
<button v-if="!isEditingPath" @click="closeHovers" class="button button--flat button--grey"
:aria-label="$t('buttons.close')" :title="$t('buttons.close')">
{{ $t("buttons.close") }}
</button>
<template v-if="isEditingPath">
<button class="button button--flat" @click="cancelPathChange" :aria-label="$t('buttons.cancel')" :title="$t('buttons.cancel')">
{{ $t("buttons.cancel") }}
</button>
<button class="button button--flat" @click="confirmPathChange" :aria-label="$t('buttons.ok')" :title="$t('buttons.ok')">
{{ $t("buttons.ok") }}
</button>
</template>
<template v-else>
<button @click="closeHovers" class="button button--flat button--grey" :aria-label="$t('buttons.close')" :title="$t('buttons.close')">
{{ $t("buttons.close") }}
</button>
</template>
</div>
</template>
+149 -34
View File
@@ -1,51 +1,74 @@
<template>
<div>
<!-- Source Selection Dropdown (only show if multiple sources available) -->
<div v-if="availableSources.length > 1" class="source-selector" style="margin-bottom: 1rem;">
<div v-if="isDisplayMode" class="card-title">
<h2>{{ effectiveTitle }}</h2>
</div>
<div class="card-content">
<!-- Source Selection Dropdown (only show if multiple sources available and not in display mode) -->
<div v-if="availableSources.length > 1 && !isDisplayMode" class="source-selector" style="margin-bottom: 1rem;">
<label for="destinationSource" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
{{ $t("prompts.destinationSource") }}
</label>
<select id="destinationSource" v-model="currentSource" @change="onSourceChange"
class="input">
<select id="destinationSource" v-model="currentSource" @change="onSourceChange" class="input">
<option v-for="source in availableSources" :key="source" :value="source">
{{ source }}
</option>
</select>
</div>
<div>Source: {{ sourcePath.source }} </div> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<div aria-label="filelist-path" class="searchContext button clickable">{{$t('search.path')}} {{ sourcePath.path }}</div>
<div v-if="!isDisplayMode" aria-label="filelist-path" class="searchContext button clickable">{{ $t('search.path') }}
{{ sourcePath.path }}</div>
<ul class="file-list">
<li
@click="itemClick"
@touchstart="touchstart"
@dblclick="next"
role="button"
tabindex="0"
:aria-label="item.name"
:aria-selected="selected == item.path"
:key="item.name"
v-for="item in items"
:data-path="item.path"
>
{{ item.name }}
<li @click="itemClick" @touchstart="touchstart" @dblclick="next" role="button" tabindex="0"
:aria-label="item.name" :aria-selected="selected == item.path" :key="item.name" v-for="item in items"
:data-path="item.path" class="file-item">
<Icon v-if="isDisplayMode" :filename="item.name"
:mimetype="item.originalItem?.type || 'application/octet-stream'" :active="selected == item.path"
class="file-icon" />
<span class="file-name">{{ item.name }}</span>
</li>
</ul>
</div>
<!-- Cancel/Close button for display mode -->
<div v-if="isDisplayMode" class="card-action">
<button @click="closeModal" class="button button--flat" :aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')">
{{ $t('buttons.cancel') }}
</button>
</div>
</template>
<script>
import { state, mutations } from "@/store";
import { url } from "@/utils";
import { filesApi } from "@/api";
import Icon from "@/components/files/Icon.vue";
export default {
name: "file-list",
components: {
Icon,
},
props: {
browseSource: {
type: String,
default: null,
},
fileList: {
type: Array,
default: null,
},
mode: {
type: String,
default: "browse", // 'browse', 'navigate-up', 'quick-jump'
},
title: {
type: String,
default: null,
},
},
data: function () {
const initialSource = this.browseSource || state.req.source;
@@ -66,6 +89,9 @@ export default {
};
},
computed: {
effectiveTitle() {
return this.title || this.$t("files.files");
},
sourcePath() {
return { source: this.source, path: this.path };
},
@@ -76,6 +102,10 @@ export default {
// Get all available sources from state.sources.info
return state.sources && state.sources.info ? Object.keys(state.sources.info) : [state.req.source];
},
isDisplayMode() {
// Display mode when fileList prop is provided (drag-triggered navigation)
return this.fileList !== null;
},
},
watch: {
browseSource(newSource) {
@@ -91,19 +121,24 @@ export default {
},
},
mounted() {
// Use currentSource if provided, otherwise use state.req
const sourceToUse = this.currentSource;
const pathToUse = this.currentSource !== state.req.source ? "/" : state.req.path;
const initialReq = {
...state.req,
source: sourceToUse,
path: pathToUse,
};
// Fetch the initial data for the source
if (this.currentSource !== state.req.source) {
filesApi.fetchFiles(sourceToUse, pathToUse).then(this.fillOptions);
if (this.isDisplayMode) {
// Display mode: use provided fileList
this.fillOptionsFromList();
} else {
this.fillOptions(initialReq);
// Normal browse mode: fetch files
const sourceToUse = this.currentSource;
const pathToUse = this.currentSource !== state.req.source ? "/" : state.req.path;
const initialReq = {
...state.req,
source: sourceToUse,
path: pathToUse,
};
// Fetch the initial data for the source
if (this.currentSource !== state.req.source) {
filesApi.fetchFiles(sourceToUse, pathToUse).then(this.fillOptions);
} else {
this.fillOptions(initialReq);
}
}
},
methods: {
@@ -193,8 +228,14 @@ export default {
}
},
itemClick: function (event) {
if (state.user.singleClick) this.next(event);
else this.select(event);
if (this.isDisplayMode) {
// In display mode, navigate directly to the item
this.navigateToItem(event);
} else if (state.user.singleClick) {
this.next(event);
} else {
this.select(event);
}
},
select: function (event) {
let path = event.currentTarget.dataset.path;
@@ -232,6 +273,80 @@ export default {
onSourceChange() {
this.resetToSource(this.currentSource);
},
// Display mode methods (for drag-triggered navigation)
fillOptionsFromList() {
// Use the provided fileList, filtering out directories to show only files
const allItems = this.fileList || [];
this.items = allItems.filter(item => !item.isDirectory && item.type !== 'directory');
this.current = this.title || "Navigation";
this.source = state.req.source;
// Emit the current info
this.$emit("update:selected", {
path: this.current,
source: this.source
});
},
navigateToItem(event) {
const path = event.currentTarget.dataset.path;
const item = this.items.find(item => item.path === path);
if (!item) return;
// Close the file list modal
mutations.closeHovers();
// Navigate to the item's URL
const itemUrl = url.buildItemUrl(item.source || state.req.source, item.path);
// Use router to navigate
this.$router.replace({ path: itemUrl });
},
closeModal() {
// Close the file list modal
mutations.closeHovers();
},
},
};
</script>
<style scoped>
.file-item {
display: flex;
align-items: center;
padding: 0.5rem;
cursor: pointer;
}
.file-item:hover {
background-color: var(--surfaceSecondary, rgba(0, 0, 0, 0.05));
}
.file-icon {
margin-right: 0.75rem;
flex-shrink: 0;
}
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-list {
padding: 0;
margin: 0;
}
.file-list li[aria-selected=true] {
background: var(--primaryColor) !important;
color: #fff !important;
transition: .1s ease all;
}
</style>
+3 -3
View File
@@ -56,9 +56,9 @@
</div>
<div class="card-action">
<button type="submit" @click="closeHovers" class="button button--flat" :aria-label="$t('buttons.ok')"
:title="$t('buttons.ok')">
{{ $t("buttons.ok") }}
<button type="submit" @click="closeHovers" class="button button--flat" :aria-label="$t('buttons.close')"
:title="$t('buttons.close')">
{{ $t("buttons.close") }}
</button>
</div>
</template>
+10 -2
View File
@@ -38,7 +38,7 @@ import buttons from "@/utils/buttons";
import * as upload from "@/utils/upload";
import { url } from "@/utils";
import { notify } from "@/notify";
import { removeLastDir } from "@/utils/url";
import { removeLastDir, goToItem } from "@/utils/url";
export default {
name: "move-copy",
@@ -171,7 +171,15 @@ export default {
}
mutations.closeHovers();
mutations.setSearch(false);
notify.showSuccess(`Server is ${this.operation === "copy" ? "copying" : "moving"} files...`);
if (this.operation === "move") {
notify.showSuccess(this.$t(`prompts.moveSuccess`));
} else {
notify.showSuccess(this.$t(`prompts.copySuccess`));
}
// Navigate to the destination folder after successful operation
if (this.destSource && this.destPath) {
goToItem(this.destSource, this.destPath);
}
} catch (error) {
notify.showError(error);
}
@@ -31,6 +31,7 @@ import buttons from "@/utils/buttons";
import Totp from "./Totp.vue";
import Access from "./Access.vue";
import Password from "./Password.vue";
import FileList from "./FileList.vue";
import { state, getters, mutations } from "@/store"; // Import your custom store
export default {
@@ -58,6 +59,7 @@ export default {
ActionApi,
Access,
Password,
"file-list": FileList,
},
data() {
return {
+23 -4
View File
@@ -130,6 +130,22 @@
</option>
</select>
</div>
<p>
{{ $t("share.defaultViewMode") }}
<i
class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.defaultViewModeDescription'))"
@mouseleave="hideTooltip"
>
help
</i>
</p>
<select class="input" v-model="viewMode">
<option value="normal">{{ $t("buttons.normalView") }}</option>
<option value="list">{{ $t("buttons.listView") }}</option>
<option value="compact">{{ $t("buttons.compactView") }}</option>
<option value="gallery">{{ $t("buttons.galleryView") }}</option>
</select>
<SettingsItem :title="$t('buttons.showMore')" :collapsable="true" :start-collapsed="true">
<div class="settings-items">
<ToggleSwitch class="item" v-model="keepAfterExpiration" :name="$t('share.keepAfterExpiration')" :description="$t('share.keepAfterExpirationDescription')" />
@@ -255,7 +271,7 @@ import { fromNow } from "@/utils/moment";
import { buildItemUrl, fixDownloadURL } from "@/utils/url";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import SettingsItem from "@/components/settings/SettingsItem.vue";
import { userSelectableThemes, externalUrl, onlyOfficeUrl } from "@/utils/constants";
import { globalVars } from "@/utils/constants";
import { eventBus } from "@/store/eventBus";
//import ViewMode from "@/components/settings/ViewMode.vue";
@@ -316,6 +332,7 @@ export default {
disableShareCard: false,
disableSidebar: false,
enforceDarkLightMode: "default",
viewMode: "normal",
enableOnlyOffice: false,
enableOnlyOfficeEditing: false,
//viewMode: "normal",
@@ -323,10 +340,10 @@ export default {
},
computed: {
onlyOfficeAvailable() {
return onlyOfficeUrl !== "";
return globalVars.onlyOfficeUrl !== "";
},
availableThemes() {
return userSelectableThemes || {};
return globalVars.userSelectableThemes || {};
},
closeHovers() {
return mutations.closeHovers;
@@ -397,6 +414,7 @@ export default {
this.disableShareCard = this.link.disableShareCard || false;
this.disableSidebar = this.link.disableSidebar || false;
this.enforceDarkLightMode = this.link.enforceDarkLightMode || "default";
this.viewMode = this.link.viewMode || "normal";
this.enableOnlyOffice = this.link.enableOnlyOffice || false;
this.enableOnlyOfficeEditing = this.link.enableOnlyOfficeEditing || false;
//this.viewMode = this.link.viewMode || "normal";
@@ -478,6 +496,7 @@ export default {
disableShareCard: this.disableShareCard,
disableSidebar: this.disableSidebar,
enforceDarkLightMode: this.enforceDarkLightMode,
viewMode: this.viewMode,
enableOnlyOffice: this.enableOnlyOffice,
enableOnlyOfficeEditing: this.enableOnlyOfficeEditing,
};
@@ -543,7 +562,7 @@ export default {
* @param {Share} share
*/
buildDownloadLink(share) {
if (share.downloadURL && externalUrl == "") {
if (share.downloadURL && globalVars.externalUrl == "") {
return this.fixDownloadURL(share.downloadURL);
}
return publicApi.getDownloadURL(share, [this.item.name]);
+21 -4
View File
@@ -48,7 +48,8 @@
? $t('prompts.conflictsDetected')
: (file.progress / 100) * file.size
" :unit="file.status === 'completed' || file.status === 'error' ? '' : 'bytes'" :max="file.size"
:status="file.status" text-position="inside" size="20">
:status="file.status" text-position="inside" size="20"
:help-text="file.status === 'error' && file.errorDetails ? file.errorDetails : ''">
</progress-bar>
<div v-else class="status-label">
<span>{{ $t(`prompts.${file.status}`) }}</span>
@@ -94,7 +95,7 @@
:aria-label="$t('buttons.resumeAll')" :title="$t('buttons.resumeAll')">
{{ $t("buttons.resumeAll") }}
</button>
<button @click="clearCompleted" class="button button--flat" :disabled="!hasCompleted"
<button @click="clearCompleted" class="button button--flat" :disabled="!hasClearable"
:aria-label="$t('buttons.clearCompleted')" :title="$t('buttons.clearCompleted')">
{{ $t("buttons.clearCompleted") }}
</button>
@@ -184,7 +185,7 @@ export default {
console.error("No conflicting folder found for rename");
return;
}
mutations.showHover({
name: "rename",
confirm: (newName) => {
@@ -205,7 +206,7 @@ export default {
// Update upload manager with the new folder name
await uploadManager.renameFolder(oldName, newName);
// Resolve the conflict and continue upload
if (conflictResolver) {
conflictResolver({ rename: newName });
@@ -256,6 +257,21 @@ export default {
files.value.some((file) => file.status === "completed")
);
const hasClearable = computed(() => {
if (state.user.fileLoading?.clearAll) {
// For "clear all" mode: check for completed, error, conflict, or paused uploads
return files.value.some((file) =>
file.status === "completed" ||
file.status === "error" ||
file.status === "conflict" ||
file.status === "paused"
);
} else {
// For "clear completed" mode: only check for completed uploads
return files.value.some((file) => file.status === "completed");
}
});
const canPauseAll = computed(() =>
files.value.some((file) => file.status === "uploading")
);
@@ -447,6 +463,7 @@ export default {
close,
clearCompleted,
hasCompleted,
hasClearable,
showConflictPrompt,
resolveConflict,
showRenamePrompt,
@@ -6,7 +6,7 @@
<i class="material-icons">sentiment_dissatisfied</i>
<span>{{ $t("files.lonely") }}</span>
</h2>
<div v-if="user.loginMethod == 'password' && passwordAvailable && !isNew">
<div v-if="user.loginMethod == 'password' && globalVars.passwordAvailable && !isNew">
<label for="password">{{ $t("settings.password") }}</label>
<div class="form-flex-group">
<input
@@ -63,7 +63,7 @@
/>
</p>
<div v-if="user.loginMethod == 'password' && passwordAvailable && isNew">
<div v-if="user.loginMethod == 'password' && globalVars.passwordAvailable && isNew">
<label for="password">{{ $t("settings.password") }}</label>
<div class="form-flex-group">
<input
@@ -98,7 +98,7 @@
</div>
<div
v-if="user.loginMethod == 'password' && passwordAvailable"
v-if="user.loginMethod == 'password' && globalVars.passwordAvailable"
class="settings-items"
>
<ToggleSwitch
@@ -186,7 +186,7 @@ import { mutations, state } from "@/store";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import { notify } from "@/notify";
import { usersApi, settingsApi } from "@/api";
import { passwordAvailable } from "@/utils/constants";
import { globalVars } from "@/utils/constants";
export default {
name: "UserForm",
@@ -246,7 +246,8 @@ export default {
this.user.password != this.passwordRef && this.user.password.length > 0;
return matching;
},
passwordAvailable: () => passwordAvailable,
passwordAvailable: () => globalVars.passwordAvailable,
globalVars: () => globalVars,
duplicateSources() {
const names = this.selectedSources.map((s) => s.name);
return names.filter((name, idx) => names.indexOf(name) !== idx);
+6 -6
View File
@@ -141,7 +141,7 @@
<script>
import * as auth from "@/utils/auth";
import { signup, disableExternal, noAuth, loginPage, shareInfo } from "@/utils/constants";
import { globalVars, shareInfo } from "@/utils/constants";
import ProgressBar from "@/components/ProgressBar.vue";
import { state, getters, mutations } from "@/store"; // Import your custom store
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
@@ -170,11 +170,11 @@ export default {
showSources: () => !getters.isShare(),
currentPrompt: () => getters.currentPrompt(),
active: () => getters.isSidebarVisible(),
signup: () => signup,
version: () => version,
commitSHA: () => commitSHA,
disableExternal: () => disableExternal,
canLogout: () => !noAuth && loginPage,
signup: () => globalVars.signup,
version: () => globalVars.version,
commitSHA: () => globalVars.commitSHA,
disableExternal: () => globalVars.disableExternal,
canLogout: () => !globalVars.noAuth && globalVars.loginPage,
route: () => state.route,
sourceInfo: () => state.sources.info,
activeSource: () => state.sources.current,
+8 -11
View File
@@ -33,7 +33,7 @@
</template>
<script>
import { externalLinks, name, updateAvailable } from "@/utils/constants";
import { globalVars } from "@/utils/constants";
import { getters, mutations, state } from "@/store"; // Import your custom store
import SidebarGeneral from "./General.vue";
import SidebarSettings from "./Settings.vue";
@@ -46,19 +46,16 @@ export default {
SidebarSettings,
SidebarShare,
},
data() {
return {
externalLinks,
name,
};
},
mounted() {
// Ensure the sidebar is initialized correctly
mutations.setSeenUpdate(localStorage.getItem("seenUpdate"));
},
computed: {
externalLinks: () => globalVars.externalLinks,
name: () => globalVars.name,
isValidShare: () => getters.isValidShare(),
releaseUrl: () => updateAvailable,
releaseUrl: () => globalVars.updateAvailable,
isDarkMode: () => getters.isDarkMode(),
isLoggedIn: () => getters.isLoggedIn(),
isSettings: () => getters.isSettings(),
@@ -66,9 +63,9 @@ export default {
behindOverlay: () => state.isSearchActive,
shouldShow() {
return (
updateAvailable != "" &&
globalVars.updateAvailable != "" &&
state.user.permissions.admin &&
state.seenUpdate != updateAvailable &&
state.seenUpdate != globalVars.updateAvailable &&
!state.user.disableUpdateNotifications
);
},
@@ -79,7 +76,7 @@ export default {
mutations.showHover("help");
},
setSeenUpdate() {
mutations.setSeenUpdate(updateAvailable);
mutations.setSeenUpdate(globalVars.updateAvailable);
},
},
};
+1 -1
View File
@@ -1,4 +1,4 @@
body {
body {
font-family: "Roboto", sans-serif;
padding-top: 4em;
background-color: var(--background);
-9
View File
@@ -155,15 +155,6 @@
color: var(--textSecondary);
}
/* File list item */
.dark-mode .file-list li:hover {
background: var(--surfaceSecondary);
}
.dark-mode .file-list li:before {
color: var(--textSecondary);
}
/* Shell */
.dark-mode .shell {
background: var(--surfacePrimary);
+24 -43
View File
@@ -164,7 +164,6 @@ table tr>*:last-child {
background-color: var(--background);
border-radius: 2px;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2);
overflow: auto;
border-radius: 1em;
}
@@ -176,8 +175,11 @@ table tr>*:last-child {
z-index: 5;
max-width: 40em;
width: 90%;
max-height: 95%;
max-height: 80vh; /* Constrain modal height for scrolling */
animation: .1s show forwards;
display: flex !important;
flex-direction: column;
overflow: hidden;
}
.card>*>*:first-child {
@@ -188,6 +190,12 @@ table tr>*:last-child {
margin-bottom: 0;
}
/* Apply flex behavior only to direct children of floating cards */
.card.floating > .card-title {
text-align: center;
flex-shrink: 0;
}
.card-title {
text-align: center;
}
@@ -220,6 +228,19 @@ body.rtl .card-title>*:first-child {
margin: 0;
}
/* Apply flex behavior only to direct children of floating cards */
.card.floating > .card-action {
text-align: right;
flex-shrink: 0;
}
.card.floating > .card-content {
flex-grow: 1;
overflow: auto;
min-height: 0; /* Allows flex child to shrink below content size */
}
/* Regular card styles (non-floating) */
.card .card-action {
text-align: right;
}
@@ -251,46 +272,6 @@ body.rtl .card-title>*:first-child {
max-width: 15em;
}
.file-list {
max-height: 50vh;
overflow: auto;
list-style: none;
margin: 0;
padding: 0;
width: 100%;
}
.file-list li {
width: 100%;
user-select: none;
border-radius: .2em;
padding: .3em;
}
.file-list li[aria-selected=true] {
background: var(--primaryColor) !important;
color: #fff !important;
transition: .1s ease all;
}
.file-list li:hover {
background-color: #e9eaeb;
cursor: pointer;
}
.file-list li:before {
content: "folder";
color: #6f6f6f;
vertical-align: middle;
line-height: 1.4;
font-family: 'Material Icons';
font-size: 1.75em;
margin-right: .25em;
}
.file-list li[aria-selected=true]:before {
color: white;
}
.help {
max-width: 24em;
@@ -421,4 +402,4 @@ body.rtl .credits {
z-index: 4;
top: 0;
animation: .3s show ease-in;
}
}
-4
View File
@@ -335,10 +335,6 @@ body.rtl .card-content .small + input {
text-align: left;
}
body.rtl .card.floating .card-content .file-list {
direction: ltr;
text-align: left;
}
.fb-shadow {
box-shadow: 0px 0px 15px 0px rgb(100 100 100 / 75%);
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "اعرض المزيد",
"showLess": "اعرض أقل",
"openParentFolder": "فتح المجلد الأصل",
"selectedCount": "عدد العناصر المحددة لتنفيذ إجراء ما"
"selectedCount": "عدد العناصر المحددة لتنفيذ إجراء ما",
"listView": "عرض القائمة",
"compactView": "عرض مدمج",
"normalView": "عرض عادي",
"galleryView": "عرض المعرض"
},
"download": {
"downloadFile": "Download File",
@@ -188,7 +192,10 @@
"numberFiles": "عدد الملفات{suffix}",
"renameMessageConflict": "عنصر باسم \"{filename}\" موجود بالفعل!",
"uploadSettingsChunked": "الحد الأقصى للتحميلات المتزامنة: {maxConcurrentUpload} ، حجم القطعة: {uploadChunkSizeMb} ميغابايت",
"uploadSettingsNoChunk": "الحد الأقصى للتحميلات المتزامنة: {maxConcurrentUpload} ، تم تعطيل التحميل المقطّع"
"uploadSettingsNoChunk": "الحد الأقصى للتحميلات المتزامنة: {maxConcurrentUpload} ، تم تعطيل التحميل المقطّع",
"quickJump": "الانتقال بسرعة إلى ملفات أخرى",
"moveSuccess": "تم نقل الملفات بنجاح!",
"copySuccess": "تم نسخ الملفات بنجاح!"
},
"search": {
"images": "الصور",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "السماح بعرض ملفات المكتب باستخدام OnlyOffice في هذه المشاركة.",
"enableOnlyOfficeEditing": "تمكين التحرير في المكتب فقط",
"enableOnlyOfficeEditingDescription": "السماح بتحرير الملفات المكتبية باستخدام OnlyOffice في هذه الحصة.",
"titleDefault": "الملفات المشتركة - {title}"
"titleDefault": "الملفات المشتركة - {title}",
"defaultViewMode": "وضع العرض الافتراضي",
"defaultViewModeDescription": "تعيين وضع العرض الافتراضي للمستخدمين المجهولين الذين يشاهدون هذه المشاركة. لا يزال بإمكان المستخدمين المسجلين الدخول تغيير تفضيلات العرض الخاصة بهم."
},
"api": {
"title": "مفاتيح واجهة برمجة التطبيقات (API):",
@@ -482,7 +491,9 @@
"maxConcurrentUpload": "الحد الأقصى للتحميلات المتزامنة",
"maxConcurrentUploadHelp": "الحد الأقصى لعدد التحميلات المتزامنة. عادةً ما يكون أداء القيم الأعلى أفضل، ولكن هذا يعتمد على العديد من العوامل.",
"uploadChunkSizeMb": "حجم قطعة التحميل بالميجابايت",
"uploadChunkSizeMbHelp": "عادةً من 5 إلى 50 ميغابايت، والأصغر هو الأفضل للاتصالات غير المستقرة أو المقيدة. سيؤدي التعيين إلى 0 إلى تعطيل تقطيع التحميل."
"uploadChunkSizeMbHelp": "عادةً من 5 إلى 50 ميغابايت، والأصغر هو الأفضل للاتصالات غير المستقرة أو المقيدة. سيؤدي التعيين إلى 0 إلى تعطيل تقطيع التحميل.",
"clearAll": "يجب أن يزيل المسح المكتمل الخطأ والإيقاف المؤقت والتعارض",
"clearAllDescription": "سيؤدي زر مسح المكتمل إلى مسح جميع التحميلات في حالة الانتهاء، بما في ذلك حالات الخطأ والإيقاف المؤقت والتعارض."
},
"profileSettings": {
"setDateFormat": "تعيين تنسيق التاريخ الدقيق",
@@ -539,7 +550,9 @@
"debugOfficeEditor": "تمكين وضع التصحيح فقط",
"debugOfficeEditorDescription": "إظهار نافذة منبثقة للتصحيح مع معلومات إضافية في محرر OnlyOffice.",
"fileViewerOptions": "خيارات عارض الملفات",
"themeAndLanguage": "الموضوع واللغة"
"themeAndLanguage": "الموضوع واللغة",
"previewFolder": "إظهار الصور المصغرة للمجلدات إذا كانت متوفرة",
"previewFolderDescription": "إظهار معاينات الصور المصغرة للمجلدات التي تحتوي على العنصر الفرعي الأول مع صورة مصغرة كغلاف."
},
"colors": {
"red": "أحمر",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "Zobrazit více",
"showLess": "Zobrazit méně",
"openParentFolder": "Otevření nadřazené složky",
"selectedCount": "Počet položek vybraných k provedení akce"
"selectedCount": "Počet položek vybraných k provedení akce",
"listView": "Zobrazení seznamu",
"compactView": "Kompaktní zobrazení",
"normalView": "Normální zobrazení",
"galleryView": "Pohled do galerie"
},
"download": {
"downloadFile": "Stáhnout soubor",
@@ -176,7 +180,9 @@
"enableOnlyOfficeDescription": "Povolit prohlížení kancelářských souborů pomocí OnlyOffice v této sdílené složce.",
"enableOnlyOfficeEditing": "Povolení úprav OnlyOffice",
"enableOnlyOfficeEditingDescription": "Povolit úpravy kancelářských souborů pomocí OnlyOffice v této sdílené složce.",
"titleDefault": "Sdílené soubory - {title}"
"titleDefault": "Sdílené soubory - {title}",
"defaultViewMode": "Výchozí režim zobrazení",
"defaultViewModeDescription": "Nastavení výchozího režimu zobrazení pro anonymní uživatele prohlížející tuto sdílenou složku. Přihlášení uživatelé mohou stále měnit předvolby zobrazení."
},
"prompts": {
"copy": "Kopírovat",
@@ -234,7 +240,10 @@
"numberFiles": "Počet souborů{suffix}",
"renameMessageConflict": "Položka s názvem \"{filename}\" již existuje!",
"uploadSettingsChunked": "Maximální souběžné nahrávání: {maxConcurrentUpload}, velikost chunků: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Maximální souběžné nahrávání: {maxConcurrentUpload}, vypnuto hromadné nahrávání"
"uploadSettingsNoChunk": "Maximální souběžné nahrávání: {maxConcurrentUpload}, vypnuto hromadné nahrávání",
"quickJump": "Rychlý přechod na jiné soubory",
"moveSuccess": "Soubory byly úspěšně přesunuty!",
"copySuccess": "Soubory byly úspěšně zkopírovány!"
},
"search": {
"images": "Obrázky",
@@ -317,7 +326,9 @@
"debugOfficeEditor": "Povolení režimu ladění OnlyOffice",
"debugOfficeEditorDescription": "Zobrazení vyskakovacího okna pro ladění s dalšími informacemi v editoru OnlyOffice.",
"fileViewerOptions": "Možnosti prohlížeče souborů",
"themeAndLanguage": "Téma a jazyk"
"themeAndLanguage": "Téma a jazyk",
"previewFolder": "Zobrazení miniatur složek, pokud jsou k dispozici",
"previewFolderDescription": "Zobrazení náhledů miniatur pro složky s první podřízenou položkou s miniaturou jako obálkou."
},
"settings": {
"enterPassword": "Zadejte heslo",
@@ -503,7 +514,9 @@
"maxConcurrentUpload": "Maximální souběžné nahrávání",
"maxConcurrentUploadHelp": "Maximální počet souběžných odesílání. Vyšší hodnoty jsou obvykle výkonnější, ale záleží na mnoha faktorech.",
"uploadChunkSizeMb": "Velikost nahraného souboru v MB",
"uploadChunkSizeMbHelp": "Obvykle 5 až 50 MB, menší velikost je lepší pro nestabilní nebo omezené připojení. Nastavením na hodnotu 0 se vypne rozdělování nahrávání."
"uploadChunkSizeMbHelp": "Obvykle 5 až 50 MB, menší velikost je lepší pro nestabilní nebo omezené připojení. Nastavením na hodnotu 0 se vypne rozdělování nahrávání.",
"clearAll": "Vymazání dokončeno by mělo odstranit chybu, pauzu a konflikt.",
"clearAllDescription": "Tlačítko Vymazat dokončené vymaže všechna odesílání v dokončeném stavu, včetně chybových, pozastavených a konfliktních stavů."
},
"colors": {
"red": "červená",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "Mehr anzeigen",
"showLess": "Weniger anzeigen",
"openParentFolder": "Übergeordneten Ordner öffnen",
"selectedCount": "Anzahl der zur Durchführung einer Aktion ausgewählten Elemente"
"selectedCount": "Anzahl der zur Durchführung einer Aktion ausgewählten Elemente",
"listView": "Listenansicht",
"compactView": "Kompakte Ansicht",
"normalView": "Normale Ansicht",
"galleryView": "Galerie-Ansicht"
},
"download": {
"downloadFile": "Datei herunterladen",
@@ -188,7 +192,10 @@
"numberFiles": "Anzahl der Dateien{suffix}",
"renameMessageConflict": "Ein Artikel mit dem Namen \"{filename}\" existiert bereits!",
"uploadSettingsChunked": "Max. gleichzeitige Uploads: {maxConcurrentUpload}, Chunk-Größe: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Max. gleichzeitige Uploads: {maxConcurrentUpload}, Chunked Upload deaktiviert"
"uploadSettingsNoChunk": "Max. gleichzeitige Uploads: {maxConcurrentUpload}, Chunked Upload deaktiviert",
"quickJump": "Schnelles Springen zu anderen Dateien",
"moveSuccess": "Dateien erfolgreich verschoben!",
"copySuccess": "Dateien erfolgreich kopiert!"
},
"search": {
"images": "Bilder",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "Erlauben Sie die Anzeige von Office-Dateien mit OnlyOffice in dieser Freigabe.",
"enableOnlyOfficeEditing": "OnlyOffice-Bearbeitung aktivieren",
"enableOnlyOfficeEditingDescription": "Erlauben Sie die Bearbeitung von Office-Dateien mit OnlyOffice in dieser Freigabe.",
"titleDefault": "Gemeinsame Dateien - {title}"
"titleDefault": "Gemeinsame Dateien - {title}",
"defaultViewMode": "Standard-Ansichtsmodus",
"defaultViewModeDescription": "Legen Sie den Standardanzeigemodus für anonyme Benutzer fest, die diese Freigabe anzeigen. Angemeldete Benutzer können ihre Ansichtseinstellungen immer noch ändern."
},
"api": {
"title": "API-Schlüssel:",
@@ -482,7 +491,9 @@
"maxConcurrentUpload": "Max. gleichzeitige Uploads",
"maxConcurrentUploadHelp": "Die maximale Anzahl der gleichzeitigen Uploads. Höhere Werte führen in der Regel zu einer besseren Leistung, aber dies hängt von vielen Faktoren ab.",
"uploadChunkSizeMb": "Größe des hochzuladenden Pakets in MB",
"uploadChunkSizeMbHelp": "Normalerweise 5 bis 50 MB, kleiner ist besser für instabile oder eingeschränkte Verbindungen. Die Einstellung 0 deaktiviert das Chunking beim Hochladen."
"uploadChunkSizeMbHelp": "Normalerweise 5 bis 50 MB, kleiner ist besser für instabile oder eingeschränkte Verbindungen. Die Einstellung 0 deaktiviert das Chunking beim Hochladen.",
"clearAll": "Clear completed sollte Fehler, Pausen und Konflikte beseitigen",
"clearAllDescription": "Mit der Schaltfläche Abgeschlossen löschen werden alle Uploads im abgeschlossenen Zustand gelöscht, einschließlich Fehler-, Pausen- und Konfliktstatus."
},
"colors": {
"red": "rot",
@@ -547,7 +558,9 @@
"debugOfficeEditor": "OnlyOffice-Debug-Modus einschalten",
"debugOfficeEditorDescription": "Ein Debug-Popup mit zusätzlichen Informationen im OnlyOffice-Editor anzeigen.",
"fileViewerOptions": "Optionen des Dateibetrachters",
"themeAndLanguage": "Thema und Sprache"
"themeAndLanguage": "Thema und Sprache",
"previewFolder": "Miniaturansichten für Ordner anzeigen, falls vorhanden",
"previewFolderDescription": "Zeigen Sie Miniaturvorschauen für Ordner an, deren erstes untergeordnetes Element ein Miniaturbild als Cover hat."
},
"editor": {
"uninitialized": "Der Editor konnte nicht initialisiert werden. Bitte neu laden.",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "Εμφάνιση περισσότερων",
"showLess": "Εμφάνιση λιγότερων",
"openParentFolder": "Άνοιγμα γονικού φακέλου",
"selectedCount": "Αριθμός επιλεγμένων στοιχείων για την εκτέλεση μιας ενέργειας"
"selectedCount": "Αριθμός επιλεγμένων στοιχείων για την εκτέλεση μιας ενέργειας",
"listView": "Προβολή λίστας",
"compactView": "Συμπαγής προβολή",
"normalView": "Κανονική προβολή",
"galleryView": "Προβολή γκαλερί"
},
"download": {
"downloadFile": "Λήψη αρχείου",
@@ -191,7 +195,10 @@
"numberFiles": "Αριθμός αρχείων{suffix}",
"renameMessageConflict": "Ένα στοιχείο με όνομα \"{filename}\" υπάρχει ήδη!",
"uploadSettingsChunked": "Μέγιστες ταυτόχρονες μεταφορτώσεις: {maxConcurrentUpload}, μέγεθος τεμαχίου: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Μέγιστες ταυτόχρονες μεταφορτώσεις: {maxConcurrentUpload}, chunked upload disabled"
"uploadSettingsNoChunk": "Μέγιστες ταυτόχρονες μεταφορτώσεις: {maxConcurrentUpload}, chunked upload disabled",
"quickJump": "Γρήγορη μετάβαση σε άλλα αρχεία",
"moveSuccess": "Τα αρχεία μετακινήθηκαν με επιτυχία!",
"copySuccess": "Τα αρχεία αντιγράφηκαν με επιτυχία!"
},
"search": {
"images": "Εικόνες",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "Επιτρέψτε την προβολή αρχείων γραφείου χρησιμοποιώντας το OnlyOffice σε αυτό το κοινόχρηστο χώρο.",
"enableOnlyOfficeEditing": "Ενεργοποίηση της επεξεργασίας OnlyOffice",
"enableOnlyOfficeEditingDescription": "Επιτρέψτε την επεξεργασία αρχείων γραφείου χρησιμοποιώντας το OnlyOffice σε αυτό το κοινόχρηστο χώρο.",
"titleDefault": "Κοινόχρηστα αρχεία - {title}"
"titleDefault": "Κοινόχρηστα αρχεία - {title}",
"defaultViewMode": "Προεπιλεγμένη λειτουργία προβολής",
"defaultViewModeDescription": "Ορίστε την προεπιλεγμένη λειτουργία προβολής για τους ανώνυμους χρήστες που προβάλλουν αυτή την κοινή χρήση. Οι συνδεδεμένοι χρήστες μπορούν ακόμα να αλλάξουν τις προτιμήσεις προβολής τους."
},
"api": {
"title": "Κλειδιά API:",
@@ -482,7 +491,9 @@
"maxConcurrentUpload": "Μέγιστες ταυτόχρονες μεταφορτώσεις",
"maxConcurrentUploadHelp": "Ο μέγιστος αριθμός ταυτόχρονων μεταφορτώσεων. Υψηλότερες τιμές συνήθως έχουν καλύτερες επιδόσεις, αλλά αυτό εξαρτάται από πολλούς παράγοντες.",
"uploadChunkSizeMb": "Μέγεθος τμήματος φόρτωσης σε MB",
"uploadChunkSizeMbHelp": "Συνήθως 5 έως 50 MB, τα μικρότερα είναι καλύτερα για ασταθείς ή περιορισμένες συνδέσεις. Η ρύθμιση σε 0 θα απενεργοποιήσει την ομαδοποίηση μεταφόρτωσης."
"uploadChunkSizeMbHelp": "Συνήθως 5 έως 50 MB, τα μικρότερα είναι καλύτερα για ασταθείς ή περιορισμένες συνδέσεις. Η ρύθμιση σε 0 θα απενεργοποιήσει την ομαδοποίηση μεταφόρτωσης.",
"clearAll": "Η εκκαθάριση που ολοκληρώθηκε θα πρέπει να καταργήσει το σφάλμα, την παύση και τη σύγκρουση",
"clearAllDescription": "Το κουμπί Clear completed θα διαγράψει όλες τις μεταφορτώσεις σε κατάσταση ολοκλήρωσης, συμπεριλαμβανομένων των καταστάσεων σφάλματος, παύσης και σύγκρουσης."
},
"colors": {
"red": "κόκκινο",
@@ -547,7 +558,9 @@
"debugOfficeEditor": "Ενεργοποίηση λειτουργίας εντοπισμού σφαλμάτων OnlyOffice",
"debugOfficeEditorDescription": "Εμφάνιση ενός αναδυόμενου παραθύρου εντοπισμού σφαλμάτων με πρόσθετες πληροφορίες στον επεξεργαστή OnlyOffice.",
"fileViewerOptions": "Επιλογές προβολής αρχείων",
"themeAndLanguage": "Θέμα & Γλώσσα"
"themeAndLanguage": "Θέμα & Γλώσσα",
"previewFolder": "Εμφάνιση μικρογραφιών για φακέλους, εάν είναι διαθέσιμες",
"previewFolderDescription": "Εμφάνιση προεπισκοπήσεων μικρογραφιών για φακέλους με το πρώτο στοιχείο-παιδί με μικρογραφία ως εξώφυλλο."
},
"editor": {
"uninitialized": "Απέτυχε η αρχικοποίηση του συντάκτη. Παρακαλούμε επαναφορτώστε.",
+15 -2
View File
@@ -50,6 +50,10 @@
"showMore": "Show more",
"showLess": "Show less",
"openParentFolder": "Open parent folder",
"listView": "List View",
"compactView": "Compact View",
"normalView": "Normal View",
"galleryView": "Gallery View",
"selectedCount": "Number of items selected to perform an action"
},
"download": {
@@ -170,7 +174,9 @@
"disableSidebarDescription": "Disable the sidebar on the shared page.",
"enforceDarkLightMode": "Enforce theme mode",
"enforceDarkLightModeDescription": "Force a specific theme mode (dark or light) for this share, overriding user preferences.",
"default": "Do not enforce a mode",
"defaultViewMode": "Default view mode",
"defaultViewModeDescription": "Set the default view mode for anonymous users viewing this share. Logged-in users can still change their view preference.",
"default": "Use user preference",
"dark": "Dark",
"light": "Light",
"enableOnlyOffice": "Enable OnlyOffice viewer",
@@ -179,6 +185,7 @@
"enableOnlyOfficeEditingDescription": "Allow editing of office files using OnlyOffice in this share."
},
"prompts": {
"quickJump": "Quickly jump to other files",
"downloadsLimit": "Number of downloads limit",
"maxBandwidth": "Max download bandwidth (kbps)",
"shareTheme": "Share theme",
@@ -209,6 +216,8 @@
"filesSelected": "files selected",
"lastModified": "Last Modified{suffix}",
"move": "Move",
"moveSuccess": "Files moved successfully!",
"copySuccess": "Files copied successfully!",
"moveMessage": "Choose new house for your file(s)/folder(s):",
"newArchetype": "Create a new post based on an archetype. Your file will be created on content folder.",
"newDir": "New directory",
@@ -263,6 +272,8 @@
"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": {
"previewFolder": "Show thumbnails for folders if available",
"previewFolderDescription": "Show thumbnail previews for folders with first child item with thumbnail as cover.",
"debugOfficeEditor": "Enable OnlyOffice Debug Mode",
"debugOfficeEditorDescription": "Show a debug popup with additional information in the OnlyOffice editor.",
"defaultMediaPlayer": "Use your browser's native media player",
@@ -539,7 +550,9 @@
"maxConcurrentUpload": "Max concurrent uploads",
"maxConcurrentUploadHelp": "The maximum number of concurrent uploads. Higher values typically perform better, but this depends on many factors.",
"uploadChunkSizeMb": "Upload chunk size in MB",
"uploadChunkSizeMbHelp": "Typically 5 to 50 MB, smaller is better for unstable or restricted connections. Setting to 0 will disable upload chunking."
"uploadChunkSizeMbHelp": "Typically 5 to 50 MB, smaller is better for unstable or restricted connections. Setting to 0 will disable upload chunking.",
"clearAll": "Clear completed should remove error, pause, and conflict",
"clearAllDescription": "The clear completed button will clear all uploads in a finished state, including error, paused, and conflict states."
},
"colors": {
"red": "red",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "Ver más",
"showLess": "Mostrar menos",
"openParentFolder": "Abrir carpeta principal",
"selectedCount": "Número de elementos seleccionados para realizar una acción"
"selectedCount": "Número de elementos seleccionados para realizar una acción",
"listView": "Ver lista",
"compactView": "Vista compacta",
"normalView": "Vista normal",
"galleryView": "Vista de la galería"
},
"download": {
"downloadFile": "Descargar archivo",
@@ -188,7 +192,10 @@
"numberFiles": "Número de ficheros{suffix}",
"renameMessageConflict": "Ya existe un elemento llamado \"{filename}\".",
"uploadSettingsChunked": "Carga máxima simultánea: {maxConcurrentUpload} Tamaño del chunk {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Carga máxima simultánea: {maxConcurrentUpload} Carga por trozos desactivada"
"uploadSettingsNoChunk": "Carga máxima simultánea: {maxConcurrentUpload} Carga por trozos desactivada",
"quickJump": "Saltar rápidamente a otros archivos",
"moveSuccess": "¡Archivos movidos con éxito!",
"copySuccess": "¡Archivos copiados correctamente!"
},
"search": {
"images": "Imágenes",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "Permitir la visualización de archivos de oficina utilizando OnlyOffice en este recurso compartido.",
"enableOnlyOfficeEditing": "Habilitar la edición de OnlyOffice",
"enableOnlyOfficeEditingDescription": "Permitir la edición de archivos de oficina utilizando OnlyOffice en este recurso compartido.",
"titleDefault": "Archivos compartidos - {title}"
"titleDefault": "Archivos compartidos - {title}",
"defaultViewMode": "Modo de vista por defecto",
"defaultViewModeDescription": "Establece el modo de visualización predeterminado para los usuarios anónimos que vean esta acción. Los usuarios registrados pueden cambiar su preferencia de visualización."
},
"api": {
"title": "Claves API",
@@ -482,7 +491,9 @@
"maxConcurrentUpload": "Cargas máximas simultáneas",
"maxConcurrentUploadHelp": "El número máximo de archivos a subir a la vez. Los valores más altos suelen dar mejores resultados, pero esto depende de muchos factores",
"uploadChunkSizeMb": "Velocidad de subida (en MB)",
"uploadChunkSizeMbHelp": "Típicamente 5 a 50 MB, más pequeño es mejor para conexiones inestables. Si se pone a 0, se desactiva la subida de archivo en partes"
"uploadChunkSizeMbHelp": "Típicamente 5 a 50 MB, más pequeño es mejor para conexiones inestables. Si se pone a 0, se desactiva la subida de archivo en partes",
"clearAll": "El borrado completado debería eliminar el error, la pausa y el conflicto",
"clearAllDescription": "El botón de borrado de cargas completadas borrará todas las cargas en estado finalizado, incluidos los estados de error, pausa y conflicto."
},
"colors": {
"red": "Rojo",
@@ -547,7 +558,9 @@
"debugOfficeEditor": "Activar el modo de depuración de OnlyOffice",
"debugOfficeEditorDescription": "Mostrar una ventana emergente de depuración con información adicional en el editor OnlyOffice.",
"fileViewerOptions": "Opciones del visor de archivos",
"themeAndLanguage": "Tema y lengua"
"themeAndLanguage": "Tema y lengua",
"previewFolder": "Mostrar miniaturas de las carpetas si están disponibles",
"previewFolderDescription": "Mostrar vistas previas en miniatura para carpetas con el primer elemento hijo con miniatura como portada."
},
"editor": {
"uninitialized": "Error al iniciar el editor, inténtelo de nuevo",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "Afficher plus",
"showLess": "Montrer moins",
"openParentFolder": "Ouvrir le dossier parent",
"selectedCount": "Nombre d'éléments sélectionnés pour effectuer une action"
"selectedCount": "Nombre d'éléments sélectionnés pour effectuer une action",
"listView": "Vue de la liste",
"compactView": "Vue compacte",
"normalView": "Vue normale",
"galleryView": "Vue de la galerie"
},
"download": {
"downloadFile": "Télécharger le fichier",
@@ -188,7 +192,10 @@
"numberFiles": "Nombre de fichiers{suffix}",
"renameMessageConflict": "Un article nommé \"{filename}\" existe déjà !",
"uploadSettingsChunked": "Nombre maximum de téléchargements simultanés : {maxConcurrentUpload}, taille de chunk : {uploadChunkSizeMb} MO",
"uploadSettingsNoChunk": "Nombre maximum de téléchargements simultanés : {maxConcurrentUpload}, chunked upload désactivé"
"uploadSettingsNoChunk": "Nombre maximum de téléchargements simultanés : {maxConcurrentUpload}, chunked upload désactivé",
"quickJump": "Passer rapidement à d'autres fichiers",
"moveSuccess": "Les fichiers ont été déplacés avec succès !",
"copySuccess": "Les fichiers ont été copiés avec succès !"
},
"search": {
"images": "Images",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "Autoriser la visualisation de fichiers bureautiques en utilisant OnlyOffice dans ce partage.",
"enableOnlyOfficeEditing": "Activer l'édition OnlyOffice",
"enableOnlyOfficeEditingDescription": "Autoriser l'édition de fichiers bureautiques en utilisant OnlyOffice dans ce partage.",
"titleDefault": "Fichiers partagés - {title}"
"titleDefault": "Fichiers partagés - {title}",
"defaultViewMode": "Mode d'affichage par défaut",
"defaultViewModeDescription": "Définit le mode d'affichage par défaut pour les utilisateurs anonymes qui consultent ce partage. Les utilisateurs connectés peuvent toujours modifier leurs préférences d'affichage."
},
"api": {
"title": "Clés API :",
@@ -482,7 +491,9 @@
"maxConcurrentUpload": "Nombre maximum de téléchargements simultanés",
"maxConcurrentUploadHelp": "Nombre maximal de téléchargements simultanés. Des valeurs plus élevées sont généralement plus performantes, mais cela dépend de nombreux facteurs.",
"uploadChunkSizeMb": "Taille de la zone de téléchargement en Mo",
"uploadChunkSizeMbHelp": "Généralement de 5 à 50 Mo, une taille inférieure est préférable pour les connexions instables ou restreintes. La valeur 0 désactive le fractionnement de l'upload."
"uploadChunkSizeMbHelp": "Généralement de 5 à 50 Mo, une taille inférieure est préférable pour les connexions instables ou restreintes. La valeur 0 désactive le fractionnement de l'upload.",
"clearAll": "Clear completed devrait supprimer les erreurs, les pauses et les conflits.",
"clearAllDescription": "Le bouton \"Effacer les téléchargements terminés\" efface tous les téléchargements terminés, y compris les erreurs, les pauses et les conflits."
},
"colors": {
"red": "rouge",
@@ -547,7 +558,9 @@
"debugOfficeEditor": "Activer le mode de débogage d'OnlyOffice",
"debugOfficeEditorDescription": "Afficher une fenêtre de débogage avec des informations supplémentaires dans l'éditeur OnlyOffice.",
"fileViewerOptions": "Options de visualisation des fichiers",
"themeAndLanguage": "Thème et langue"
"themeAndLanguage": "Thème et langue",
"previewFolder": "Afficher les vignettes des dossiers si elles sont disponibles",
"previewFolderDescription": "Afficher des aperçus de vignettes pour les dossiers dont le premier élément enfant a une vignette comme couverture."
},
"editor": {
"uninitialized": "Échec de l'initialisation de l'éditeur. Veuillez recharger.",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "הצג עוד",
"showLess": "הצג פחות",
"openParentFolder": "פתח את התיקייה הראשית",
"selectedCount": "מספר הפריטים שנבחרו לביצוע פעולה"
"selectedCount": "מספר הפריטים שנבחרו לביצוע פעולה",
"listView": "תצוגת רשימה",
"compactView": "תצוגה קומפקטית",
"normalView": "תצוגה רגילה",
"galleryView": "תצוגת גלריה"
},
"download": {
"downloadFile": "הורד קובץ",
@@ -188,7 +192,10 @@
"numberFiles": "מספר הקבצים{suffix}",
"renameMessageConflict": "פריט בשם \"{filename}\" כבר קיים!",
"uploadSettingsChunked": "מספר העלאות מקסימלי בו-זמנית: {maxConcurrentUpload}, גודל קובץ: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "מספר העלאות מקסימלי בו-זמנית: {maxConcurrentUpload}, העלאה מקוטעת מושבתת"
"uploadSettingsNoChunk": "מספר העלאות מקסימלי בו-זמנית: {maxConcurrentUpload}, העלאה מקוטעת מושבתת",
"quickJump": "קפיצה מהירה לקבצים אחרים",
"moveSuccess": "הקבצים הועברו בהצלחה!",
"copySuccess": "הקבצים הועתקו בהצלחה!"
},
"search": {
"images": "תמונות",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "אפשר צפייה בקבצי Office באמצעות OnlyOffice בשיתוף זה.",
"enableOnlyOfficeEditing": "אפשר עריכה ב-OnlyOffice",
"enableOnlyOfficeEditingDescription": "אפשר עריכת קבצי Office באמצעות OnlyOffice בשיתוף זה.",
"titleDefault": "קבצים משותפים - {title}"
"titleDefault": "קבצים משותפים - {title}",
"defaultViewMode": "מצב תצוגה ברירת מחדל",
"defaultViewModeDescription": "הגדר את מצב התצוגה המוגדר כברירת מחדל עבור משתמשים אנונימיים הצופים בשיתוף זה. משתמשים מחוברים עדיין יכולים לשנות את העדפות התצוגה שלהם."
},
"api": {
"title": "מפתחות API",
@@ -482,7 +491,9 @@
"maxConcurrentUpload": "העלאות מקסימליות בו-זמנית",
"maxConcurrentUploadHelp": "המספר המרבי של העלאות בו-זמנית. ערכים גבוהים יותר בדרך כלל מספקים ביצועים טובים יותר, אך הדבר תלוי בגורמים רבים.",
"uploadChunkSizeMb": "גודל הקובץ להעלאה במגה-בייט",
"uploadChunkSizeMbHelp": "בדרך כלל 5 עד 50 MB, קטן יותר עדיף עבור חיבורים לא יציבים או מוגבלים. הגדרה ל-0 תשבית את חלוקת הקבצים להעלאה."
"uploadChunkSizeMbHelp": "בדרך כלל 5 עד 50 MB, קטן יותר עדיף עבור חיבורים לא יציבים או מוגבלים. הגדרה ל-0 תשבית את חלוקת הקבצים להעלאה.",
"clearAll": "השלמת הניקוי אמורה להסיר את השגיאה, ההשהיה והקונפליקט.",
"clearAllDescription": "הכפתור 'נקה את ההשלמות' ינקה את כל ההעלאות במצב 'הושלם', כולל מצבי שגיאה, השהיה וקונפליקט."
},
"colors": {
"red": "אדום",
@@ -547,7 +558,9 @@
"debugOfficeEditor": "הפעל מצב ניפוי באגים של OnlyOffice",
"debugOfficeEditorDescription": "הצג חלון קופץ לאיתור באגים עם מידע נוסף בעורך OnlyOffice.",
"fileViewerOptions": "אפשרויות תצוגת קבצים",
"themeAndLanguage": "נושא ושפה"
"themeAndLanguage": "נושא ושפה",
"previewFolder": "הצג תמונות ממוזערות של תיקיות, אם זמינות",
"previewFolderDescription": "הצג תצוגה מקדימה של תמונות ממוזערות עבור תיקיות עם פריט משנה ראשון עם תמונה ממוזערת ככריכה."
},
"editor": {
"uninitialized": "לא ניתן לאתחל את העורך. אנא טען מחדש.",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "Többet mutasson",
"showLess": "Kevesebb mutatása",
"openParentFolder": "Szülői mappa megnyitása",
"selectedCount": "A művelet végrehajtásához kiválasztott elemek száma"
"selectedCount": "A művelet végrehajtásához kiválasztott elemek száma",
"listView": "Lista nézet",
"compactView": "Kompakt nézet",
"normalView": "Normál nézet",
"galleryView": "Galéria nézet"
},
"download": {
"downloadFile": "Fájl letöltése",
@@ -188,7 +192,10 @@
"numberFiles": "Fájlok száma{suffix}",
"renameMessageConflict": "A \"{filename}\" nevű elem már létezik!",
"uploadSettingsChunked": "Maximális egyidejű feltöltés: {maxConcurrentUpload}, chunk méret: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Maximális egyidejű feltöltés: {maxConcurrentUpload}, chunked upload disabled"
"uploadSettingsNoChunk": "Maximális egyidejű feltöltés: {maxConcurrentUpload}, chunked upload disabled",
"quickJump": "Gyors ugrás más fájlokra",
"moveSuccess": "Fájlok sikeresen áthelyezve!",
"copySuccess": "A fájlok másolása sikeres volt!"
},
"search": {
"images": "Képek",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "Engedélyezze az irodai fájlok megtekintését az OnlyOffice használatával ezen a megosztáson.",
"enableOnlyOfficeEditing": "Engedélyezze az OnlyOffice szerkesztést",
"enableOnlyOfficeEditingDescription": "Engedélyezze az irodai fájlok szerkesztését az OnlyOffice használatával ezen a megosztáson.",
"titleDefault": "Megosztott fájlok - {title}"
"titleDefault": "Megosztott fájlok - {title}",
"defaultViewMode": "Alapértelmezett nézetmód",
"defaultViewModeDescription": "A megosztást megtekintő névtelen felhasználók alapértelmezett megtekintési módjának beállítása. A bejelentkezett felhasználók továbbra is módosíthatják a nézeti beállításaikat."
},
"api": {
"title": "API-kulcsok:",
@@ -482,7 +491,9 @@
"maxConcurrentUpload": "Maximális egyidejű feltöltés",
"maxConcurrentUploadHelp": "Az egyidejű feltöltések maximális száma. A magasabb értékek általában jobban teljesítenek, de ez sok tényezőtől függ.",
"uploadChunkSizeMb": "Feltöltési csomag mérete MB-ban",
"uploadChunkSizeMbHelp": "Általában 5 és 50 MB között, instabil vagy korlátozott kapcsolatok esetén a kisebb méret jobb. A 0 értékre állítás kikapcsolja a feltöltési darabolás funkciót."
"uploadChunkSizeMbHelp": "Általában 5 és 50 MB között, instabil vagy korlátozott kapcsolatok esetén a kisebb méret jobb. A 0 értékre állítás kikapcsolja a feltöltési darabolás funkciót.",
"clearAll": "A befejezett törlésnek el kell távolítania a hibát, a szünetet és a konfliktust.",
"clearAllDescription": "A befejezett állapot törlése gomb az összes befejezett állapotban lévő feltöltést törli, beleértve a hiba, a szüneteltetés és a konfliktus állapotokat is."
},
"colors": {
"red": "piros",
@@ -547,7 +558,9 @@
"debugOfficeEditor": "OnlyOffice hibakeresési mód engedélyezése",
"debugOfficeEditorDescription": "További információkat tartalmazó hibakeresési felugró ablak megjelenítése az OnlyOffice szerkesztőben.",
"fileViewerOptions": "Fájlnéző beállításai",
"themeAndLanguage": "Téma és nyelv"
"themeAndLanguage": "Téma és nyelv",
"previewFolder": "A mappák miniatűrjeinek megjelenítése, ha rendelkezésre állnak",
"previewFolderDescription": "Miniatűr előnézetek megjelenítése olyan mappákhoz, amelyekben az első gyermekelem miniatűrrel borítóképként szerepel."
},
"editor": {
"uninitialized": "Nem sikerült inicializálni a szerkesztőt. Kérjük, töltse újra.",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "Mostra di più",
"showLess": "Mostra meno",
"openParentFolder": "Aprire la cartella madre",
"selectedCount": "Numero di elementi selezionati per eseguire un'azione"
"selectedCount": "Numero di elementi selezionati per eseguire un'azione",
"listView": "Vista elenco",
"compactView": "Vista compatta",
"normalView": "Vista normale",
"galleryView": "Vista della galleria"
},
"download": {
"downloadFile": "Scarica file",
@@ -188,7 +192,10 @@
"numberFiles": "Numero di file{suffix}",
"renameMessageConflict": "Un elemento chiamato \"{filename}\" esiste già!",
"uploadSettingsChunked": "Carichi massimi contemporanei: {maxConcurrentUpload}, dimensione dei pezzi: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Carichi massimi contemporanei: {maxConcurrentUpload}, caricamento chunked disabilitato"
"uploadSettingsNoChunk": "Carichi massimi contemporanei: {maxConcurrentUpload}, caricamento chunked disabilitato",
"quickJump": "Salto rapido ad altri file",
"moveSuccess": "I file sono stati spostati con successo!",
"copySuccess": "I file sono stati copiati con successo!"
},
"search": {
"images": "Immagini",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "Consente di visualizzare i file di Office utilizzando OnlyOffice in questa condivisione.",
"enableOnlyOfficeEditing": "Abilita la modifica di OnlyOffice",
"enableOnlyOfficeEditingDescription": "Consentire la modifica dei file di Office utilizzando OnlyOffice in questa condivisione.",
"titleDefault": "File condivisi - {title}"
"titleDefault": "File condivisi - {title}",
"defaultViewMode": "Modalità di visualizzazione predefinita",
"defaultViewModeDescription": "Imposta la modalità di visualizzazione predefinita per gli utenti anonimi che visualizzano questa condivisione. Gli utenti registrati possono comunque modificare le loro preferenze di visualizzazione."
},
"api": {
"title": "Chiavi API:",
@@ -482,7 +491,9 @@
"maxConcurrentUpload": "Carichi massimi contemporanei",
"maxConcurrentUploadHelp": "Il numero massimo di upload contemporanei. I valori più alti sono in genere più performanti, ma ciò dipende da molti fattori.",
"uploadChunkSizeMb": "Dimensione dei pezzi caricati in MB",
"uploadChunkSizeMbHelp": "In genere da 5 a 50 MB, ma per connessioni instabili o limitate è meglio che sia più piccolo. Impostando 0 si disattiva il chunking del caricamento."
"uploadChunkSizeMbHelp": "In genere da 5 a 50 MB, ma per connessioni instabili o limitate è meglio che sia più piccolo. Impostando 0 si disattiva il chunking del caricamento.",
"clearAll": "L'opzione Clear completed dovrebbe rimuovere gli errori, le pause e i conflitti.",
"clearAllDescription": "Il pulsante Cancella completato cancella tutti i caricamenti in stato completato, compresi gli stati di errore, pausa e conflitto."
},
"colors": {
"red": "rosso",
@@ -547,7 +558,9 @@
"debugOfficeEditor": "Abilita la modalità di debug di OnlyOffice",
"debugOfficeEditorDescription": "Mostra un popup di debug con informazioni aggiuntive nell'editor di OnlyOffice.",
"fileViewerOptions": "Opzioni del visualizzatore di file",
"themeAndLanguage": "Tema e linguaggio"
"themeAndLanguage": "Tema e linguaggio",
"previewFolder": "Mostra le miniature delle cartelle, se disponibili.",
"previewFolderDescription": "Mostra le anteprime per le cartelle con il primo elemento figlio con miniatura come copertina."
},
"editor": {
"uninitialized": "Impossibile inizializzare l'editor. Ricaricare.",
+15 -2
View File
@@ -50,7 +50,11 @@
"showMore": "もっと見る",
"showLess": "表示を減らす",
"openParentFolder": "親フォルダを開く",
"selectedCount": "アクションを実行するために選択された項目の数"
"selectedCount": "アクションを実行するために選択された項目の数",
"listView": "リスト表示",
"compactView": "コンパクトビュー",
"normalView": "通常表示",
"galleryView": "ギャラリービュー"
},
"download": {
"downloadFile": "ファイルをダウンロード",
@@ -186,6 +190,9 @@
"lastModified": "最終更新日時{suffix}",
"numberDirs": "ディレクトリ数{suffix}",
"numberFiles": "ファイル数{suffix}",
"quickJump": "他のファイルに素早くジャンプ",
"moveSuccess": "ファイルは正常に移動しました!",
"copySuccess": "ファイルは正常にコピーされました!",
"renameMessageConflict": "\"{filename}\"という名前のアイテムがすでに存在します!",
"uploadSettingsChunked": "最大同時アップロード数:{maxConcurrentUpload}、チャンクサイズ:{uploadChunkSizeMb}MB",
"uploadSettingsNoChunk": "最大同時アップロード数:{maxConcurrentUpload}、チャンクアップロード無効"
@@ -389,6 +396,8 @@
"enableOnlyOffice": "OnlyOfficeビューアを有効にする",
"enableOnlyOfficeDescription": "この共有でOnlyOfficeを使用したオフィスファイルの閲覧を許可する",
"enableOnlyOfficeEditing": "OnlyOffice編集を有効にする",
"defaultViewMode": "デフォルトの表示モード",
"defaultViewModeDescription": "匿名ユーザーがこの共有を閲覧する際のデフォルトの表示モードを設定します。ログインしているユーザーは、表示モードを変更することができます。",
"enableOnlyOfficeEditingDescription": "この共有でOnlyOfficeを使ったオフィスファイルの編集を許可する",
"titleDefault": "共有ファイル{title}"
},
@@ -481,6 +490,8 @@
"title": "アップロードとダウンロード",
"maxConcurrentUpload": "最大同時アップロード数",
"maxConcurrentUploadHelp": "同時アップロードの最大数。一般的に高い値の方がパフォーマンスが良いですが、これは多くの要因に依存します。",
"clearAll": "クリアが完了すると、エラー、一時停止、コンフリクトが取り除かれる。",
"clearAllDescription": "clear completedボタンは、エラー、一時停止、競合状態を含む、完了状態のすべてのアップロードをクリアします。",
"uploadChunkSizeMb": "アップロードチャンクサイズ(MB)",
"uploadChunkSizeMbHelp": "通常5~50MBですが、不安定な接続や制限のある接続の場合は小さい方が良いでしょう。0に設定するとアップロードチャンキングが無効になります。"
},
@@ -547,7 +558,9 @@
"debugOfficeEditor": "OnlyOfficeデバッグモードを有効にする",
"debugOfficeEditorDescription": "OnlyOfficeエディタに追加情報のデバッグポップアップを表示します。",
"fileViewerOptions": "ファイルビューアオプション",
"themeAndLanguage": "テーマと言語"
"themeAndLanguage": "テーマと言語",
"previewFolder": "利用可能であれば、フォルダのサムネイルを表示する",
"previewFolderDescription": "サムネイルを持つ最初の子アイテムをカバーとするフォルダのサムネイルプレビューを表示します。"
},
"editor": {
"uninitialized": "エディタの初期化に失敗しました。再読み込みしてください。",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "자세히 보기",
"showLess": "덜 보기",
"openParentFolder": "상위 폴더 열기",
"selectedCount": "작업을 수행하기 위해 선택한 항목 수"
"selectedCount": "작업을 수행하기 위해 선택한 항목 수",
"listView": "목록 보기",
"compactView": "컴팩트 보기",
"normalView": "일반 보기",
"galleryView": "갤러리 보기"
},
"download": {
"downloadFile": "파일 다운로드",
@@ -188,7 +192,10 @@
"numberFiles": "파일 수{suffix}",
"renameMessageConflict": "\"{filename}\"라는 이름의 항목이 이미 존재합니다!",
"uploadSettingsChunked": "최대 동시 업로드: {maxConcurrentUpload}, 청크 크기: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "최대 동시 업로드: {maxConcurrentUpload}, 청크 업로드 비활성화"
"uploadSettingsNoChunk": "최대 동시 업로드: {maxConcurrentUpload}, 청크 업로드 비활성화",
"quickJump": "다른 파일로 빠르게 이동",
"moveSuccess": "파일이 성공적으로 이동했습니다!",
"copySuccess": "파일이 성공적으로 복사되었습니다!"
},
"search": {
"images": "이미지",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "이 공유에서 OnlyOffice를 사용하여 Office 파일을 볼 수 있도록 허용합니다.",
"enableOnlyOfficeEditing": "Office 편집만 사용",
"enableOnlyOfficeEditingDescription": "이 공유에서 OnlyOffice를 사용하여 Office 파일을 편집할 수 있도록 허용합니다.",
"titleDefault": "공유 파일 - {title}"
"titleDefault": "공유 파일 - {title}",
"defaultViewMode": "기본 보기 모드",
"defaultViewModeDescription": "이 공유를 보는 익명 사용자의 기본 보기 모드를 설정합니다. 로그인한 사용자는 여전히 보기 기본 설정을 변경할 수 있습니다."
},
"api": {
"title": "API 키:",
@@ -482,7 +491,9 @@
"maxConcurrentUpload": "최대 동시 업로드 수",
"maxConcurrentUploadHelp": "최대 동시 업로드 수입니다. 일반적으로 값이 높을수록 성능이 향상되지만, 이는 여러 요인에 따라 달라집니다.",
"uploadChunkSizeMb": "업로드 청크 크기(MB)",
"uploadChunkSizeMbHelp": "일반적으로 5~50MB이며, 연결이 불안정하거나 제한적인 경우에는 이보다 작을수록 좋습니다. 0으로 설정하면 업로드 청킹이 비활성화됩니다."
"uploadChunkSizeMbHelp": "일반적으로 5~50MB이며, 연결이 불안정하거나 제한적인 경우에는 이보다 작을수록 좋습니다. 0으로 설정하면 업로드 청킹이 비활성화됩니다.",
"clearAll": "완료를 지우면 오류, 일시 중지 및 충돌이 제거됩니다.",
"clearAllDescription": "완료 지우기 버튼은 오류, 일시 중지, 충돌 상태를 포함하여 완료된 상태의 모든 업로드를 지웁니다."
},
"colors": {
"red": "빨간색",
@@ -547,7 +558,9 @@
"debugOfficeEditor": "Office 디버그 모드만 사용",
"debugOfficeEditorDescription": "OnlyOffice 편집기에서 추가 정보가 포함된 디버그 팝업을 표시합니다.",
"fileViewerOptions": "파일 뷰어 옵션",
"themeAndLanguage": "테마 및 언어"
"themeAndLanguage": "테마 및 언어",
"previewFolder": "가능한 경우 폴더의 썸네일 표시",
"previewFolderDescription": "썸네일을 표지로 사용하는 첫 번째 하위 항목이 있는 폴더의 썸네일 미리 보기를 표시합니다."
},
"editor": {
"uninitialized": "편집기를 초기화하지 못했습니다. 다시 로드하세요.",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "Meer tonen",
"showLess": "Minder tonen",
"openParentFolder": "Bovenliggende map openen",
"selectedCount": "Aantal items geselecteerd om een actie uit te voeren"
"selectedCount": "Aantal items geselecteerd om een actie uit te voeren",
"listView": "Lijstweergave",
"compactView": "Compacte weergave",
"normalView": "Normale weergave",
"galleryView": "Galerij"
},
"download": {
"downloadFile": "Bestand downloaden",
@@ -188,7 +192,10 @@
"numberFiles": "Aantal bestanden{suffix}",
"renameMessageConflict": "Een item met de naam \"{filename}\" bestaat al!",
"uploadSettingsChunked": "Max gelijktijdige uploads: {maxConcurrentUpload}, brokgrootte: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Max gelijktijdige uploads: {maxConcurrentUpload}, chunked upload uitgeschakeld"
"uploadSettingsNoChunk": "Max gelijktijdige uploads: {maxConcurrentUpload}, chunked upload uitgeschakeld",
"quickJump": "Snel naar andere bestanden springen",
"moveSuccess": "Bestanden succesvol verplaatst!",
"copySuccess": "Bestanden succesvol gekopieerd!"
},
"search": {
"images": "Afbeeldingen",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "Sta het bekijken van Office-bestanden toe met OnlyOffice in deze share.",
"enableOnlyOfficeEditing": "Bewerken met OnlyOffice inschakelen",
"enableOnlyOfficeEditingDescription": "Laat het bewerken van Office-bestanden met OnlyOffice toe in deze share.",
"titleDefault": "Gedeelde bestanden - {title}"
"titleDefault": "Gedeelde bestanden - {title}",
"defaultViewMode": "Standaard weergavemodus",
"defaultViewModeDescription": "Stel de standaard weergavemodus in voor anonieme gebruikers die deze share bekijken. Aangemelde gebruikers kunnen nog steeds hun weergavevoorkeur wijzigen."
},
"api": {
"title": "API-sleutels",
@@ -446,7 +455,9 @@
"maxConcurrentUpload": "Max. gelijktijdige uploads",
"maxConcurrentUploadHelp": "Het maximum aantal gelijktijdige uploads. Hogere waarden presteren meestal beter, maar dit hangt van veel factoren af.",
"uploadChunkSizeMb": "Upload brokgrootte in MB",
"uploadChunkSizeMbHelp": "Gewoonlijk 5 tot 50 MB, kleiner is beter voor onstabiele of beperkte verbindingen. Instellen op 0 schakelt chunking uit."
"uploadChunkSizeMbHelp": "Gewoonlijk 5 tot 50 MB, kleiner is beter voor onstabiele of beperkte verbindingen. Instellen op 0 schakelt chunking uit.",
"clearAll": "Wissen voltooid moet fout, pauze en conflict verwijderen",
"clearAllDescription": "De knop Voltooid wissen wist alle uploads in een voltooide status, inclusief fout-, gepauzeerde en conflictstatussen."
},
"colors": {
"red": "rood",
@@ -511,7 +522,9 @@
"debugOfficeEditor": "Debugmodus OnlyOffice inschakelen",
"debugOfficeEditorDescription": "Een foutopsporingspop-up weergeven met aanvullende informatie in de OnlyOffice-editor.",
"fileViewerOptions": "Opties voor bestandsviewer",
"themeAndLanguage": "Thema & Taal"
"themeAndLanguage": "Thema & Taal",
"previewFolder": "Toon miniaturen voor mappen indien beschikbaar",
"previewFolderDescription": "Toon miniatuurafbeeldingen voor mappen met het eerste kinditem met miniatuurafbeelding als omslag."
},
"general": {
"name": "Naam",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "Pokaż więcej",
"showLess": "Pokaż mniej",
"openParentFolder": "Otwórz folder nadrzędny",
"selectedCount": "Liczba elementów wybranych do wykonania akcji"
"selectedCount": "Liczba elementów wybranych do wykonania akcji",
"listView": "Widok listy",
"compactView": "Widok kompaktowy",
"normalView": "Widok normalny",
"galleryView": "Widok galerii"
},
"download": {
"downloadFile": "Pobierz plik",
@@ -188,7 +192,10 @@
"numberFiles": "Liczba plików{suffix}",
"renameMessageConflict": "Element o nazwie \"{filename}\" już istnieje!",
"uploadSettingsChunked": "Maks. jednoczesne przesyłanie: {maxConcurrentUpload}, rozmiar fragmentu: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Maksymalny jednoczesny upload: {maxConcurrentUpload}, przesyłanie fragmentaryczne wyłączone"
"uploadSettingsNoChunk": "Maksymalny jednoczesny upload: {maxConcurrentUpload}, przesyłanie fragmentaryczne wyłączone",
"quickJump": "Szybkie przechodzenie do innych plików",
"moveSuccess": "Pliki zostały przeniesione pomyślnie!",
"copySuccess": "Pliki zostały skopiowane pomyślnie!"
},
"search": {
"images": "Zdjęcia",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "Zezwalaj na wyświetlanie plików biurowych przy użyciu OnlyOffice w tym udziale.",
"enableOnlyOfficeEditing": "Włącz edycję OnlyOffice",
"enableOnlyOfficeEditingDescription": "Zezwalaj na edycję plików biurowych za pomocą OnlyOffice w tym udziale.",
"titleDefault": "Udostępnione pliki - {title}"
"titleDefault": "Udostępnione pliki - {title}",
"defaultViewMode": "Domyślny tryb widoku",
"defaultViewModeDescription": "Ustawia domyślny tryb widoku dla anonimowych użytkowników przeglądających ten folder udostępniony. Zalogowani użytkownicy mogą nadal zmieniać swoje preferencje widoku."
},
"api": {
"title": "Klucze API:",
@@ -482,7 +491,9 @@
"maxConcurrentUpload": "Maksymalne jednoczesne przesyłanie",
"maxConcurrentUploadHelp": "Maksymalna liczba jednocześnie przesyłanych plików. Wyższe wartości zazwyczaj działają lepiej, ale zależy to od wielu czynników.",
"uploadChunkSizeMb": "Rozmiar przesyłanego fragmentu w MB",
"uploadChunkSizeMbHelp": "Zazwyczaj od 5 do 50 MB, mniejsze są lepsze dla niestabilnych lub ograniczonych połączeń. Ustawienie wartości 0 spowoduje wyłączenie podziału na fragmenty."
"uploadChunkSizeMbHelp": "Zazwyczaj od 5 do 50 MB, mniejsze są lepsze dla niestabilnych lub ograniczonych połączeń. Ustawienie wartości 0 spowoduje wyłączenie podziału na fragmenty.",
"clearAll": "Wyczyszczenie zakończone powinno usunąć błąd, pauzę i konflikt",
"clearAllDescription": "Przycisk wyczyść ukończone spowoduje wyczyszczenie wszystkich przesłanych plików w stanie ukończenia, w tym w stanie błędu, wstrzymania i konfliktu."
},
"colors": {
"red": "czerwony",
@@ -547,7 +558,9 @@
"debugOfficeEditor": "Włącz tryb debugowania OnlyOffice",
"debugOfficeEditorDescription": "Wyświetla wyskakujące okienko debugowania z dodatkowymi informacjami w edytorze OnlyOffice.",
"fileViewerOptions": "Opcje przeglądarki plików",
"themeAndLanguage": "Temat i język"
"themeAndLanguage": "Temat i język",
"previewFolder": "Wyświetlanie miniatur folderów, jeśli są dostępne.",
"previewFolderDescription": "Wyświetlanie podglądu miniatur dla folderów z pierwszym elementem podrzędnym z miniaturą jako okładką."
},
"editor": {
"uninitialized": "Nie udało się zainicjować edytora. Przeładuj ponownie.",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "Mostrar mais",
"showLess": "Mostrar menos",
"openParentFolder": "Abrir a pasta principal",
"selectedCount": "Número de itens selecionados para executar uma ação"
"selectedCount": "Número de itens selecionados para executar uma ação",
"listView": "Exibição de lista",
"compactView": "Visão compacta",
"normalView": "Visualização normal",
"galleryView": "Visualização da galeria"
},
"download": {
"downloadFile": "Baixar arquivo",
@@ -188,7 +192,10 @@
"numberFiles": "Número de arquivos{suffix}",
"renameMessageConflict": "Já existe um item chamado \"{filename}\"!",
"uploadSettingsChunked": "Máximo de uploads simultâneos: {maxConcurrentUpload}, chunk size: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Máximo de uploads simultâneos: {maxConcurrentUpload}, upload em pedaços desativado"
"uploadSettingsNoChunk": "Máximo de uploads simultâneos: {maxConcurrentUpload}, upload em pedaços desativado",
"quickJump": "Pular rapidamente para outros arquivos",
"moveSuccess": "Os arquivos foram movidos com sucesso!",
"copySuccess": "Os arquivos foram copiados com sucesso!"
},
"search": {
"images": "Imagens",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "Permitir a visualização de arquivos do Office usando o OnlyOffice nesse compartilhamento.",
"enableOnlyOfficeEditing": "Ativar a edição do OnlyOffice",
"enableOnlyOfficeEditingDescription": "Permitir a edição de arquivos do Office usando o OnlyOffice nesse compartilhamento.",
"titleDefault": "Arquivos compartilhados - {title}"
"titleDefault": "Arquivos compartilhados - {title}",
"defaultViewMode": "Modo de exibição padrão",
"defaultViewModeDescription": "Define o modo de exibição padrão para usuários anônimos que visualizam esse compartilhamento. Os usuários conectados ainda podem alterar sua preferência de visualização."
},
"api": {
"title": "Chaves de API:",
@@ -482,7 +491,9 @@
"maxConcurrentUpload": "Máximo de uploads simultâneos",
"maxConcurrentUploadHelp": "O número máximo de uploads simultâneos. Valores mais altos costumam ter melhor desempenho, mas isso depende de muitos fatores.",
"uploadChunkSizeMb": "Tamanho do bloco de upload em MB",
"uploadChunkSizeMbHelp": "Normalmente, de 5 a 50 MB; quanto menor, melhor para conexões instáveis ou restritas. A configuração como 0 desabilitará o chunking de upload."
"uploadChunkSizeMbHelp": "Normalmente, de 5 a 50 MB; quanto menor, melhor para conexões instáveis ou restritas. A configuração como 0 desabilitará o chunking de upload.",
"clearAll": "O Clear completed deve remover erros, pausas e conflitos",
"clearAllDescription": "O botão limpar concluído limpará todos os uploads em um estado concluído, inclusive os estados de erro, pausa e conflito."
},
"colors": {
"red": "vermelho",
@@ -547,7 +558,9 @@
"debugOfficeEditor": "Ativar o modo de depuração do OnlyOffice",
"debugOfficeEditorDescription": "Mostrar um pop-up de depuração com informações adicionais no editor do OnlyOffice.",
"fileViewerOptions": "Opções do visualizador de arquivos",
"themeAndLanguage": "Tema e linguagem"
"themeAndLanguage": "Tema e linguagem",
"previewFolder": "Mostrar miniaturas das pastas, se disponíveis",
"previewFolderDescription": "Mostrar visualizações em miniatura para pastas com o primeiro item filho com miniatura como capa."
},
"editor": {
"uninitialized": "Falha ao inicializar o editor. Favor recarregar.",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "Mostrar mais",
"showLess": "Mostrar menos",
"openParentFolder": "Abrir a pasta principal",
"selectedCount": "Número de itens selecionados para executar uma ação"
"selectedCount": "Número de itens selecionados para executar uma ação",
"listView": "Vista de lista",
"compactView": "Vista compacta",
"normalView": "Vista normal",
"galleryView": "Vista da galeria"
},
"download": {
"downloadFile": "Descarregar ficheiro",
@@ -188,7 +192,10 @@
"numberFiles": "Número de ficheiros{suffix}",
"renameMessageConflict": "Já existe um item com o nome \"{filename}\"!",
"uploadSettingsChunked": "Máximo de carregamentos simultâneos: {maxConcurrentUpload}, tamanho do pedaço: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Máximo de carregamentos simultâneos: {maxConcurrentUpload}, carregamento em pedaços desativado"
"uploadSettingsNoChunk": "Máximo de carregamentos simultâneos: {maxConcurrentUpload}, carregamento em pedaços desativado",
"quickJump": "Saltar rapidamente para outros ficheiros",
"moveSuccess": "Ficheiros movidos com sucesso!",
"copySuccess": "Ficheiros copiados com sucesso!"
},
"search": {
"images": "Imagens",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "Permitir a visualização de ficheiros de escritório utilizando o OnlyOffice nesta partilha.",
"enableOnlyOfficeEditing": "Ativar a edição OnlyOffice",
"enableOnlyOfficeEditingDescription": "Permitir a edição de ficheiros do Office utilizando o OnlyOffice nesta partilha.",
"titleDefault": "Ficheiros partilhados - {title}"
"titleDefault": "Ficheiros partilhados - {title}",
"defaultViewMode": "Modo de visualização predefinido",
"defaultViewModeDescription": "Define o modo de visualização predefinido para os utilizadores anónimos que visualizam esta partilha. Os utilizadores com sessão iniciada ainda podem alterar a sua preferência de visualização."
},
"api": {
"title": "Chaves da API",
@@ -446,7 +455,9 @@
"maxConcurrentUpload": "Máximo de carregamentos simultâneos",
"maxConcurrentUploadHelp": "O número máximo de carregamentos simultâneos. Os valores mais elevados têm normalmente um melhor desempenho, mas isto depende de muitos factores.",
"uploadChunkSizeMb": "Tamanho do bloco de carregamento em MB",
"uploadChunkSizeMbHelp": "Tipicamente, 5 a 50 MB, mas um valor mais pequeno é melhor para ligações instáveis ou restritas. Definir como 0 desactivará a fragmentação do carregamento."
"uploadChunkSizeMbHelp": "Tipicamente, 5 a 50 MB, mas um valor mais pequeno é melhor para ligações instáveis ou restritas. Definir como 0 desactivará a fragmentação do carregamento.",
"clearAll": "Limpar concluído deve eliminar erros, pausas e conflitos",
"clearAllDescription": "O botão Limpar concluído limpa todos os carregamentos num estado concluído, incluindo os estados de erro, pausa e conflito."
},
"colors": {
"red": "vermelho",
@@ -511,7 +522,9 @@
"debugOfficeEditor": "Ativar o modo de depuração do OnlyOffice",
"debugOfficeEditorDescription": "Mostrar um popup de depuração com informações adicionais no editor OnlyOffice.",
"fileViewerOptions": "Opções do visualizador de ficheiros",
"themeAndLanguage": "Tema e linguagem"
"themeAndLanguage": "Tema e linguagem",
"previewFolder": "Mostrar miniaturas das pastas, se disponíveis",
"previewFolderDescription": "Mostrar pré-visualizações em miniatura para pastas com o primeiro item filho com miniatura como capa."
},
"general": {
"name": "Nome",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "Arată mai multe",
"showLess": "Arată mai puțin",
"openParentFolder": "Deschideți folderul părinte",
"selectedCount": "Numărul de elemente selectate pentru a efectua o acțiune"
"selectedCount": "Numărul de elemente selectate pentru a efectua o acțiune",
"listView": "Vizualizare listă",
"compactView": "Vedere compactă",
"normalView": "Vizualizare normală",
"galleryView": "Vizualizare galerie"
},
"download": {
"downloadFile": "Descarcă fișier",
@@ -188,7 +192,10 @@
"numberFiles": "Număr de fișiere{suffix}",
"renameMessageConflict": "Un element numit \"{filename}\" există deja!",
"uploadSettingsChunked": "Încărcări simultane maxime: {maxConcurrentUpload}, dimensiunea bucăților: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Încărcări simultane maxime: {maxConcurrentUpload}, încărcare fragmentată dezactivată"
"uploadSettingsNoChunk": "Încărcări simultane maxime: {maxConcurrentUpload}, încărcare fragmentată dezactivată",
"quickJump": "Salt rapid la alte fișiere",
"moveSuccess": "Fișiere mutate cu succes!",
"copySuccess": "Fișiere copiate cu succes!"
},
"search": {
"images": "Imagini",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "Permiteți vizualizarea fișierelor office utilizând OnlyOffice în această partajare.",
"enableOnlyOfficeEditing": "Activați editarea OnlyOffice",
"enableOnlyOfficeEditingDescription": "Permiteți editarea fișierelor de birou utilizând OnlyOffice în această partajare.",
"titleDefault": "Fișiere partajate - {title}"
"titleDefault": "Fișiere partajate - {title}",
"defaultViewMode": "Mod de vizualizare implicit",
"defaultViewModeDescription": "Setează modul de vizualizare implicit pentru utilizatorii anonimi care vizualizează această partajare. Utilizatorii conectați își pot schimba în continuare preferințele de vizualizare."
},
"api": {
"title": "Chei API:",
@@ -482,7 +491,9 @@
"maxConcurrentUpload": "Încărcări simultane maxime",
"maxConcurrentUploadHelp": "Numărul maxim de încărcări simultane. Valorile mai mari au de obicei performanțe mai bune, dar acest lucru depinde de mai mulți factori.",
"uploadChunkSizeMb": "Mărimea pachetului de încărcare în MB",
"uploadChunkSizeMbHelp": "De obicei între 5 și 50 MB, mai mic este mai bine pentru conexiunile instabile sau restricționate. Setarea la 0 va dezactiva fragmentarea încărcării."
"uploadChunkSizeMbHelp": "De obicei între 5 și 50 MB, mai mic este mai bine pentru conexiunile instabile sau restricționate. Setarea la 0 va dezactiva fragmentarea încărcării.",
"clearAll": "Clear completed ar trebui să elimine erorile, pauzele și conflictele",
"clearAllDescription": "Butonul șterge finalizat va șterge toate încărcările în stare finalizată, inclusiv stările de eroare, pauză și conflict."
},
"colors": {
"red": "roșu",
@@ -547,7 +558,9 @@
"debugOfficeEditor": "Activați modul de depanare OnlyOffice",
"debugOfficeEditorDescription": "Afișați o fereastră pop-up de depanare cu informații suplimentare în editorul OnlyOffice.",
"fileViewerOptions": "Opțiuni vizualizator de fișiere",
"themeAndLanguage": "Temă și limbaj"
"themeAndLanguage": "Temă și limbaj",
"previewFolder": "Afișați miniaturi pentru foldere, dacă sunt disponibile",
"previewFolderDescription": "Afișează previzualizări ale miniaturilor pentru folderele cu primul element copil cu miniatură ca copertă."
},
"editor": {
"uninitialized": "Nu s-a reușit inițializarea editorului. Vă rugăm să reîncărcați.",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "Показать еще",
"showLess": "Показать меньше",
"openParentFolder": "Откройте родительскую папку",
"selectedCount": "Количество элементов, выбранных для выполнения действия"
"selectedCount": "Количество элементов, выбранных для выполнения действия",
"listView": "Просмотр списка",
"compactView": "Компактный вид",
"normalView": "Обычный вид",
"galleryView": "Просмотр галереи"
},
"download": {
"downloadFile": "Скачать файл",
@@ -188,7 +192,10 @@
"numberFiles": "Количество файлов{suffix}",
"renameMessageConflict": "Элемент с именем \"{filename}\" уже существует!",
"uploadSettingsChunked": "Максимальное количество одновременных загрузок: {maxConcurrentUpload}, размер чанка: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Максимальное количество одновременных загрузок: {maxConcurrentUpload}, загрузка в куски отключена"
"uploadSettingsNoChunk": "Максимальное количество одновременных загрузок: {maxConcurrentUpload}, загрузка в куски отключена",
"quickJump": "Быстрый переход к другим файлам",
"moveSuccess": "Файлы успешно перемещены!",
"copySuccess": "Файлы успешно скопированы!"
},
"search": {
"images": "Изображения",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "Разрешите просмотр офисных файлов с помощью OnlyOffice в этом ресурсе.",
"enableOnlyOfficeEditing": "Включить редактирование OnlyOffice",
"enableOnlyOfficeEditingDescription": "Разрешите редактирование офисных файлов с помощью OnlyOffice в этом ресурсе.",
"titleDefault": "Общие файлы - {title}"
"titleDefault": "Общие файлы - {title}",
"defaultViewMode": "Режим просмотра по умолчанию",
"defaultViewModeDescription": "Установите режим просмотра по умолчанию для анонимных пользователей, просматривающих этот ресурс. Пользователи, вошедшие в систему, могут изменить свой режим просмотра."
},
"api": {
"title": "Ключи API:",
@@ -482,7 +491,9 @@
"maxConcurrentUpload": "Максимальное количество одновременных загрузок",
"maxConcurrentUploadHelp": "Максимальное количество одновременных загрузок. Более высокие значения обычно работают лучше, но это зависит от многих факторов.",
"uploadChunkSizeMb": "Размер загружаемого фрагмента в МБ",
"uploadChunkSizeMbHelp": "Обычно от 5 до 50 МБ, меньший размер лучше для нестабильных или ограниченных соединений. При установке значения 0 отключается функция \"кусковой загрузки\"."
"uploadChunkSizeMbHelp": "Обычно от 5 до 50 МБ, меньший размер лучше для нестабильных или ограниченных соединений. При установке значения 0 отключается функция \"кусковой загрузки\".",
"clearAll": "Завершение работы должно устранить ошибки, паузы и конфликты",
"clearAllDescription": "Кнопка \"Очистить завершенные\" очистит все загрузки, находящиеся в состоянии завершения, включая состояния ошибки, паузы и конфликта."
},
"colors": {
"red": "красный",
@@ -547,7 +558,9 @@
"debugOfficeEditor": "Включить режим отладки OnlyOffice",
"debugOfficeEditorDescription": "Отображение всплывающего окна отладки с дополнительной информацией в редакторе OnlyOffice.",
"fileViewerOptions": "Параметры просмотра файлов",
"themeAndLanguage": "Тема и язык"
"themeAndLanguage": "Тема и язык",
"previewFolder": "Показывайте миниатюры для папок, если они доступны",
"previewFolderDescription": "Показывать предварительный просмотр миниатюр для папок, в которых первый дочерний элемент с миниатюрой является обложкой."
},
"editor": {
"uninitialized": "Не удалось инициализировать редактор. Пожалуйста, перезагрузитесь.",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "Zobraziť viac",
"showLess": "Zobraziť menej",
"openParentFolder": "Otvorenie nadradeného priečinka",
"selectedCount": "Počet položiek vybraných na vykonanie akcie"
"selectedCount": "Počet položiek vybraných na vykonanie akcie",
"listView": "Zobrazenie zoznamu",
"compactView": "Kompaktný pohľad",
"normalView": "Normálne zobrazenie",
"galleryView": "Zobrazenie galérie"
},
"download": {
"downloadFile": "Stiahnuť súbor",
@@ -188,7 +192,10 @@
"numberFiles": "Počet súborov{suffix}",
"renameMessageConflict": "Položka s názvom \"{filename}\" už existuje!",
"uploadSettingsChunked": "Maximálne súbežné nahrávanie: {maxConcurrentUpload}, veľkosť chunk: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Maximálne súbežné nahrávanie: {maxConcurrentUpload}, chunked upload vypnuté"
"uploadSettingsNoChunk": "Maximálne súbežné nahrávanie: {maxConcurrentUpload}, chunked upload vypnuté",
"quickJump": "Rýchly prechod na iné súbory",
"moveSuccess": "Súbory boli úspešne presunuté!",
"copySuccess": "Súbory boli úspešne skopírované!"
},
"search": {
"images": "Obrázky",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "Povoľte zobrazovanie kancelárskych súborov pomocou OnlyOffice v tejto zdieľanej zložke.",
"enableOnlyOfficeEditing": "Povolenie úprav OnlyOffice",
"enableOnlyOfficeEditingDescription": "Povoľte úpravu kancelárskych súborov pomocou aplikácie OnlyOffice v tejto zdieľanej zložke.",
"titleDefault": "Zdieľané súbory - {title}"
"titleDefault": "Zdieľané súbory - {title}",
"defaultViewMode": "Predvolený režim zobrazenia",
"defaultViewModeDescription": "Nastavenie predvoleného režimu zobrazenia pre anonymných používateľov pri prezeraní tejto zdieľanej zložky. Prihlásení používatelia môžu stále zmeniť svoje preferencie zobrazenia."
},
"api": {
"title": "Kľúče API:",
@@ -482,7 +491,9 @@
"maxConcurrentUpload": "Maximálne súbežné nahrávanie",
"maxConcurrentUploadHelp": "Maximálny počet súbežných odosielaní. Vyššie hodnoty sú zvyčajne výkonnejšie, ale závisí to od mnohých faktorov.",
"uploadChunkSizeMb": "Veľkosť nahraného úseku v MB",
"uploadChunkSizeMbHelp": "Zvyčajne 5 až 50 MB, v prípade nestabilných alebo obmedzených pripojení je vhodnejšie použiť menšiu veľkosť. Nastavením na hodnotu 0 sa vypne zoskupovanie pri odosielaní."
"uploadChunkSizeMbHelp": "Zvyčajne 5 až 50 MB, v prípade nestabilných alebo obmedzených pripojení je vhodnejšie použiť menšiu veľkosť. Nastavením na hodnotu 0 sa vypne zoskupovanie pri odosielaní.",
"clearAll": "Vymazanie dokončenia by malo odstrániť chybu, pauzu a konflikt",
"clearAllDescription": "Tlačidlo Vymazať dokončené vymaže všetky odoslania v stave dokončenia vrátane chybových, pozastavených a konfliktných stavov."
},
"colors": {
"red": "červená",
@@ -547,7 +558,9 @@
"debugOfficeEditor": "Povolenie režimu ladenia OnlyOffice",
"debugOfficeEditorDescription": "Zobrazenie vyskakovacieho okna ladenia s ďalšími informáciami v editore OnlyOffice.",
"fileViewerOptions": "Možnosti prehliadača súborov",
"themeAndLanguage": "Téma a jazyk"
"themeAndLanguage": "Téma a jazyk",
"previewFolder": "Zobrazenie miniatúr priečinkov, ak sú k dispozícii",
"previewFolderDescription": "Zobrazenie náhľadov miniatúr pre priečinky s prvou podriadenou položkou s miniatúrou ako obálkou."
},
"editor": {
"uninitialized": "Nepodarilo sa inicializovať editor. Prosím, načítajte ho znova.",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "Visa mer",
"showLess": "Visa mindre",
"openParentFolder": "Öppna överordnad mapp",
"selectedCount": "Antal objekt som valts för att utföra en åtgärd"
"selectedCount": "Antal objekt som valts för att utföra en åtgärd",
"listView": "Listvy",
"compactView": "Kompakt vy",
"normalView": "Normal vy",
"galleryView": "Galleri utsikt"
},
"download": {
"downloadFile": "Ladda ner fil",
@@ -188,7 +192,10 @@
"numberFiles": "Antal filer{suffix}",
"renameMessageConflict": "Ett objekt med namnet \"{filename}\" finns redan!",
"uploadSettingsChunked": "Max samtidiga uppladdningar: {maxConcurrentUpload}, chunkstorlek: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Max samtidiga uppladdningar: {maxConcurrentUpload}, chunked uppladdning inaktiverad"
"uploadSettingsNoChunk": "Max samtidiga uppladdningar: {maxConcurrentUpload}, chunked uppladdning inaktiverad",
"quickJump": "Hoppa snabbt till andra filer",
"moveSuccess": "Filerna flyttades framgångsrikt!",
"copySuccess": "Filerna kopierades framgångsrikt!"
},
"search": {
"images": "Bilder",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "Tillåt visning av kontorsfiler med OnlyOffice i denna delning.",
"enableOnlyOfficeEditing": "Aktivera redigering av OnlyOffice",
"enableOnlyOfficeEditingDescription": "Tillåt redigering av kontorsfiler med hjälp av OnlyOffice i denna andel.",
"titleDefault": "Delade filer {title}"
"titleDefault": "Delade filer {title}",
"defaultViewMode": "Standardläge för visning",
"defaultViewModeDescription": "Ange standardvisningsläge för anonyma användare som visar den här andelen. Inloggade användare kan fortfarande ändra sina visningspreferenser."
},
"api": {
"title": "API-nycklar",
@@ -446,7 +455,9 @@
"maxConcurrentUpload": "Max samtidiga uppladdningar",
"maxConcurrentUploadHelp": "Det maximala antalet samtidiga uppladdningar. Högre värden ger normalt bättre prestanda, men detta beror på många faktorer.",
"uploadChunkSizeMb": "Ladda upp chunkstorlek i MB",
"uploadChunkSizeMbHelp": "Vanligtvis 5 till 50 MB, men mindre är bättre för instabila eller begränsade anslutningar. Om du ställer in 0 inaktiveras chunking för uppladdning."
"uploadChunkSizeMbHelp": "Vanligtvis 5 till 50 MB, men mindre är bättre för instabila eller begränsade anslutningar. Om du ställer in 0 inaktiveras chunking för uppladdning.",
"clearAll": "Klart avslutat ska ta bort fel, paus och konflikt",
"clearAllDescription": "Knappen Clear Completed rensar alla uppladdningar som är klara, inklusive fel-, paus- och konflikttillstånd."
},
"colors": {
"red": "röd",
@@ -511,7 +522,9 @@
"debugOfficeEditor": "Aktivera OnlyOffice felsökningsläge",
"debugOfficeEditorDescription": "Visa en debug-popup med ytterligare information i OnlyOffice-redigeraren.",
"fileViewerOptions": "Alternativ för filvisare",
"themeAndLanguage": "Tema & språk"
"themeAndLanguage": "Tema & språk",
"previewFolder": "Visa miniatyrbilder för mappar om de är tillgängliga",
"previewFolderDescription": "Visa förhandsvisningar av miniatyrbilder för mappar med första underordnade objekt med miniatyrbild som omslag."
},
"general": {
"name": "Namn",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "Daha fazla göster",
"showLess": "Daha az göster",
"openParentFolder": "Üst klasörü açın",
"selectedCount": "Bir eylemi gerçekleştirmek için seçilen öğe sayısı"
"selectedCount": "Bir eylemi gerçekleştirmek için seçilen öğe sayısı",
"listView": "Liste Görünümü",
"compactView": "Kompakt Görünüm",
"normalView": "Normal Görünüm",
"galleryView": "Galeri Görünümü"
},
"download": {
"downloadFile": "Dosyayı indir",
@@ -188,7 +192,10 @@
"numberFiles": "Dosya sayısı{suffix}",
"renameMessageConflict": "\"{filename}\" adında bir öğe zaten var!",
"uploadSettingsChunked": "Maksimum eşzamanlı yükleme: {maxConcurrentUpload}, yığın boyutu: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Maksimum eşzamanlı yükleme: {maxConcurrentUpload}, yığınlanmış yükleme devre dışı"
"uploadSettingsNoChunk": "Maksimum eşzamanlı yükleme: {maxConcurrentUpload}, yığınlanmış yükleme devre dışı",
"quickJump": "Diğer dosyalara hızlıca atlayın",
"moveSuccess": "Dosyalar başarıyla taşındı!",
"copySuccess": "Dosyalar başarıyla kopyalandı!"
},
"search": {
"images": "Görseller",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "Bu paylaşımda OnlyOffice kullanarak ofis dosyalarının görüntülenmesine izin verin.",
"enableOnlyOfficeEditing": "OnlyOffice düzenlemesini etkinleştirin",
"enableOnlyOfficeEditingDescription": "Bu paylaşımda OnlyOffice kullanarak ofis dosyalarının düzenlenmesine izin verin.",
"titleDefault": "Paylaşılan dosyalar - {title}"
"titleDefault": "Paylaşılan dosyalar - {title}",
"defaultViewMode": "Varsayılan görünüm modu",
"defaultViewModeDescription": "Bu paylaşımı görüntüleyen anonim kullanıcılar için varsayılan görünüm modunu ayarlayın. Oturum açmış kullanıcılar yine de görünüm tercihlerini değiştirebilirler."
},
"api": {
"title": "API Anahtarları:",
@@ -482,7 +491,9 @@
"maxConcurrentUpload": "Maksimum eşzamanlı yükleme",
"maxConcurrentUploadHelp": "Eşzamanlı yüklemelerin maksimum sayısı. Daha yüksek değerler genellikle daha iyi performans gösterir, ancak bu birçok faktöre bağlıdır.",
"uploadChunkSizeMb": "MB cinsinden yığın boyutu yükleme",
"uploadChunkSizeMbHelp": "Tipik olarak 5 ila 50 MB, daha küçük kararsız veya kısıtlı bağlantılar için daha iyidir. 0 olarak ayarlandığında yükleme yığınlaması devre dışı bırakılır."
"uploadChunkSizeMbHelp": "Tipik olarak 5 ila 50 MB, daha küçük kararsız veya kısıtlı bağlantılar için daha iyidir. 0 olarak ayarlandığında yükleme yığınlaması devre dışı bırakılır.",
"clearAll": "Clear completed hata, duraklama ve çakışmayı kaldırmalıdır",
"clearAllDescription": "Tamamlananları temizle düğmesi hata, duraklatılmış ve çakışma durumları da dahil olmak üzere bitmiş durumdaki tüm yüklemeleri temizler."
},
"colors": {
"red": "kırmızı",
@@ -547,7 +558,9 @@
"debugOfficeEditor": "OnlyOffice Hata Ayıklama Modunu Etkinleştir",
"debugOfficeEditorDescription": "OnlyOffice düzenleyicisinde ek bilgiler içeren bir hata ayıklama açılır penceresi gösterin.",
"fileViewerOptions": "Dosya Görüntüleyici Seçenekleri",
"themeAndLanguage": "Tema ve Dil"
"themeAndLanguage": "Tema ve Dil",
"previewFolder": "Varsa klasörler için küçük resimleri göster",
"previewFolderDescription": "Kapak olarak küçük resmi olan ilk alt öğeye sahip klasörler için küçük resim önizlemelerini gösterin."
},
"editor": {
"uninitialized": "Düzenleyici başlatılamadı. Lütfen yeniden yükleyin.",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "Показати більше",
"showLess": "Показати менше",
"openParentFolder": "Відкрийте батьківську папку",
"selectedCount": "Кількість елементів, вибраних для виконання дії"
"selectedCount": "Кількість елементів, вибраних для виконання дії",
"listView": "Перегляд списку",
"compactView": "Компактний вигляд",
"normalView": "Звичайний вигляд",
"galleryView": "Перегляд галереї"
},
"download": {
"downloadFile": "Завантажити файл",
@@ -188,7 +192,10 @@
"numberFiles": "Кількість файлів{suffix}",
"renameMessageConflict": "Елемент з назвою \"{filename}\" вже існує!",
"uploadSettingsChunked": "Максимальна кількість одночасних завантажень: {maxConcurrentUpload}, розмір фрагмента: {uploadChunkSizeMb} МБ",
"uploadSettingsNoChunk": "Максимальне одночасне завантаження: {maxConcurrentUpload}, chunked upload вимкнено"
"uploadSettingsNoChunk": "Максимальне одночасне завантаження: {maxConcurrentUpload}, chunked upload вимкнено",
"quickJump": "Швидкий перехід до інших файлів",
"moveSuccess": "Файли успішно перенесено!",
"copySuccess": "Файли успішно скопійовано!"
},
"search": {
"images": "Зображення",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "Дозволити перегляд офісних файлів за допомогою OnlyOffice у цьому ресурсі.",
"enableOnlyOfficeEditing": "Увімкнути редагування OnlyOffice",
"enableOnlyOfficeEditingDescription": "Дозволити редагування офісних файлів за допомогою OnlyOffice у цьому ресурсі.",
"titleDefault": "Спільні файли - {title}"
"titleDefault": "Спільні файли - {title}",
"defaultViewMode": "Режим перегляду за замовчуванням",
"defaultViewModeDescription": "Встановити режим перегляду за замовчуванням для анонімних користувачів, які переглядають цей ресурс. Користувачі, що увійшли в систему, можуть змінити режим перегляду."
},
"api": {
"title": "Ключі API",
@@ -446,7 +455,9 @@
"maxConcurrentUpload": "Максимальне одночасне завантаження",
"maxConcurrentUploadHelp": "Максимальна кількість одночасних завантажень. Вищі значення зазвичай працюють краще, але це залежить від багатьох факторів.",
"uploadChunkSizeMb": "Розмір завантажуваного фрагмента в МБ",
"uploadChunkSizeMbHelp": "Зазвичай від 5 до 50 МБ, менший розмір краще для нестабільних або обмежених з'єднань. Якщо встановити значення 0, вимкнеться фрагментація завантаження."
"uploadChunkSizeMbHelp": "Зазвичай від 5 до 50 МБ, менший розмір краще для нестабільних або обмежених з'єднань. Якщо встановити значення 0, вимкнеться фрагментація завантаження.",
"clearAll": "Очистити завершено має видалити помилку, паузу та конфлікт",
"clearAllDescription": "Кнопка \"Очистити завершені\" очистить всі завантаження в завершеному стані, включаючи помилки, призупинені та конфліктні стани."
},
"colors": {
"red": "червоний",
@@ -511,7 +522,9 @@
"debugOfficeEditor": "Увімкнути режим налагодження OnlyOffice",
"debugOfficeEditorDescription": "Показати налагоджувальне спливаюче вікно з додатковою інформацією в редакторі OnlyOffice.",
"fileViewerOptions": "Параметри перегляду файлів",
"themeAndLanguage": "Тема та мова"
"themeAndLanguage": "Тема та мова",
"previewFolder": "Показувати мініатюри для папок, якщо вони доступні",
"previewFolderDescription": "Показувати попередній перегляд мініатюр для папок з першим дочірнім елементом, що має мініатюру як обкладинку."
},
"general": {
"name": "Ім'я",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "显示更多",
"showLess": "显示更少",
"openParentFolder": "打开父文件夹",
"selectedCount": "选择执行操作的项目数"
"selectedCount": "选择执行操作的项目数",
"listView": "列表查看",
"compactView": "紧凑型视图",
"normalView": "正常视图",
"galleryView": "图库浏览"
},
"download": {
"downloadFile": "下载文件",
@@ -188,7 +192,10 @@
"numberFiles": "文件数量{suffix}",
"renameMessageConflict": "名为 \"{filename}\" 的项目已经存在!",
"uploadSettingsChunked": "最大并发上传:{maxConcurrentUpload}块大小 {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "最大并发上传:{maxConcurrentUpload}已禁用分块上传"
"uploadSettingsNoChunk": "最大并发上传:{maxConcurrentUpload}已禁用分块上传",
"quickJump": "快速跳转到其他文件",
"moveSuccess": "文件移动成功!",
"copySuccess": "文件复制成功!"
},
"search": {
"images": "图像",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "允许在此共享中使用 OnlyOffice 查看办公文件。",
"enableOnlyOfficeEditing": "启用 OnlyOffice 编辑功能",
"enableOnlyOfficeEditingDescription": "允许在此共享中使用 OnlyOffice 编辑办公文件。",
"titleDefault": "共享文件{title}"
"titleDefault": "共享文件{title}",
"defaultViewMode": "默认视图模式",
"defaultViewModeDescription": "设置匿名用户查看此共享的默认视图模式。已登录的用户仍可更改其查看首选项。"
},
"api": {
"title": "应用程序接口密钥",
@@ -446,7 +455,9 @@
"maxConcurrentUpload": "最大并发上传",
"maxConcurrentUploadHelp": "最大并发上传次数。数值越大,性能通常越好,但这取决于很多因素。",
"uploadChunkSizeMb": "上传块大小(以 MB 为单位",
"uploadChunkSizeMbHelp": "通常为 5 至 50 MB,对于不稳定或受限制的连接,越小越好。设置为 0 将禁用上传分块。"
"uploadChunkSizeMbHelp": "通常为 5 至 50 MB,对于不稳定或受限制的连接,越小越好。设置为 0 将禁用上传分块。",
"clearAll": "清除完成后,应消除错误、暂停和冲突",
"clearAllDescription": "清除已完成按钮将清除所有处于完成状态的上传,包括错误、暂停和冲突状态。"
},
"colors": {
"red": "红",
@@ -511,7 +522,9 @@
"debugOfficeEditor": "启用 OnlyOffice 调试模式",
"debugOfficeEditorDescription": "在 OnlyOffice 编辑器中显示包含附加信息的调试弹出窗口。",
"fileViewerOptions": "文件查看器选项",
"themeAndLanguage": "主题与语言"
"themeAndLanguage": "主题与语言",
"previewFolder": "如果有的话,显示文件夹的缩略图",
"previewFolderDescription": "显示文件夹的缩略图预览,第一个子项的缩略图为封面。"
},
"general": {
"name": "名称",
+18 -5
View File
@@ -50,7 +50,11 @@
"showMore": "顯示更多",
"showLess": "顯示更少",
"openParentFolder": "開啟父資料夾",
"selectedCount": "選取執行動作的項目數量"
"selectedCount": "選取執行動作的項目數量",
"listView": "清單檢視",
"compactView": "緊湊型視圖",
"normalView": "正常檢視",
"galleryView": "圖庫瀏覽"
},
"download": {
"downloadFile": "下載檔案",
@@ -188,7 +192,10 @@
"numberFiles": "檔案數量{suffix}",
"renameMessageConflict": "名為 \"{filename}\" 的項目已經存在!",
"uploadSettingsChunked": "最大同時上傳數量:{maxConcurrentUpload}, 分塊大小: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "最大同時上傳數量:{maxConcurrentUpload}, 已停用分塊上傳"
"uploadSettingsNoChunk": "最大同時上傳數量:{maxConcurrentUpload}, 已停用分塊上傳",
"quickJump": "快速跳到其他檔案",
"moveSuccess": "檔案移動成功!",
"copySuccess": "檔案已成功複製!"
},
"search": {
"images": "影像",
@@ -390,7 +397,9 @@
"enableOnlyOfficeDescription": "允許在此共用中使用 OnlyOffice 檢視辦公室檔案。",
"enableOnlyOfficeEditing": "啟用 OnlyOffice 編輯",
"enableOnlyOfficeEditingDescription": "允許在此共用中使用 OnlyOffice 編輯辦公室檔案。",
"titleDefault": "共用檔案 -{title}"
"titleDefault": "共用檔案 -{title}",
"defaultViewMode": "預設檢視模式",
"defaultViewModeDescription": "設定匿名使用者檢視此分享的預設檢視模式。登入的使用者仍可變更檢視偏好。"
},
"api": {
"title": "API 金鑰",
@@ -485,7 +494,9 @@
"debugOfficeEditor": "啟用 OnlyOffice 除錯模式",
"debugOfficeEditorDescription": "在 OnlyOffice 編輯器中顯示包含其他資訊的除錯彈出式視窗。",
"fileViewerOptions": "檔案檢視器選項",
"themeAndLanguage": "主題與語言"
"themeAndLanguage": "主題與語言",
"previewFolder": "顯示資料夾的縮圖(若有",
"previewFolderDescription": "顯示資料夾的縮圖預覽,第一個子項目以縮圖為封面。"
},
"otp": {
"name": "雙因素驗證",
@@ -503,7 +514,9 @@
"maxConcurrentUpload": "最大同時上傳數量",
"maxConcurrentUploadHelp": "最大同時上傳數量。較高的值通常效能較佳,但這取決於許多因素。",
"uploadChunkSizeMb": "上傳區塊大小(以 MB 為單位",
"uploadChunkSizeMbHelp": "通常為 5 到 50 MB,對於不穩定或受限制的連線,越小越好。設定為 0 會停用上傳分塊。"
"uploadChunkSizeMbHelp": "通常為 5 到 50 MB,對於不穩定或受限制的連線,越小越好。設定為 0 會停用上傳分塊。",
"clearAll": "清除完成應移除錯誤、暫停和衝突",
"clearAllDescription": "清除完成按鈕將會清除所有處於完成狀態的上傳,包括錯誤、暫停和衝突狀態。"
},
"colors": {
"red": "紅色",
+3 -3
View File
@@ -1,6 +1,6 @@
import { mutations, state } from '@/store'
import { notify } from '@/notify'
import { baseURL } from '@/utils/constants'
import { globalVars } from '@/utils/constants'
import { filesApi } from '@/api'
let eventSrc = null
@@ -47,7 +47,7 @@ function clearReconnect () {
// Test the events endpoint to check for authentication before setting up EventSource
async function testEventsEndpoint() {
const url = `${baseURL}api/events?sessionId=${state.sessionId}`
const url = `${globalVars.baseURL}api/events?sessionId=${state.sessionId}`
try {
const response = await fetch(url, {
method: 'GET',
@@ -85,7 +85,7 @@ async function setupSSE () {
}
}
const url = `${baseURL}api/events?sessionId=${state.sessionId}`
const url = `${globalVars.baseURL}api/events?sessionId=${state.sessionId}`
eventSrc = new EventSource(url)
isManuallyClosed = false
+9 -9
View File
@@ -1,10 +1,10 @@
import { RouteLocation, createRouter, createWebHistory } from "vue-router";
import { RouteLocation, createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import Login from "@/views/Login.vue";
import Layout from "@/views/Layout.vue";
import Files from "@/views/Files.vue";
import Settings from "@/views/Settings.vue";
import Errors from "@/views/Errors.vue";
import { baseURL, name, oidcAvailable, passwordAvailable } from "@/utils/constants";
import { globalVars } from "@/utils/constants";
import { getters, state } from "@/store";
import { mutations } from "@/store";
import { validateLogin } from "@/utils/auth";
@@ -28,7 +28,7 @@ const titles = {
InternalServerError: "errors.internal",
};
const routes = [
const routes: RouteRecordRaw[] = [
{
path: "/login",
name: "Login",
@@ -132,7 +132,7 @@ const routes = [
];
const router = createRouter({
history: createWebHistory(baseURL),
history: createWebHistory(globalVars.baseURL),
routes,
});
@@ -148,7 +148,7 @@ router.beforeResolve(async (to, from, next) => {
}
const title = i18n.global.t(titles[to.name as keyof typeof titles]);
document.title = name + " - " + title;
document.title = globalVars.name + " - " + title;
mutations.setRoute(to);
if (
@@ -168,14 +168,14 @@ router.beforeResolve(async (to, from, next) => {
if (getters.isLoggedIn() || to.matched.some((record) => record.meta.optionalAuth)) {
// do nothing
} else {
if (passwordAvailable) {
if (globalVars.passwordAvailable) {
next({ path: "/login", query: { redirect: to.fullPath } });
return;
}
if (oidcAvailable) {
const modifiedPath = encodeURIComponent(baseURL+removeLeadingSlash(to.fullPath))
window.location.href = baseURL+`api/auth/oidc/login?redirect=${modifiedPath}`;
if (globalVars.oidcAvailable) {
const modifiedPath = encodeURIComponent(globalVars.baseURL+removeLeadingSlash(to.fullPath))
window.location.href = globalVars.baseURL+`api/auth/oidc/login?redirect=${modifiedPath}`;
return;
}
}
+5
View File
@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+24 -12
View File
@@ -1,7 +1,7 @@
import { removePrefix, buildItemUrl, removeLeadingSlash } from '@/utils/url.js'
import { getFileExtension } from '@/utils/files.js'
import { state, mutations } from '@/store'
import { noAuth, shareInfo } from '@/utils/constants.js'
import { globalVars, shareInfo, previewViews } from '@/utils/constants.js'
import { getTypeInfo } from '@/utils/mimetype'
import { fromNow } from '@/utils/moment'
import * as i18n from '@/i18n'
@@ -32,9 +32,30 @@ export const getters = {
}
return true
},
displayPreference: () => {
let source = state.sources.current;
if (getters.isShare()) {
source = getters.currentHash();
}
const path = state.route.path;
if (state.displayPreferences?.[source]?.[path]) {
return state.displayPreferences[source][path];
}
return null;
},
viewMode: () => {
// If user is anonymous and on a share, check for defaultViewMode override
if (getters.isShare() && state.user?.username === 'anonymous' && shareInfo.viewMode) {
return shareInfo.viewMode;
}
return getters.displayPreference()?.viewMode || state.user.viewMode || "normal";
},
sorting: () => {
return getters.displayPreference()?.sorting || state.user.sorting || { by: "name", asc: true };
},
previewType: () => getTypeInfo(state.req.type).simpleType,
isCardView: () =>
(state.user.viewMode == 'gallery' || state.user.viewMode == 'normal') &&
(getters.viewMode() == 'gallery' || getters.viewMode() == 'normal') &&
getters.currentView() == 'listingView',
currentHash: () => shareInfo.hash,
isMobile: () => state.isMobile,
@@ -66,7 +87,7 @@ export const getters = {
}
mutations.updateCurrentUser({ locale: savedLocale })
}
if (noAuth) {
if (globalVars.noAuth) {
return true
}
if (
@@ -150,15 +171,6 @@ export const getters = {
if (cv == 'onlyOfficeEditor') {
return false
}
const previewViews = [
'preview',
'markdownViewer',
'epubViewer',
'docViewer',
'onlyOfficeEditor',
'editor'
]
let visible = (state.showSidebar || getters.isStickySidebar())
if (getters.currentPromptName() && !getters.isStickySidebar()) {
visible = false
+6 -2
View File
@@ -1,10 +1,14 @@
// store/index.js
// store/index.ts
import { state } from "./state.js";
import { getters } from "./getters.js";
import { mutations } from "./mutations.js";
import type { StoreState } from "./types";
// Type assertion to tell TypeScript about the actual structure
const typedState = state as StoreState;
export {
state,
typedState as state,
getters,
mutations
};
+185 -5
View File
@@ -1,10 +1,14 @@
import * as i18n from "@/i18n";
import { state } from "./state.js";
import { getters } from "./getters.js";
import { emitStateChanged } from './eventBus'; // Import the function from eventBus.js
import { usersApi } from "@/api";
import { notify } from "@/notify";
import { sortedItems } from "@/utils/sort.js";
import { serverHasMultipleSources } from "@/utils/constants.js";
import { url } from "@/utils";
import { getTypeInfo } from "@/utils/mimetype";
import { filesApi } from "@/api";
export const mutations = {
setPreviousHistoryItem: (value) => {
@@ -241,7 +245,7 @@ export const mutations = {
emitStateChanged();
return;
}
if (value.username != "anonymous") {
if (value.username !== "anonymous") {
mutations.setSources(value);
}
// Ensure locale exists and is valid
@@ -251,8 +255,14 @@ export const mutations = {
i18n.setLocale(value.locale);
}
state.user = value;
state.user.sorting = {};
state.user.sorting.by = "name";
state.user.sorting.asc = true;
// Load display preferences for the current user
const allPreferences = JSON.parse(localStorage.getItem("displayPreferences") || "{}");
state.displayPreferences = allPreferences[state.user.username] || {};
} catch (error) {
console.log(error);
}
@@ -377,8 +387,9 @@ export const mutations = {
}
let sortby = "name"
let asc = true
sortby = state.user.sorting.by;
asc = state.user.sorting.asc;
const sorting = getters.sorting();
sortby = sorting.by;
asc = sorting.asc;
// Separate directories and files
const dirs = value.items.filter((item) => item.type === 'directory');
const files = value.items.filter((item) => item.type !== 'directory');
@@ -404,8 +415,7 @@ export const mutations = {
if (!state.user.sorting) {
state.user.sorting = {};
}
state.user.sorting.by = field;
state.user.sorting.asc = asc;
mutations.updateDisplayPreferences({ sorting: { by: field, asc: asc } });
emitStateChanged();
},
updateListingItems: () => {
@@ -461,4 +471,174 @@ export const mutations = {
state.user.fileLoading.maxConcurrentUpload = value;
emitStateChanged();
},
updateViewModeHistory: ({ source, path, viewMode }) => {
if (!source || !path) return;
if (!state.viewModeHistory) {
state.viewModeHistory = {};
}
if (!state.viewModeHistory[source]) {
state.viewModeHistory[source] = {};
}
state.viewModeHistory[source][path] = viewMode;
localStorage.setItem("viewModeHistory", JSON.stringify(state.viewModeHistory));
emitStateChanged();
},
updateDisplayPreferences: (payload) => {
let source = state.sources.current;
if (getters.isShare()) {
source = getters.currentHash();
}
const path = state.route.path;
if (!source || !path) return;
if (!state.displayPreferences) {
state.displayPreferences = {};
}
if (!state.displayPreferences[source]) {
state.displayPreferences[source] = {};
}
if (!state.displayPreferences[source][path]) {
state.displayPreferences[source][path] = {};
}
state.displayPreferences[source][path] = {
...state.displayPreferences[source][path],
...payload,
};
const allPreferences = JSON.parse(localStorage.getItem("displayPreferences") || "{}");
if (!allPreferences[state.user.username]) {
allPreferences[state.user.username] = {};
}
allPreferences[state.user.username] = state.displayPreferences;
localStorage.setItem("displayPreferences", JSON.stringify(allPreferences));
emitStateChanged();
},
setNavigationEnabled: (enabled) => {
if (state.navigation.enabled === enabled) {
return;
}
state.navigation.enabled = enabled;
if (!enabled) {
mutations.clearNavigation();
}
emitStateChanged();
},
setupNavigation: ({ listing, currentItem, directoryPath }) => {
state.navigation.listing = listing;
state.navigation.currentIndex = -1;
state.navigation.previousItem = null;
state.navigation.nextItem = null;
state.navigation.previousLink = "";
state.navigation.nextLink = "";
state.navigation.previousRaw = "";
state.navigation.nextRaw = "";
if (!listing || !currentItem) {
emitStateChanged();
return;
}
// Find current item index in the listing
for (let i = 0; i < listing.length; i++) {
if (listing[i].name === currentItem.name) {
state.navigation.currentIndex = i;
break;
}
}
if (state.navigation.currentIndex === -1) {
emitStateChanged();
return;
}
// Find previous item (skip directories)
for (let j = state.navigation.currentIndex - 1; j >= 0; j--) {
let item = listing[j];
if (item.type === 'directory') continue;
item.path = directoryPath + "/" + item.name;
state.navigation.previousItem = item;
state.navigation.previousLink = url.buildItemUrl(item.source, item.path);
if (getTypeInfo(item.type).simpleType === "image") {
state.navigation.previousRaw = mutations.getPrefetchUrl(item);
}
break;
}
// Find next item (skip directories)
for (let j = state.navigation.currentIndex + 1; j < listing.length; j++) {
let item = listing[j];
if (item.type === 'directory') continue;
item.path = directoryPath + "/" + item.name;
state.navigation.nextItem = item;
state.navigation.nextLink = url.buildItemUrl(item.source, item.path);
if (getTypeInfo(item.type).simpleType === "image") {
state.navigation.nextRaw = mutations.getPrefetchUrl(item);
}
break;
}
emitStateChanged();
// Auto-show navigation when it's first set up
if (state.navigation.enabled && (state.navigation.previousLink || state.navigation.nextLink)) {
mutations.setNavigationShow(true);
setTimeout(() => {
if (!state.navigation.hoverNav) {
mutations.setNavigationShow(false);
}
}, 3000);
}
},
getPrefetchUrl: (item) => {
if (getters.isShare()) {
return filesApi.getDownloadURL(state.req.source, item.path, true);
}
return filesApi.getDownloadURL(state.req.source, item.path, true);
},
setNavigationShow: (show) => {
if (state.navigation.show === show) {
return;
}
state.navigation.show = show;
emitStateChanged();
},
setNavigationHover: (hover) => {
if (state.navigation.hoverNav === hover) {
return;
}
state.navigation.hoverNav = hover;
emitStateChanged();
},
setNavigationTimeout: (timeout) => {
if (state.navigation.timeout) {
clearTimeout(state.navigation.timeout);
}
state.navigation.timeout = timeout;
},
clearNavigationTimeout: () => {
if (state.navigation.timeout) {
clearTimeout(state.navigation.timeout);
state.navigation.timeout = null;
}
},
clearNavigation: () => {
state.navigation.show = false;
state.navigation.hoverNav = false;
state.navigation.listing = null;
state.navigation.currentIndex = -1;
state.navigation.previousItem = null;
state.navigation.nextItem = null;
state.navigation.previousLink = "";
state.navigation.nextLink = "";
state.navigation.previousRaw = "";
state.navigation.nextRaw = "";
mutations.clearNavigationTimeout();
emitStateChanged();
},
};
+15
View File
@@ -24,6 +24,7 @@ export const state = reactive({
isMobile: window.innerWidth <= 800,
isSearchActive: false,
showSidebar: false,
displayPreferences: {},
usages: {},
editor: null,
serverHasMultipleSources: false,
@@ -117,6 +118,20 @@ export const state = reactive({
files: "",
},
},
navigation: {
show: false,
hoverNav: false,
listing: null,
currentIndex: -1,
previousItem: null,
nextItem: null,
previousLink: "",
nextLink: "",
previousRaw: "",
nextRaw: "",
timeout: null,
enabled: false,
},
});
function stickyStartup() {
+168
View File
@@ -0,0 +1,168 @@
// Store type definitions
export interface ReqObject {
// Base properties always present
sorting: {
by: string;
asc: boolean;
};
items: any[];
numDirs: number;
numFiles: number;
// File-specific properties (added dynamically)
name?: string;
path?: string;
size?: number;
type?: string;
source?: string;
content?: string;
modified?: string;
subtitles?: any[];
// Directory listing properties
listing?: any[];
}
export interface ShareObject {
hash: string | null;
token: string;
subPath: string;
}
export interface UserObject {
preview: {
video: boolean;
image: boolean;
popup: boolean;
highQuality: boolean;
autoplayMedia?: boolean;
defaultMediaPlayer?: boolean;
};
loginType: string;
username: string;
quickDownloadEnabled: boolean;
gallarySize: number;
singleClick: boolean;
stickySidebar: boolean;
locale: string;
viewMode: string;
showHidden: boolean;
scopes: any[];
permissions: any;
darkMode: boolean;
disableSettings: boolean;
debugOffice: boolean;
profile: {
username: string;
email: string;
avatarUrl: string;
};
// Optional properties that may be added dynamically
disableViewingExt?: string[];
displayNames?: string[];
id?: number;
password?: string;
scope?: string;
rules?: any[];
lockPassword?: boolean;
hideDotfiles?: boolean;
commands?: string[];
sorting?: {
by: string;
asc: boolean;
};
dateFormat?: boolean;
perm?: any;
email?: string;
avatarUrl?: string;
}
export interface RouteObject {
name?: string;
path?: string;
params?: any;
query?: any;
}
export interface StoreState {
tooltip: {
show: boolean;
content: string;
x: number;
y: number;
pointerEvents: boolean;
width: number | null;
};
previousHistoryItem: {
name: string;
source: string;
path: string;
};
contextMenuHasItems: boolean;
deletedItem: boolean;
showOverflowMenu: boolean;
sessionId: string;
isSafari: boolean;
activeSettingsView: string;
isMobile: boolean;
isSearchActive: boolean;
showSidebar: boolean;
displayPreferences: any;
usages: any;
editor: any;
serverHasMultipleSources: boolean;
realtimeActive: boolean | undefined;
realtimeDownCount: number;
popupPreviewSource: string;
share: ShareObject;
sources: {
current: string;
count: number;
hasSourceInfo: boolean;
info: any;
};
user: UserObject;
req: ReqObject;
listing: {
category: string;
letter: string;
scrolling: boolean;
scrollRatio: number;
};
previewRaw: string;
oldReq: any;
clipboard: {
key: string;
items: any[];
};
jwt: string;
sharePassword: string;
loading: any[];
reload: boolean;
selected: any[];
lastSelectedIndex: number | null;
multiple: boolean;
upload: {
uploads: any;
queue: any[];
progress: any[];
sizes: any[];
};
prompts: any[];
show: any;
showConfirm: any;
route: RouteObject;
settings: {
signup: boolean;
createUserDir: boolean;
userHomeBasePath: string;
rules: any[];
frontend: {
disableExternal: boolean;
name: string;
files: string;
};
};
}
+4 -4
View File
@@ -1,7 +1,7 @@
import { mutations, getters,state } from "@/store";
import { usersApi } from "@/api";
import { getApiPath } from "@/utils/url.js";
import { recaptcha, loginPage, baseURL } from "@/utils/constants";
import { globalVars } from "@/utils/constants";
export async function setNewToken(token) {
document.cookie = `auth=${token}; path=/`;
@@ -65,7 +65,7 @@ export async function logout() {
mutations.setCurrentUser(null);
mutations.setJWT("");
if (!logoutUrl) {
logoutUrl = baseURL+"login";
logoutUrl = globalVars.baseURL+"login";
}
// Add a small delay to ensure cookie deletion completes before redirect
setTimeout(() => {
@@ -92,11 +92,11 @@ export async function logout() {
//}
export async function initAuth() {
if (loginPage && !getters.isShare()) {
if (globalVars.loginPage && !getters.isShare()) {
console.log("validating login");
await validateLogin();
}
if (recaptcha) {
if (globalVars.recaptcha) {
await new Promise((resolve) => {
const check = () => {
if (typeof window.grecaptcha === "undefined") {
+15 -50
View File
@@ -1,30 +1,7 @@
const name = window.filebrowser.name;
const disableExternal = window.filebrowser.disableExternal;
const externalLinks = window.filebrowser.externalLinks;
const baseURL = window.filebrowser.baseURL;
const staticURL = window.filebrowser.staticURL;
const darkMode = window.filebrowser.darkMode;
const recaptcha = window.filebrowser.recaptcha;
const recaptchaKey = window.filebrowser.recaptchaKey;
const signup = window.filebrowser.signup;
const version = window.filebrowser.version;
const commitSHA = window.filebrowser.commitSHA;
const logoURL = `${staticURL}/img/logo.png`;
const noAuth = window.filebrowser.noAuth;
const loginPage = window.filebrowser.loginPage;
const enableThumbs = window.filebrowser.enableThumbs;
const externalUrl = window.filebrowser.externalUrl
const onlyOfficeUrl = window.filebrowser.onlyOfficeUrl
const serverHasMultipleSources = window.filebrowser.sourceCount > 1;
const oidcAvailable = window.filebrowser.oidcAvailable;
const passwordAvailable = window.filebrowser.passwordAvailable;
const mediaAvailable = window.filebrowser.mediaAvailable;
const muPdfAvailable = window.filebrowser.muPdfAvailable;
const updateAvailable = window.filebrowser.updateAvailable;
const disableNavButtons = window.filebrowser.disableNavButtons;
const userSelectableThemes = window.filebrowser.userSelectableThemes;
const minSearchLength = window.filebrowser.minSearchLength;
const shareInfo = window.filebrowser.share;
const globalVars = window.globalVars;
const logoURL = `${globalVars.staticURL}/img/logo.png`;
const serverHasMultipleSources = globalVars.sourceCount > 1;
const shareInfo = window.shareVars;
const origin = window.location.origin;
const settings = [
@@ -37,33 +14,21 @@ const settings = [
{ id: 'systemAdmin', label: 'settings.systemAdmin', component: 'SystemAdmin', permissions: { admin: true } },
];
const previewViews = [
'preview',
'markdownViewer',
'epubViewer',
'docViewer',
'onlyOfficeEditor',
'editor'
];
export {
minSearchLength,
globalVars,
shareInfo,
userSelectableThemes,
disableNavButtons,
updateAvailable,
muPdfAvailable,
mediaAvailable,
oidcAvailable,
passwordAvailable,
serverHasMultipleSources,
name,
externalUrl,
disableExternal,
externalLinks,
baseURL,
logoURL,
recaptcha,
recaptchaKey,
signup,
version,
commitSHA,
noAuth,
loginPage,
enableThumbs,
origin,
darkMode,
settings,
onlyOfficeUrl,
previewViews,
};
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more