diff --git a/src/calibre/__init__.py b/src/calibre/__init__.py index ff4bab6a9a..65618aaff4 100644 --- a/src/calibre/__init__.py +++ b/src/calibre/__init__.py @@ -41,6 +41,7 @@ mimetypes.add_type('application/vnd.palm', '.pdb') mimetypes.add_type('application/x-mobipocket-ebook', '.mobi') mimetypes.add_type('application/x-mobipocket-ebook', '.prc') mimetypes.add_type('application/x-mobipocket-ebook', '.azw') +mimetypes.add_type('application/x-koboreader-ebook', '.kobo') mimetypes.add_type('image/wmf', '.wmf') guess_type = mimetypes.guess_type import cssutils diff --git a/src/calibre/devices/kobo/books.py b/src/calibre/devices/kobo/books.py new file mode 100644 index 0000000000..95daec58a9 --- /dev/null +++ b/src/calibre/devices/kobo/books.py @@ -0,0 +1,112 @@ +__license__ = 'GPL v3' +__copyright__ = '2010, Timothy Legge ' +''' +''' + +import os +import re +import time + +from calibre.ebooks.metadata import MetaInformation +from calibre.devices.interface import BookList as _BookList +from calibre.constants import filesystem_encoding, preferred_encoding +from calibre import isbytestring + +class Book(MetaInformation): + + BOOK_ATTRS = ['lpath', 'size', 'mime', 'device_collections'] + + JSON_ATTRS = [ + 'lpath', 'title', 'authors', 'mime', 'size', 'tags', 'author_sort', + 'title_sort', 'comments', 'category', 'publisher', 'series', + 'series_index', 'rating', 'isbn', 'language', 'application_id', + 'book_producer', 'lccn', 'lcc', 'ddc', 'rights', 'publication_type', + 'uuid', + ] + + def __init__(self, mountpath, path, title, authors, mime, date, ContentType, ImageID, other=None): + + MetaInformation.__init__(self, '') + self.device_collections = [] + + self.title = title + if not authors: + self.authors = [''] + else: + self.authors = [authors] + self.mime = mime + self.path = path + try: + self.size = os.path.getsize(path) + except OSError: + self.size = 0 + try: + if ContentType == '6': + self.datetime = time.strptime(date, "%Y-%m-%dT%H:%M:%S.%f") + else: + self.datetime = time.gmtime(os.path.getctime(path)) + except ValueError: + self.datetime = time.gmtime() + except OSError: + self.datetime = time.gmtime() + self.lpath = path + + self.thumbnail = ImageWrapper(mountpath + '.kobo/images/' + ImageID + ' - iPhoneThumbnail.parsed') + self.tags = [] + if other: + self.smart_update(other) + + def __eq__(self, other): + return self.path == getattr(other, 'path', None) + + @dynamic_property + def db_id(self): + doc = '''The database id in the application database that this file corresponds to''' + def fget(self): + match = re.search(r'_(\d+)$', self.lpath.rpartition('.')[0]) + if match: + return int(match.group(1)) + return None + return property(fget=fget, doc=doc) + + @dynamic_property + def title_sorter(self): + doc = '''String to sort the title. If absent, title is returned''' + def fget(self): + return re.sub('^\s*A\s+|^\s*The\s+|^\s*An\s+', '', self.title).rstrip() + return property(doc=doc, fget=fget) + + @dynamic_property + def thumbnail(self): + return None + + def smart_update(self, other): + ''' + Merge the information in C{other} into self. In case of conflicts, the information + in C{other} takes precedence, unless the information in C{other} is NULL. + ''' + + MetaInformation.smart_update(self, other) + + for attr in self.BOOK_ATTRS: + if hasattr(other, attr): + val = getattr(other, attr, None) + setattr(self, attr, val) + + def to_json(self): + json = {} + for attr in self.JSON_ATTRS: + val = getattr(self, attr) + if isbytestring(val): + enc = filesystem_encoding if attr == 'lpath' else preferred_encoding + val = val.decode(enc, 'replace') + elif isinstance(val, (list, tuple)): + val = [x.decode(preferred_encoding, 'replace') if + isbytestring(x) else x for x in val] + json[attr] = val + return json + +class ImageWrapper(object): + def __init__(self, image_path): + self.image_path = image_path + diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 4b14b2bf8e..a480b8de2a 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -2,17 +2,26 @@ # vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai __license__ = 'GPL v3' -__copyright__ = '2010, Kovid Goyal ' +__copyright__ = '2010, Timothy Legge and Kovid Goyal ' __docformat__ = 'restructuredtext en' +import os + +import cStringIO +import sqlite3 as sqlite +from calibre.devices.usbms.books import BookList +from calibre.devices.kobo.books import Book +from calibre.devices.kobo.books import ImageWrapper +from calibre.devices.mime import mime_type_ext from calibre.devices.usbms.driver import USBMS + class KOBO(USBMS): name = 'Kobo Reader Device Interface' gui_name = 'Kobo Reader' description = _('Communicate with the Kobo Reader') - author = 'Kovid Goyal' + author = 'Timothy Legge and Kovid Goyal' supported_platforms = ['windows', 'osx', 'linux'] @@ -29,3 +38,292 @@ class KOBO(USBMS): EBOOK_DIR_MAIN = '' SUPPORTS_SUB_DIRS = True + def initialize(self): + USBMS.initialize(self) + self.book_class = Book + + def books(self, oncard=None, end_session=True): + from calibre.ebooks.metadata.meta import path_to_ext + + dummy_bl = BookList(None, None, None) + + if oncard == 'carda' and not self._card_a_prefix: + self.report_progress(1.0, _('Getting list of books on device...')) + return dummy_bl + elif oncard == 'cardb' and not self._card_b_prefix: + self.report_progress(1.0, _('Getting list of books on device...')) + return dummy_bl + elif oncard and oncard != 'carda' and oncard != 'cardb': + self.report_progress(1.0, _('Getting list of books on device...')) + return dummy_bl + + prefix = self._card_a_prefix if oncard == 'carda' else \ + self._card_b_prefix if oncard == 'cardb' \ + else self._main_prefix + + ebook_dirs = self.EBOOK_DIR_CARD_A if oncard == 'carda' else \ + self.EBOOK_DIR_CARD_B if oncard == 'cardb' else \ + self.get_main_ebook_dir() + + # get the metadata cache + bl = self.booklist_class(oncard, prefix, self.settings) + need_sync = self.parse_metadata_cache(bl, prefix, self.METADATA_CACHE) + + # make a dict cache of paths so the lookup in the loop below is faster. + bl_cache = {} + for idx,b in enumerate(bl): + bl_cache[b.lpath] = idx + + def update_booklist(mountpath, ContentID, filename, title, authors, mime, date, ContentType, ImageID): + changed = False + # if path_to_ext(filename) in self.FORMATS: + try: + # lpath = os.path.join(path, filename).partition(self.normalize_path(prefix))[2] + # if lpath.startswith(os.sep): + # lpath = lpath[len(os.sep):] + # lpath = lpath.replace('\\', '/') + idx = bl_cache.get(filename, None) + if idx is not None: + bl[idx].thumbnail = ImageWrapper(mountpath + '.kobo/images/' + ImageID + ' - iPhoneThumbnail.parsed') + bl_cache[filename] = None + if ContentType != '6': + if self.update_metadata_item(bl[idx]): + # print 'update_metadata_item returned true' + changed = True + else: + book = Book(mountpath, filename, title, authors, mime, date, ContentType, ImageID) + # print 'Update booklist' + if bl.add_book(book, replace_metadata=False): + changed = True + except: # Probably a filename encoding error + import traceback + traceback.print_exc() + return changed + + connection = sqlite.connect(self._main_prefix + '.kobo/KoboReader.sqlite') + cursor = connection.cursor() + + query = 'select count(distinct volumeId) from volume_shortcovers' + cursor.execute(query) + for row in (cursor): + numrows = row[0] + cursor.close() + + query= 'select Title, Attribution, DateCreated, ContentID, MimeType, ContentType, ' \ + 'ImageID from content where ContentID in (select distinct volumeId from volume_shortcovers)' + + cursor.execute (query) + + changed = False + + for i, row in enumerate(cursor): + self.report_progress((i+1) / float(numrows), _('Getting list of books on device...')) + + filename = row[3] + if row[5] == "6": + filename = filename + '.kobo' + mime = mime_type_ext(path_to_ext(row[3])) + + if oncard != 'carda' and oncard != 'cardb': + if row[5] == '6': + # print "shortbook: " + filename + changed = update_booklist(self._main_prefix, row[3], filename, row[0], row[1], mime, row[2], row[5], row[6]) + if changed: + need_sync = True + else: + if filename.startswith("file:///mnt/onboard/"): + filename = filename.replace("file:///mnt/onboard/", self._main_prefix) + # print "Internal: " + filename + changed = update_booklist(self._main_prefix, row[3], filename, row[0], row[1], mime, row[2], row[5], row[6]) + if changed: + need_sync = True + elif oncard == 'carda': + if filename.startswith("file:///mnt/sd/"): + filename = filename.replace("file:///mnt/sd/", self._card_a_prefix) + # print "SD Card: " + filename + changed = update_booklist(self._card_a_prefix, row[3], filename, row[0], row[1], mime, row[2], row[5], row[6]) + if changed: + need_sync = True + else: + print "Add card b support" + + #FIXME - NOT NEEDED flist.append({'filename': filename, 'path':row[3]}) + #bl.append(book) + + cursor.close() + connection.close() + + # Remove books that are no longer in the filesystem. Cache contains + # indices into the booklist if book not in filesystem, None otherwise + # Do the operation in reverse order so indices remain valid + for idx in sorted(bl_cache.itervalues(), reverse=True): + if idx is not None: + need_sync = True + del bl[idx] + + print "count found in cache: %d, count of files in metadata: %d, need_sync: %s" % \ + (len(bl_cache), len(bl), need_sync) + if need_sync: #self.count_found_in_bl != len(bl) or need_sync: + if oncard == 'cardb': + self.sync_booklists((None, None, bl)) + elif oncard == 'carda': + self.sync_booklists((None, bl, None)) + else: + self.sync_booklists((bl, None, None)) + + self.report_progress(1.0, _('Getting list of books on device...')) + return bl + + def delete_via_sql(self, ContentID, ContentType): + # Delete Order: + # 1) shortcover_page + # 2) volume_shorcover + # 2) content + + connection = sqlite.connect(self._main_prefix + '.kobo/KoboReader.sqlite') + cursor = connection.cursor() + t = (ContentID,) + cursor.execute('select ImageID from content where ContentID = ?', t) + + for row in cursor: + # First get the ImageID to delete the images + ImageID = row[0] + cursor.close() + + cursor = connection.cursor() + if ContentType == 6: + # Delete the shortcover_pages first + cursor.execute('delete from shortcover_page where shortcoverid in (select ContentID from content where BookID = ?)', t) + + #Delete the volume_shortcovers second + cursor.execute('delete from volume_shortcovers where volumeid = ?', t) + + # Delete the chapters associated with the book next + t = (ContentID,ContentID,) + cursor.execute('delete from content where BookID = ? or ContentID = ?', t) + + connection.commit() + + cursor.close() + connection.close() + # If all this succeeds we need to delete the images files via the ImageID + return ImageID + + def delete_images(self, ImageID): + path_prefix = '.kobo/images/' + path = self._main_prefix + path_prefix + ImageID + + file_endings = (' - iPhoneThumbnail.parsed', ' - bbMediumGridList.parsed', ' - NickelBookCover.parsed',) + + for ending in file_endings: + fpath = path + ending + fpath = self.normalize_path(fpath) + + if os.path.exists(fpath): + # print 'Image File Exists: ' + fpath + os.unlink(fpath) + + def delete_books(self, paths, end_session=True): + for i, path in enumerate(paths): + self.report_progress((i+1) / float(len(paths)), _('Removing books from device...')) + path = self.normalize_path(path) + extension = os.path.splitext(path)[1] + + if extension == '.kobo': + # Kobo books do not have book files. They do have some images though + #print "kobo book" + ContentType = 6 + ContentID = os.path.splitext(path)[0] + # Remove the prefix on the file. it could be either + ContentID = ContentID.replace(self._main_prefix, '') + if self._card_a_prefix is not None: + ContentID = ContentID.replace(self._card_a_prefix, '') + + ImageID = self.delete_via_sql(ContentID, ContentType) + #print " We would now delete the Images for" + ImageID + self.delete_images(ImageID) + if extension == '.pdf' or extension == '.epub': + # print "ePub or pdf" + ContentType = 16 + #print "Path: " + path + ContentID = path + ContentID = ContentID.replace(self._main_prefix, "file:///mnt/onboard/") + if self._card_a_prefix is not None: + ContentID = ContentID.replace(self._card_a_prefix, "file:///mnt/sd/") + # print "ContentID: " + ContentID + ImageID = self.delete_via_sql(ContentID, ContentType) + #print " We would now delete the Images for" + ImageID + self.delete_images(ImageID) + + if os.path.exists(path): + # Delete the ebook + # print "Delete the ebook: " + path + os.unlink(path) + + filepath = os.path.splitext(path)[0] + for ext in self.DELETE_EXTS: + if os.path.exists(filepath + ext): + # print "Filename: " + filename + os.unlink(filepath + ext) + if os.path.exists(path + ext): + # print "Filename: " + filename + os.unlink(path + ext) + + if self.SUPPORTS_SUB_DIRS: + try: + # print "removed" + os.removedirs(os.path.dirname(path)) + except: + pass + self.report_progress(1.0, _('Removing books from device...')) + + def remove_books_from_metadata(self, paths, booklists): + for i, path in enumerate(paths): + self.report_progress((i+1) / float(len(paths)), _('Removing books from device metadata listing...')) + for bl in booklists: + for book in bl: + #print "Book Path: " + book.path + if path.endswith(book.path): + #print " Remove: " + book.path + bl.remove_book(book) + self.report_progress(1.0, _('Removing books from device metadata listing...')) + + def add_books_to_metadata(self, locations, metadata, booklists): + metadata = iter(metadata) + for i, location in enumerate(locations): + self.report_progress((i+1) / float(len(locations)), _('Adding books to device metadata listing...')) + info = metadata.next() + blist = 2 if location[1] == 'cardb' else 1 if location[1] == 'carda' else 0 + + # Extract the correct prefix from the pathname. To do this correctly, + # we must ensure that both the prefix and the path are normalized + # so that the comparison will work. Book's __init__ will fix up + # lpath, so we don't need to worry about that here. + path = self.normalize_path(location[0]) + if self._main_prefix: + prefix = self._main_prefix if \ + path.startswith(self.normalize_path(self._main_prefix)) else None + if not prefix and self._card_a_prefix: + prefix = self._card_a_prefix if \ + path.startswith(self.normalize_path(self._card_a_prefix)) else None + if not prefix and self._card_b_prefix: + prefix = self._card_b_prefix if \ + path.startswith(self.normalize_path(self._card_b_prefix)) else None + if prefix is None: + prints('in add_books_to_metadata. Prefix is None!', path, + self._main_prefix) + continue + lpath = path.partition(prefix)[2] + if lpath.startswith('/') or lpath.startswith('\\'): + lpath = lpath[1:] + #book = self.book_class(prefix, lpath, other=info) + book = Book(prefix, lpath, '', '', '', '', '', '', other=info) + if book.size is None: + book.size = os.stat(self.normalize_path(path)).st_size + booklists[blist].add_book(book, replace_metadata=True) + self.report_progress(1.0, _('Adding books to device metadata listing...')) + +#class ImageWrapper(object): +# def __init__(self, image_path): +# self.image_path = image_path +