package com.gmail.dpierron.calibre.datamodel;
import com.gmail.dpierron.calibre.configuration.Configuration;
import com.gmail.dpierron.tools.Helper;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.File;
import java.util.*;
import java.util.regex.*;
public class Book extends GenericDataObject {
private final static Logger logger = LogManager.getLogger(Book.class);
private File bookFolder;
private final String id;
private final String uuid;
private String title;
private String titleSort;
private final String path;
private String comment;
private String summary;
private Integer summaryMaxLength;
private final Float serieIndex;
private final Date timestamp;
private final Date modified;
private final Date publicationDate;
private final String isbn;
private List<Author> authors;
private String listOfAuthors;
private String authorSort;
private Publisher publisher;
private Series series;
private List<Tag> tags;
private List<EBookFile> files;
private EBookFile preferredFile;
private EBookFile epubFile;
private String epubFileName;
private long latestFileModifiedDate = -1;
private final BookRating rating;
private List<Language> bookLanguages = new LinkedList<Language>();
private List<CustomColumnValue> customColumnValues;
private static Date ZERO;
private static final Pattern tag_br = Pattern.compile("\\<br\\>", Pattern.CASE_INSENSITIVE);
protected Book copyOfBook; // If a book is copied then this points to the original
// Flags
// NOTE: Using byte plus bit settings is more memory efficient than using boolean types
private final static byte FLAG_ALL_CLEAR = 0;
private final static byte FLAG_DONE = 0x01; // Set when the Book full details have been generated
private final static byte FLAG_REFERENCED = 0x02; // Set if book full details must be generated as referenced
private final static byte FLAG_FILES_SORTED = 0x04;
private final static byte FLAG_EPUBFILE_COMPUTED = 0x08;
private final static byte FLAG_PREFERREDFILECOMPUTED = 0x10;
private final static byte FLAG_CHANGED = 0x20;
private final static byte FLAG_FLAGGED = 0x40;
private byte flags = FLAG_ALL_CLEAR;
static {
Calendar c = Calendar.getInstance();
c.setTimeInMillis(0);
ZERO = c.getTime();
}
// CONSTRUCTORS
public Book(String id,
String uuid,
String title,
String title_sort,
String path,
Float serieIndex,
Date timestamp,
Date modified,
Date publicationDate,
String isbn,
String authorSort,
BookRating rating) {
super();
copyOfBook = null;
this.id = id;
this.uuid = uuid;
if (title != null) {
// Do some (possibly unnecessary) tidyong of title
title = title.trim();
this.title = title.substring(0, 1).toUpperCase() + title.substring(1);
// title_sort is normally set by Calibre autoamtically, but it can be cleared
// by users and may not be set by older versions of Calibre. In these
// cases we fall back to using the (mandatory) title field and issue a warning
if (Helper.isNullOrEmpty(title_sort)) {
logger.warn("Title_Sort not set - using Title for book '" + this.title + "'");
this.titleSort = DataModel.getNoiseword(getFirstBookLanguage()).removeLeadingNoiseWords(this.title);
} else {
this.titleSort = title_sort;
}
// Small memory optimisation to re-use title object if possible
// TODO check if unecessary if Java does this automatically?
if (this.title.equalsIgnoreCase(this.titleSort)) {
this.titleSort = this.title;
}
}
this.path = path;
this.serieIndex = serieIndex;
this.timestamp = timestamp;
this.modified = modified;
this.publicationDate = publicationDate;
this.tags = new LinkedList<Tag>();
this.files = new LinkedList<EBookFile>();
this.authors = new LinkedList<Author>();
this.isbn = isbn;
this.authorSort = authorSort;
this.rating = rating;
}
// METHODS and PROPERTIES
/**
* Helper routine to set flags bits state
*/
private void setFlags (boolean b, int f) {
if (b == true)
flags |= f; // Set flag bits
else
flags &= ~f; // Clear flag bits;
}
/**
* Helper routine to check if specified kags set
* @param f
* @return
*/
private boolean isFlags( int f) {
return ((flags & f) == f);
}
public String getId() {
return (copyOfBook == null) ? id : copyOfBook.getId();
}
public String getUuid() {
return (copyOfBook == null) ? uuid : copyOfBook.getUuid();
}
public String getTitle() {
return (copyOfBook == null) ? title : copyOfBook.getTitle();
}
public String getTitle_Sort() {
return (copyOfBook == null) ? titleSort : copyOfBook.getTitle_Sort();
}
/**
* Return the first listed language for a book
* (this is on the assumption it is the most important one)
* @return
*/
public Language getFirstBookLanguage() {
if (copyOfBook != null) return copyOfBook.getFirstBookLanguage();
List<Language> languages = getBookLanguages();
if ((languages == null) || (languages.size() == 0))
return null;
else
return languages.get(0);
}
/**
* Get all the languages for a book.
* Most of the time there will only be one, but Ca;ibre
* allows for multpiple languages on the same book.
* @return
*/
public List<Language> getBookLanguages() {
return (copyOfBook == null) ? bookLanguages : copyOfBook.getBookLanguages();
}
/**
* Add a language to the book.
*
* @param bookLanguage
*/
public void addBookLanguage(Language bookLanguage) {
assert copyOfBook == null; // Never expect this to be used on a copy of the book!
if (getBookLanguages() == null) {
bookLanguages = new LinkedList<Language>();
}
if (!bookLanguages.contains(bookLanguage)) {
bookLanguages.add(bookLanguage);
}
}
public String getIsbn() {
return (copyOfBook == null) ? (isbn == null ? "" : isbn) : copyOfBook.getIsbn();
}
public String getPath() {
return (copyOfBook == null) ? path : copyOfBook.getPath();
}
public String getComment() {
return (copyOfBook == null) ? comment : copyOfBook.getComment();
}
/**
* Remove leading text from the given XHTNL string. This is
* used to remove words such as SUMMARY that we add ourselves
* in the catalog. It is also used to remove other common
* expressions from the Summary to try and leave text that is more
* useful as a summary.
*
* Special processing requirements:
* - Any leading HTML tags or spaces are ignored
* - Any trailing ':' or spaces are removed
* - If after removing the desired text there are
* HTML tags that were purely surrounding the text
* that has been removed, they are also removed.
* @param text Text that is being worked on
* @param leadingText String to be checked for (case ignored)
* @return Result of removing text, or null if input was null
*/
private String removeLeadingText (String text, String leadingText) {
// Check for fast exit conditions
if (Helper.isNullOrEmpty(text) || Helper.isNullOrEmpty(leadingText) )
return text;
int textLength = text.length();
int leadingLength = leadingText.length();
int cutStart = 0; // Start scanning from beginning of string
int cutStartMax = textLength - leadingLength - 1 ;
// skip over leading tags and spaces
boolean scanning = true;
while (scanning) {
// Give up no room left to match text
if (cutStart > cutStartMax)
return text;
// Look for special characters
switch (text.charAt(cutStart)) {
case ' ':
cutStart++;
break;
case '<':
// Look for end of tag
int tagEnd = text.indexOf('>',cutStart);
// If not found then give up But should this really occur)
if (tagEnd == -1)
return text;
else
cutStart = tagEnd + 1;
break;
default:
scanning = false;
break;
}
}
// Exit if text does not match
if (! text.substring(cutStart).toUpperCase(Locale.ENGLISH).startsWith(leadingText.toUpperCase(Locale.ENGLISH)))
return text;
// Set end of text to remove
int cutEnd = cutStart + leadingLength;
// After removing leading text, now remove any tags that are now empty of content
// TODO - complete the logic here. Currently does not remove such empty tags
scanning=true;
while (scanning) {
if (cutEnd >= textLength) {
scanning = false;
}
else {
switch (text.charAt(cutEnd)) {
case ' ':
case ':':
cutEnd++;
break;
case '<':
if (text.charAt(cutEnd+1) != '/'){
// Handle case of BR tag following removed text
if (text.substring(cutEnd).toUpperCase().startsWith("<BR")) {
int tagEnd = text.indexOf('>', cutEnd+1);
if (tagEnd != -1) {
cutEnd = tagEnd + 1;
break;
}
}
scanning = false;
break;
}
else {
int tagEnd = text.indexOf('>');
if (tagEnd == -1) {
scanning = false;
break;
}
else {
cutEnd = tagEnd + 1;
}
}
break;
default:
scanning = false;
break;
} // End of switch
}
} // End of while
if (cutStart > 0)
return (text.substring(0, cutStart) + text.substring(cutEnd)).trim();
else
return text.substring(cutEnd).trim();
}
/**
* Sets the comment value
* If it starts with 'SUMMARY' then this is removed as superfluous
* NOTE. Comments are allowed to contain (X)HTML) tags
* @param value the new comment
*/
public void setComment(String value) {
assert copyOfBook == null; // Never expect this to be used on a copy
summary = null;
summaryMaxLength = -1;
if (Helper.isNotNullOrEmpty(value)) {
if (value.startsWith("<")) {
if (value.contains("<\\br") || value.contains("<\\BR")) {
int dummy = 1;
}
// Remove newlines from HTML as they are meangless in that context
value = value.replaceAll("\\n","");
// Remove any empty paragraphs.
value= value.replaceAll("<p></p>","");
}
comment = removeLeadingText(value, "SUMMARY");
comment = removeLeadingText(comment, "PRODUCT DESCRIPTION");
// The following log entry can be useful if trying to debug character encoding issues
// logger.info("Book " + id + ", setComment (Hex): " + Database.stringToHex(comment));
if (comment != null && comment.matches("(?i)<br>")) {
logger.warn("<br> tag in comment changed to <br /> for Book: Id=" + id + " Title=" + title);
comment.replaceAll("(?i)<br>", "<br />");
}
}
}
/**
* Get the book summary
* This starts with any series information, and then as much of the book comment as
* will fit in the space allowed.
*
* Special processing Requirements
* - The word 'SUMMARY' is removed as superfluous at the start of the comment text
* - The words 'PRODUCT DESCRIPTION' are removed as superfluous at the start of the comment text
* - The calculated value is stored for later re-use (which is very likely to happen).
* NOTE. Summary must be pure text (no (X)HTML tags) for OPDS compatibility
* @param maxLength Maximum length of text allowed in summary
* @return The value of the calculated summary field
*/
public String getSummary(int maxLength) {
if (copyOfBook != null) return copyOfBook.getSummary(maxLength);
if (summary == null || maxLength != summaryMaxLength) {
summary = "";
summaryMaxLength = maxLength;
// Check for series info to include
if (Helper.isNotNullOrEmpty(getSeries())) {
float seriesIndexFloat = getSerieIndex();
if (seriesIndexFloat == 0 ) {
// We do not add the index when it is zero
summary += getSeries().getName() + ": ";
} else {
int seriesIndexInt = (int) seriesIndexFloat;
String seriesIndexText;
// For the commonest case of integers we want to truncate off the fractional part
if (seriesIndexFloat == (float) seriesIndexInt)
seriesIndexText = String.format("%d", seriesIndexInt);
else {
// For fractions we want only 2 decimal places to match calibre
seriesIndexText = String.format("%.2f", seriesIndexFloat);
}
summary += getSeries().getName() + " [" + seriesIndexText + "]: ";
}
if (maxLength != -1) {
summary = Helper.shorten(summary, maxLength);
}
}
// See if still space for comment info
// allow for special case of -1 which means no limit.
if (maxLength == -1 || (maxLength > (summary.length() + 3))) {
String noHtml = Helper.removeHtmlElements(getComment());
if (noHtml != null) {
noHtml = removeLeadingText(noHtml, "SUMMARY");
noHtml = removeLeadingText(noHtml, "PRODUCT DESCRIPTION");
if (maxLength == -1 ) {
summary += noHtml;
} else {
summary += Helper.shorten(noHtml, maxLength - summary.length());
}
}
}
}
return summary;
}
/**&
* Get the series index.
* It is thought that Calibre always sets this but it is better to play safe!
* @return
*/
public Float getSerieIndex() {
if (copyOfBook != null) return copyOfBook.getSerieIndex();
if (Helper.isNotNullOrEmpty(serieIndex)) {
return serieIndex;
}
// We never expect to get here!
logger.warn("Unexpected null/empty Series Index for book " + getTitle() + "(" + getId() + ")");
return (float)1.0;
}
public Date getTimestamp() {
if (copyOfBook != null) return copyOfBook.getTimestamp();
// ITIMPI: Return 'now' if timestamp not set - would 0 be better?
if (timestamp == null) {
logger.warn("Date/Time Added not set for book '" + title + "'");
return new Date();
}
return timestamp;
}
public Date getModified() {
if (copyOfBook != null) return copyOfBook.getModified();
// ITIMPI: Return 'now' if modified not set - would 0 be better?
if (modified == null) {
logger.warn("Date/Time Modified not set for book '" + title + "'");
return new Date();
}
return modified;
}
public Date getPublicationDate() {
if (copyOfBook != null) return copyOfBook.getPublicationDate();
if (publicationDate == null) {
logger.warn("Publication Date not set for book '" + title + "'");
return ZERO;
}
return (publicationDate);
}
public boolean hasAuthor() {
return Helper.isNotNullOrEmpty(getAuthors());
}
public boolean hasSingleAuthor() {
if (copyOfBook != null) return copyOfBook.hasSingleAuthor();
return (Helper.isNotNullOrEmpty(authors) && authors.size() == 1);
}
public List<Author> getAuthors() {
if (copyOfBook != null) return copyOfBook.getAuthors();
assert authors != null && authors.size() > 0;
return authors;
}
/**
* Create a comma separated list of authors
* @return
*/
public String getListOfAuthors() {
if (copyOfBook != null) return copyOfBook.getListOfAuthors();
if (listOfAuthors == null)
listOfAuthors = Helper.concatenateList(" & ", getAuthors(), "getName");
return listOfAuthors;
}
public Author getMainAuthor() {
if (copyOfBook != null) return copyOfBook.getMainAuthor();
if (getAuthors() == null || getAuthors().size() == 0)
return null;
return getAuthors().get(0);
}
public String getAuthorSort() {
return (copyOfBook == null) ? authorSort : copyOfBook.getAuthorSort();
}
public Publisher getPublisher() {
return (copyOfBook == null) ? publisher : copyOfBook.getPublisher();
}
public void setPublisher(Publisher publisher) {
assert copyOfBook == null; // Do not expect this on a copy
this.publisher = publisher;
}
public Series getSeries() {
return (copyOfBook == null) ? series : copyOfBook.getSeries();
}
public void setSeries(Series value) {
assert copyOfBook == null; // Do not expect this on a copy
this.series = value;
}
/**
* Note that the list of tags for a books can be different
* in the master and in any copies of the book object
*
* @return
*/
public List<Tag> getTags() {
return tags;
}
/**
* Get the list of eBook files associated with this book.
* @return
*/
public List<EBookFile> getFiles() {
if (copyOfBook != null) return copyOfBook.getFiles();
if (! isFlags(FLAG_FILES_SORTED)) {
if (files != null && files.size() > 1) {
Collections.sort(files, new Comparator<EBookFile>() {
public int compare(EBookFile o1, EBookFile o2) {
if (o1 == null)
if (o2 == null)
return 0;
else
return 1;
if (o2 == null)
if (o1 == null)
return 0;
else
return -1;
return Helper.checkedCompare(o1.getFormat(), o2.getFormat());
}
});
}
setFlags(true, FLAG_FILES_SORTED);;
}
return files;
}
public void removeFile(EBookFile file) {
assert copyOfBook == null; // Do not expect this on a copy
files.remove(file);
epubFile = null;
preferredFile = null;
latestFileModifiedDate = -1;
setFlags(false,FLAG_EPUBFILE_COMPUTED + FLAG_PREFERREDFILECOMPUTED + FLAG_FILES_SORTED);
}
/**
*
* @param file
*/
public void addFile(EBookFile file) {
assert copyOfBook == null; // Fo not expect this on a copy
files.add(file);
epubFile = null;
preferredFile = null;
latestFileModifiedDate = -1;
setFlags(false,FLAG_EPUBFILE_COMPUTED + FLAG_PREFERREDFILECOMPUTED + FLAG_FILES_SORTED);
}
public EBookFile getPreferredFile() {
if (copyOfBook != null) return copyOfBook.getPreferredFile();
if (! isFlags(FLAG_PREFERREDFILECOMPUTED)) {
for (EBookFile file : getFiles()) {
if (preferredFile == null || file.getFormat().getPriority() > preferredFile.getFormat().getPriority())
preferredFile = file;
}
setFlags(true, FLAG_PREFERREDFILECOMPUTED);
}
return preferredFile;
}
/**
* @param author
*/
public void addAuthor(Author author) {
assert copyOfBook == null; // Do not expect this on a copy
listOfAuthors = null; // Force display list to be recalculated
if (authors == null)
authors = new LinkedList<Author>();
if (!authors.contains(author))
authors.add(author);
}
public String toString() {
return getId() + " - " + getTitle();
}
@Override
public boolean equals(Object obj) {
if (obj == null)
return false;
if (obj instanceof Book) {
return (Helper.checkedCompare(((Book) obj).getId(), getId()) == 0);
} else
return super.equals(obj);
}
public String toDetailedString() {
return getId() + " - " + getMainAuthor().getName() + " - " + getTitle() + " - " + Helper.concatenateList(getTags()) + " - " + getPath();
}
public File getBookFolder() {
if (copyOfBook != null) return copyOfBook.getBookFolder();
if (bookFolder == null) {
File calibreLibraryFolder = Configuration.instance().getDatabaseFolder();
bookFolder = new File(calibreLibraryFolder, getPath());
}
return bookFolder;
}
public String getEpubFilename() {
if (copyOfBook != null) return copyOfBook.getEpubFilename();
if (! isFlags(FLAG_EPUBFILE_COMPUTED)) {
getEpubFile();
}
return epubFileName;
}
public EBookFile getEpubFile() {
if (copyOfBook != null) return copyOfBook.getEpubFile();
if (!isFlags(FLAG_EPUBFILE_COMPUTED)) {
epubFile = null;
epubFileName = null;
for (EBookFile file : getFiles()) {
if (file.getFormat() == EBookFormat.EPUB) {
epubFile = file;
epubFileName = epubFile.getName() + epubFile.getExtension();
}
}
setFlags(true, FLAG_EPUBFILE_COMPUTED);
}
return epubFile;
}
public boolean doesEpubFileExist() {
if (copyOfBook != null) return copyOfBook.doesEpubFileExist();
EBookFile file = getEpubFile();
if (file == null)
return false;
File f = file.getFile();
return (f != null && f.exists());
}
public long getLatestFileModifiedDate() {
if (copyOfBook != null) return copyOfBook.getLatestFileModifiedDate();
if (latestFileModifiedDate == -1) {
latestFileModifiedDate = 0;
for (EBookFile file : getFiles()) {
File f = file.getFile();
if (f.exists()) {
long m = f.lastModified();
if (m > latestFileModifiedDate)
latestFileModifiedDate = m;
}
}
}
return latestFileModifiedDate;
}
public BookRating getRating() {
if (copyOfBook != null) return copyOfBook.getRating();
return rating;
}
public String getTitleToSplitByLetter() {
return DataModel.getLibrarySortTitle() ? getTitle() : getTitle_Sort();
}
/**
* Make a copy of the book object.
*
* The copy has some special behavior in that most properties a@e read
* from the original, but the tags one is still private.
*
* NOTE: Can pass as null values that are always read from parent!
* Not sure if this reduces RAM usage or not!
*
* @return Book object that is the copy
*/
public Book copy() {
Book result = new Book(
null,
null,
null,
null,
null,
null,timestamp,modified,publicationDate,isbn,authorSort,rating);
// The tags aassciated with this entry may be changed, so we make
// a copy of the ones currently associated
result.tags = new LinkedList<Tag>(this.getTags());
// Indicate this is a copy by setting a reference to the parent
// This is used to read/set variables that must be in parent.
result.copyOfBook = (this.copyOfBook == null) ? this : this.copyOfBook;
return result;
}
public boolean isFlagged() {
if (copyOfBook != null) return copyOfBook.isFlagged();
return isFlags(FLAG_FLAGGED);
}
public void setFlagged() {
if (copyOfBook != null) {
copyOfBook.setFlagged();
return;
}
setFlags(true, FLAG_FLAGGED);
}
public void clearFlagged() {
if (copyOfBook != null) {
copyOfBook.clearFlagged();
return;
}
setFlags(false, FLAG_FLAGGED);
}
/**
* Return whether we believe book has been changed since last run
* @return
*/
public boolean isChanged() {
if (copyOfBook != null) return copyOfBook.isChanged();
return isFlags(FLAG_CHANGED);
}
/**
* Set the changed status to be true
* (default is false, -so should only need to set to true.
* If neccesary could change to pass in required state).
*/
public void setChanged() {
if (copyOfBook != null) {
copyOfBook.setChanged();
return;
}
setFlags(true, FLAG_CHANGED);
}
public List<CustomColumnValue> getCustomColumnValues() {
return customColumnValues;
}
public void setCustomColumnValues (List <CustomColumnValue> values) {
assert copyOfBook == null:
customColumnValues = values;
}
public CustomColumnValue getCustomColumnValue (String name) {
assert false : "getCustomColumnValue() not yet ready for use";
return null;
}
public void setCustomColumnValue (String name, String value) {
assert false : "setCustomColumnValue() not yet ready for use";
}
public void setDone() {
if (copyOfBook != null) {
copyOfBook.setDone();
return;
}
setFlags(true, FLAG_DONE);
}
public boolean isDone() {
if (copyOfBook != null) return copyOfBook.isDone();
return isFlags(FLAG_DONE);
}
/**
* Set referenced flag.
*/
public void setReferenced() {
if (copyOfBook != null) {
copyOfBook.setReferenced();
return;
}
setFlags(true, FLAG_REFERENCED);
}
/**
* Get referenced flag
*
* @return
*/
public boolean isReferenced() {
if (copyOfBook != null) return copyOfBook.isReferenced();
return isFlags(FLAG_REFERENCED);
}
}