github.com/cryptix/massren@v1.0.1/main.go (about)

     1  package main
     2  
     3  import (
     4  	"crypto/md5"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"io/ioutil"
     9  	"os"
    10  	"os/exec"
    11  	"os/signal"
    12  	"path/filepath"
    13  	"runtime"	
    14  	"sort"
    15  	"strings"
    16  	"time"
    17  	
    18  	"github.com/jessevdk/go-flags"
    19  	"github.com/kr/text"
    20  )
    21  
    22  var flagParser_ *flags.Parser
    23  var newline_ string
    24  
    25  const (
    26  	APPNAME = "massren"
    27  	LINE_LENGTH = 80
    28  	VERSION = "1.0.1"
    29  )
    30  
    31  type CommandLineOptions struct {
    32  	DryRun bool `short:"n" long:"dry-run" description:"Don't rename anything but show the operation that would have been performed."`
    33  	Verbose bool `short:"v" long:"verbose" description:"Enable verbose output."`
    34  	Config bool `short:"c" long:"config" description:"Set a configuration value. eg. massren --config <name> [value]"`
    35  	Undo bool `short:"u" long:"undo" description:"Undo a rename operation. eg. massren --undo [path]"`
    36  	Version bool `short:"V" long:"version" description:"Displays version information."`
    37  }
    38  
    39  func stringHash(s string) string {
    40  	h := md5.New()
    41  	io.WriteString(h, s)
    42  	return fmt.Sprintf("%x", h.Sum(nil))	
    43  }
    44  
    45  func tempFolder() string {
    46  	output := profileFolder() + "/temp"
    47  	err := os.MkdirAll(output, CONFIG_PERM)
    48  	if err != nil {
    49  		panic(err)
    50  	}
    51  	return output
    52  }
    53  
    54  func criticalError(err error) {
    55  	logError("%s", err)
    56  	logInfo("Run '%s --help' for usage\n", APPNAME) 
    57  	os.Exit(1)
    58  }
    59  
    60  func watchFile(filePath string) error {
    61  	initialStat, err := os.Stat(filePath)
    62  	if err != nil {
    63  		return err
    64  	}
    65  
    66  	for {
    67  		stat, err := os.Stat(filePath)
    68  		if err != nil {
    69  			return err
    70  		}
    71  		
    72  		if stat.Size() != initialStat.Size() || stat.ModTime() != initialStat.ModTime() {
    73  			return nil
    74  		}
    75  		
    76  		time.Sleep(1 * time.Second)
    77  	}
    78  	
    79  	panic("unreachable")
    80  }
    81  
    82  func guessEditorCommand() (string, error) {
    83  	switch runtime.GOOS {
    84  		
    85  		case "windows":
    86  			
    87  			return "notepad.exe", nil
    88  		
    89  		default: // assumes a POSIX system
    90  		
    91  			editors := []string{
    92  				"nano",
    93  				"vim",
    94  				"emacs",
    95  				"vi",
    96  				"ed",
    97  			}
    98  			
    99  			for _, editor := range editors {
   100  				err := exec.Command("type", editor).Run()
   101  				if err == nil {
   102  					return editor, nil
   103  				} else {
   104  					err = exec.Command("sh", "-c", "type " + editor).Run()
   105  					if err == nil {
   106  						return editor, nil
   107  					}
   108  				}
   109  			}
   110  	
   111  	}
   112  			
   113  	return "", errors.New("could not guess editor command")
   114  }
   115  
   116  func editFile(filePath string) error {
   117  	var err error
   118  	editorCmd := config_.String("editor")
   119  	if editorCmd == "" {
   120  		editorCmd, err = guessEditorCommand()
   121  		setupInfo := fmt.Sprintf("Run `%s --config editor \"name-of-editor\"` to set up the editor. eg. `%s --config editor \"vim\"`", APPNAME, APPNAME)
   122  		if err != nil {
   123  			criticalError(errors.New(fmt.Sprintf("No text editor defined in configuration, and could not guess a text editor.\n%s", setupInfo)))
   124  		} else {
   125  			logInfo("No text editor defined in configuration. Using \"%s\" as default. %s", editorCmd, setupInfo) 
   126  		}
   127  	}
   128  	
   129  	cmd := exec.Command(editorCmd, filePath)
   130  	cmd.Stdin = os.Stdin
   131      cmd.Stdout = os.Stdout
   132  	err = cmd.Run()
   133  
   134  	if err != nil {
   135  		return err
   136  	}
   137  	return nil
   138  }
   139  
   140  func filePathsFromArgs(args []string) ([]string, error) {
   141  	var output []string
   142  	var err error
   143  	
   144  	if len(args) == 0 {
   145  		output, err = filepath.Glob("*")
   146  		if err != nil {
   147  			return []string{}, err
   148  		}
   149  	} else {
   150  		for _, arg := range args {
   151  			if strings.Index(arg, "*") < 0 && strings.Index(arg, "?") < 0 {
   152  				output = append(output, arg)
   153  				continue
   154  			}
   155  			matches, err := filepath.Glob(arg)
   156  			if err != nil {
   157  				return []string{}, err
   158  			}
   159  			for _, match := range matches {
   160  				output = append(output, match)
   161  			}
   162  		}
   163  	}
   164  	
   165  	sort.Strings(output)
   166  	
   167  	return output, nil
   168  }
   169  
   170  func stripBom(s string) string {
   171  	if len(s) < 3 {
   172  		return s
   173  	}
   174  	if s[0] != 239 || s[1] != 187 || s[2] != 191 {
   175  		return s
   176  	}
   177  	return s[3:]
   178  }
   179  
   180  func filePathsFromListFile(filePath string) ([]string, error) {
   181  	contentB, err := ioutil.ReadFile(filePath)
   182  	if err != nil {
   183  		return []string{}, err
   184  	}
   185  	
   186  	var output []string
   187  	content := string(contentB)
   188  	lines := strings.Split(content, newline_)
   189  	for i, line := range lines {
   190  		line := strings.Trim(line, "\n\r")
   191  		if i == 0 {
   192  			line = stripBom(line)
   193  		}
   194  		if line == "" {
   195  			continue
   196  		}
   197  		if len(line) >= 2 && line[0:2] == "//" {
   198  			continue
   199  		}
   200  		output = append(output, line)
   201  	}
   202  	
   203  	return output, nil
   204  }
   205  
   206  func twoColumnPrint(col1 []string, col2 []string, separator string) {
   207  	if len(col1) != len(col2) {
   208  		panic("col1 and col2 length do not match")
   209  	}
   210  	
   211  	maxColLength1 := 0
   212  	for _, d1 := range col1 {
   213  		if len(d1) > maxColLength1 {
   214  			maxColLength1 = len(d1)
   215  		}
   216  	}
   217  	
   218  	for i, d1 := range col1 {
   219  		d2 := col2[i]
   220  		for len(d1) < maxColLength1 {
   221  			d1 += " "
   222  		}
   223  		fmt.Println(d1 + separator + d2)
   224  	}
   225  }
   226  
   227  func printHelp() {
   228  	flagParser_.WriteHelp(os.Stdout)
   229  	
   230  	examples := `
   231  Examples:
   232  
   233    Process all the files in the current directory:
   234    % APPNAME	
   235    
   236    Process all the JPEGs in the specified directory:
   237    % APPNAME /path/to/photos/*.jpg
   238    
   239    Undo the changes done by the previous operation:
   240    % APPNAME --undo /path/to/photos/*.jpg
   241  
   242    Set VIM as the default text editor:
   243    % APPNAME --config editor vim
   244  `
   245  	fmt.Println(strings.Replace(examples, "APPNAME", APPNAME, -1))
   246  }
   247  
   248  func deleteTempFiles() error {	
   249  	tempFiles, err := filepath.Glob(tempFolder() + "/*")
   250  	if err != nil {
   251  		return err
   252  	}
   253  
   254  	for _, p := range tempFiles {
   255  		os.Remove(p)
   256  	}
   257  	
   258  	return nil
   259  }
   260  
   261  func handleVersionCommand(opts *CommandLineOptions, args []string) error {
   262  	fmt.Println(APPNAME + " version " + VERSION)
   263  	return nil
   264  }
   265  
   266  func onExit() {
   267  	deleteTempFiles()
   268  	deleteOldHistoryItems(time.Now().Unix() - 60 * 60 * 24 * 7)
   269  	profileClose()
   270  }
   271  
   272  func main() {
   273  	if runtime.GOOS == "windows" {
   274  		newline_ = "\r\n"
   275  	} else {
   276  		newline_ = "\n"
   277  	}
   278  	
   279  	minLogLevel_ = 1
   280  	
   281  	// -----------------------------------------------------------------------------------
   282  	// Handle SIGINT (Ctrl + C)
   283  	// -----------------------------------------------------------------------------------
   284  	
   285  	signalChan := make(chan os.Signal, 1)
   286  	signal.Notify(signalChan, os.Interrupt, os.Kill)
   287  	go func() {
   288  		<-signalChan
   289  		logInfo("Operation has been aborted.")
   290  		onExit()
   291  		os.Exit(2)
   292  	}()
   293  	
   294  	defer onExit()
   295  	
   296  	// -----------------------------------------------------------------------------------
   297  	// Parse arguments
   298  	// -----------------------------------------------------------------------------------
   299  	
   300  	var opts CommandLineOptions
   301  	flagParser_ = flags.NewParser(&opts, flags.HelpFlag | flags.PassDoubleDash)
   302  	args, err := flagParser_.Parse()
   303  	if err != nil {
   304  		t := err.(*flags.Error).Type
   305  		if t == flags.ErrHelp {
   306  			printHelp()
   307  			return
   308  		} else {
   309  			criticalError(err)
   310  		}
   311  	}
   312  	
   313  	if opts.Verbose {
   314  		minLogLevel_ = 0
   315  	}
   316  	
   317  	profileOpen()
   318  
   319  	// -----------------------------------------------------------------------------------
   320  	// Handle selected command
   321  	// -----------------------------------------------------------------------------------
   322  	
   323  	var commandName string
   324  	if opts.Config {
   325  		commandName = "config"
   326  	} else if opts.Undo {
   327  		commandName = "undo"
   328  	} else if opts.Version {
   329  		commandName = "version"
   330  	} else {
   331  		commandName = "rename"
   332  	}
   333  	
   334  	var commandErr error
   335  	switch commandName {
   336  		case "config": commandErr = handleConfigCommand(&opts, args)
   337  		case "undo": commandErr = handleUndoCommand(&opts, args)
   338  		case "version": commandErr = handleVersionCommand(&opts, args)
   339  	}
   340  	
   341  	if commandErr != nil {
   342  		criticalError(commandErr)		
   343  	}
   344  	
   345  	if commandName != "rename" {
   346  		return
   347  	}
   348  	
   349  	filePaths, err := filePathsFromArgs(args)
   350  
   351  	if err != nil {
   352  		criticalError(err)
   353  	}
   354  	
   355  	if len(filePaths) == 0 {
   356  		criticalError(errors.New("no file to rename"))
   357  	}
   358  		
   359  	// -----------------------------------------------------------------------------------
   360  	// Build file list
   361  	// -----------------------------------------------------------------------------------
   362  	
   363  	listFileContent := ""
   364  	baseFilename := ""
   365  
   366  	// NOTE: kr/text.Wrap returns lines separated by \n for all platforms.
   367  	// So here hard-code \n too. Later it will be changed to \r\n for Windows.		
   368  	header := text.Wrap("Please change the filenames that need to be renamed and save the file. Lines that are not changed will be ignored by " + APPNAME + " (no file will be renamed), so will empty lines or lines beginning with \"//\".", LINE_LENGTH - 3)
   369  	header += "\n"
   370  	header += "\n" + text.Wrap("Please do not swap the order of lines as this is what is used to match the original filenames to the new ones. Also do not delete lines as the rename operation will be cancelled due to a mismatch between the number of filenames before and after saving the file. You may test the effect of the rename operation using the --dry-run parameter.", LINE_LENGTH - 3)
   371  	header += "\n"
   372  	header += "\n" + text.Wrap("Caveats: " + APPNAME + " expects filenames to be reasonably sane. Filenames that include newlines or non-printable characters for example will probably not work.", LINE_LENGTH - 3)
   373  
   374  	headerLines := strings.Split(header, "\n")
   375  	temp := ""
   376  	for _, line := range headerLines {
   377  		if temp != "" {
   378  			temp += newline_
   379  		}
   380  		temp += "// " + line
   381  	}
   382  	header = temp
   383  	
   384  	for _, filePath := range filePaths {
   385  		if listFileContent != "" {
   386  			listFileContent += newline_
   387  		}
   388  		listFileContent += filepath.Base(filePath)
   389  		baseFilename += filePath + "|"
   390  	}
   391  	
   392  	baseFilename = stringHash(baseFilename)
   393  	listFilePath := tempFolder() + "/" + baseFilename + ".files.txt"
   394  	
   395  	listFileContent = header + newline_ + newline_ + listFileContent
   396  	ioutil.WriteFile(listFilePath, []byte(listFileContent), CONFIG_PERM)
   397  	
   398  	// -----------------------------------------------------------------------------------
   399  	// Watch for changes in file list
   400  	// -----------------------------------------------------------------------------------
   401  	
   402  	waitForFileChange := make(chan bool)
   403  	waitForCommand := make(chan bool)
   404  	
   405  	go func(doneChan chan bool) {		
   406  		defer func() {
   407  			doneChan <- true
   408  		}()
   409  
   410  		logInfo("Waiting for file list to be saved... (Press Ctrl + C to abort)")
   411  		err := watchFile(listFilePath)
   412  		if err != nil {
   413  			criticalError(err)
   414  		}
   415  	}(waitForFileChange)
   416  	
   417  	// -----------------------------------------------------------------------------------
   418  	// Launch text editor
   419  	// -----------------------------------------------------------------------------------
   420  
   421  	go func(doneChan chan bool) {	
   422  		defer func() {
   423  			doneChan <- true
   424  		}()
   425  
   426  		err := editFile(listFilePath)
   427  		if err != nil {
   428  			criticalError(err)
   429  		}
   430  	}(waitForCommand)
   431  	
   432  	<- waitForCommand
   433  	<- waitForFileChange
   434  	
   435  	// -----------------------------------------------------------------------------------
   436  	// Check that the filenames have not been changed while the list was being edited
   437  	// -----------------------------------------------------------------------------------
   438  	
   439  	for _, filePath := range filePaths {
   440  		if _, err := os.Stat(filePath); os.IsNotExist(err) {
   441  			criticalError(errors.New("Filenames have been changed or some files have been deleted or moved while the list was being edited. To avoid any data loss, the operation has been aborted. You may resume it by running the same command."))
   442  		}
   443  	}
   444  
   445  	// -----------------------------------------------------------------------------------
   446  	// Get new filenames from list file
   447  	// -----------------------------------------------------------------------------------
   448  	
   449  	newFilePaths, err := filePathsFromListFile(listFilePath)
   450  	if err != nil {
   451  		criticalError(err)		
   452  	}
   453  	
   454  	if len(newFilePaths) != len(filePaths) {
   455  		criticalError(errors.New(fmt.Sprintf("Number of files in list (%d) does not match original number of files (%d).", len(newFilePaths), len(filePaths))))
   456  	}
   457  
   458  	// -----------------------------------------------------------------------------------
   459  	// Check for duplicates
   460  	// -----------------------------------------------------------------------------------
   461  	
   462  	for i1, p1 := range newFilePaths {
   463  		for i2, p2 := range newFilePaths {
   464  			if i1 == i2 {
   465  				continue
   466  			}
   467  			if p1 == p2 {
   468  				criticalError(errors.New("There are duplicate filenames in the list. To avoid any data loss, the operation has been aborted. You may resume it by running the same command. The duplicate filenames are: " + p1))
   469  			}
   470  		}
   471  	}	
   472  
   473  	// -----------------------------------------------------------------------------------
   474  	// Rename the files
   475  	// -----------------------------------------------------------------------------------
   476  
   477  	var dryRunCol1 []string
   478  	var dryRunCol2 []string
   479  	hasChanges := false
   480  	
   481  	var sources []string
   482  	var destinations []string
   483  	defer func() {
   484  		err := saveHistoryItems(sources, destinations)
   485  		if err != nil {
   486  			logError("Could not save history items: %s", err)
   487  		}
   488  	}()
   489  	 
   490  	for i, sourceFilePath := range filePaths {
   491  		destFilePath := newFilePaths[i]
   492  		
   493  		if filepath.Base(sourceFilePath) == filepath.Base(destFilePath) {
   494  			continue
   495  		}
   496  		
   497  		destFilePath = filepath.Dir(sourceFilePath) + "/" + filepath.Base(destFilePath)
   498  		
   499  		hasChanges = true
   500  		
   501  		if opts.DryRun {
   502  			dryRunCol1 = append(dryRunCol1, sourceFilePath)
   503  			dryRunCol2 = append(dryRunCol2, destFilePath)
   504  		} else {
   505  			logDebug("\"%s\"  =>  \"%s\"", sourceFilePath, destFilePath) 
   506  			err = os.Rename(sourceFilePath, destFilePath)
   507  			if err != nil {
   508  				criticalError(err)
   509  			}
   510  			sources = append(sources, sourceFilePath)
   511  			destinations = append(destinations, destFilePath)
   512  		}
   513  	}
   514  	
   515  	if opts.DryRun {
   516  		twoColumnPrint(dryRunCol1, dryRunCol2, "  =>  ")
   517  	}
   518  	
   519  	if !hasChanges {
   520  		logDebug("No changes.")
   521  	}
   522  }