mirror of
https://github.com/rclone/rclone.git
synced 2025-07-20 05:43:42 +02:00
Before this change the help for convmv was generated by running the examples each time rclone started up. Unfortunately this involved running the echo command which did not work on Windows. This pre-generates the help into `transform.md` and embeds it. It can be re-generated with `go generate` which is a better solution. See: https://forum.rclone.org/t/invoke-of-1-70-0-complains-of-echo-not-found/51618
350 lines
9.3 KiB
Go
350 lines
9.3 KiB
Go
// Package transform holds functions for path name transformations
|
|
//
|
|
//go:generate go run gen_help.go transform.md
|
|
package transform
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
_ "embed"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"mime"
|
|
"net/url"
|
|
"os/exec"
|
|
"path"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/rclone/rclone/fs"
|
|
"github.com/rclone/rclone/fs/fserrors"
|
|
"github.com/rclone/rclone/lib/encoder"
|
|
"golang.org/x/text/encoding/charmap"
|
|
"golang.org/x/text/unicode/norm"
|
|
)
|
|
|
|
//go:embed transform.md
|
|
var help string
|
|
|
|
// Help returns the help string cleaned up to simplify appending
|
|
func Help() string {
|
|
// Chop off auto generated message
|
|
nl := strings.IndexRune(help, '\n')
|
|
return strings.TrimSpace(help[nl:]) + "\n\n"
|
|
}
|
|
|
|
// Path transforms a path s according to the --name-transform options in use
|
|
//
|
|
// If no transforms are in use, s is returned unchanged
|
|
func Path(ctx context.Context, s string, isDir bool) string {
|
|
if !Transforming(ctx) {
|
|
return s
|
|
}
|
|
|
|
old := s
|
|
opt, err := getOptions(ctx)
|
|
if err != nil {
|
|
err = fs.CountError(ctx, err)
|
|
fs.Errorf(s, "Failed to parse transform flags: %v", err)
|
|
}
|
|
for _, t := range opt {
|
|
if isDir && t.tag == file {
|
|
continue
|
|
}
|
|
baseOnly := !isDir && t.tag == file
|
|
if t.tag == dir && !isDir {
|
|
s, err = transformDir(s, t)
|
|
} else {
|
|
s, err = transformPath(s, t, baseOnly)
|
|
}
|
|
if err != nil {
|
|
err = fs.CountError(ctx, fserrors.NoRetryError(err))
|
|
fs.Errorf(s, "Failed to transform: %v", err)
|
|
}
|
|
}
|
|
if old != s {
|
|
fs.Debugf(old, "transformed to: %v", s)
|
|
}
|
|
if strings.Count(old, "/") != strings.Count(s, "/") {
|
|
err = fs.CountError(ctx, fserrors.NoRetryError(fmt.Errorf("number of path segments must match: %v (%v), %v (%v)", old, strings.Count(old, "/"), s, strings.Count(s, "/"))))
|
|
fs.Errorf(old, "%v", err)
|
|
return old
|
|
}
|
|
return s
|
|
}
|
|
|
|
// transformPath transforms a path string according to the chosen TransformAlgo.
|
|
// Each path segment is transformed separately, to preserve path separators.
|
|
// If baseOnly is true, only the base will be transformed (useful for renaming while walking a dir tree recursively.)
|
|
// for example, "some/nested/path" -> "some/nested/CONVERTEDPATH"
|
|
// otherwise, the entire is path is transformed.
|
|
func transformPath(s string, t transform, baseOnly bool) (string, error) {
|
|
if s == "" || s == "/" || s == "\\" || s == "." {
|
|
return "", nil
|
|
}
|
|
|
|
if baseOnly {
|
|
transformedBase, err := transformPathSegment(path.Base(s), t)
|
|
if err := validateSegment(transformedBase); err != nil {
|
|
return "", err
|
|
}
|
|
return path.Join(path.Dir(s), transformedBase), err
|
|
}
|
|
|
|
segments := strings.Split(s, "/")
|
|
transformedSegments := make([]string, len(segments))
|
|
for _, seg := range segments {
|
|
convSeg, err := transformPathSegment(seg, t)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := validateSegment(convSeg); err != nil {
|
|
return "", err
|
|
}
|
|
transformedSegments = append(transformedSegments, convSeg)
|
|
}
|
|
return path.Join(transformedSegments...), nil
|
|
}
|
|
|
|
// transform all but the last path segment
|
|
func transformDir(s string, t transform) (string, error) {
|
|
dirPath, err := transformPath(path.Dir(s), t, false)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return path.Join(dirPath, path.Base(s)), nil
|
|
}
|
|
|
|
// transformPathSegment transforms one path segment (or really any string) according to the chosen TransformAlgo.
|
|
// It assumes path separators have already been trimmed.
|
|
func transformPathSegment(s string, t transform) (string, error) {
|
|
switch t.key {
|
|
case ConvNone:
|
|
return s, nil
|
|
case ConvToNFC:
|
|
return norm.NFC.String(s), nil
|
|
case ConvToNFD:
|
|
return norm.NFD.String(s), nil
|
|
case ConvToNFKC:
|
|
return norm.NFKC.String(s), nil
|
|
case ConvToNFKD:
|
|
return norm.NFKD.String(s), nil
|
|
case ConvBase64Encode:
|
|
return base64.URLEncoding.EncodeToString([]byte(s)), nil // URLEncoding to avoid slashes
|
|
case ConvBase64Decode:
|
|
if s == ".DS_Store" {
|
|
return s, nil
|
|
}
|
|
b, err := base64.URLEncoding.DecodeString(s)
|
|
if err != nil {
|
|
fs.Errorf(s, "base64 error")
|
|
}
|
|
return string(b), err
|
|
case ConvFindReplace:
|
|
split := strings.Split(t.value, ":")
|
|
if len(split) != 2 {
|
|
return s, fmt.Errorf("wrong number of values: %v", t.value)
|
|
}
|
|
return strings.ReplaceAll(s, split[0], split[1]), nil
|
|
case ConvPrefix:
|
|
return t.value + s, nil
|
|
case ConvSuffix:
|
|
return s + t.value, nil
|
|
case ConvSuffixKeepExtension:
|
|
return SuffixKeepExtension(s, t.value), nil
|
|
case ConvTrimPrefix:
|
|
return strings.TrimPrefix(s, t.value), nil
|
|
case ConvTrimSuffix:
|
|
return strings.TrimSuffix(s, t.value), nil
|
|
case ConvTruncate:
|
|
max, err := strconv.Atoi(t.value)
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
if max <= 0 {
|
|
return s, nil
|
|
}
|
|
if utf8.RuneCountInString(s) <= max {
|
|
return s, nil
|
|
}
|
|
runes := []rune(s)
|
|
return string(runes[:max]), nil
|
|
case ConvEncoder:
|
|
var enc encoder.MultiEncoder
|
|
err := enc.Set(t.value)
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
return enc.Encode(s), nil
|
|
case ConvDecoder:
|
|
var enc encoder.MultiEncoder
|
|
err := enc.Set(t.value)
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
return enc.Decode(s), nil
|
|
case ConvISO8859_1:
|
|
return encodeWithReplacement(s, charmap.ISO8859_1), nil
|
|
case ConvWindows1252:
|
|
return encodeWithReplacement(s, charmap.Windows1252), nil
|
|
case ConvMacintosh:
|
|
return encodeWithReplacement(s, charmap.Macintosh), nil
|
|
case ConvCharmap:
|
|
var cmapType CharmapChoices
|
|
err := cmapType.Set(t.value)
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
c := charmapByID(cmapType)
|
|
return encodeWithReplacement(s, c), nil
|
|
case ConvLowercase:
|
|
return strings.ToLower(s), nil
|
|
case ConvUppercase:
|
|
return strings.ToUpper(s), nil
|
|
case ConvTitlecase:
|
|
return strings.ToTitle(s), nil
|
|
case ConvASCII:
|
|
return toASCII(s), nil
|
|
case ConvURL:
|
|
return url.QueryEscape(s), nil
|
|
case ConvDate:
|
|
return s + AppyTimeGlobs(t.value, time.Now()), nil
|
|
case ConvRegex:
|
|
split := strings.Split(t.value, "/")
|
|
if len(split) != 2 {
|
|
return s, fmt.Errorf("regex syntax error: %v", t.value)
|
|
}
|
|
re := regexp.MustCompile(split[0])
|
|
return re.ReplaceAllString(s, split[1]), nil
|
|
case ConvCommand:
|
|
return mapper(s, t.value)
|
|
default:
|
|
return "", errors.New("this option is not yet implemented")
|
|
}
|
|
}
|
|
|
|
// SuffixKeepExtension adds a suffix while keeping extension
|
|
//
|
|
// i.e. file.txt becomes file_somesuffix.txt not file.txt_somesuffix
|
|
func SuffixKeepExtension(remote string, suffix string) string {
|
|
var (
|
|
base = remote
|
|
exts = ""
|
|
first = true
|
|
ext = path.Ext(remote)
|
|
)
|
|
for ext != "" {
|
|
// Look second and subsequent extensions in mime types.
|
|
// If they aren't found then don't keep it as an extension.
|
|
if !first && mime.TypeByExtension(ext) == "" {
|
|
break
|
|
}
|
|
base = base[:len(base)-len(ext)]
|
|
exts = ext + exts
|
|
first = false
|
|
ext = path.Ext(base)
|
|
}
|
|
return base + suffix + exts
|
|
}
|
|
|
|
// forbid transformations that add/remove path separators
|
|
func validateSegment(s string) error {
|
|
if strings.TrimSpace(s) == "" {
|
|
return errors.New("transform cannot render path segments empty")
|
|
}
|
|
if strings.ContainsRune(s, '/') {
|
|
return fmt.Errorf("transform cannot add path separators: %v", s)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ParseGlobs determines whether a string contains {brackets}
|
|
// and returns the substring (including both brackets) for replacing
|
|
// substring is first opening bracket to last closing bracket --
|
|
// good for {{this}} but not {this}{this}
|
|
func ParseGlobs(s string) (hasGlobs bool, substring string) {
|
|
open := strings.Index(s, "{")
|
|
close := strings.LastIndex(s, "}")
|
|
if open >= 0 && close > open {
|
|
return true, s[open : close+1]
|
|
}
|
|
return false, ""
|
|
}
|
|
|
|
// TrimBrackets converts {{this}} to this
|
|
func TrimBrackets(s string) string {
|
|
return strings.Trim(s, "{}")
|
|
}
|
|
|
|
// TimeFormat converts a user-supplied string to a Go time constant, if possible
|
|
func TimeFormat(timeFormat string) string {
|
|
switch timeFormat {
|
|
case "Layout":
|
|
timeFormat = time.Layout
|
|
case "ANSIC":
|
|
timeFormat = time.ANSIC
|
|
case "UnixDate":
|
|
timeFormat = time.UnixDate
|
|
case "RubyDate":
|
|
timeFormat = time.RubyDate
|
|
case "RFC822":
|
|
timeFormat = time.RFC822
|
|
case "RFC822Z":
|
|
timeFormat = time.RFC822Z
|
|
case "RFC850":
|
|
timeFormat = time.RFC850
|
|
case "RFC1123":
|
|
timeFormat = time.RFC1123
|
|
case "RFC1123Z":
|
|
timeFormat = time.RFC1123Z
|
|
case "RFC3339":
|
|
timeFormat = time.RFC3339
|
|
case "RFC3339Nano":
|
|
timeFormat = time.RFC3339Nano
|
|
case "Kitchen":
|
|
timeFormat = time.Kitchen
|
|
case "Stamp":
|
|
timeFormat = time.Stamp
|
|
case "StampMilli":
|
|
timeFormat = time.StampMilli
|
|
case "StampMicro":
|
|
timeFormat = time.StampMicro
|
|
case "StampNano":
|
|
timeFormat = time.StampNano
|
|
case "DateTime":
|
|
timeFormat = time.DateTime
|
|
case "DateOnly":
|
|
timeFormat = time.DateOnly
|
|
case "TimeOnly":
|
|
timeFormat = time.TimeOnly
|
|
case "MacFriendlyTime", "macfriendlytime", "mac":
|
|
timeFormat = "2006-01-02 0304PM" // not actually a Go constant -- but useful as macOS filenames can't have colons
|
|
case "YYYYMMDD":
|
|
timeFormat = "20060102"
|
|
}
|
|
return timeFormat
|
|
}
|
|
|
|
// AppyTimeGlobs converts "myfile-{DateOnly}.txt" to "myfile-2006-01-02.txt"
|
|
func AppyTimeGlobs(s string, t time.Time) string {
|
|
hasGlobs, substring := ParseGlobs(s)
|
|
if !hasGlobs {
|
|
return s
|
|
}
|
|
timeString := t.Local().Format(TimeFormat(TrimBrackets(substring)))
|
|
return strings.ReplaceAll(s, substring, timeString)
|
|
}
|
|
|
|
func mapper(s string, command string) (string, error) {
|
|
out, err := exec.Command(command, s).CombinedOutput()
|
|
if err != nil {
|
|
out = bytes.TrimSpace(out)
|
|
return s, fmt.Errorf("%s: error running command %q: %v", out, command+" "+s, err)
|
|
}
|
|
return string(bytes.TrimSpace(out)), nil
|
|
}
|