From 41e7de3077d8d9ad262496759ed1f72ae5926f05 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 9 Nov 2007 21:08:23 +0000 Subject: [PATCH] Support for collections on the 505 --- src/libprs500/devices/interface.py | 4 +- src/libprs500/devices/prs500/books.py | 2 +- src/libprs500/devices/prs505/books.py | 365 +++++++++++++++++++++++++ src/libprs500/devices/prs505/driver.py | 10 +- src/libprs500/gui2/library.py | 1 + src/libprs500/gui2/main.py | 2 +- 6 files changed, 376 insertions(+), 8 deletions(-) create mode 100644 src/libprs500/devices/prs505/books.py diff --git a/src/libprs500/devices/interface.py b/src/libprs500/devices/interface.py index 4507aa72f0..c88291e785 100644 --- a/src/libprs500/devices/interface.py +++ b/src/libprs500/devices/interface.py @@ -193,8 +193,8 @@ class BookList(list): 7. tags (a list of strings, can be empty). ''' - def __init__(self): - list.__init__(self) + __getslice__ = None + __setslice__ = None def supports_tags(self): ''' Return True if the the device supports tags (collections) for this book list. ''' diff --git a/src/libprs500/devices/prs500/books.py b/src/libprs500/devices/prs500/books.py index 270896d042..00c3d306d7 100644 --- a/src/libprs500/devices/prs500/books.py +++ b/src/libprs500/devices/prs500/books.py @@ -220,7 +220,7 @@ class BookList(_BookList): def remove_book(self, path): ''' - Remove DOM node corresponding to book with C{id == cid}. + Remove DOM node corresponding to book with C{path == path}. Also remove book from any collections it is part of. ''' for book in self: diff --git a/src/libprs500/devices/prs505/books.py b/src/libprs500/devices/prs505/books.py new file mode 100644 index 0000000000..c8773f7f49 --- /dev/null +++ b/src/libprs500/devices/prs505/books.py @@ -0,0 +1,365 @@ +## Copyright (C) 2007 Kovid Goyal kovid@kovidgoyal.net +## This program is free software; you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation; either version 2 of the License, or +## (at your option) any later version. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License along +## with this program; if not, write to the Free Software Foundation, Inc., +## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +''' +''' +import re +from uuid import uuid4 as _uuid +import xml.dom.minidom as dom +from base64 import b64decode as decode +from base64 import b64encode as encode + + +from libprs500.devices.interface import BookList as _BookList +from libprs500.devices import strftime, strptime + +MIME_MAP = { + "lrf" : "application/x-sony-bbeb", + "rtf" : "application/rtf", + "pdf" : "application/pdf", + "txt" : "text/plain" + } + +def uuid(): + return str(_uuid()).replace('-', '', 1).upper() + +def sortable_title(title): + return re.sub('^\s*A\s+|^\s*The\s+|^\s*An\s+', '', title).rstrip() + +class book_metadata_field(object): + """ Represents metadata stored as an attribute """ + def __init__(self, attr, formatter=None, setter=None): + self.attr = attr + self.formatter = formatter + self.setter = setter + + def __get__(self, obj, typ=None): + """ Return a string. String may be empty if self.attr is absent """ + return self.formatter(obj.elem.getAttribute(self.attr)) if \ + self.formatter else obj.elem.getAttribute(self.attr).strip() + + def __set__(self, obj, val): + """ Set the attribute """ + val = self.setter(val) if self.setter else val + if not isinstance(val, unicode): + val = unicode(val, 'utf8', 'replace') + obj.elem.setAttribute(self.attr, val) + + +class Book(object): + """ Provides a view onto the XML element that represents a book """ + + title = book_metadata_field("title") + authors = book_metadata_field("author", \ + formatter=lambda x: x if x and x.strip() else "Unknown") + mime = book_metadata_field("mime") + rpath = book_metadata_field("path") + id = book_metadata_field("id", formatter=int) + sourceid = book_metadata_field("sourceid", formatter=int) + size = book_metadata_field("size", formatter=int) + # When setting this attribute you must use an epoch + datetime = book_metadata_field("date", formatter=strptime, setter=strftime) + + @apply + def title_sorter(): + doc = '''String to sort the title. If absent, title is returned''' + def fget(self): + src = self.elem.getAttribute('titleSorter').strip() + if not src: + src = self.title + return src + def fset(self, val): + self.elem.setAttribute('titleSorter', sortable_title(unicode(val))) + return property(doc=doc, fget=fget, fset=fset) + + @apply + def thumbnail(): + doc = \ + """ + The thumbnail. Should be a height 68 image. + Setting is not supported. + """ + def fget(self): + th = self.elem.getElementsByTagName(self.prefix + "thumbnail") + if not len(th): + th = self.elem.getElementsByTagName("cache:thumbnail") + if len(th): + for n in th[0].childNodes: + if n.nodeType == n.ELEMENT_NODE: + th = n + break + rc = "" + for node in th.childNodes: + if node.nodeType == node.TEXT_NODE: + rc += node.data + return decode(rc) + return property(fget=fget, doc=doc) + + @apply + def path(): + doc = """ Absolute path to book on device. Setting not supported. """ + def fget(self): + return self.mountpath + self.rpath + return property(fget=fget, doc=doc) + + def __init__(self, node, mountpath, tags, prefix=""): + self.elem = node + self.prefix = prefix + self.tags = tags + self.mountpath = mountpath + + def __str__(self): + """ Return a utf-8 encoded string with title author and path information """ + return self.title.encode('utf-8') + " by " + \ + self.authors.encode('utf-8') + " at " + self.path.encode('utf-8') + + +class BookList(_BookList): + + def __init__(self, xml_file, mountpath): + _BookList.__init__(self) + xml_file.seek(0) + self.document = dom.parse(xml_file) + self.root_element = self.document.documentElement + self.mountpath = mountpath + records = self.root_element.getElementsByTagName('records') + + if records: + self.prefix = 'xs1:' + self.root_element = records[0] + else: + self.prefix = '' + + for book in self.root_element.childNodes: + if hasattr(book, 'tagName') and book.tagName.endswith('text'): + tags = [i.getAttribute('title') for i in self.get_playlists(book.getAttribute('id'))] + self.append(Book(book, mountpath, tags, prefix=self.prefix)) + + def max_id(self): + max = 0 + for child in self.root_element.childNodes: + if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"): + nid = int(child.getAttribute('id')) + if nid > max: + max = nid + return max + + def is_id_valid(self, id): + '''Return True iff there is an element with C{id==id}.''' + id = str(id) + for child in self.root_element.childNodes: + if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"): + if child.getAttribute('id') == id: + return True + return False + + def supports_tags(self): + return True + + def add_book(self, info, name, size, ctime): + """ Add a node into the DOM tree, representing a book """ + node = self.document.createElement(self.prefix + "text") + mime = MIME_MAP[name.rpartition('.')[-1]] + cid = self.max_id()+1 + sourceid = str(self[0].sourceid) if len(self) else "1" + attrs = { + "title" : info["title"], + 'titleSorter' : sortable_title(info['title']), + "author" : info["authors"] if info['authors'] else 'Unknown', \ + "page":"0", "part":"0", "scale":"0", \ + "sourceid":sourceid, "id":str(cid), "date":"", \ + "mime":mime, "path":name, "size":str(size) + } + for attr in attrs.keys(): + node.setAttributeNode(self.document.createAttribute(attr)) + node.setAttribute(attr, attrs[attr]) + try: + w, h, data = info["cover"] + except TypeError: + w, h, data = None, None, None + + if data: + th = self.document.createElement(self.prefix + "thumbnail") + th.setAttribute("width", str(w)) + th.setAttribute("height", str(h)) + jpeg = self.document.createElement(self.prefix + "jpeg") + jpeg.appendChild(self.document.createTextNode(encode(data))) + th.appendChild(jpeg) + node.appendChild(th) + self.root_element.appendChild(node) + book = Book(node, self.mountpath, [], prefix=self.prefix) + book.datetime = ctime + self.append(book) + if info.has_key('tags'): + self.set_tags(book, info['tags']) + + def _delete_book(self, node): + nid = node.getAttribute('id') + self.remove_from_playlists(nid) + node.parentNode.removeChild(node) + node.unlink() + + def delete_book(self, cid): + ''' + Remove DOM node corresponding to book with C{id == cid}. + Also remove book from any collections it is part of. + ''' + for book in self: + if str(book.id) == str(cid): + self.remove(book) + self._delete_book(book.elem) + break + + def remove_book(self, path): + ''' + Remove DOM node corresponding to book with C{path == path}. + Also remove book from any collections it is part of. + ''' + for book in self: + if path.endswith(book.path): + self.remove(book) + self._delete_book(book.elem) + break + + def playlists(self): + ans = [] + for c in self.root_element.childNodes: + if hasattr(c, 'tagName') and c.tagName.endswith('playlist'): + ans.append(c) + return ans + + def playlist_items(self): + plitems = [] + for pl in self.playlists(): + for c in pl.childNodes: + if hasattr(c, 'tagName') and c.tagName.endswith('item'): + plitems.append(c) + return plitems + + def purge_corrupted_files(self): + if not self.root_element: + return [] + corrupted = self.root_element.getElementsByTagName(self.prefix+'corrupted') + paths = [] + for c in corrupted: + paths.append(c.getAttribute('path')) + c.parentNode.removeChild(c) + c.unlink() + return paths + + def purge_empty_playlists(self): + ''' Remove all playlists that have no children. Also removes any invalid playlist items.''' + for pli in self.playlist_items(): + if not self.is_id_valid(pli.getAttribute('id')): + pli.parentNode.removeChild(pli) + pli.unlink() + for pl in self.playlists(): + empty = True + for c in pl.childNodes: + if hasattr(c, 'tagName') and c.tagName.endswith('item'): + empty = False + break + if empty: + pl.parentNode.removeChild(pl) + pl.unlink() + + def playlist_by_title(self, title): + for pl in self.playlists(): + if pl.getAttribute('title').lower() == title.lower(): + return pl + + def add_playlist(self, title): + cid = self.max_id()+1 + pl = self.document.createElement(self.prefix+'playlist') + pl.setAttribute('id', str(cid)) + pl.setAttribute('title', title) + pl.setAttribute('uuid', uuid()) + self.root_element.insertBefore(pl, self.root_element.childNodes[-1]) + return pl + + def remove_from_playlists(self, id): + for pli in self.playlist_items(): + if pli.getAttribute('id') == str(id): + pli.parentNode.removeChild(pli) + pli.unlink() + + def set_tags(self, book, tags): + tags = [t for t in tags if t] + book.tags = tags + self.set_playlists(book.id, tags) + + def set_playlists(self, id, collections): + self.remove_from_playlists(id) + for collection in set(collections): + coll = self.playlist_by_title(collection) + if not coll: + coll = self.add_playlist(collection) + item = self.document.createElement(self.prefix+'item') + item.setAttribute('id', str(id)) + coll.appendChild(item) + + def get_playlists(self, bookid): + ans = [] + for pl in self.playlists(): + for item in pl.childNodes: + if hasattr(item, 'tagName') and item.tagName.endswith('item'): + if item.getAttribute('id') == str(bookid): + ans.append(pl) + return ans + + def next_id(self): + return self.document.documentElement.getAttribute('nextID') + + def set_next_id(self, id): + self.document.documentElement.setAttribute('nextID', str(id)) + + def write(self, stream): + """ Write XML representation of DOM tree to C{stream} """ + src = self.document.toxml('utf-8') + '\n' + stream.write(src.replace("'", ''')) + +def fix_ids(main, card): + ''' + Adjust ids the XML databases. + ''' + if hasattr(main, 'purge_empty_playlists'): + main.purge_empty_playlists() + if hasattr(card, 'purge_empty_playlists'): + card.purge_empty_playlists() + + def regen_ids(db): + if not hasattr(db, 'root_element'): + return + id_map = {} + db.purge_empty_playlists() + cid = 0 if db == main else 1 + for child in db.root_element.childNodes: + if child.nodeType == child.ELEMENT_NODE and child.hasAttribute('id'): + id_map[child.getAttribute('id')] = str(cid) + child.setAttribute("sourceid", '1') + child.setAttribute('id', str(cid)) + cid += 1 + + for item in db.playlist_items(): + oid = item.getAttribute('id') + try: + item.setAttribute('id', id_map[oid]) + except KeyError: + item.parentNode.removeChild(item) + item.unlink() + + regen_ids(main) + regen_ids(card) + + main.set_next_id(str(main.max_id()+1)) diff --git a/src/libprs500/devices/prs505/driver.py b/src/libprs500/devices/prs505/driver.py index ac3e0ad6bc..e5b472ec2c 100644 --- a/src/libprs500/devices/prs505/driver.py +++ b/src/libprs500/devices/prs505/driver.py @@ -20,7 +20,7 @@ from itertools import cycle from libprs500.devices.interface import Device from libprs500.devices.errors import DeviceError, FreeSpaceError -from libprs500.devices.prs500.books import BookList, fix_ids +from libprs500.devices.prs505.books import BookList, fix_ids from libprs500 import iswindows, islinux, isosx from libprs500.devices.libusb import get_device_by_id from libprs500.devices.libusb import Error as USBError @@ -270,10 +270,10 @@ class PRS505(Device): return [] db = self.__class__.CACHE_XML if oncard else self.__class__.MEDIA_XML prefix = self._card_prefix if oncard else self._main_prefix - f = open(prefix + db, 'rb') - bl = BookList(root=self._card_prefix if oncard else self._main_prefix, sfile=f) + bl = BookList(open(prefix + db, 'rb'), prefix) paths = bl.purge_corrupted_files() for path in paths: + path = os.path.join(self._card_prefix if oncard else self._main_prefix, path) if os.path.exists(path): os.unlink(path) return bl @@ -366,6 +366,7 @@ class PRS505(Device): on_card = 1 if location[3] else 0 name = path.rpartition('/')[2] name = (cls.CARD_PATH_PREFIX+'/' if on_card else 'database/media/books/') + name + name = name.replace('//', '/') booklists[on_card].add_book(info, name, *location[1:-1]) fix_ids(*booklists) @@ -377,7 +378,8 @@ class PRS505(Device): def remove_books_from_metadata(cls, paths, booklists): for path in paths: for bl in booklists: - bl.remove_book(path) + if hasattr(bl, 'remove_book'): + bl.remove_book(path) fix_ids(*booklists) def sync_booklists(self, booklists, end_session=True): diff --git a/src/libprs500/gui2/library.py b/src/libprs500/gui2/library.py index e68dd8a619..2aa9693f24 100644 --- a/src/libprs500/gui2/library.py +++ b/src/libprs500/gui2/library.py @@ -638,6 +638,7 @@ class DeviceBooksModel(BooksModel): self.db[idx].authors = val elif col == 4: tags = [i.strip() for i in val.split(',')] + tags = [t for t in tags if t] self.db.set_tags(self.db[idx], tags) self.emit(SIGNAL("dataChanged(QModelIndex, QModelIndex)"), index, index) self.emit(SIGNAL('booklist_dirtied()')) diff --git a/src/libprs500/gui2/main.py b/src/libprs500/gui2/main.py index ae1483a40a..a0d3ef59d8 100644 --- a/src/libprs500/gui2/main.py +++ b/src/libprs500/gui2/main.py @@ -373,8 +373,8 @@ class Main(MainWindow, Ui_MainWindow): if self.delete_memory.has_key(id): paths, model = self.delete_memory.pop(id) self.device_manager.remove_books_from_metadata(paths, self.booklists()) - self.upload_booklists() model.paths_deleted(paths) + self.upload_booklists() ############################################################################