package com.gmail.dpierron.calibre.opds; /** * Abstract class containing functions and variables common to all catalog types */ import com.gmail.dpierron.calibre.cache.CachedFile; import com.gmail.dpierron.calibre.cache.CachedFileManager; import com.gmail.dpierron.calibre.configuration.ConfigurationHolder; import com.gmail.dpierron.calibre.configuration.ConfigurationManager; import com.gmail.dpierron.calibre.datamodel.*; import com.gmail.dpierron.tools.i18n.Localization; import com.gmail.dpierron.tools.Helper; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jdom2.Document; import org.jdom2.Element; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.text.Collator; import java.util.*; import java.util.zip.CRC32; public abstract class SubCatalog { // cache some widely used objects. private final static Logger logger = LogManager.getLogger(SubCatalog.class); // Get some non-mutable configuration options once for efffeciency that are used widely in subcatalog variants // TODO: Decide if perhaps these should be moved to CatalogManager? protected static ConfigurationHolder currentProfile; protected static Integer maxBeforeSplit; protected static Integer maxSplitLevels; protected static Integer maxBeforePaginate; protected static Boolean useExternalIcons; protected static Boolean useExternalImages; protected static Boolean includeCoversInCatalog; protected static String booksURI; protected static Collator collator = Collator.getInstance(ConfigurationManager.getLocale()); protected static final CRC32 crc32 = new CRC32(); protected static CachedFile defaultCoverFile; protected static String defaultCoverUri; // PROPERTIES // This variable is set to the level (if any) for a particular catalog instance. // It would be a null/empty string for top level catalogs. It will be set when // generating any additional level This could be a custom catalog, the Featured // catalog or perhaps an additional level from extra tags/custom columns. private String catalogLevel; // This identifies the particular catalog type. It is set within the classes // derived from this class. It is used in conjuction with the level to work // out the default catalog and basefilename for a particular catalog instance. private String catalogType; // The folder in which the files for this sub-catalog are to be placed. // It should always be set - for the top level it is an empty string // It should always be stored without level and/or security information as // there are methods available to get the version with these added. private String catalogFolder; // The filename on which files in this catalog are based. // If not set then it is assumed to be the same as the catalog folder private String catalogBaseFilename; // The full path for the folder and base filename including the security code, // level in the folder part and all relevant separators. It is a cached copy for // effeciency reasons as it is needed for each catalog entry. private String catalogFolderBaseFilename; // Set to true if the XSL file for the list entries has changed private static Boolean xslCatalogChanged; // Set to true if the XSL for the full entries has changed. private static Boolean xslFullEntryChanged; private List<Object> stuffToFilterOut; private List<Book> books; private String optimizeUrlPrefix; // String that is used when trying to optimize URL's // CONSTRUCTORS public SubCatalog(List<Book> books) { this(null, books); initalise(); } public SubCatalog() { initalise(); } public SubCatalog(List<Object> stuffToFilterOut, List<Book> books) { initalise(); setStuffToFilterOut(stuffToFilterOut); setBooks(books); //if (crc32 == null) // crc32 = new CRC32(); } /** * Initialise the static variables to the correct values for the * catalog that is bing generated */ private static void initalise() { if (currentProfile == null) { currentProfile = ConfigurationManager.getCurrentProfile(); maxBeforeSplit = currentProfile.getMaxBeforeSplit(); maxSplitLevels = currentProfile.getMaxSplitLevels(); maxBeforePaginate = currentProfile.getMaxBeforePaginate(); useExternalIcons = currentProfile.getExternalIcons(); useExternalImages = currentProfile.getExternalImages(); includeCoversInCatalog = currentProfile.getIncludeCoversInCatalog(); booksURI = currentProfile.getUrlBooks(); collator = Collator.getInstance(ConfigurationManager.getLocale()); xslCatalogChanged = ! CatalogManager.isGenerateFileSameAsCatalogFile(Constants.CATALOG_XSL); xslFullEntryChanged = ! CatalogManager.isGenerateFileSameAsCatalogFile(Constants.FULLENTRY_XSL); defaultCoverFile = CachedFileManager.addCachedFile(new File(CatalogManager.getGenerateFolder(), Constants.DEFAULT_IMAGE_FILENAME)); defaultCoverUri = Constants.PARENT_PATH_PREFIX + Constants.DEFAULT_IMAGE_FILENAME; } } /** * Ensure all the static variables are reset to be correct for the * catalog that is about to be generated. */ public static void reset() { currentProfile = null; initalise(); } private void setOptimizUrlPrefix() { optimizeUrlPrefix = Constants.PARENT_PATH_PREFIX + getCatalogPrefix() + getCatalogFolder() + Constants.FOLDER_SEPARATOR; } // METHODS /** * Get the current catalog level for this catalog instance * If we are not in a sub-level this will be empty * * @return */ public String getCatalogLevel() { if (catalogLevel == null) { catalogLevel = ""; } return catalogLevel; } /** * Set the catalog level for this particular catalog instance * It is only set for catalogs that are not top-level ones * * @param newlevel */ public void setCatalogLevel(String newlevel) { assert newlevel != null; catalogLevel = newlevel; catalogFolderBaseFilename = null; setOptimizUrlPrefix(); } /** * set the Catalog Level based on the breadcrumbs to this point * This is used when a complete set of sub-catalogs are required. * TODO assumes that the breadcrumbs are unique - this assumption needs validation * * @param breadcrumbs */ public void setCatalogLevel(Breadcrumbs breadcrumbs) { assert (breadcrumbs != null); setCatalogLevel(encryptString(breadcrumbs.toString())); setOptimizUrlPrefix(); } /** * Routine to get CRC32 value for given string * Used as helper function when encrypting folder and file names * * @param data * @return */ protected static String encryptString(String data) { crc32.reset(); crc32.update(data.getBytes()); return Long.toHexString(crc32.getValue()); } /** * If the user has asked for encrypted filename, then an * encryption string is added to the start of the filename * that is derived from the actual filename, and then the * filename in the clear is added. This should mean that * catalogs are easy to read while the filenames are hard to guess. * <p/> * As a special case we need to separate out the foldername * (if present) and only encrypt the filename part. * * @param filename * @return */ private static String encryptFilename(String filename) { if (CatalogManager.getSecurityCode().length() == 0) { // Do nothing if encryption not active return filename; } int pos = filename.indexOf(Constants.SECURITY_SEPARATOR); if (pos != -1) { int dummy = 1; } return encryptString(filename) + Constants.SECURITY_SEPARATOR + filename.substring(pos + 1); } /** * Get the current level and if necessary add the prefix if not empty * * @return */ private String getCatalogPrefix() { if (catalogLevel == null) catalogLevel = ""; String result = (CatalogManager.getSecurityCode().length() == 0 ? "" : CatalogManager.getSecurityCodeAndSeparator()) + catalogLevel; if (catalogLevel.length() > 0) result += Constants.LEVEL_SEPARATOR; return result; } /** * Get the current catalog type. * If it has never been set we assume an empty string * * @return */ public String getCatalogType() { if (catalogType == null) catalogType = ""; return catalogType; } /** * Set the catalog type. * This would normally only be used for special catalog types as the main * ones will have set this to a final value as part of class initialisation * * @param type */ public void setCatalogType(String type) { assert (type != null); catalogType = catalogFolder = catalogBaseFilename = type; catalogFolderBaseFilename = null; setOptimizUrlPrefix(); } /** * Get the folder for this sub-catalog * * @return The foldername for this catalog */ public String getCatalogFolder() { if (Helper.isNullOrEmpty(catalogFolder)) { catalogFolder = getCatalogFolder(getCatalogType()); } // Debugging asserts - could be removed if not wanted assert catalogFolder.indexOf(Constants.SECURITY_SEPARATOR) == -1 : "Program error: catalogFolder contains SECURITY_SEPARATOR (" + catalogFolder + ")"; // assert catalogFolder.indexOf(Constants.LEVEL_SEPARATOR) == -1 : // "Program error: catalogFolder contains LEVEL_SEPARATOR (" + catalogFolder + ")"; return catalogFolder; } /** * Get the full folder name for the given folder type * Needs to take into account any level we may be working at * and also any secutiry code that might be active. * * @param foldertype // The type (ignoring level) of folder we want * @return // The folder name including any level and security prefix if type not empty */ public String getCatalogFolder(String foldertype) { assert (foldertype != null); if (foldertype.length() == 0) { return foldertype; } return getCatalogPrefix() + foldertype; } /** * Get the catalog folder for the given type with the name derived * from catalog type. It should have the security prefix the catalog name * but omit any level information. This is primarily used for the * sub-catalog types such as 'books' and 'author' which are always * at the top level. * * @return The catalog name preceded with any security infomation, but no level */ public String getCatalogFolderWithSecurityNoLevel() { assert Helper.isNotNullOrEmpty(catalogType) : "Program Error catalogType not set"; return getCatalogFolderWithSecurityNoLevel(catalogType); } /** * Get the catalog folder for the given type with the name derived * from type. It should have the security prefix the catalog name * but omit any level information. This is primarily used for the * sub-catalog types such as 'books' and 'author' which are always * at the top level. * * @param foldertype The catalog 'type' * @return The catalog name preceded with any security infomation, but no level */ public String getCatalogFolderWithSecurityNoLevel(String foldertype) { assert (Helper.isNotNullOrEmpty(foldertype)) : "Program Error: foldertype not set"; String result = (CatalogManager.getSecurityCode().length() == 0 ? "" : CatalogManager.getSecurityCodeAndSeparator()) + foldertype; // Debugging asserts - could be removed if not wanted int pos = result.indexOf(Constants.SECURITY_SEPARATOR); assert result.substring(pos + 1).indexOf(Constants.SECURITY_SEPARATOR) == -1 : "Program error: Two occurences of SECURITY_SEPARATOR (" + result + ")"; assert result.indexOf(Constants.LEVEL_SEPARATOR) == -1 : "Program error: Unexpected LEVEL_SEPARATOR (" + result + ")"; return result + foldertype; } /** * Get the catalog folder for the given type with the name derived * from type. It should have the security prefix the catalog name * but omit any level information. This is primarily used for the * sub-catalog types such as 'books' and 'author' which are always * at the top level. * * @param foldertype The catalog 'type' * @return The catalog name preceded with any security infomation, but no level */ public String getCatalogFolderWithLevelAndSecurity(String foldertype) { assert (Helper.isNotNullOrEmpty(foldertype)) : "Program Error: foldertype not set"; String result = (CatalogManager.getSecurityCode().length() == 0 ? "" : CatalogManager.getSecurityCodeAndSeparator()) + foldertype; // Debugging asserts - could be removed if not wanted int pos = result.indexOf(Constants.SECURITY_SEPARATOR) + 1; assert result.substring(pos).indexOf(Constants.SECURITY_SEPARATOR) == -1 : "Program error: Two occurences of SECURITY_SEPARATOR (" + result + ")"; pos = result.indexOf(Constants.LEVEL_SEPARATOR); assert result.substring(pos).indexOf(Constants.LEVEL_SEPARATOR) == -1 : "Program error: Two occurences of LEVEL_SEPARATOR (" + result + ")"; return result; } /** * Set the folder to be used * It is always stored decoded and without any trailing slash * There should also not be any security code present - if so remove it. * <p/> * NOTE: For convenience we also allow a full folder.filename path * to be passed in and then we extract the folder part. * * @param folder folder name to set */ public void setCatalogFolder(String folder) { assert folder != null; int pos = folder.indexOf(Constants.FOLDER_SEPARATOR); if (pos != -1) { folder = folder.substring(0, pos); assert folder.indexOf(Constants.FOLDER_SEPARATOR) == -1 : "Program Error: Unexpected occurence of FOLDER_SEPARATOR (folder=" + folder + ")"; } pos = folder.indexOf(Constants.SECURITY_SEPARATOR); if (pos != -1) { assert (folder.substring(0, pos).equals(CatalogManager.getSecurityCode())) : "Program Error: Security Code does not seem to match expected value (folder=" + folder + ")"; assert folder.indexOf(Constants.SECURITY_SEPARATOR, pos + 1) == -1 : "Program error: Unexpected Second Occurencs of SECURITY_SEPARATOR (folder=" + folder + ")"; ; folder = folder.substring(pos + 1); } pos = folder.indexOf(Constants.LEVEL_SEPARATOR); if (pos != -1) { assert (folder.substring(0, pos).equals(catalogLevel)) : "Program Error: Catalog level does not seem to match expected value (folder=" + folder + ")"; assert folder.indexOf(Constants.LEVEL_SEPARATOR, pos + 1) == -1 : "Program error: Unexpected second occurencs of LEVEL_SEPARATOR (folder=" + folder + ")"; folder = folder.substring(pos + 1); } catalogFolder = folder; setOptimizUrlPrefix(); } /** * Variant of setFolder when we want to split according to Id * * @param folder * @param id */ public void setCatalogFolderSplit(String folder, String id) { setCatalogFolder(folder + Constants.TYPE_SEPARATOR + (int) (Long.valueOf(id) / 1000)); } /** * Get the Current Catalog Base filename * <p/> * If both the folder name, catalog type and catalog level are not set we treat * this as a special case and add in the security code. * * @return */ public String getCatalogBasefilename() { if (catalogBaseFilename == null) { catalogBaseFilename = getCatalogType(); } // Debugging assert - could be removed if not wanted assert catalogBaseFilename.indexOf(Constants.FOLDER_SEPARATOR) == -1 : "Program Error: Unexpected FOLDER_SEPARATOR (" + catalogBaseFilename + ")"; if (catalogLevel.length() == 0 && catalogFolder.length() == 0 && catalogType.length() == 0) { // The special case for top level return CatalogManager.getSecurityCodeAndSeparator() + catalogBaseFilename; } else { // The normal case return catalogBaseFilename; } } /** * Set the base filename to be used for this catalog. * Only needed when it cannot be derived automatically from the type * <p/> * NOTE: The name is always stored 'in the clear' so any security code * or level type information needs removing. * * @param name */ public void setCatalogBaseFilename(String name) { assert Helper.isNotNullOrEmpty(name) : "Program Error: invalid name parameter (" + name + ")"; // We want to skip over any leading folder name int pos = name.indexOf(Constants.FOLDER_SEPARATOR); // Debugging assert - could be removed if not wanted assert name.substring(pos + 1).indexOf(Constants.FOLDER_SEPARATOR) == -1 : "Program Error: Multiple FOLDER_SEPARATORS found (" + name + ")"; if (pos != -1) { name = name.substring(pos + 1); // Remove the folder part } // We also want to remove any leading occurrence of security code if (CatalogManager.getSecurityCode().length() > 0 && name.startsWith(CatalogManager.getSecurityCodeAndSeparator())) { name = name.substring(CatalogManager.getSecurityCodeAndSeparator().length()); // Remove the security code } // Finally we want to remove any existing encryption string pos = name.indexOf(Constants.SECURITY_SEPARATOR); name = name.substring(pos + 1); catalogBaseFilename = name; catalogFolderBaseFilename = null; } /** * Get the base folder/file name based on the object propertied * Level is added from the current object as require * * @return */ public String getCatalogBaseFolderFileName() { if (catalogFolderBaseFilename == null) { // Special case of empty folder and level (as used for index files!) if (catalogFolder.length() == 0 & catalogLevel.length() == 0) { catalogFolderBaseFilename = getCatalogBasefilename(); } else { catalogFolderBaseFilename = getCatalogPrefix() + getCatalogFolder(); // This will include security/level prefixes catalogFolderBaseFilename += Constants.FOLDER_SEPARATOR + encryptFilename(getCatalogBasefilename()); } } //int pos = catalogFolderBaseFilename.indexOf(Constants.SECURITY_SEPARATOR); //pos = catalogFolderBaseFilename.indexOf(Constants.LEVEL_SEPARATOR); //assert catalogFolderBaseFilename.substring(pos+1).indexOf(Constants.LEVEL_SEPARATOR) == -1 : // "Program error: Two occurences of LEVEL_SEPARATOR (" + catalogFolderBaseFilename + ")"; return catalogFolderBaseFilename; } /** * Get the folder/file name based on the type parameter * Level is added from the current object as require . * It will have the embedded level/security information if needed. * * @return */ public String getCatalogBaseFolderFileName(String type) { assert Helper.isNotNullOrEmpty(type); String folder = getCatalogFolder(type); return folder + ((folder.length() != 0) ? Constants.FOLDER_SEPARATOR : "") + encryptFilename(type); } public String getCatalogBaseFolderFileNameNoLevel(String type) { assert Helper.isNotNullOrEmpty(type); String result = (CatalogManager.getSecurityCode().length() == 0 ? "" : CatalogManager.getSecurityCodeAndSeparator()) + type; if (result.length() > 0) result += Constants.FOLDER_SEPARATOR; return result + encryptFilename(type); } /** * Get the full base folder/filename including the speified id. * It will get the level/security information from the current catalog properties. * * @param id * @return */ public String getCatalogBaseFolderFileNameId(String id) { String name = getCatalogBaseFolderFileName(); int pos = name.indexOf(Constants.FOLDER_SEPARATOR); String folder = ""; if (pos != -1) { folder = name.substring(0, pos + 1); name = name.substring(pos + 1); } String result = encryptFilename(name + Constants.TYPE_SEPARATOR + id); return folder + result; } /** * Get the full base folder/filename for the given type and id * It will get the level information from the current catalog properties. * Security information will also be added as required * * @param type * @param id * @return */ public String getCatalogBaseFolderFileNameId(String type, String id) { String folder = getCatalogFolder(type); return folder + ((folder.length() != 0) ? Constants.FOLDER_SEPARATOR : "") + encryptFilename(type + Constants.TYPE_SEPARATOR + id); } /** * Get the full base folder/filename for the given type and id * It will get the level information from the current catalog properties. * Security information will also be added as required. * <p/> * To keep the number of files in a single folder down (which can affect * perforance we store a maximum of 1000 book id;s in a single folder * (although in practise it is likely to be slightly less due to gaps * in the Calibre Id sequence after books have been deleted/altered/merged. * * @param type * @param id * @return */ public String getCatalogBaseFolderFileNameIdSplit(String type, String id, int splitSize) { String filename = getCatalogBaseFolderFileNameId(type, id); int pos = filename.indexOf(Constants.FOLDER_SEPARATOR); assert pos != -1; filename = filename.substring(0, pos) + Constants.TYPE_SEPARATOR + ((long) (Long.parseLong(id) / splitSize)) + filename.substring(pos); return filename; } /** * Get the full base folder/filename for the given type and id * Security information will be added, but no level information. * This is intended for entry types that are always at the top level * (such as books) * * @param type * @param id * @return */ private static String getCatalogBaseFolderFileNameIdNoLevel(String type, String id) { String result = (CatalogManager.getSecurityCode().length() == 0 ? "" : CatalogManager.getSecurityCodeAndSeparator()) + type; if (result.length() > 0) result += Constants.FOLDER_SEPARATOR; result = result + encryptFilename(type + Constants.TYPE_SEPARATOR + id); return result; } /** * Get the full base folder/filename for the given type and id * Security information will be added, but no level information. * This is intended for entry types that are always at the top level * (such as books) * <p/> * To keep the number of files in a single folder down (which can affect * perforance we store a maximum of 1000 book id;s in a single folder * (although in practise it is likely to be slightly less due to gaps * in the Calibre Id sequence after books have been deleted/altered/merged. * * @param id * @return */ public static String getCatalogBaseFolderFileNameIdNoLevelSplit(String type, String id, int splitSize) { String filename = getCatalogBaseFolderFileNameIdNoLevel(type, id); int pos = filename.indexOf(Constants.FOLDER_SEPARATOR); assert pos != -1; filename = filename.substring(0, pos) + Constants.TYPE_SEPARATOR + ((long) (Long.parseLong(id) / splitSize)) + filename.substring(pos); return filename; } /** * Determine if the icon prefix should be for the current or parent folder * * @param inSubDir * @return */ protected String getIconPrefix(boolean inSubDir) { return inSubDir ? Constants.PARENT_PATH_PREFIX : Constants.CURRENT_PATH_PREFIX; } /** * Optimise the URN to simplify them if pointing to files in sthe currentcatalog folder * <p/> * NOTE: We should never optimize breadcrumb URL's as we do not know ehere they are called from * * @param url the unoptimised URL * @return the optimized URL */ public String optimizeCatalogURL(String url) { assert optimizeUrlPrefix != null : "Program Error: optimizeUrlPrefix should not be null!"; // See if start is pointing back to current folder? if (url.startsWith(optimizeUrlPrefix)) { // If so we can strip the folder name part int pos = optimizeUrlPrefix.length(); assert url.length() > pos : "Program Error: URL only has prefix!"; //TODO Activate the following code if trace shows would achieve expected results if (logger.isTraceEnabled()) logger.trace("should be able to optimize following URL: " + url + ", (folder=" + getCatalogFolder() + ") to " + Constants.CURRENT_PATH_PREFIX + url.substring(pos)); // return Constants.CURRENT_PATH_PREFIX + url.substring(pos); } return url; } /** * Set the list of books to be included in this (sub)catalog * * @param books */ void setBooks(List<Book> books) { this.books = null; if (Helper.isNotNullOrEmpty(stuffToFilterOut)) { this.books = filterOutStuff(books); } if (this.books == null) { this.books = books; } } /** * Get the list of books associated with this sub-catalog * * @return */ List<Book> getBooks() { return books; } /** * Function to sort books by timestamp (last modified) * * @param books */ static void sortBooksByTimestamp(List<Book> books) { // sort the books by timestamp Collections.sort(books, new Comparator<Book>() { public int compare(Book o1, Book o2) { Date ts1 = (o1 == null ? new Date() : o1.getTimestamp()); Date ts2 = (o2 == null ? new Date() : o2.getTimestamp()); return ts2.compareTo(ts1); } }); } /** * Sort the list of books alphabetically * We allow the field that is to be used for sorting * titles to be set as a configuration parameter * * @param books */ static void sortBooksByTitle(List<Book> books) { Collections.sort(books, new Comparator<Book>() { public int compare(Book o1, Book o2) { String title1 = o1.getTitleToSplitByLetter(); String title2 = o2.getTitleToSplitByLetter(); return Helper.checkedCollatorCompareIgnoreCase(title1, title2, collator); } }); } /** * Get the list of stuff acting as a filter for this sub-catalog * * @return */ List<Object> getStuffToFilterOut() { return stuffToFilterOut; } /** * Get the list of stuff to filter out extended by new values * * @param newStuff * @return */ List<Object> getStuffToFilterOutAnd(Object newStuff) { List<Object> result = new ArrayList<Object>(); if (stuffToFilterOut != null) result.addAll(stuffToFilterOut); if (newStuff != null) result.add(newStuff); return result; } /** * Set the list of stuff to filter out * * @param stuffToFilterOut * @return */ SubCatalog setStuffToFilterOut(List<Object> stuffToFilterOut) { this.stuffToFilterOut = stuffToFilterOut; return this; } /** * Get the list of books filtered according to the filter criteria * * @param originalBooks * @return */ List<Book> filterOutStuff(List<Book> originalBooks) { // by default, simply return the book list return originalBooks; } /** * Extract the folder part of the filename * * @param pCatalogFileName * @return */ public String getFolderName(String pCatalogFileName) { assert (Helper.isNotNullOrEmpty(pCatalogFileName)) : "Program Error: empty filename!"; int pos = pCatalogFileName.indexOf(Constants.FOLDER_SEPARATOR); return (pos == -1) ? pCatalogFileName : pCatalogFileName.substring(0, pos); } /** * Determine if the conditions for SplitByLetter to be active are tru * * @param splitOption * @param count * @return */ public Boolean checkSplitByLetter(SplitOption splitOption, int count) { return (splitOption == SplitOption.SplitByLetter) && (maxSplitLevels > 0) && count > maxBeforeSplit; } /** * Determine if Splitoption should be changed from SplitByLetter to Paginate * because we have already split by the maximum number of levels requested.. * * @param splitLetters * @return */ public SplitOption checkSplitByLetter(String splitLetters) { return splitLetters.length() < maxSplitLevels ? SplitOption.SplitByLetter : SplitOption.Paginate; } boolean isInDeepLevel() { return Helper.isNotNullOrEmpty(stuffToFilterOut); } /** * @return a result composed of the resulting OPDS entry, and the relative url to the subcatalog */ // public abstract Composite<Element, String> getSubCatalogEntry(Breadcrumbs pBreadcrumbs, boolean inSubDir) throws IOException; /** * Create the XML and HTML files (as required by configuration parameters) from * the XML document that has just been created. * * @param feed The feed that is to be used to generate the output files * @param xmlFilename The name of the output file. * @param feedType The type of file that is to be generated * @param isHtmlOptimiseAllowed Set when HTML optimisation mustbe suppressed * @throws IOException Any exception would be unexpected, but it is always theoretically possible! */ public void createFilesFromElement(Element feed, String xmlFilename, HtmlManager.FeedType feedType, Boolean isHtmlOptimiseAllowed) throws IOException { // Various asserts to help with identifying logic faults in the program! assert feed != null : "Programerror: Unexpected attempt to create file from non-existent feed"; assert Helper.isNotNullOrEmpty(xmlFilename) : "Program error: Attempt to create XML file for empty/null filename"; assert !xmlFilename.startsWith(CatalogManager.getGenerateFolder().toString()) : "Program Error: filename should not include catalog folder (" + xmlFilename + ")"; // int pos = outputFilename.indexOf(Constants.SECURITY_SEPARATOR); // assert outputFilename.substring(pos+1).indexOf(Constants.SECURITY_SEPARATOR) == -1 : // "Program error: Two occurences of SECURITY_SEPARATOR (" + outputFilename + ")"; // pos = outputFilename.indexOf(Constants.LEVEL_SEPARATOR); // assert outputFilename.substring(pos+1).indexOf(Constants.LEVEL_SEPARATOR) == -1 : // "Program error: Two occurences of LEVEL_SEPARATOR (" + outputFilename + ")"; if (!xmlFilename.endsWith(Constants.XML_EXTENSION)) { xmlFilename += Constants.XML_EXTENSION; } CachedFile xmlFile = CachedFileManager.addCachedFile(CatalogManager.storeCatalogFile(xmlFilename)); // Avoid creating files that already exist. // (if xml file exists then HTML one will as well) if (xmlFile.exists()) { logger.trace("\n\n*** Attempt to generate file already done (" + xmlFilename + ") - see if it can be optimised out! ***\n"); // if (logger.isTraceEnabled()) logger.trace("\n\n*** Attempt to generate file already done (" + outputFilename + ") - see if it can be optimised out! ***\n"); return; } // Create as a DOM object // TODO Check if there might be a cheaper way to do this not using DOM? Document document = new Document(); document.addContent(feed); // write the XML file // (unless the user has suppressed the OPDS catalogs) if (currentProfile.getGenerateOpds()) { FileOutputStream fos = null; try { fos = new FileOutputStream(xmlFile); String prettyXML = JDOMManager.getPrettyXML().outputString(document); String compactXML = JDOMManager.getCompactXML().outputString(document); String rawXML = JDOMManager.getRawXml().outputString(document); JDOMManager.getPrettyXML().output(document, fos);; } catch (RuntimeException e) { logger.warn("Error writing file " + xmlFilename + "(" + e.toString() + ")"); } finally { if (fos != null) fos.close(); xmlFile.clearCachedInformation(); } } // generate corresponding HTML file CachedFile htmlFile = CachedFileManager.addCachedFile(HtmlManager.getHtmlFilename( xmlFile.getAbsolutePath())); if (htmlFile.exists()) { logger.warn("Program Error? Attempt to recreate existing HTML file '" + htmlFile + "'"); return; } // See if we can optimise things by avoiding generating the HTML file // if: // - The flag to allow HTML optimisation is true // (typically false for book lists after browse-by-cover mode changed/unknown) // - the target HTML file already exists in the catalog // - the XML file is identical to the one already in the catalog // - the XSL file is older than the HTML file CachedFile catalogXmlFile = CachedFileManager.addCachedFile(CatalogManager.getCatalogFolder() + xmlFile.getAbsolutePath().substring(CatalogManager.getGenerateFolderpathLength())); CachedFile catalogHtmlFile = CachedFileManager.addCachedFile(CatalogManager.getCatalogFolder() + htmlFile.getAbsolutePath().substring(CatalogManager.getGenerateFolderpathLength())); boolean xslChanged; switch (feedType) { case MainCatalog: case Catalog: xslChanged = xslCatalogChanged; break; case BookFullEntry: xslChanged = xslFullEntryChanged; break; default: logger.error("Program Error: unrecognised feedType for file '" + xmlFile + "'" ); return; } if (catalogXmlFile.exists() && catalogXmlFile.getCrc() == xmlFile.getCrc()) { catalogXmlFile.setChanged(false); xmlFile.setChanged(false); } if (isHtmlOptimiseAllowed && ! xslChanged && catalogHtmlFile.exists() && xmlFile.isChanged() == false ) { // && CatalogManager.isSourceFileSameAsTargetFile(xmlFile, catalogXmlFile)) { catalogHtmlFile.setChanged(false); htmlFile.setChanged(false); CatalogManager.statsHtmlUnchanged++; } else { if (currentProfile.getGenerateHtml()) { CatalogManager.htmlManager.generateHtmlFromDOM(document, htmlFile.getAbsoluteFile(), feedType); htmlFile.clearCachedInformation(); CatalogManager.statsHtmlChanged++; // } else { // CachedFileManager.removeCachedFile(htmlFile); } } // See if we need to keep the XML file if (currentProfile.getGenerateOpds()) { if (xmlFile.isChanged() == false) { xmlFile.delete(); // We do not keep the XML file in the temp folder if marked as unchanged CatalogManager.statsXmlUnchanged++; } else { CatalogManager.statsXmlChanged++; } } else { xmlFile.delete(); CachedFileManager.removeCachedFile(xmlFile); CatalogManager.statsXmlDiscarded++; } } /* * Decide if a Series cross-reference should be generated for this book * * Takes into account if this is the only book and the relevant setting */ protected boolean isSeriesCrossreferences(Book book) { if (! currentProfile.getGenerateCrossLinks() || ! currentProfile.getIncludeSerieCrossReferences()) { return false; } Series series = book.getSeries(); if (series == null) { return false; } if (currentProfile.getSingleBookCrossReferences() || DataModel.getMapOfBooksBySeries().get(series).size() > 1) { return true; } return false; } /** * Decide if an Author cross-reference should be generated for this book * * Does not take into account whether an author has a single book */ protected boolean isAuthorCrossReferences(Book book) { if (! currentProfile.getGenerateCrossLinks() || ! currentProfile.getIncludeAuthorCrossReferences()) { return false; } return book.hasAuthor(); } /** * Decide if Tag cross-references should be generated for this book * * Does not take into account whether a tag has a single book */ protected boolean isTagCrossReferences(Book book) { if (! currentProfile.getGenerateCrossLinks() || ! currentProfile.getIncludeTagCrossReferences()) { return false; } List<Tag> authors = book.getTags(); return (book.getTags() != null); } /** * Decide if the rating cross reference should be included. * * Takes into account if this is the only book and the relevant setting * Decide if Ratings cross-reference should be generated for this book */ protected boolean isRatingCrossReferences(Book book) { if (! currentProfile.getGenerateCrossLinks() || ! currentProfile.getIncludeRatingCrossReferences()) { return false; } BookRating rating = book.getRating(); if (rating == null) { return false; } if (currentProfile.getSingleBookCrossReferences() || DataModel.getMapOfBooksByRating().get(rating).size() > 1) { return true; } return false; } /** * #c2o-208 * Create additional links for a paginated set depending on * - the current pages * - the maximum page count * * A Prev link is created if currenta page > 1 * A Last link is created if (max pages - current page) > 2 * A First link is created if Current Page > 2 * * NOTE: This is always called on pages AFTRER the first, * to provide the links to be inserted into the previous page * * @param filename Base filename for the URL. * @param pageNumber current page number * @param maxPages maximum pages in the set. */ public Element createPaginateLinks (Element feed, String filename, int pageNumber, int maxPages) { int pos = filename.lastIndexOf(Constants.XML_EXTENSION); if (pos > 0) filename = filename.substring(0,pos); pos = filename.lastIndexOf(Constants.TYPE_SEPARATOR); assert pos > 0; // assert Integer.toString(pageNumber).equals((filename.substring(pos + 1))); if (! Integer.toString(pageNumber).equals((filename.substring(pos + 1)))) { int dummy = 1; } filename = filename.substring(0,pos + 1); // Prev link if (pageNumber > 1) { feed.addContent(FeedHelper.getNavigationLink(filename + (pageNumber-1) + Constants.XML_EXTENSION, FeedHelper.RELATION_PREV, Localization.Main.getText("title.prevpage", pageNumber-1, maxPages))); } // First link if (pageNumber > 2) { feed.addContent(FeedHelper.getNavigationLink(filename + "1" + Constants.XML_EXTENSION, FeedHelper.RELATION_FIRST, Localization.Main.getText("title.firstpage", 1, maxPages))); } // Last link if ((maxPages - pageNumber) > 1) { feed.addContent(FeedHelper.getNavigationLink(filename + maxPages + Constants.XML_EXTENSION, FeedHelper.RELATION_LAST, Localization.Main.getText("title.lastpage", maxPages, maxPages))); } // Next link // It is always one page out because of the way the result is used if (pageNumber == 1) return null; return FeedHelper.getNavigationLink(filename + (pageNumber) + Constants.XML_EXTENSION, FeedHelper.RELATION_NEXT, Localization.Main.getText("title.nextpage", pageNumber, maxPages)); } // TODO GENERALISED SUB-CATALOG TYPE HANDLING // TODO: WORK IN PROGRESS ! // See if we can generalise the cration of sub-catalog pages to a // treatment with a series of standardised methods implemented in each specific // specific sub-catalog type. This would have the huge advantage of greatly // reducing the chance of errors in a particular type as well as making the // maintenance of the types simpler. It will also simplify adding new types. // The method to be used to sort objects of this type /* protected abstract void sortMethod(List<Object> obj); */ /** * Get a page withina sub-catalog * * @param pBreadcrumbs * @param listobjects * @param inSubDir * @param from * @param title * @param summary * @param urn * @param pFilename * @param splitOption * @param icon * @param firstElements * @param options * @return * @throws IOException */ /* protected Element getListOfObjects(Breadcrumbs pBreadcrumbs, List<? extends GenericDataObject> listobjects, boolean inSubDir, int from, String title, String summary, String urn, String pFilename, SplitOption splitOption, String icon, List<Element> firstElements, Option... options) throws IOException { if (logger.isDebugEnabled()) logger.debug("getListOfBooks: START"); // Special case of first time through when not all values set assert listobjects != null; // if (listobjects == null) listobjects = getBooks(); if (pFilename == null) pFilename = getCatalogBaseFolderFileName(); // Now some consistency checks // Now get on with main processing int catalogSize = listobjects.size(); if (logger.isDebugEnabled()) logger.debug("getListOfBooks:catalogSize=" + catalogSize); if (from != 0) inSubDir = true; if (Helper.isNotNullOrEmpty(pBreadcrumbs) && pBreadcrumbs.size() > 1) inSubDir = true; if (inSubDir && icon.startsWith(Constants.CURRENT_PATH_PREFIX)) icon = Constants.PARENT_PATH_PREFIX + icon.substring(2); // Work out any split options // Fixes #716917 when applied to author books list boolean willSplitByLetter; boolean willSplitByDate; if (splitOption == null) { // ITIMPI: Null seems to be equivalent to SplitByLetter ! // Might be better to replace calls by explicit value? splitOption = SplitOption.SplitByLetter; if (logger.isDebugEnabled()) logger.debug("getListOfBooks:splitOption=null. Changed to SplitByLetter"); } switch (splitOption) { case Paginate: if (logger.isTraceEnabled()) logger.trace("getListOfBooks:splitOption=Paginate"); willSplitByLetter = false; willSplitByDate = false; break; case DontSplitNorPaginate: if (logger.isTraceEnabled()) logger.trace("getListOfBooks:splitOption=DontSplitNorPaginate"); assert from == 0 : "getListBooks: DontSplitNorPaginate, from=" + from; willSplitByLetter = false; willSplitByDate = false; break; case DontSplit: // Bug #716917 Do not split on letter (used in Author and Series book lists) if (logger.isTraceEnabled()) logger.trace("getListOfBooks:splitOption=DontSplit"); assert from == 0 : "getListBooks: DontSplit, from=" + from; willSplitByLetter = false; willSplitByDate = false; break; case SplitByDate: if (logger.isTraceEnabled()) logger.trace("getListOfBooks:splitOption=SplitByDate"); assert from == 0 : "getListBooks: splitByDate, from=" + from; willSplitByLetter = checkSplitByLetter(splitOption, listobjects.size()); willSplitByDate = true; break; case SplitByLetter: if (logger.isTraceEnabled()) logger.trace("getListOfBooks:splitOption=SplitByLetter"); assert from == 0 : "getListBooks: splitByLetter, from=" + from; willSplitByLetter = checkSplitByLetter(splitOption, listobjects.size()); willSplitByDate = false; break; default: // ITIMPI: Not sure that this case can ever arise // Just added as a safety check if (logger.isTraceEnabled()) logger.trace("getListOfBooks:splitOption=" + splitOption); assert from == 0 : "getListBooks: unknown splitOption, from=" + from; willSplitByLetter = checkSplitByLetter(splitOption, listobjects.size()); willSplitByDate = false; break; } // See if SplitByLetter conditions actually apply? if ((currentProfile.getBrowseByCover()) && (currentProfile.getBrowseByCoverWithoutSplit())) { willSplitByLetter = false; } if (logger.isTraceEnabled()) logger.trace("getListOfBooks:willSplitByLetter=" + willSplitByLetter); if (logger.isTraceEnabled()) logger.trace("getListOfBooks:willSplitByDate=" + willSplitByDate); if (logger.isTraceEnabled()) logger.trace("listing books from=" + from + ", title=" + title); int pageNumber = Summarizer.getPageNumber(from + 1); int maxPages = Summarizer.getPageNumber((willSplitByDate || willSplitByLetter) ? 0 : catalogSize); // generate the book list files String filename = pFilename + Constants.PAGE_DELIM + Integer.toString(pageNumber); String urlExt = CatalogManager.getCatalogFileUrl(filename + Constants.XML_EXTENSION, pBreadcrumbs.size() > 1 || inSubDir); Element feed; feed = FeedHelper.getFeedRootElement(pBreadcrumbs, title, urn, urlExt, true); // Update breadcrumbs ready for next iteration Breadcrumbs breadcrumbs; // #c2o-204 breadrumbs should already be correct if listing firt page of books for an author. if (from ==0 && getCatalogFolder().startsWith(Constants.AUTHOR_TYPE)) { breadcrumbs = pBreadcrumbs; } else { breadcrumbs = Breadcrumbs.addBreadcrumb(pBreadcrumbs, title, urlExt); } // list the books (or split them) List<Element> result; if (willSplitByDate) { // Split by date listing result = getListOfObjectsSplitByDate( breadcrumbs, DataModel.splitObjectsByDate(listobjects), true, // Musy be true if splitting by date title, urn, pFilename, icon, options); } else if (willSplitByLetter) { // Split by letter listing result = getListOfObjectsSplitByLetter( breadcrumbs, DataModel.splitObjectsByLetter(listobjects), true, // Must be true if splitting by letter title, urn, pFilename, SplitOption.SplitByLetter, icon, options); } else { // Paginated listing result = new LinkedList<Element>(); String progressText = Breadcrumbs.getProgressText(breadcrumbs); progressText += " (" + Summarizer.getBookWord(listobjects.size()) + ")"; CatalogManager.callback.showMessage(progressText.toString()); for (int i = from; i < listobjects.size(); i++) { // check if we must continue CatalogManager.callback.checkIfContinueGenerating(); // See if we need to do the next page if ((splitOption != SplitOption.DontSplitNorPaginate) && ((i - from) >= maxBeforePaginate)) { // TODO #c2o-208 Add Previous, First and Last links if needed // ... YES - so go for next page if (logger.isDebugEnabled()) logger.debug("making a nextpage link"); Element nextLink = getListOfObjects(pBreadcrumbs, listobjects, true, // Awlays in SubDir (need to check this) i, // Continue nfrom where we were title, summary, urn, pFilename, splitOption != SplitOption.DontSplitNorPaginate ? SplitOption.Paginate : splitOption, icon, null, // No firstElements options); result.add(0, nextLink); break; } else { // ... NO - so add book to this page Object book = listobjects.get(i); if (logger.isTraceEnabled()) logger.trace("getListOfObjects: adding object to the list : " + book); try { logger.trace("getListOfBooks: breadcrumbs=" + breadcrumbs + ", book=" + book + ", options=" + options); Element entry = getDetailedEntry(breadcrumbs, book, options); if (entry != null) { if (logger.isTraceEnabled()) logger.trace("getListOfBooks: entry=" + entry); result.add(entry); TrookSpecificSearchDatabaseManager.addBook((Book) book, entry); } } catch (RuntimeException e) { logger.error("getListOfBooks: Exception on book: " + ((Book)book).getTitle() + "[" + ((Book)book).getId() + "]", e); throw e; } } } } // if needed, add the first elements to the feed if (Helper.isNotNullOrEmpty(firstElements)) feed.addContent(firstElements); // add the book entries to the feed feed.addContent(result); Element entry; String urlInItsSubfolder = CatalogManager.getCatalogFileUrl(filename + Constants.XML_EXTENSION, inSubDir); entry = createPaginateLinks(feed, filename, pageNumber, maxPages); createFilesFromElement(feed, filename, HtmlManager.FeedType.Catalog); if (from == 0) { entry = FeedHelper.getCatalogEntry(title, urn, urlInItsSubfolder, summary, icon); } return entry; }; */ /** * Produce a list plit by letter * * @param pBreadcrumbs * @param mapOfObjectsByLetter * @param inSubDir * @param baseTitle * @param baseUrn * @param baseFilename * @param splitOption * @param icon * @param options * @return * @throws IOException */ /* protected List<Element> getListOfObjectsSplitByLetter( Breadcrumbs pBreadcrumbs, Map<String, List<? extends GenericDataObject>> mapOfObjectsByLetter, boolean inSubDir, String baseTitle, String baseUrn, String baseFilename, SplitOption splitOption, String icon, Option... options) throws IOException { if (Helper.isNullOrEmpty(mapOfObjectsByLetter)) return null; if (pBreadcrumbs.size() > 1) inSubDir = true; String sTitle = baseTitle; if (Helper.isNotNullOrEmpty(sTitle)) sTitle += ", "; List<Element> result = new LinkedList<Element>(); SortedSet<String> letters = new TreeSet<String>(mapOfObjectsByLetter.keySet()); for (String letter : letters) { // generate the letter file String letterFilename = Helper.getSplitString(baseFilename, letter, Constants.TYPE_SEPARATOR); String letterUrn = Helper.getSplitString(baseUrn, letter, Constants.URN_SEPARATOR); List<? extends GenericDataObject> objectsInThisLetter = mapOfObjectsByLetter.get(letter); String letterTitle; if (letter.equals("_")) letterTitle = Localization.Main.getText("splitByLetter.book.other"); else letterTitle = Localization.Main.getText("splitByLetter.letter", Localization.Main.getText("bookword.title"), letter.length() > 1 ? letter.substring(0,1) + letter.substring(1).toLowerCase() : letter); // try and list the items to make the summary String summary = Summarizer.summarizeBooks(objectsInThisLetter); Element element = null; if (objectsInThisLetter.size() > 0) { element = getListOfObjects(pBreadcrumbs, objectsInThisLetter, true, // Always inSubDir if in letter 0, // start at first page letterTitle, summary, letterUrn, letterFilename, checkSplitByLetter(letter), icon, null, // No firstElements options); } else { // ITIMPI: Assert to check if the logic can ever let this be zero! assert (objectsInThisLetter.size() <= 0) : "booksInThisLetter=" + objectsInThisLetter.size() + " for letter '" + letter + "'"; } if (element != null) result.add(element); } return result; }; */ /** * Produce a list split by date * * Only used as part of the 'recent' section at the moment * so maybe it should only be present in that calss? * * @param pBreadcrumbs * @param mapOfObjectsByDate * @param inSubDir * @param baseTitle * @param baseUrn * @param baseFilename * @param icon * @param options * @return * @throws IOException */ /* protected List<Element> getListOfObjectsSplitByDate( Breadcrumbs pBreadcrumbs, Map<DateRange, List<GenericDataObject>> mapOfObjectsByDate, boolean inSubDir, String baseTitle, String baseUrn, String baseFilename, String icon, Option... options) throws IOException { if (Helper.isNullOrEmpty(mapOfObjectsByDate)) return null; String sTitle = baseTitle; if (Helper.isNotNullOrEmpty(sTitle)) sTitle = sTitle + ", "; if (pBreadcrumbs.size() > 1) inSubDir = true; List<Element> result = new LinkedList<Element>(); SortedSet<DateRange> ranges = new TreeSet<DateRange>(mapOfObjectsByDate.keySet()); for (DateRange range : ranges) { // generate the range file String rangeFilename = baseFilename + Constants.TYPE_SEPARATOR + range; String rangeUrn = Helper.getSplitString(baseUrn, range.toString(), Constants.URN_SEPARATOR); String rangeTitle = LocalizationHelper.getEnumConstantHumanName(range); List<? extends GenericDataObject> booksInThisRange = mapOfObjectsByDate.get(range); // try and list the items to make the summary String summary = Summarizer.summarizeBooks(booksInThisRange); Element element = null; if (booksInThisRange.size() > 0) { element = getListOfObjects(pBreadcrumbs, booksInThisRange, true, // Always inSubDir 0, // Start at first page rangeTitle, summary, rangeUrn, rangeFilename, SplitOption.Paginate, icon, null, options); } if (element != null) result.add(element); } // end of for return result; } */ // public abstract <T extends GenericDataObject> List<T> getObjectList() ; /** * Get the detailed entry for this object type * We need to over-ride this method in each subcatalog type * as the details are going to be very type dependent. * * @param pBreadcrumbs * @param obj * @param options * @return * @throws IOException */ public abstract Element getDetailedEntry(Breadcrumbs pBreadcrumbs, Object obj, Option... options) throws IOException; }