#!/usr/bin/env ruby
# frozen_string_literal: true
# == Name
#
# deblank - remove special characters from filenames
#
# == Description
#
# +deblank+ renames files and replaces or removes special characters
# like spaces, parentheses, or umlauts.
#
# == See also
#
# Use deblank --help to display a brief help message.
#
# == Author
#
# Copyright (C) 2012-2024 Marcus Stollsteimer
#
# License GPLv3+: GNU GPL version 3 or later
require "optparse"
# This module contains the classes for the +deblank+ tool.
module Deblank
PROGNAME = "deblank"
VERSION = "0.2.0"
DATE = "2024-01-05"
HOMEPAGE = "https://github.com/stomar/deblank"
TAGLINE = "remove special characters from filenames"
COPYRIGHT = <<~TEXT
Copyright (C) 2012-2024 Marcus Stollsteimer.
License GPLv3+: GNU GPL version 3 or later .
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
TEXT
# Parser for the command line options.
# The class method parse! does the job.
class Optionparser
# Parses the command line options from +argv+.
# (+argv+ is cleared).
# Might print out help or version information.
#
# +argv+ - array with the command line options
#
# Returns a hash containing the option parameters.
def self.parse!(argv)
options = {
files: nil,
simulate: false
}
opt_parser = OptionParser.new do |opt|
opt.banner = "Usage: #{PROGNAME} [options] file[s]"
opt.separator ""
opt.separator <<~DESCRIPTION
deblank renames files and replaces or removes special characters
like spaces, parentheses, or umlauts.
The new filename will only contain the following characters:
#{NameConverter.default_valid_chars_to_s}
Spaces are replaced by underscores, German umlauts and eszett are
transliterated, all other invalid characters are removed.
Options:
DESCRIPTION
# process --version and --help first,
# exit successfully (GNU Coding Standards)
opt.on_tail("-h", "--help", "Print a brief help message and exit.") do
puts opt_parser
puts "\nReport bugs on the #{PROGNAME} home page: <#{HOMEPAGE}>"
exit
end
opt.on_tail("-v", "--version",
"Print a brief version information and exit.") do
puts "#{PROGNAME} #{VERSION}"
puts COPYRIGHT
exit
end
opt.on("-l", "--list",
"List the used character substitutions.") do
puts NameConverter.default_substitutions_to_s
exit
end
opt.on("-n", "--no-act",
"Do not rename files, only display what would happen.") do
options[:simulate] = true
end
opt.separator ""
end
opt_parser.parse!(argv)
# only file[s] should be left (at least 1 argument)
raise(ArgumentError, "wrong number of arguments") if argv.empty?
options[:files] = Array.new(argv).map do |filename|
correct_encoding(filename).encode("UTF-8")
end
argv.clear
options
end
# Corrects the encoding for (seemingly) CP850 encoded strings
# from `CP850' to `Windows-1252'.
#
# Returns a copy of +string+ with corrected encoding or +string+.
#
# [On the Windows test machine (which uses code page 850 for the
# command prompt) the command line arguments are interpreted by Ruby
# as CP850 encoded strings but actually are Windows-1252 encoded.]
def self.correct_encoding(string)
return string unless string.encoding == Encoding::CP850
string.dup.force_encoding("Windows-1252")
end
end
# This class provides a converter method for filenames
# (only the base name is modified).
class NameConverter
VALID_CHARS = "A-Za-z0-9._-" # `-' must be last
SUBSTITUTIONS = {
" " => "_",
"ä" => "ae",
"ö" => "oe",
"ü" => "ue",
"Ä" => "Ae",
"Ö" => "Oe",
"Ü" => "Ue",
"ß" => "ss"
}.freeze
def initialize
@valid_characters = VALID_CHARS
@substitutions = SUBSTITUTIONS
end
def convert(filename)
dir, basename = File.dirname(filename), File.basename(filename)
@substitutions.each {|from, to| basename.gsub!(/#{from}/, to) }
basename.gsub!(invalid_characters, "")
dir == "." ? basename : "#{dir}/#{basename}"
end
def invalid?(filename)
filename.match?(invalid_characters)
end
def self.default_valid_chars_to_s
VALID_CHARS.scan(/.-.|./).join(" ")
end
def self.default_substitutions_to_s
SUBSTITUTIONS.map {|from, to| "#{from} => #{to}\n" }.join
end
private
def invalid_characters
/[^#{@valid_characters}]/
end
end
# The main program. It's run! method is called
# if the script is run from the command line.
# It parses the command line arguments and renames the files.
class Application
ERRORCODE = { general: 1, usage: 2 }.freeze
def initialize
begin
options = Optionparser.parse!(ARGV)
rescue StandardError => e
usage_fail(e.message)
end
@files = options[:files]
@simulate = options[:simulate]
@converter = NameConverter.new
end
# The main program.
def run!
message = "This is a dry run, files will not be renamed."
warn "#{message}\n#{'-' * message.size}\n" if @simulate
@files.each do |filename|
next unless file_exist?(filename)
next unless invalid?(filename)
new_filename = @converter.convert(filename)
secure_rename(filename, new_filename)
end
end
private
def skip_warn(message)
warn "#{message} (Skipped.)"
end
def file_exist?(filename)
fail_message = "There is no file `#{filename}'."
File.exist?(filename) or skip_warn(fail_message)
end
def invalid?(filename)
fail_message = "`#{filename}' already is a valid filename."
@converter.invalid?(filename) or skip_warn(fail_message)
end
def secure_rename(old_filename, new_filename)
return if File.exist?(new_filename) && !overwrite?(new_filename)
warn "Moving from `#{old_filename}' to `#{new_filename}'."
File.rename(old_filename, new_filename) unless @simulate
end
def overwrite?(filename)
confirm("File `#{filename}' already exists. Overwrite?")
end
# Asks for yes or no (y/n).
#
# +question+ - string to be printed
#
# Returns +true+ if the answer is yes.
def confirm(question)
loop do
$stderr.print "#{question} [y/n] "
reply = $stdin.gets.chomp.downcase # $stdin avoids gets/ARGV problem
return reply == "y" if reply.match?(/\A[yn]\z/)
warn "Please answer `y' or `n'."
end
end
# Prints an error message and a short help information, then exits.
def usage_fail(message)
warn "#{PROGNAME}: #{message}"
warn "Use `#{PROGNAME} --help' for valid options."
exit ERRORCODE[:usage]
end
end
end
### call main method only if called on command line
Deblank::Application.new.run! if __FILE__ == $PROGRAM_NAME