/**
* MediaSniper 3.0 (2008-08-02)
* Copyright 2007 - 2008 Zach Scrivena
* zachscrivena@gmail.com
* http://mediasniper.sourceforge.net/
*
* Simple program for downloading media files from popular websites.
*
* TERMS AND CONDITIONS:
* 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 <http://www.gnu.org/licenses/>.
*/
package org.freeshell.zs.mediasniper;
import java.io.File;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.freeshell.zs.common.HtmlManipulator;
import org.freeshell.zs.common.StringManipulator;
/**
* Manage the local filenames used for downloads.
*/
class LocalFilenameManager
{
/** illegal filename characters */
private static final String ILLEGAL_FILENAME_CHARS = "\\/:*?\"<>|";
/** reserved device names; must all be in upper case */
private static final String[] RESERVED_DEVICE_NAMES =
{
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
"COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
};
/** legal filename characters */
private static final String RESTRICTED_ASCII_CHARS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
"abcdefghijklmnopqrstuvwxyz" +
"0123456789 `~!@#$%^&()-=_+[]{};',.";
/** character substitution table (raw format) */
private static final String[] CHAR_SUBSTITUTION_TABLE_RAW =
{
/* Array of "source-target" pairs, where any character in the "source" string is to be */
/* substituted by the "target" string. */
/* Example: {"*?", "_"} means that the characters * and ? are to be substituted by _ */
"\"", "'",
"<", "(",
">", ")",
":", "-",
"?*|/\\", " "
};
/** character substitution table */
private static final Map<Character,String> CHAR_SUBSTITUTION_TABLE = new HashMap<Character,String>();
/** registered filenames (canonical full pathnames) */
private final Set<File> filenames = new HashSet<File>();
/**
* Static initialization block.
*/
static
{
/* simple check to ensure RAW_TABLE is not accidentally modified */
if ((CHAR_SUBSTITUTION_TABLE_RAW.length % 2) != 0)
{
throw new RuntimeException("(INTERNAL) Malformed CHAR_SUBSTITUTION_TABLE_RAW in LocalFilenameManager.");
}
for (int i = 0; i < CHAR_SUBSTITUTION_TABLE_RAW.length; i += 2)
{
for (char c : CHAR_SUBSTITUTION_TABLE_RAW[i].toCharArray())
{
CHAR_SUBSTITUTION_TABLE.put(c, CHAR_SUBSTITUTION_TABLE_RAW[i + 1]);
}
}
}
/**
* Check if the specified pathname is valid according to file-naming conventions
* (see http://msdn2.microsoft.com/en-us/library/aa365247.aspx).
*
* @param pathname
* Pathname to be checked
* @return
* null if the specified pathname is valid; an error message otherwise
*/
static String isValidPathname(
final String pathname)
{
/* check for degenerate filename */
if (pathname.isEmpty())
return "Empty pathname";
/* check each component of the pathname */
/* initialize parent pathname to the neutral-form of the specified pathname */
String parent = (File.separatorChar == '/') ? pathname : pathname.replace(File.separatorChar, '/');
/* do not allow double file separator (e.g. // or \\) */
final int doubleFileSeparator = parent.indexOf("//", 1); // skip UNC-style names beginning with "//"
if (doubleFileSeparator >= 0)
return "Pathname contains double file separator \"" +
pathname.substring(doubleFileSeparator, doubleFileSeparator + 2) + "\"";
/* drop trailing '/' character */
if (!parent.isEmpty() && !"/".equals(parent) && !"//".equals(parent) &&
parent.endsWith("/"))
{
parent = parent.substring(0, parent.length() - 1);
}
while (true)
{
/* check for root directory */
if (parent.isEmpty() || "/".equals(parent) || "//".equals(parent) ||
((parent.length() == 2) && (parent.charAt(1) == ':'))) // Windows-style prefix (e.g. "C:")
{
break;
}
/* get name of the last component */
String component = null;
final int lastSlash = parent.lastIndexOf('/');
if (lastSlash >= 0)
{
component = parent.substring(lastSlash + 1);
parent = parent.substring(0, lastSlash);
}
else
{
component = parent;
parent = "";
}
/* check name length (1 <= len <= 255) */
if (component.isEmpty())
return "Pathname contains empty component";
if (component.length() > 255)
return "Pathname component \"" + component + "\" is longer than 255 characters";
/* check for illegal characters */
for (char c : component.toCharArray())
{
if (c < 32)
return "Pathname component \"" + component + "\" contains illegal character of value " +
((int) c) + " (i.e. in the range 0 to 31)";
if (ILLEGAL_FILENAME_CHARS.contains(c + ""))
return "Pathname component \"" + component + "\" contains illegal character '" + c +
"' (characters " + ILLEGAL_FILENAME_CHARS + " are not allowed)";
}
/* check for illegal trailing characters */
if (component.endsWith(" "))
return "Pathname component \"" + component + "\" contains trailing space ' '";
if (component.endsWith("."))
return "Pathname component \"" + component + "\" contains trailing period '.'";
/* check if filename is a device name (e.g. "COM1"), or */
/* a device name with an extension (e.g. "COM1.txt") */
final String nameUpperCase = component.toUpperCase(Locale.ENGLISH);
for (String s : RESERVED_DEVICE_NAMES)
{
if (nameUpperCase.equals(s) || nameUpperCase.startsWith(s + "."))
return "Pathname component \"" + component + "\" clashes with reserved device name \"" + s + "\"";
}
}
/* check if Java can create the corresponding File object */
File f = null;
try
{
f = (new File(pathname)).getCanonicalFile();
}
catch (Exception e)
{
final String error = e.getMessage();
return "Failed to create Java File object for pathname" +
((error == null) ? "" : (" (" + error + ")"));
}
/* specified pathname is valid */
return null;
}
/**
* Return a "clean" filename, derived from the specified base filename and file extension.
* A "clean" filename satisfies three conditions:
* - it is valid according to file naming conventions
* (see http://msdn2.microsoft.com/en-us/library/aa365247.aspx)
* - it is not already taken by an earlier request
* - it does not already exist
*
* @param base
* Base filename
* @param ext
* File extension (assumed to be nonempty and valid)
* @param parentDirectory
* Parent directory for this file (canonical full pathname)
* @param restrictedAsciiFilenamesMode
* true if restricted ASCII filename is to be generated; false otherwise
* @param shortFilenamesMode
* true if short (8.3) filename is to be generated; false otherwise
* @return
* File object (canonical full pathname) representing the "clean" filename;
* null if such a filename cannot be generated
*/
File getCleanFilename(
final String base,
final String ext,
final File parentDirectory,
final boolean restrictedAsciiFilenamesMode,
final boolean shortFilenamesMode)
{
/* initialize desired "clean" base filename */
String cleanBase = base;
/* replace HTML entities with Unicode characters */
cleanBase = HtmlManipulator.replaceHtmlEntities(cleanBase);
/* perform character substitutions */
final StringBuilder t = new StringBuilder();
for (char c : cleanBase.toCharArray())
{
final String s = CHAR_SUBSTITUTION_TABLE.get(c);
if (s == null)
{
t.append(c);
}
else
{
t.append(s);
}
}
cleanBase = t.toString();
/* drop all illegal characters */
t.delete(0, t.length()); // reset
for (char c : cleanBase.toCharArray())
{
if ((c >= 32) &&
(!ILLEGAL_FILENAME_CHARS.contains(c + "")) &&
(!restrictedAsciiFilenamesMode || RESTRICTED_ASCII_CHARS.contains(c + "")))
{
t.append(c);
}
}
cleanBase = t.toString();
/* replace contiguous whitespace with a single space */
cleanBase = StringManipulator.deleteExtraWhitespace(cleanBase);
/* trim off undesirable trailing and leading strings */
cleanBase = StringManipulator.trimTrailingStrings(cleanBase, new String[] {".", " ", ext});
cleanBase = StringManipulator.trimLeadingStrings(cleanBase, new String[] {".", " "});
/* check if filename is a device name (e.g. "COM1"), or */
/* a device name with an extension (e.g. "COM1.txt") */
String cleanBaseUpperCase = cleanBase.toUpperCase(Locale.ENGLISH);
for (String s : RESERVED_DEVICE_NAMES)
{
if (cleanBaseUpperCase.equals(s) || cleanBaseUpperCase.startsWith(s + "."))
{
/* add an underscore '_' prefix */
cleanBase = "_" + cleanBase;
cleanBaseUpperCase = "_" + cleanBaseUpperCase;
}
}
/* initialize desired "clean" file extension */
String cleanExt = ext;
/* truncate filename */
if (shortFilenamesMode)
{
/* allow up to "8.3" characters */
if (cleanBase.length() > 8)
{
cleanBase = cleanBase.substring(0, 8);
}
if (cleanExt.length() > 3)
{
cleanExt = cleanExt.substring(0, 3);
}
}
else
{
/* allow up to 255 characters (including file extension) */
if (cleanBase.length() + 1 + cleanExt.length() > 255)
{
final int truncatedBaseLength = 255 - (1 + cleanExt.length());
if (truncatedBaseLength < 1)
{
/* failed to generate filename */
return null;
}
cleanBase = cleanBase.substring(0, truncatedBaseLength);
}
}
/* validate filename */
String name = cleanBase + "." + cleanExt;
if (cleanBase.isEmpty() || (isValidPathname(name) != null))
return null;
File f = new File(parentDirectory, name);
synchronized (filenames)
{
if (filenames.contains(f) || f.exists())
{
/* conflict found; append an integer suffix to the base filename */
f = null;
for (int i = 1; i < Integer.MAX_VALUE; i++)
{
final String baseSuffix = "-" + i;
if (shortFilenamesMode)
{
/* allow up to "8.3" characters */
if (cleanBase.length() + baseSuffix.length() > 8)
{
final int truncatedBaseLength = 8 - baseSuffix.length();
if (truncatedBaseLength < 1)
return null; // failed to generate filename
/* truncate base filename */
name = cleanBase.substring(0, truncatedBaseLength) + baseSuffix + "." + cleanExt;
}
else
{
name = cleanBase + baseSuffix + "." + cleanExt;
}
}
else
{
/* allow up to 255 characters (including file extension) */
if (cleanBase.length() + baseSuffix.length() + 1 + cleanExt.length() > 255)
{
final int truncatedBaseLength = 255 - (baseSuffix.length() + 1 + cleanExt.length());
if (truncatedBaseLength < 1)
return null; // failed to generate filename
/* truncate base filename */
name = cleanBase.substring(0, truncatedBaseLength) + baseSuffix + "." + cleanExt;
}
else
{
name = cleanBase + baseSuffix + "." + cleanExt;
}
}
f = new File(parentDirectory, name);
if (filenames.contains(f) || f.exists())
{
/* conflict still exists */
f = null;
}
else
{
/* no conflict; found an unused filename */
break;
}
}
}
if (f != null)
{
/* no conflict; register this filename */
filenames.add(f);
}
}
return f;
}
/**
* Replace a previously assigned filename with the given filename.
*
* @param oldFile
* File object that was previously assigned
* @param parentDirectory
* Parent directory for the new file (canonical full pathname)
* @param newFilename
* Replacement filename
* @return
* File object (canonical full pathname) representing the replacement filename
* if successful; null otherwise
*/
File replaceFilename(
final File oldFile,
final File parentDirectory,
final String newFilename)
{
if (isValidPathname(newFilename) != null)
{
return null;
}
File newFile = new File(newFilename);
/* get canonical full pathname of the new file */
try
{
if (newFile.isAbsolute())
{
newFile = newFile.getCanonicalFile();
}
else
{
/* resolve against specified parent directory */
newFile = new File(parentDirectory, newFilename).getCanonicalFile();
}
}
catch (Exception e)
{
return null;
}
if (newFile.equals(oldFile) && newFile.getPath().equals(oldFile.getPath()))
return oldFile; // no change
if (newFile.exists())
return null;
synchronized (filenames)
{
if (!filenames.contains(oldFile))
{
return null;
}
if (!newFile.equals(oldFile) &&
filenames.contains(newFile))
{
return null;
}
/* release previously assigned filename; error if not found */
if (!filenames.remove(oldFile))
{
return null;
}
/* register replacement filename */
filenames.add(newFile);
}
return newFile;
}
/**
* Release a previously assigned filename.
*
* @param releaseFile
* File object that was previously assigned
*/
void releaseFilename(
final File releaseFile)
{
synchronized (filenames)
{
filenames.remove(releaseFile);
}
}
}