owl/main.go

318 lines
7.7 KiB
Go
Raw Normal View History

/**
* 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 (
ErrNoPwd = errors.New("Could not get current working directory!")
)
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.
* Note that paths a,b MUST be absolute.
*/
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
}
2025-05-27 13:08:48 +01:00
/**
* Parses the targeting options into a list of files to rename (stored in a
* map as keys, for storing their new names as values later) and a list of
* all files in the same directory as a target file, with their name in
* lower case, for checking name collisions.
*/
// TODO: Write tests for this function
func (ctx *context) parseFileList() ([]string, map[string]bool, error) {
errMsgs := []string{}
2025-05-27 13:08:48 +01:00
nearbyFiles := make(map[string]bool)
targets := []string{}
addPath := func(pathList *[]string, path string) {
loc, isDup := slices.BinarySearchFunc(*pathList, path, comparePaths)
if !isDup {
2025-05-27 13:08:48 +01:00
*pathList = slices.Insert(*pathList, loc, path)
}
}
2025-05-27 13:08:48 +01:00
addRecursively := func(path string, entry fs.DirEntry, err error) error {
if err != nil {
// This can only happen with the path passed to WalkDir
2025-05-27 13:08:48 +01:00
errMsgs = append(
errMsgs, fmt.Sprintf("Directory <<%s>> not searchable", path))
return nil
}
2025-05-27 13:08:48 +01:00
lowerCasedPath := fpath.Join(fpath.Dir(path), strings.ToLower(entry.Name()))
nearbyFiles[lowerCasedPath] = true
addPath(&targets, path)
return nil
}
for _, dir := range ctx.RecurseDirs {
dir, err := fpath.Abs(dir)
if err != nil {
2025-05-27 13:08:48 +01:00
return targets, nearbyFiles, ErrNoPwd
}
fpath.WalkDir(dir, addRecursively)
}
for _, path := range ctx.FileList {
if _, err := os.Stat(path); err != nil {
msg := fmt.Sprintf( "File <<%s>> does not exist", path)
errMsgs = append(errMsgs, msg)
}
path, err := fpath.Abs(path)
if err != nil {
2025-05-27 13:08:48 +01:00
return targets, nearbyFiles, ErrNoPwd
}
2025-05-27 13:08:48 +01:00
addPath(&targets, path)
}
if len(errMsgs) == 0 {
2025-05-27 13:08:48 +01:00
return targets, nearbyFiles, nil
} else {
2025-05-27 13:08:48 +01:00
return targets, nearbyFiles, 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 (ctx *context) restrictRuneset(s string) string {
result := s
toValidSubs := make(map[rune]string)
if ctx.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 "-r", "--recurse":
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
-r,--recurse 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 {
2025-05-27 13:08:48 +01:00
targets, nearbyFiles, err := ctx.parseFileList()
if err == ErrNoPwd {
kaput(err)
}
if err != nil {
warn(err.Error())
}
2025-05-27 13:08:48 +01:00
numRenamed := 0
for _, file := range targets {
// Calculate the new name
oldName := fpath.Base(file)
dirName := fpath.Dir(file)
newName := ctx.restrictRuneset(
strings.ToValidUTF8(oldName, "_INVALID_"),
)
newPath := fpath.Join(dirName, newName)
2025-05-27 13:08:48 +01:00
// Check that we want to rename this file
if newPath == file {
continue
}
2025-05-27 13:08:48 +01:00
lowerCasedPath := fpath.Join(dirName, strings.ToLower(newName))
if nearbyFiles[lowerCasedPath] {
warn(fmt.Sprintf(
"Path <<%s>> would be renamed to\n <<%s>>,\nwhich collides with\n <<%s>>\nSkipping...",
file,
newPath,
lowerCasedPath,
))
continue
}
nearbyFiles[lowerCasedPath] = true
// Do the rename, or just print what would happen
numRenamed++
if ctx.DryRun {
format := "%s -> <<%s>>\n"
2025-05-27 13:08:48 +01:00
if numRenamed % 2 == 0 {
format = "\x1b[2m%s -> <<%s>>\x1b[0m\n"
}
fmt.Printf(
format,
file,
newPath,
)
} else {
os.Rename(file, newPath)
}
}
2025-05-27 13:08:48 +01:00
fmt.Printf("%d files renamed!\n", numRenamed)
}
}