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 }