package com.gmail.dpierron.calibre.opds; import com.gmail.dpierron.calibre.cache.CachedFile; import com.gmail.dpierron.calibre.cache.CachedFileManager; import com.gmail.dpierron.calibre.configuration.ConfigurationManager; import com.gmail.dpierron.calibre.opds.JDOMManager.Namespace; import com.gmail.dpierron.tools.Helper; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jdom2.Element; import java.io.UnsupportedEncodingException; import java.io.File; import java.net.URLEncoder; import java.util.Calendar; import java.util.Date; public class FeedHelper { private final static Logger logger = LogManager.getLogger(ImageManager.class); /** * An Acquisition Feed with newly released OPDS Catalog Entries. These Acquisition Feeds typically contain a subset of the OPDS Catalog * Entries in an OPDS Catalog based on the publication date of the Publication */ private final static String RELATION_SORT_NEW = "http://opds-spec.org/sort/new"; /** * An Acquisition Feed with popular OPDS Catalog Entries. These Acquisition Feeds typically contain a subset of the OPDS Catalog * Entries in an OPDS Catalog based on a numerical ranking criteria. */ private final static String RELATION_SORT_POPULAR = "http://opds-spec.org/sort/popular"; /** * An Acquisition Feed with featured OPDS Catalog Entries. These Acquisition Feeds typically contain a subset of the OPDS Catalog * Entries in an OPDS Catalog that have been selected for promotion by the OPDS Catalog provider. No order is implied. */ private final static String RELATION_FEATURED = "http://opds-spec.org/featured"; /** * An Acquisition Feed with recommended OPDS Catalog Entries. These Acquisition Feeds typically contain a subset of the OPDS Catalog * Entries in an OPDS Catalog that have been selected specifically for the user. */ private final static String RELATION_RECOMMENDED = "http://opds-spec.org/recommended"; /** * A link to a downloadable book */ private final static String RELATION_ACQUISITION = "http://opds-spec.org/acquisition"; /** * The following page in a paginated Acquisition Feed */ public final static String RELATION_NEXT = "next"; /** * The previous page in a paginated Acquisition Feed */ public final static String RELATION_PREV = "prev"; /** * The first page in a paginated Acquisition Feed */ public final static String RELATION_FIRST = "first"; /** * The last page in a paginated Acquisition Feed */ public final static String RELATION_LAST = "last"; /** * Atom relation for an alternate link - only used for full entry links (see LINKTYPE_FULLENTRY) */ private final static String RELATION_ALTERNATE = "alternate"; /** * A related or suggested Acquisition Feed. An example would be a "related" link from the newest releases in a category to the most * popular in a category. */ private final static String RELATION_RELATED = "related"; /** * A link to the same page (self-link) */ private final static String RELATION_SELF = "self"; /** * A link to the start page of the catalog */ private final static String RELATION_START = "start"; /** * A breadcrumb link */ private final static String RELATION_BREADCRUM = "breadcrumb"; /** * A link to an author of the item */ public final static String RELATION_AUTHOR = "author"; /** * a graphical Resource associated to the OPDS Catalog Entry */ private final static String RELATION_COVER = "http://opds-spec.org/image"; /** * a reduced-size version of a graphical Resource associated to the OPS Catalog Entry */ private final static String RELATION_THUMBNAIL = "http://opds-spec.org/image/thumbnail"; /** * a link from a partial book entry in a catalog to a full book entry in a separate entry document */ private final static String LINKTYPE_FULLENTRY = "application/atom+xml;type=entry;profile=opds-catalog"; /** * a navigation link, i.e. to another catalog */ public final static String LINKTYPE_NAVIGATION = "application/atom+xml;profile=opds-catalog;kind=navigation"; /** * a link to an html page - external links use this type, in our catalogs */ private final static String LINKTYPE_HTML = "text/html"; /** * a link to a jpeg image */ private final static String LINKTYPE_JPEG = "image/jpg"; private final static String LINKTYPE_PNG= "image/png"; /* ---------- ELEMENTS -----------*/ /** * create the root of an OPDS feed * * @param breadcrumbs the navigation elements * @param pTitle the title of the feed * @param urn the identifier of the feed * @param urlExt the URL of the feed (relative to the base URL) * @return a 'feed' element */ public static Element getFeedRootElement(Breadcrumbs breadcrumbs, String pTitle, String urn, String urlExt, boolean inSubDir) { Element feed = getAtomElement(true,Constants.OPDS_ELEMENT_FEED, pTitle, urn, null, LINKTYPE_NAVIGATION, null, true, null); // updated tag Element updated = getUpdatedTag(); feed.addContent(updated); decorateElementWithNavigationLinks(feed, breadcrumbs, pTitle, urlExt, false); return feed; } /** * Generate a link to a catalog entry adding an updated element * * @param pTitle * @param urn * @param filename * @param pSummary * @param icon * @return */ public static Element getCatalogEntry( String pTitle, String urn, String filename, String pSummary, String icon) { Element result = getAtomElement(false, Constants.OPDS_ELEMENT_ENTRY, pTitle, urn, filename, pSummary, false, icon); // add updated result.addContent(getUpdatedTag()); return result; } /** * Generate a link to a book details entry * * @param pTitle * @param urn * @param timestamp * @return */ public static Element getBookEntry(String pTitle, String urn, long timestamp) { Element result = getAtomElement(false, Constants.OPDS_ELEMENT_ENTRY, pTitle, urn, null, null, null, (String) null, false, null); // add updated result.addContent(getUpdatedTag(timestamp)); return result; } /** * Generate a link to the 'About entry' * * @param title * @param urn * @param url * @param summary * @param icon * @return */ public static Element getAboutEntry(String title, String urn, String url, String summary, String icon) { Element result = getAtomElement(false, Constants.OPDS_ELEMENT_ENTRY, title, urn, url, LINKTYPE_HTML, summary, true, icon); // add updated result.addContent(getUpdatedTag()); return result; } public static Element getExternalLinkEntry(String title, String summary, boolean opdsLink, String urn, String url, String icon) { Element result = getAtomElement(false, Constants.OPDS_ELEMENT_ENTRY, title, urn, url, opdsLink ? LINKTYPE_NAVIGATION : LINKTYPE_HTML, summary, false, icon); // add updated result.addContent(getUpdatedTag()); return result; } /* ---------- LINKS -----------*/ public static Element getNavigationLink(String url, String navType, String title) { return getLinkElement(url, LINKTYPE_NAVIGATION, navType, title); } public static Element getNextLink(String url, String title) { return getLinkElement(url, LINKTYPE_NAVIGATION, RELATION_NEXT, title); } public static Element getFullEntryLink(String url) { return getLinkElement(url, LINKTYPE_FULLENTRY, RELATION_ALTERNATE, null); } public static Element getRelatedLink(String url, String title) { return getLinkElement(url, LINKTYPE_NAVIGATION, RELATION_RELATED, title); } public static Element getRelatedHtmlLink(String url, String title) { return getLinkElement(url, LINKTYPE_HTML, RELATION_RELATED, title); } public static Element getAcquisitionLink(String url, String mimeType, String title, String size) { Element link = getLinkElement(url, mimeType, RELATION_ACQUISITION, title); if (Helper.isNotNullOrEmpty(size)) { link.setAttribute("displaysize", size); } return link; } /** * Add an image link (cover or thumbnail) * * @param url * @param isCover * @return */ public static Element getImageLink(String url, boolean isCover) { return getLinkElement(url, url.toUpperCase().endsWith(".PNG") ? LINKTYPE_PNG : LINKTYPE_JPEG, isCover ? RELATION_COVER : RELATION_THUMBNAIL, null); } public static Element getFeaturedLink(String url, String title) { return getLinkElement(url, LINKTYPE_NAVIGATION, RELATION_FEATURED, title); } /** * Decorate a root element * (feed or entry, in the case of a full book entry) with the start and self links, and the breadcrumb navigation tree * * @param feed the feed to decorate * @param breadcrumbs the breadcrumbs retracing steps to the root * @param title the title of the page * @param catalogFilename the url (filename) of the page being decorated * @param isEntry if true, the document is a full entry, if false, it's a catalog */ public static void decorateElementWithNavigationLinks(Element feed, Breadcrumbs breadcrumbs, String title, String catalogFilename, boolean isEntry) { if (feed == null) return; assert breadcrumbs != null; assert catalogFilename!= null; assert catalogFilename.endsWith(Constants.XML_EXTENSION) || catalogFilename.endsWith(Constants.HTML_EXTENSION) : "Program Error: url should end with .xml extension"; if (catalogFilename.contains("custom")) { int dummy = 1; } // We want to get past any folder separators to get to the base filename; int pos = 0; while (catalogFilename.substring(pos).contains(Constants.FOLDER_SEPARATOR)) pos = catalogFilename.indexOf(Constants.FOLDER_SEPARATOR,pos) + 1; String filename = catalogFilename.substring(pos); String folder = catalogFilename.substring(0,pos); pos = folder.indexOf(Constants.CURRENT_PATH_PREFIX); // Also handles parent case! if (pos != -1) folder = folder.substring(pos+Constants.CURRENT_PATH_PREFIX.length()); feed.addContent(getLinkElement(Constants.CURRENT_PATH_PREFIX + filename, isEntry ? LINKTYPE_FULLENTRY : LINKTYPE_NAVIGATION, RELATION_SELF, title)); // add a "start" link to the catalog main page String startUrl = (folder.length() == 0 ? Constants.CURRENT_PATH_PREFIX : Constants.PARENT_PATH_PREFIX) + CatalogManager.getInitialUr() + Constants.XML_EXTENSION; // c2o-87 - Title should use value from settings feed.addContent(getLinkElement(startUrl, LINKTYPE_NAVIGATION, RELATION_START, ConfigurationManager.getCurrentProfile().getCatalogTitle())); // add a navigation link to every breadcrumb in the hierarchy // Special treatment for first breadcrumb (start URL) if (breadcrumbs.size() > 0) { // Add breadcrumb links for (int i = 0 ; i < breadcrumbs.size() ; i++) { Breadcrumb breadcrumb = breadcrumbs.elementAt(i); String breadcrumbUrl = breadcrumb.url; while (breadcrumbUrl.substring(pos).contains(Constants.FOLDER_SEPARATOR)) pos = breadcrumbUrl.indexOf(Constants.FOLDER_SEPARATOR,pos) + 1; String breadcrumbFilename = breadcrumbUrl.substring(pos); String breadcrumbFolder = breadcrumbUrl.substring(0,pos); pos = breadcrumbFolder.indexOf(Constants.CURRENT_PATH_PREFIX); // Also handles parent case! if (pos != -1) breadcrumbFolder = breadcrumbFolder.substring(pos+Constants.CURRENT_PATH_PREFIX.length()); feed.addContent(getLinkElement((breadcrumbFolder.equals(folder) ? Constants.CURRENT_PATH_PREFIX : Constants.PARENT_PATH_PREFIX + breadcrumbFolder) + breadcrumbFilename, LINKTYPE_NAVIGATION, RELATION_BREADCRUM, breadcrumb.title)); } } } /* ---------- METADATA ----------*/ public static Element getDublinCoreLanguageElement(String lang) { Element result = JDOMManager.element("language", Namespace.DcTerms); result.setText(lang); return result; } public static Element getDublinCorePublisherElement(String publisher) { Element result = JDOMManager.element("publisher", Namespace.DcTerms); result.setText(publisher); return result; } public static Element getCategoryElement(String term) { Element result = JDOMManager.element("category"); result.setAttribute("term", term); return result; } /* ---------- UTILITIES -----------*/ /** * URL encode a string. Any embedded slashes are NOT encoded */ public static String urlEncode(String s) { return urlEncode(s, false); } /** * URL encode a string with control over how special characters are handled * * @param s the string to be encoded * @param doNotEncodeSlashOrColon if true, slashes and colons will not be encoded * This ,eams sequences like http:// stay intact */ public static String urlEncode(String s, boolean doNotEncodeSlashOrColon) { try { String result = s; if (doNotEncodeSlashOrColon) { result = result.replace("/", "HERELIESASLASH_ICIUNSLASH"); result = result.replace(":", "HERELIESACOLON_ICIUNSLASH"); } result = URLEncoder.encode(result, "utf-8"); // this dumb java converts spaces to "+" and I don't like it result = result.replace("+", "%20"); if (doNotEncodeSlashOrColon) { result = result.replace("HERELIESASLASH_ICIUNSLASH", "/"); result = result.replace("HERELIESACOLON_ICIUNSLASH", ":"); } return result; } catch (UnsupportedEncodingException e) { // we don't give a damn return null; } } /* ---------- PRIVATE -----------*/ private static Element getFeedAuthorElement() { return getFeedAuthorElement(Constants.AUTHORNAME, Constants.HOME_URL, Constants.AUTHOREMAIL); } private static Element getFeedAuthorElement(String name, String uri, String email) { Element author = JDOMManager.element(Constants.OPDS_ELEMENT_AUTHOR); if (Helper.isNotNullOrEmpty(author)) author.addContent(JDOMManager.element(Constants.OPDS_ELEMENT_NAME).addContent(name)); if (Helper.isNotNullOrEmpty(uri)) author.addContent(JDOMManager.element( Constants.OPDS_ELEMENT_URI).addContent(uri)); if (Helper.isNotNullOrEmpty(email)) author.addContent(JDOMManager.element(Constants.OPDS_ELEMENT_EMAIL).addContent(email)); return author; } private static Element getUpdatedTag() { // TODO: Revisit sinc minimizChangedFiles removed as an option // TODO: prbably best to act as if it was set? // if (!ConfigurationManager.getCurrentProfile().getMinimizeChangedFiles()) { // Calendar c = Calendar.getInstance(); // return getUpdatedTag(c); // } else { // DP: return fake updated time - Oh, my birthday, what a coincidence ;) return JDOMManager.element("updated").addContent("1973-01-26T08:00:00Z"); // } } private static String getDateAsIsoDate(Date d) { Calendar c = Calendar.getInstance(); c.setTime(d); return getDateAsIsoDate(c); } private static String getDateAsIsoDate(Calendar c) { StringBuffer result = new StringBuffer(); result.append(Helper.leftPad("" + c.get(Calendar.YEAR), '0', 4)); result.append('-'); result.append(Helper.leftPad("" + (c.get(Calendar.MONTH) + 1), '0', 2)); result.append('-'); result.append(Helper.leftPad("" + c.get(Calendar.DAY_OF_MONTH), '0', 2)); result.append('T'); result.append(Helper.leftPad("" + c.get(Calendar.HOUR), '0', 2)); result.append(':'); result.append(Helper.leftPad("" + c.get(Calendar.MINUTE), '0', 2)); result.append(':'); result.append(Helper.leftPad("" + c.get(Calendar.SECOND), '0', 2)); result.append('Z'); return result.toString(); } public static Element getUpdatedTag(long timeInMilli) { Calendar c = Calendar.getInstance(); c.setTimeInMillis(timeInMilli); return getUpdatedTag(c); } private static Element getUpdatedTag(Calendar c) { return JDOMManager.element("updated").addContent(getDateAsIsoDate(c)); } public static Element getPublishedTag(Date d) { return JDOMManager.element("published").addContent(getDateAsIsoDate(d)); } /** * * @param url * @param urlType * @param urlRelation * @param title * @return */ public static Element getLinkElement(String url, String urlType, String urlRelation, String title) { Element link = JDOMManager.element(Constants.OPDS_ELEMENT_LINK); if (urlType != null && urlRelation != null && urlType.equals(LINKTYPE_NAVIGATION) && (urlRelation.equals(RELATION_NEXT) || urlRelation.equals(RELATION_PREV) || urlRelation.equals(RELATION_FIRST) || urlRelation.equals(RELATION_LAST))) { // Next URL's mean we are already in a folder, so ensure we go up a level as part of the URL (c2o-104) if (! url.startsWith("../")) url = "../" + url; } link.setAttribute("href", url); if (Helper.isNotNullOrEmpty(urlType)) { link.setAttribute("type", urlType); // #c20-277 Set download attribute for acquisition links // #c2o-280 undo previ/us chande as download attiibute now added via XRL // if (Helper.isNotNullOrEmpty(urlRelation) && urlRelation.equals(RELATION_ACQUISITION)) { // String filename = url.substring(url.lastIndexOf('/') + 1); // link.setAttribute("download", filename); // } } if (Helper.isNotNullOrEmpty(urlRelation)) link.setAttribute("rel", urlRelation); if (Helper.isNotNullOrEmpty(title)) link.setAttribute("title", title); return link; } private static Element getAtomElement(boolean isRoot, String pElement, String pTitle, String urn, String filename, String pSummary, boolean includeAuthor, String icon) { return getAtomElement(isRoot, pElement, pTitle, urn, filename, LINKTYPE_NAVIGATION, pSummary, includeAuthor, icon); } private static Element getAtomElement(boolean isRoot, String pElement, String pTitle, String urn, String url, String urlType, String pSummary, boolean includeAuthor, String icon) { return getAtomElement(isRoot, pElement, pTitle, urn, url, urlType, null, // Relation not required pSummary, includeAuthor, icon); } private static Element getAtomElement(boolean isRoot, String elementName, String title, String id, String url, String urlType, String urlRelation, String content, boolean includeAuthor, String icon) { Element contentElement = null; if (Helper.isNotNullOrEmpty(content)) { contentElement = JDOMManager.element("content").addContent(content); contentElement.setAttribute("type", "text"); } Element element; if (isRoot) element = JDOMManager.rootElement(elementName, Namespace.Atom, Namespace.DcTerms, Namespace.Atom, Namespace.Xhtml, Namespace.Opds); else element = JDOMManager.element(elementName); // title Element titleElement = JDOMManager.element("title").addContent(title); element.addContent(titleElement); // id Element idElement = JDOMManager.element("id").addContent(id); element.addContent(idElement); // content if (contentElement != null) { element.addContent(contentElement); } // link if (Helper.isNotNullOrEmpty(url)) { element.addContent(getLinkElement(url, urlType, urlRelation, null)); } // icon link if (Helper.isNotNullOrEmpty(icon)) { Element iconElt = getLinkElement(icon, "image/png", "http://opds-spec.org/image/thumbnail", null); element.addContent(iconElt); } // add the feed author if (includeAuthor) element.addContent(getFeedAuthorElement()); return element; } /** * We changed the standard for naming files. * This carries out the check if a file exists with the old naming * standard and and if necessary renames it to the new standard. * It use the CachedFile class to try and minimise any I/O from repeated checks * * @param newfile * @param oldfile */ public static void checkFileNameIsNewStandard (CachedFile newfile, File oldfile) { if (! newfile.exists() && oldfile.exists()) { oldfile.renameTo(newfile); newfile.clearCachedInformation(); // Clear cached information CachedFileManager.removeCachedFile(oldfile); logger.info("File " + oldfile.getName() + " renamed to " + newfile.getName()); } } }