mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
* Add collation to directory listings. Closes #1806 Introduce a new `locale` arg to the `Query.directory` field. Set "en" as the default for the field for backward compatibility. Use the given locale, sending it through a language matcher, and use `x/text` as the collation engine for the matched language. Augment the file `ListDirs` call to optionally take a Collator. If the Collator is given, sort file listings according to the collators rules. While here, document the GraphQL schema a bit more. Add matchers by looking at the current front-end locales, and make sure each of these occur in the matcher list. * Language matcher touchups * Avoid having `en-US` twice. * Introduce `en-AU`. * Pass IgnoreCase and Numeric collation Allow the collator to be configured with options. Pass the options IgnoreCase and Numeric to the collator.
379 lines
9.1 KiB
Go
379 lines
9.1 KiB
Go
package utils
|
|
|
|
import (
|
|
"archive/zip"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/h2non/filetype"
|
|
"github.com/h2non/filetype/types"
|
|
"github.com/stashapp/stash/pkg/logger"
|
|
"golang.org/x/text/collate"
|
|
)
|
|
|
|
// FileType uses the filetype package to determine the given file path's type
|
|
func FileType(filePath string) (types.Type, error) {
|
|
file, _ := os.Open(filePath)
|
|
|
|
// We only have to pass the file header = first 261 bytes
|
|
head := make([]byte, 261)
|
|
_, _ = file.Read(head)
|
|
|
|
return filetype.Match(head)
|
|
}
|
|
|
|
// FileExists returns true if the given path exists
|
|
func FileExists(path string) (bool, error) {
|
|
_, err := os.Stat(path)
|
|
if err == nil {
|
|
return true, nil
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
// DirExists returns true if the given path exists and is a directory
|
|
func DirExists(path string) (bool, error) {
|
|
fileInfo, err := os.Stat(path)
|
|
if err != nil {
|
|
return false, fmt.Errorf("path doesn't exist <%s>", path)
|
|
}
|
|
if !fileInfo.IsDir() {
|
|
return false, fmt.Errorf("path is not a directory <%s>", path)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// Touch creates an empty file at the given path if it doesn't already exist
|
|
func Touch(path string) error {
|
|
var _, err = os.Stat(path)
|
|
if os.IsNotExist(err) {
|
|
var file, err = os.Create(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EnsureDir will create a directory at the given path if it doesn't already exist
|
|
func EnsureDir(path string) error {
|
|
exists, err := FileExists(path)
|
|
if !exists {
|
|
err = os.Mkdir(path, 0755)
|
|
return err
|
|
}
|
|
return err
|
|
}
|
|
|
|
// EnsureDirAll will create a directory at the given path along with any necessary parents if they don't already exist
|
|
func EnsureDirAll(path string) error {
|
|
return os.MkdirAll(path, 0755)
|
|
}
|
|
|
|
// RemoveDir removes the given dir (if it exists) along with all of its contents
|
|
func RemoveDir(path string) error {
|
|
return os.RemoveAll(path)
|
|
}
|
|
|
|
// EmptyDir will recursively remove the contents of a directory at the given path
|
|
func EmptyDir(path string) error {
|
|
d, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer d.Close()
|
|
|
|
names, err := d.Readdirnames(-1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, name := range names {
|
|
err = os.RemoveAll(filepath.Join(path, name))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type dirSorter []fs.DirEntry
|
|
|
|
func (s dirSorter) Len() int {
|
|
return len(s)
|
|
}
|
|
|
|
func (s dirSorter) Swap(i, j int) {
|
|
s[j], s[i] = s[i], s[j]
|
|
}
|
|
|
|
func (s dirSorter) Bytes(i int) []byte {
|
|
return []byte(s[i].Name())
|
|
}
|
|
|
|
// ListDir will return the contents of a given directory path as a string slice
|
|
func ListDir(col *collate.Collator, path string) ([]string, error) {
|
|
var dirPaths []string
|
|
files, err := os.ReadDir(path)
|
|
if err != nil {
|
|
path = filepath.Dir(path)
|
|
files, err = os.ReadDir(path)
|
|
if err != nil {
|
|
return dirPaths, err
|
|
}
|
|
}
|
|
|
|
if col != nil {
|
|
col.Sort(dirSorter(files))
|
|
}
|
|
|
|
for _, file := range files {
|
|
if !file.IsDir() {
|
|
continue
|
|
}
|
|
dirPaths = append(dirPaths, filepath.Join(path, file.Name()))
|
|
}
|
|
return dirPaths, nil
|
|
}
|
|
|
|
// GetHomeDirectory returns the path of the user's home directory. ~ on Unix and C:\Users\UserName on Windows
|
|
func GetHomeDirectory() string {
|
|
currentUser, err := user.Current()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return currentUser.HomeDir
|
|
}
|
|
|
|
func SafeMove(src, dst string) error {
|
|
err := os.Rename(src, dst)
|
|
|
|
if err != nil {
|
|
logger.Errorf("[Util] unable to rename: \"%s\" due to %s. Falling back to copying.", src, err.Error())
|
|
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
|
|
out, err := os.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
|
|
_, err = io.Copy(out, in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = out.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = os.Remove(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsZipFileUnmcompressed returns true if zip file in path is using 0 compression level
|
|
func IsZipFileUncompressed(path string) (bool, error) {
|
|
r, err := zip.OpenReader(path)
|
|
if err != nil {
|
|
fmt.Printf("Error reading zip file %s: %s\n", path, err)
|
|
return false, err
|
|
} else {
|
|
defer r.Close()
|
|
for _, f := range r.File {
|
|
if f.FileInfo().IsDir() { // skip dirs, they always get store level compression
|
|
continue
|
|
}
|
|
return f.Method == 0, nil // check compression level of first actual file
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// WriteFile writes file to path creating parent directories if needed
|
|
func WriteFile(path string, file []byte) error {
|
|
pathErr := EnsureDirAll(filepath.Dir(path))
|
|
if pathErr != nil {
|
|
return fmt.Errorf("cannot ensure path %s", pathErr)
|
|
}
|
|
|
|
err := os.WriteFile(path, file, 0755)
|
|
if err != nil {
|
|
return fmt.Errorf("write error for thumbnail %s: %s ", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetIntraDir returns a string that can be added to filepath.Join to implement directory depth, "" on error
|
|
// eg given a pattern of 0af63ce3c99162e9df23a997f62621c5 and a depth of 2 length of 3
|
|
// returns 0af/63c or 0af\63c ( dependin on os) that can be later used like this filepath.Join(directory, intradir, basename)
|
|
func GetIntraDir(pattern string, depth, length int) string {
|
|
if depth < 1 || length < 1 || (depth*length > len(pattern)) {
|
|
return ""
|
|
}
|
|
intraDir := pattern[0:length] // depth 1 , get length number of characters from pattern
|
|
for i := 1; i < depth; i++ { // for every extra depth: move to the right of the pattern length positions, get length number of chars
|
|
intraDir = filepath.Join(intraDir, pattern[length*i:length*(i+1)]) // adding each time to intradir the extra characters with a filepath join
|
|
}
|
|
return intraDir
|
|
}
|
|
|
|
func GetDir(path string) string {
|
|
if path == "" {
|
|
path = GetHomeDirectory()
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
func GetParent(path string) *string {
|
|
isRoot := path[len(path)-1:] == "/"
|
|
if isRoot {
|
|
return nil
|
|
} else {
|
|
parentPath := filepath.Clean(path + "/..")
|
|
return &parentPath
|
|
}
|
|
}
|
|
|
|
// ServeFileNoCache serves the provided file, ensuring that the response
|
|
// contains headers to prevent caching.
|
|
func ServeFileNoCache(w http.ResponseWriter, r *http.Request, filepath string) {
|
|
w.Header().Add("Cache-Control", "no-cache")
|
|
|
|
http.ServeFile(w, r, filepath)
|
|
}
|
|
|
|
// MatchEntries returns a string slice of the entries in directory dir which
|
|
// match the regexp pattern. On error an empty slice is returned
|
|
// MatchEntries isn't recursive, only the specific 'dir' is searched
|
|
// without being expanded.
|
|
func MatchEntries(dir, pattern string) ([]string, error) {
|
|
var res []string
|
|
var err error
|
|
|
|
re, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
f, err := os.Open(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
files, err := f.Readdirnames(-1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, file := range files {
|
|
if re.Match([]byte(file)) {
|
|
res = append(res, filepath.Join(dir, file))
|
|
}
|
|
}
|
|
return res, err
|
|
}
|
|
|
|
// IsPathInDir returns true if pathToCheck is within dir.
|
|
func IsPathInDir(dir, pathToCheck string) bool {
|
|
rel, err := filepath.Rel(dir, pathToCheck)
|
|
|
|
if err == nil {
|
|
if !strings.HasPrefix(rel, "..") {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// GetNameFromPath returns the name of a file from its path
|
|
// if stripExtension is true the extension is omitted from the name
|
|
func GetNameFromPath(path string, stripExtension bool) string {
|
|
fn := filepath.Base(path)
|
|
if stripExtension {
|
|
ext := filepath.Ext(fn)
|
|
fn = strings.TrimSuffix(fn, ext)
|
|
}
|
|
return fn
|
|
}
|
|
|
|
// GetFunscriptPath returns the path of a file
|
|
// with the extension changed to .funscript
|
|
func GetFunscriptPath(path string) string {
|
|
ext := filepath.Ext(path)
|
|
fn := strings.TrimSuffix(path, ext)
|
|
return fn + ".funscript"
|
|
}
|
|
|
|
// IsFsPathCaseSensitive checks the fs of the given path to see if it is case sensitive
|
|
// if the case sensitivity can not be determined false and an error != nil are returned
|
|
func IsFsPathCaseSensitive(path string) (bool, error) {
|
|
// The case sensitivity of the fs of "path" is determined by case flipping
|
|
// the first letter rune from the base string of the path
|
|
// If the resulting flipped path exists then the fs should not be case sensitive
|
|
// ( we check the file mod time to avoid matching an existing path )
|
|
|
|
fi, err := os.Stat(path)
|
|
if err != nil { // path cannot be stat'd
|
|
return false, err
|
|
}
|
|
|
|
base := filepath.Base(path)
|
|
fBase, err := FlipCaseSingle(base)
|
|
if err != nil { // cannot be case flipped
|
|
return false, err
|
|
}
|
|
i := strings.LastIndex(path, base)
|
|
if i < 0 { // shouldn't happen
|
|
return false, fmt.Errorf("could not case flip path %s", path)
|
|
}
|
|
|
|
flipped := []byte(path)
|
|
for _, c := range []byte(fBase) { // replace base of path with the flipped one ( we need to flip the base or last dir part )
|
|
flipped[i] = c
|
|
i++
|
|
}
|
|
|
|
fiCase, err := os.Stat(string(flipped))
|
|
if err != nil { // cannot stat the case flipped path
|
|
return true, nil // fs of path should be case sensitive
|
|
}
|
|
|
|
if fiCase.ModTime() == fi.ModTime() { // file path exists and is the same
|
|
return false, nil // fs of path is not case sensitive
|
|
}
|
|
return false, fmt.Errorf("can not determine case sensitivity of path %s", path)
|
|
}
|
|
|
|
func FindInPaths(paths []string, baseName string) string {
|
|
for _, p := range paths {
|
|
filePath := filepath.Join(p, baseName)
|
|
if exists, _ := FileExists(filePath); exists {
|
|
return filePath
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|