diff --git a/resources/catalog/stylesheet.css b/resources/catalog/stylesheet.css index 06bbf8eaab..e7281ca62d 100644 --- a/resources/catalog/stylesheet.css +++ b/resources/catalog/stylesheet.css @@ -79,6 +79,14 @@ p.unread_book { text-indent:-2em; } +p.date_read { + text-align:left; + margin-top:0px; + margin-bottom:0px; + margin-left:6em; + text-indent:-6em; + } + hr.series_divider { width:50%; margin-left:1em; diff --git a/src/calibre/devices/usbms/device.py b/src/calibre/devices/usbms/device.py index 5cfb60dede..0f3c550291 100644 --- a/src/calibre/devices/usbms/device.py +++ b/src/calibre/devices/usbms/device.py @@ -789,7 +789,7 @@ class Device(DeviceConfig, DevicePlugin): ''' return components - def create_upload_path(self, path, mdata, fname): + def create_upload_path(self, path, mdata, fname, create_dirs=True): path = os.path.abspath(path) extra_components = [] @@ -848,7 +848,7 @@ class Device(DeviceConfig, DevicePlugin): filedir = os.path.dirname(filepath) - if not os.path.exists(filedir): + if create_dirs and not os.path.exists(filedir): os.makedirs(filedir) return filepath diff --git a/src/calibre/library/catalog.py b/src/calibre/library/catalog.py index 9555a69b0d..39fb503636 100644 --- a/src/calibre/library/catalog.py +++ b/src/calibre/library/catalog.py @@ -571,7 +571,9 @@ class EPUB_MOBI(CatalogPlugin): self.__authorClip = opts.authorClip self.__authors = None self.__basename = opts.basename + self.__bookmarked_books = None self.__booksByAuthor = None + self.__booksByDateRead = None self.__booksByTitle = None self.__booksByTitle_noSeriesPrefix = None self.__catalogPath = PersistentTemporaryDirectory("_epub_mobi_catalog", prefix='') @@ -603,11 +605,13 @@ class EPUB_MOBI(CatalogPlugin): self.__useSeriesPrefixInTitlesSection = False self.__verbose = opts.verbose - # Tweak build steps based on optional sections. 1 call for HTML, 1 for NCX + # Tweak build steps based on optional sections: 1 call for HTML, 1 for NCX if self.opts.generate_titles: self.__totalSteps += 2 if self.opts.generate_recently_added: self.__totalSteps += 2 + if self.opts.connected_kindle: + self.__totalSteps += 2 # Accessors ''' @@ -642,6 +646,13 @@ class EPUB_MOBI(CatalogPlugin): self.__basename = val return property(fget=fget, fset=fset) @dynamic_property + def bookmarked_books(self): + def fget(self): + return self.__bookmarked_books + def fset(self, val): + self.__bookmarked_books = val + return property(fget=fget, fset=fset) + @dynamic_property def booksByAuthor(self): def fget(self): return self.__booksByAuthor @@ -649,6 +660,13 @@ class EPUB_MOBI(CatalogPlugin): self.__booksByAuthor = val return property(fget=fget, fset=fset) @dynamic_property + def booksByDateRead(self): + def fget(self): + return self.__booksByDateRead + def fset(self, val): + self.__booksByDateRead = val + return property(fget=fget, fset=fset) + @dynamic_property def booksByTitle(self): def fget(self): return self.__booksByTitle @@ -869,6 +887,16 @@ class EPUB_MOBI(CatalogPlugin): def fget(self): return "☆" if self.generateForKindle else ' ' return property(fget=fget) + @dynamic_property + def READ_PROGRESS_SYMBOL(self): + def fget(self): + return "▪" if self.generateForKindle else '+' + return property(fget=fget) + @dynamic_property + def UNREAD_PROGRESS_SYMBOL(self): + def fget(self): + return "▫" if self.generateForKindle else '-' + return property(fget=fget) # Methods def buildSources(self): @@ -876,12 +904,14 @@ class EPUB_MOBI(CatalogPlugin): if not self.fetchBooksByTitle(): return False self.fetchBooksByAuthor() + self.fetchBookmarks() self.generateHTMLDescriptions() self.generateHTMLByAuthor() if self.opts.generate_titles: self.generateHTMLByTitle() if self.opts.generate_recently_added: self.generateHTMLByDateAdded() + self.generateHTMLByDateRead() self.generateHTMLByTags() from calibre.utils.PythonMagickWand import ImageMagick @@ -896,6 +926,7 @@ class EPUB_MOBI(CatalogPlugin): self.generateNCXByTitle("Titles") if self.opts.generate_recently_added: self.generateNCXByDateAdded("Recently Added") + self.generateNCXByDateRead("Recently Read") self.generateNCXByGenre("Genres") self.writeNCX() return True @@ -980,10 +1011,12 @@ class EPUB_MOBI(CatalogPlugin): this_title['series_index'] = 0.0 this_title['title_sort'] = self.generateSortTitle(this_title['title']) - if 'authors' in record and record['authors']: - this_title['author'] = " & ".join(record['authors']) - else: - this_title['author'] = 'Unknown' + if 'authors' in record: + this_title['authors'] = record['authors'] + if record['authors']: + this_title['author'] = " & ".join(record['authors']) + else: + this_title['author'] = 'Unknown' ''' this_title['author_sort_original'] = record['author_sort'] @@ -1119,6 +1152,160 @@ class EPUB_MOBI(CatalogPlugin): self.authors = unique_authors + def fetchBookmarks(self): + ''' + Collect bookmarks for catalog entries + This will use the system default save_template specified in + Preferences|Add/Save|Sending to device, not a customized one specified in + the Kindle plugin + ''' + from cStringIO import StringIO + from struct import unpack + + from calibre.devices.usbms.device import Device + from calibre.ebooks.metadata import MetaInformation + from calibre.ebooks.metadata.mobi import StreamSlicer + + class BookmarkDevice(Device): + def initialize(self, save_template): + self._save_template = save_template + self.SUPPORTS_SUB_DIRS = True + def save_template(self): + return self._save_template + + class Bookmark(): + ''' + A simple class storing bookmark data + Kindle-specific + ''' + def __init__(self,path, formats, id): + self.book_format = None + self.book_length = 0 + self.id = id + self.last_read_location = 0 + self.timestamp = 0 + self.get_bookmark_data(path) + self.get_book_length(path, formats) + + + def get_bookmark_data(self, path): + ''' Return the timestamp and last_read_location ''' + with open(path) as f: + stream = StringIO(f.read()) + data = StreamSlicer(stream) + self.timestamp, = unpack('>I', data[0x24:0x28]) + bpar_offset, = unpack('>I', data[0x4e:0x52]) + #print "bpar_offset: 0x%x" % bpar_offset + bpl = bpar_offset + 0x04 + lrlo = bpar_offset + 0x0c + self.last_read_location = int(unpack('>I', data[lrlo:lrlo+4])[0]) + ''' + iolr = bpar_offset + 0x14 + index_of_last_read, = unpack('>I', data[iolr:iolr+4]) + #print "index_of_last_read: 0x%x" % index_of_last_read + bpar_len, = unpack('>I', data[bpl:bpl+4]) + bpar_len += 8 + #print "bpar_len: 0x%x" % bpar_len + dro = bpar_offset + bpar_len + #print "dro: 0x%x" % dro + + # Walk to index_of_last_read to find last_read_location + # If BKMK - offset 8 + # If DATA - offset 0x18 + 0x1c + current_entry = 1 + while current_entry < index_of_last_read: + rec_len, = unpack('>I', data[dro+4:dro+8]) + rec_len += 8 + dro += rec_len + current_entry += 1 + + # Looking at the record with last_read_location + if data[dro:dro+4] == 'DATA': + lrlo = dro + 0x18 + 0x1c + elif data[dro:dro+4] == 'BKMK': + lrlo = dro + 8 + else: + print "Unrecognized bookmark block type" + + #print "lrlo: 0x%x" % lrlo + self.last_read_location = float(unpack('>I', data[lrlo:lrlo+4])[0]) + #print "last_read_location: 0x%x" % self.last_read_location + ''' + + def get_book_length(self, path, formats): + # This assumes only one of the possible formats exists on the Kindle + book_fs = None + for format in formats: + fmt = format.rpartition('.')[2] + if fmt in ['mobi','prc','azw']: + book_fs = path.replace('.mbp','.%s' % fmt) + if os.path.exists(book_fs): + self.book_format = fmt + #print "%s exists on device" % book_fs + break + else: + #print "no files matching library formats exist on device" + self.book_length = 0 + return + # Read the book len from the header + with open(book_fs) as f: + self.stream = StringIO(f.read()) + self.data = StreamSlicer(self.stream) + self.nrecs, = unpack('>H', self.data[76:78]) + record0 = self.record(0) + #self.hexdump(record0) + self.book_length = int(unpack('>I', record0[0x04:0x08])[0]) + + def record(self, n): + if n >= self.nrecs: + raise ValueError('non-existent record %r' % n) + offoff = 78 + (8 * n) + start, = unpack('>I', self.data[offoff + 0:offoff + 4]) + stop = None + if n < (self.nrecs - 1): + stop, = unpack('>I', self.data[offoff + 8:offoff + 12]) + return StreamSlicer(self.stream, start, stop) + + def hexdump(self, src, length=16): + # Diagnostic + FILTER=''.join([(len(repr(chr(x)))==3) and chr(x) or '.' for x in range(256)]) + N=0; result='' + while src: + s,src = src[:length],src[length:] + hexa = ' '.join(["%02X"%ord(x) for x in s]) + s = s.translate(FILTER) + result += "%04X %-*s %s\n" % (N, length*3, hexa, s) + N+=length + print result + + if self.opts.connected_kindle: + self.opts.log.info(" Collecting Kindle bookmarks matching catalog entries") + + d = BookmarkDevice(None) + d.initialize(self.opts.connected_device['save_template']) + bookmarks = {} + for book in self.booksByTitle: + myMeta = MetaInformation(book['title'], + authors=book['authors']) + myMeta.author_sort = book['author_sort'] + bm_found = False + for vol in self.opts.connected_device['storage']: + bm_path = d.create_upload_path(vol, myMeta, 'x.mbp', create_dirs=False) + if os.path.exists(bm_path): + myBookmark = Bookmark(bm_path, book['formats'], book['id']) + if myBookmark.book_length: + book['percent_read'] = float(100*myBookmark.last_read_location / myBookmark.book_length) + dots = int((book['percent_read'] + 5)/10) + dot_string = self.READ_PROGRESS_SYMBOL * dots + empty_dots = self.UNREAD_PROGRESS_SYMBOL * (10 - dots) + book['reading_progress'] = '%s%s' % (dot_string,empty_dots) + bookmarks[book['id']] = ((myBookmark,book)) + bm_found = True + if bm_found: + break + + self.bookmarked_books = bookmarks + def generateHTMLDescriptions(self): # Write each title to a separate HTML file in contentdir self.updateProgressFullStep("'Descriptions'") @@ -1160,29 +1347,25 @@ class EPUB_MOBI(CatalogPlugin): titleTag = body.find(attrs={'class':'title'}) titleTag.insert(0,emTag) - # Insert the author + # Create the author anchor authorTag = body.find(attrs={'class':'author'}) aTag = Tag(soup, "a") - aTag['href'] = "%s.html#%s" % ("ByAlphaAuthor", self.generateAuthorAnchor(title['author'])) - #aTag.insert(0, escape(title['author'])) + aTag['href'] = "%s.html#%s" % ("ByAlphaAuthor", + self.generateAuthorAnchor(title['author'])) aTag.insert(0, title['author']) - # Insert READ_SYMBOL - if title['read']: - authorTag.insert(0, NavigableString(self.READ_SYMBOL + "by ")) + # This will include the reading progress dots even if we're not generating Recently Read + if self.opts.connected_kindle and title['id'] in self.bookmarked_books: + authorTag.insert(0, NavigableString(title['reading_progress'] + " by ")) + authorTag.insert(1, aTag) else: - authorTag.insert(0, NavigableString(self.NOT_READ_SYMBOL + "by ")) - authorTag.insert(1, aTag) + # Insert READ_SYMBOL + if title['read']: + authorTag.insert(0, NavigableString(self.READ_SYMBOL + "by ")) + else: + authorTag.insert(0, NavigableString(self.NOT_READ_SYMBOL + "by ")) + authorTag.insert(1, aTag) - ''' - # Insert the unlinked genres. - if 'tags' in title: - tagsTag = body.find(attrs={'class':'tags'}) - emTag = Tag(soup,"em") - emTag.insert(0,NavigableString(', '.join(title['tags']))) - tagsTag.insert(0,emTag) - - ''' ''' # Insert Series info or remove. seriesTag = body.find(attrs={'class':'series'}) @@ -1789,6 +1972,7 @@ class EPUB_MOBI(CatalogPlugin): divTag.insert(dtc,hrTag) dtc += 1 + # >>>> Books by month <<<< # Sort titles case-insensitive for by month using series prefix self.booksByMonth = sorted(self.booksByTitle, key=lambda x:(x['timestamp'], x['timestamp']),reverse=True) @@ -1817,6 +2001,192 @@ class EPUB_MOBI(CatalogPlugin): outfile.close() self.htmlFileList.append("content/ByDateAdded.html") + def generateHTMLByDateRead(self): + # Write books by active bookmarks + friendly_name = 'Recently Read' + self.updateProgressFullStep("'%s'" % friendly_name) + if not self.bookmarked_books: + return + + def add_books_to_HTML_by_day(todays_list, dtc): + if len(todays_list): + # Create a new day anchor + date_string = strftime(u'%A, %B %d', current_date.timetuple()) + pIndexTag = Tag(soup, "p") + pIndexTag['class'] = "date_index" + aTag = Tag(soup, "a") + aTag['name'] = "bdr_%s-%s-%s" % (current_date.year, current_date.month, current_date.day) + pIndexTag.insert(0,aTag) + pIndexTag.insert(1,NavigableString(date_string)) + divTag.insert(dtc,pIndexTag) + dtc += 1 + + for new_entry in todays_list: + pBookTag = Tag(soup, "p") + pBookTag['class'] = "date_read" + ptc = 0 + + # Percent read + pBookTag.insert(ptc, NavigableString(new_entry['reading_progress'])) + ptc += 1 + + aTag = Tag(soup, "a") + aTag['href'] = "book_%d.html" % (int(float(new_entry['id']))) + aTag.insert(0,escape(new_entry['title'])) + pBookTag.insert(ptc, aTag) + ptc += 1 + + # Dot + pBookTag.insert(ptc, NavigableString(" · ")) + ptc += 1 + + # Link to author + emTag = Tag(soup, "em") + aTag = Tag(soup, "a") + aTag['href'] = "%s.html#%s" % ("ByAlphaAuthor", self.generateAuthorAnchor(new_entry['author'])) + aTag.insert(0, NavigableString(new_entry['author'])) + emTag.insert(0,aTag) + pBookTag.insert(ptc, emTag) + ptc += 1 + + divTag.insert(dtc, pBookTag) + dtc += 1 + return dtc + + def add_books_to_HTML_by_date_range(date_range_list, date_range, dtc): + if len(date_range_list): + pIndexTag = Tag(soup, "p") + pIndexTag['class'] = "date_index" + aTag = Tag(soup, "a") + aTag['name'] = "bdr_%s" % date_range.replace(' ','') + pIndexTag.insert(0,aTag) + pIndexTag.insert(1,NavigableString(date_range)) + divTag.insert(dtc,pIndexTag) + dtc += 1 + + for new_entry in date_range_list: + # Add books + pBookTag = Tag(soup, "p") + pBookTag['class'] = "date_read" + ptc = 0 + + # Percent read + dots = int((new_entry['percent_read'] + 5)/10) + dot_string = self.READ_PROGRESS_SYMBOL * dots + empty_dots = self.UNREAD_PROGRESS_SYMBOL * (10 - dots) + pBookTag.insert(ptc, NavigableString('%s%s' % (dot_string,empty_dots))) + ptc += 1 + + aTag = Tag(soup, "a") + aTag['href'] = "book_%d.html" % (int(float(new_entry['id']))) + aTag.insert(0,escape(new_entry['title'])) + pBookTag.insert(ptc, aTag) + ptc += 1 + + # Dot + pBookTag.insert(ptc, NavigableString(" · ")) + ptc += 1 + + # Link to author + emTag = Tag(soup, "em") + aTag = Tag(soup, "a") + aTag['href'] = "%s.html#%s" % ("ByAlphaAuthor", self.generateAuthorAnchor(new_entry['author'])) + aTag.insert(0, NavigableString(new_entry['author'])) + emTag.insert(0,aTag) + pBookTag.insert(ptc, emTag) + ptc += 1 + + divTag.insert(dtc, pBookTag) + dtc += 1 + return dtc + + soup = self.generateHTMLEmptyHeader(friendly_name) + body = soup.find('body') + + btc = 0 + + # Insert section tag + aTag = Tag(soup,'a') + aTag['name'] = 'section_start' + body.insert(btc, aTag) + btc += 1 + + # Insert the anchor + aTag = Tag(soup, "a") + anchor_name = friendly_name.lower() + aTag['name'] = anchor_name.replace(" ","") + body.insert(btc, aTag) + btc += 1 + + #
+ #