> remove sorted paths function > use the comparator directly > simplify warn function
268 lines
6.1 KiB
Go
268 lines
6.1 KiB
Go
/**
|
|
* Owl - file renaming tool
|
|
* Copyright (C) 2025 User SixteenThousand of github.com
|
|
* Email: thomsixteenthousand@gmail.com
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
fpath "path/filepath"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
|
|
/// TYPES
|
|
/**
|
|
* A runeset represents a list of Unicode Code Points (Hereafter referred to
|
|
* as "Code Points") which are considered valid by Owl. Each pair in the
|
|
* list should be two Code Points, which represent the lower & upper bounds
|
|
* (in that order) of a range of Code Points which are valid. Ranges are
|
|
* inclusive.
|
|
*/
|
|
type runeset [][2]rune
|
|
|
|
// Unrelated to the standard library's "context".
|
|
type context struct {
|
|
FileList []string
|
|
RecurseDirs []string
|
|
DryRun bool
|
|
Strategy string
|
|
DoHelp bool
|
|
DoVersion bool
|
|
}
|
|
|
|
|
|
/// CONSTANTS
|
|
// The largest Unicode code point.
|
|
// See link below for more details.
|
|
// https://www.unicode.org/versions/Unicode16.0.0/core-spec/chapter-2/#G25564
|
|
const MAX_CODE_POINT rune = 0x10ffff
|
|
|
|
var OwlVersion string
|
|
|
|
// The runeset of valid runes for file names in FAT32 & exFAT file systems.
|
|
var FAT_RUNESET = runeset{
|
|
{ 0x20, 0x21 },
|
|
{ 0x23, 0x29 },
|
|
{ 0x2b, 0x2e },
|
|
{ 0x30, 0x39 },
|
|
{ 0x3b, 0x3b },
|
|
{ 0x3d, 0x3d },
|
|
{ 0x40, 0x5b },
|
|
{ 0x5d, 0x7b },
|
|
{ 0x7d, MAX_CODE_POINT},
|
|
}
|
|
|
|
/// MAIN FUNCTIONS
|
|
/**
|
|
* Compares paths so that all files come before the directories that contain
|
|
* them. Files in the same directory come in alphanumeric
|
|
* order.
|
|
*/
|
|
func comparePaths(a, b string) int {
|
|
sep := string(fpath.Separator)
|
|
aNumComponents := len(strings.Split(a, sep))
|
|
bNumComponents := len(strings.Split(b, sep))
|
|
if aNumComponents == bNumComponents {
|
|
return strings.Compare(fpath.Base(a), fpath.Base(b))
|
|
}
|
|
return bNumComponents - aNumComponents
|
|
}
|
|
|
|
func (ctx *context) parseFileList() ([]string, error) {
|
|
errMsgs := []string{}
|
|
result := slices.Clone(ctx.FileList)
|
|
slices.SortFunc(result, comparePaths)
|
|
for _, path := range result {
|
|
if _, err := os.Stat(path); err != nil {
|
|
msg := fmt.Sprintf( "File <<%s>> does not exist", path)
|
|
errMsgs = append(errMsgs, msg)
|
|
}
|
|
}
|
|
addFiles := func(fullPath string, fileInfo fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
msg := fmt.Sprintf(
|
|
"Directory <<%s>> does not exist or is not searchable",
|
|
fullPath,
|
|
)
|
|
errMsgs = append(errMsgs,msg)
|
|
return err
|
|
}
|
|
loc, isDup := slices.BinarySearchFunc(result, fullPath, comparePaths)
|
|
if !isDup {
|
|
result = slices.Insert(result, loc, fullPath)
|
|
}
|
|
return nil
|
|
}
|
|
for _, dir := range ctx.RecurseDirs {
|
|
fpath.WalkDir(dir, addFiles)
|
|
}
|
|
if len(errMsgs) == 0 {
|
|
return result, nil
|
|
} else {
|
|
return result, errors.New(strings.Join(errMsgs, "\n"))
|
|
}
|
|
}
|
|
|
|
|
|
func isFatValid(r rune) bool {
|
|
for _, runeRange := range FAT_RUNESET {
|
|
if runeRange[0] <= r && r <= runeRange[1] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func restrictRuneset(s, strategy string) string {
|
|
result := s
|
|
toValidSubs := make(map[rune]string)
|
|
if strategy == "remove" {
|
|
for _, r := range result {
|
|
if !isFatValid(r) {
|
|
toValidSubs[r] = ""
|
|
}
|
|
}
|
|
} else {
|
|
for _, r := range result {
|
|
if !isFatValid(r) {
|
|
toValidSubs[r] = fmt.Sprintf("_U%X_", r)
|
|
}
|
|
}
|
|
}
|
|
for old, new := range toValidSubs {
|
|
result = strings.ReplaceAll(result, string(old), new)
|
|
}
|
|
if len(result) == 0 {
|
|
return "_EMPTY_"
|
|
}
|
|
return result
|
|
}
|
|
|
|
func warn(msg string) {
|
|
fmt.Fprintf(os.Stderr, "\x1b[33m%s\x1b[0m\n", msg)
|
|
}
|
|
|
|
func kaput(err error) {
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "\x1b[31m%s\x1b[0m\n", err.Error())
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// TODO: Collect all invalid args into one error instead of failing fast.
|
|
func parseCLIArgs(args []string) (context, error) {
|
|
// Set defaults
|
|
result := context{
|
|
FileList: []string{},
|
|
RecurseDirs: []string{},
|
|
DryRun: false,
|
|
Strategy: "represent",
|
|
DoHelp: false,
|
|
DoVersion: false,
|
|
}
|
|
index := 1
|
|
isFlag := func(arg string) bool {
|
|
return arg[0] == '-'
|
|
}
|
|
for index < len(args) {
|
|
switch arg := args[index]; arg {
|
|
case "-d", "--directory":
|
|
index++
|
|
result.RecurseDirs = append(result.RecurseDirs, args[index])
|
|
case "-n", "--dry-run":
|
|
result.DryRun = true
|
|
case "-s", "--strategy":
|
|
index++
|
|
result.Strategy = args[index]
|
|
case "-h", "--help":
|
|
result.DoHelp = true
|
|
case "-v", "--version":
|
|
result.DoVersion = true
|
|
default:
|
|
if isFlag(arg) {
|
|
return result, errors.New(fmt.Sprintf(
|
|
"Invalid flag <<%s>>",
|
|
arg,
|
|
))
|
|
} else {
|
|
result.FileList = append(result.FileList, arg)
|
|
}
|
|
}
|
|
index++
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// TODO: Finish writing options short help. Mention man page where relevant.
|
|
func printHelp() {
|
|
fmt.Print(`Owl - a hunter of bad characters in filenames
|
|
|
|
Usage:
|
|
owl [options] FILES
|
|
|
|
Rename FILES such that all characters that are invalid in FAT file systems
|
|
(?,\,*,etc.) are removed.
|
|
|
|
Options:
|
|
-s,--strategy
|
|
-h,--help
|
|
-v,--version
|
|
-d,--directory DIRECTORY
|
|
|
|
`);
|
|
}
|
|
|
|
|
|
func main() {
|
|
ctx, err := parseCLIArgs(os.Args)
|
|
kaput(err)
|
|
if ctx.DoHelp {
|
|
printHelp()
|
|
} else if ctx.DoVersion {
|
|
fmt.Printf(
|
|
"Owl - a hunter of bad characters in file names\nversion %s\n",
|
|
OwlVersion,
|
|
)
|
|
} else {
|
|
ctx.FileList, err = ctx.parseFileList()
|
|
if err != nil {
|
|
warn(err.Error())
|
|
}
|
|
for _, file := range ctx.FileList {
|
|
oldName := fpath.Base(file)
|
|
dirName := fpath.Dir(file)
|
|
newPath := fpath.Join(dirName, restrictRuneset(oldName, ctx.Strategy))
|
|
if newPath == file {
|
|
continue
|
|
}
|
|
if ctx.DryRun {
|
|
fmt.Printf(
|
|
"%s -> <<%s>>\n",
|
|
file,
|
|
newPath,
|
|
)
|
|
} else {
|
|
os.Rename(file, newPath)
|
|
}
|
|
}
|
|
}
|
|
}
|