269 lines
5.2 KiB
Go
269 lines
5.2 KiB
Go
|
|
package vfs
|
||
|
|
|
||
|
|
import (
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io/fs"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
"syscall"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"golang.org/x/sys/unix"
|
||
|
|
)
|
||
|
|
|
||
|
|
type ListOptions struct {
|
||
|
|
ShowHidden bool
|
||
|
|
DirsFirst bool
|
||
|
|
SortBy string
|
||
|
|
SortReverse bool
|
||
|
|
}
|
||
|
|
|
||
|
|
func ListDir(path string, options ListOptions) ([]Entry, error) {
|
||
|
|
resolvedPath := path
|
||
|
|
if resolvedPath == "" {
|
||
|
|
resolvedPath = "."
|
||
|
|
}
|
||
|
|
|
||
|
|
dirEntries, err := os.ReadDir(resolvedPath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("read dir %s: %w", resolvedPath, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
entries := make([]Entry, 0, len(dirEntries)+1)
|
||
|
|
if parent := filepath.Dir(resolvedPath); parent != resolvedPath {
|
||
|
|
entries = append(entries, Entry{
|
||
|
|
Name: "..",
|
||
|
|
Path: parent,
|
||
|
|
IsDir: true,
|
||
|
|
IsParent: true,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, dirEntry := range dirEntries {
|
||
|
|
name := dirEntry.Name()
|
||
|
|
hidden := strings.HasPrefix(name, ".")
|
||
|
|
if hidden && !options.ShowHidden {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
fullPath := filepath.Join(resolvedPath, name)
|
||
|
|
info, err := dirEntry.Info()
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
entry := Entry{
|
||
|
|
Name: name,
|
||
|
|
Path: fullPath,
|
||
|
|
Extension: ext(name),
|
||
|
|
Mode: info.Mode(),
|
||
|
|
Size: info.Size(),
|
||
|
|
ModifiedAt: info.ModTime(),
|
||
|
|
IsDir: info.IsDir(),
|
||
|
|
IsHidden: hidden,
|
||
|
|
}
|
||
|
|
|
||
|
|
if createdAt, ok := statBirthTime(fullPath); ok {
|
||
|
|
entry.CreatedAt = createdAt
|
||
|
|
entry.CreatedKnown = true
|
||
|
|
}
|
||
|
|
|
||
|
|
entries = append(entries, entry)
|
||
|
|
}
|
||
|
|
|
||
|
|
sort.SliceStable(entries, func(i, j int) bool {
|
||
|
|
left, right := entries[i], entries[j]
|
||
|
|
|
||
|
|
if left.IsParent != right.IsParent {
|
||
|
|
return left.IsParent
|
||
|
|
}
|
||
|
|
if options.DirsFirst && left.IsDir != right.IsDir {
|
||
|
|
return left.IsDir
|
||
|
|
}
|
||
|
|
comparison := compareEntries(left, right, options.SortBy)
|
||
|
|
if options.SortReverse {
|
||
|
|
return comparison > 0
|
||
|
|
}
|
||
|
|
return comparison < 0
|
||
|
|
})
|
||
|
|
|
||
|
|
return entries, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func compareEntries(left Entry, right Entry, sortBy string) int {
|
||
|
|
switch strings.ToLower(strings.TrimSpace(sortBy)) {
|
||
|
|
case "size":
|
||
|
|
if left.Size != right.Size {
|
||
|
|
return cmpInt64(left.Size, right.Size)
|
||
|
|
}
|
||
|
|
case "modified":
|
||
|
|
if !left.ModifiedAt.Equal(right.ModifiedAt) {
|
||
|
|
return cmpTimeDesc(left.ModifiedAt, right.ModifiedAt)
|
||
|
|
}
|
||
|
|
case "created":
|
||
|
|
if left.CreatedKnown != right.CreatedKnown {
|
||
|
|
if left.CreatedKnown {
|
||
|
|
return -1
|
||
|
|
}
|
||
|
|
return 1
|
||
|
|
}
|
||
|
|
if !left.CreatedAt.Equal(right.CreatedAt) {
|
||
|
|
return cmpTimeDesc(left.CreatedAt, right.CreatedAt)
|
||
|
|
}
|
||
|
|
case "extension":
|
||
|
|
if left.Extension != right.Extension {
|
||
|
|
return strings.Compare(left.Extension, right.Extension)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return strings.Compare(strings.ToLower(left.Name), strings.ToLower(right.Name))
|
||
|
|
}
|
||
|
|
|
||
|
|
func cmpInt64(left int64, right int64) int {
|
||
|
|
switch {
|
||
|
|
case left < right:
|
||
|
|
return -1
|
||
|
|
case left > right:
|
||
|
|
return 1
|
||
|
|
default:
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func cmpTimeDesc(left time.Time, right time.Time) int {
|
||
|
|
switch {
|
||
|
|
case left.Equal(right):
|
||
|
|
return 0
|
||
|
|
case left.After(right):
|
||
|
|
return -1
|
||
|
|
default:
|
||
|
|
return 1
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func statBirthTime(path string) (time.Time, bool) {
|
||
|
|
var stx unix.Statx_t
|
||
|
|
if err := unix.Statx(unix.AT_FDCWD, path, unix.AT_STATX_SYNC_AS_STAT, unix.STATX_BTIME, &stx); err == nil {
|
||
|
|
if stx.Mask&unix.STATX_BTIME != 0 {
|
||
|
|
return time.Unix(int64(stx.Btime.Sec), int64(stx.Btime.Nsec)), true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
info, err := os.Lstat(path)
|
||
|
|
if err != nil {
|
||
|
|
return time.Time{}, false
|
||
|
|
}
|
||
|
|
|
||
|
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
||
|
|
if !ok {
|
||
|
|
return time.Time{}, false
|
||
|
|
}
|
||
|
|
|
||
|
|
seconds := int64(stat.Ctim.Sec)
|
||
|
|
nanos := int64(stat.Ctim.Nsec)
|
||
|
|
if seconds == 0 && nanos == 0 {
|
||
|
|
return time.Time{}, false
|
||
|
|
}
|
||
|
|
return time.Unix(seconds, nanos), true
|
||
|
|
}
|
||
|
|
|
||
|
|
func DirectorySize(path string) (int64, error) {
|
||
|
|
var total int64
|
||
|
|
|
||
|
|
err := filepath.WalkDir(path, func(current string, d fs.DirEntry, walkErr error) error {
|
||
|
|
if walkErr != nil {
|
||
|
|
return walkErr
|
||
|
|
}
|
||
|
|
if d.IsDir() {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
info, err := d.Info()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
total += info.Size()
|
||
|
|
return nil
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
return total, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func FindSelected(entries []Entry, key string) int {
|
||
|
|
for idx, entry := range entries {
|
||
|
|
if entry.MatchKey() == key {
|
||
|
|
return idx
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
|
||
|
|
func HumanSize(size int64) string {
|
||
|
|
if size < 0 {
|
||
|
|
return "?"
|
||
|
|
}
|
||
|
|
if size < 1024 {
|
||
|
|
return fmt.Sprintf("%d B", size)
|
||
|
|
}
|
||
|
|
units := []string{"KB", "MB", "GB", "TB"}
|
||
|
|
value := float64(size)
|
||
|
|
for _, unit := range units {
|
||
|
|
value /= 1024
|
||
|
|
if value < 1024 {
|
||
|
|
return fmt.Sprintf("%.1f %s", value, unit)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return fmt.Sprintf("%.1f PB", value/1024)
|
||
|
|
}
|
||
|
|
|
||
|
|
func ShortTime(t time.Time) string {
|
||
|
|
if t.IsZero() {
|
||
|
|
return "n/a"
|
||
|
|
}
|
||
|
|
return t.Format("2006-01-02 15:04")
|
||
|
|
}
|
||
|
|
|
||
|
|
func Permissions(mode fs.FileMode) string {
|
||
|
|
return mode.String()
|
||
|
|
}
|
||
|
|
|
||
|
|
func IsBinarySample(data []byte) bool {
|
||
|
|
if len(data) == 0 {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
var controls int
|
||
|
|
for _, b := range data {
|
||
|
|
if b == 0 {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
if b < 9 || (b > 13 && b < 32) {
|
||
|
|
controls++
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return controls > len(data)/10
|
||
|
|
}
|
||
|
|
|
||
|
|
func SafeBase(path string) string {
|
||
|
|
base := filepath.Base(path)
|
||
|
|
if base == "." || base == string(filepath.Separator) {
|
||
|
|
return path
|
||
|
|
}
|
||
|
|
return base
|
||
|
|
}
|
||
|
|
|
||
|
|
func JoinPath(path string, name string) string {
|
||
|
|
return filepath.Join(path, name)
|
||
|
|
}
|
||
|
|
|
||
|
|
func ErrOverwrite(path string) error {
|
||
|
|
return fmt.Errorf("target already exists: %s", path)
|
||
|
|
}
|
||
|
|
|
||
|
|
func IsNotExist(err error) bool {
|
||
|
|
return errors.Is(err, os.ErrNotExist)
|
||
|
|
}
|