Add support for collections. Bug fixes.

This commit is contained in:
Kovid Goyal 2007-08-09 06:11:12 +00:00
parent 55602e7daf
commit 89e77eb685
9 changed files with 228 additions and 120 deletions

View File

@ -13,7 +13,7 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
''' E-book management software'''
__version__ = "0.3.92"
__version__ = "0.3.93"
__docformat__ = "epytext"
__author__ = "Kovid Goyal <kovid@kovidgoyal.net>"
__appname__ = 'libprs500'

View File

@ -102,8 +102,7 @@ class Device(object):
@param oncard: If True return a list of ebooks on the storage card,
otherwise return list of ebooks in main memory of device.
If True and no books on card return empty list.
@return: A list of Books. Each Book object must have the fields:
title, authors, size, datetime (a UTC time tuple), path, thumbnail (can be None).
@return: A BookList.
"""
raise NotImplementedError()
@ -129,9 +128,10 @@ class Device(object):
the device.
@param locations: Result of a call to L{upload_books}
@param metadata: List of dictionaries. Each dictionary must have the
keys C{title}, C{authors}, C{cover}. The value of the C{cover} element
can be None or a three element tuple (width, height, data)
where data is the image data in JPEG format as a string.
keys C{title}, C{authors}, C{cover}, C{tags}. The value of the C{cover}
element can be None or a three element tuple (width, height, data)
where data is the image data in JPEG format as a string. C{tags} must be
a possibly empty list of strings.
@param booklists: A tuple containing the result of calls to
(L{books}(oncard=False), L{books}(oncard=True)).
'''
@ -161,4 +161,31 @@ class Device(object):
(L{books}(oncard=False), L{books}(oncard=True)).
'''
raise NotImplementedError()
class BookList(list):
'''
A list of books. Each Book object must have the fields:
1. title
2. authors
3. size (file size of the book)
4. datetime (a UTC time tuple)
5. path (path on the device to the book)
6. thumbnail (can be None)
7. tags (a list of strings, can be empty).
'''
def __init__(self):
list.__init__(self)
def supports_tags(self):
''' Return True if the the device supports tags (collections) for this book list. '''
raise NotImplementedError()
def set_tags(self, book, tags):
'''
Set the tags for C{book} to C{tags}.
@param tags: A list of strings. Can be empty.
@param book: A book object that is in this BookList.
'''
raise NotImplementedError()

View File

@ -22,6 +22,7 @@ from base64 import b64encode as encode
import time, re
from libprs500.devices.errors import ProtocolError
from libprs500.devices.interface import BookList as _BookList
MIME_MAP = { \
"lrf":"application/x-sony-bbeb", \
@ -64,7 +65,6 @@ class Book(object):
datetime = book_metadata_field("date", \
formatter=lambda x: time.strptime(x.strip(), "%a, %d %b %Y %H:%M:%S %Z"),
setter=lambda x: time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(x)))
@apply
def title_sorter():
doc = '''String to sort the title. If absent, title is returned'''
@ -105,10 +105,11 @@ class Book(object):
return self.root + self.rpath
return property(fget=fget, doc=doc)
def __init__(self, node, prefix="", root="/Data/media/"):
self.elem = node
def __init__(self, node, tags=[], prefix="", root="/Data/media/"):
self.elem = node
self.prefix = prefix
self.root = root
self.root = root
self.tags = tags
def __str__(self):
""" Return a utf-8 encoded string with title author and path information """
@ -117,33 +118,22 @@ class Book(object):
def fix_ids(media, cache):
'''
Update ids in media, cache to be consistent with their
current structure
'''
Adjust ids in cache to correspond with media.
'''
media.purge_empty_playlists()
plitems = media.playlist_items()
cid = 0
for child in media.root.childNodes:
if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"):
old_id = child.getAttribute('id')
for item in plitems:
if item.hasAttribute('id') and item.getAttribute('id') == old_id:
item.setAttribute('id', str(cid))
child.setAttribute("id", str(cid))
cid += 1
mmaxid = cid - 1
cid = mmaxid + 2
if len(cache):
if cache.root:
sourceid = media.max_id()
cid = sourceid + 1
for child in cache.root.childNodes:
if child.nodeType == child.ELEMENT_NODE and \
child.hasAttribute("sourceid"):
child.setAttribute("sourceid", str(mmaxid+1))
if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("sourceid"):
child.setAttribute("sourceid", str(sourceid))
child.setAttribute("id", str(cid))
cid += 1
media.document.documentElement.setAttribute("nextID", str(cid))
class BookList(list):
media.set_next_id(str(cid))
class BookList(_BookList):
"""
A list of L{Book}s. Created from an XML file. Can write list
to an XML file.
@ -152,7 +142,8 @@ class BookList(list):
__setslice__ = None
def __init__(self, root="/Data/media/", sfile=None):
list.__init__(self)
_BookList.__init__(self)
self.root = self.document = self.proot = None
if sfile:
sfile.seek(0)
self.document = dom.parse(sfile)
@ -160,50 +151,30 @@ class BookList(list):
self.prefix = ''
records = self.root.getElementsByTagName('records')
if records:
self.prefix = 'xs1:'
self.root = records[0]
for child in self.root.childNodes:
if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"):
self.prefix = child.tagName.partition(':')[0] + ':'
break
if not self.prefix:
raise ProtocolError, 'Could not determine prefix in media.xml'
self.proot = root
for book in self.document.getElementsByTagName(self.prefix + "text"):
self.append(Book(book, root=root, prefix=self.prefix))
for book in self.document.getElementsByTagName(self.prefix + "text"):
id = book.getAttribute('id')
pl = [i.getAttribute('title') for i in self.get_playlists(id)]
self.append(Book(book, root=root, prefix=self.prefix, tags=pl))
def supports_tags(self):
return bool(self.prefix)
def max_id(self):
""" Highest id in underlying XML file """
cid = -1
for child in self.root.childNodes:
if child.nodeType == child.ELEMENT_NODE and \
child.hasAttribute("id"):
c = int(child.getAttribute("id"))
if c > cid:
cid = c
return cid
def has_id(self, cid):
"""
Check if a book with id C{ == cid} exists already.
This *does not* check if id exists in the underlying XML file
"""
ans = False
for book in self:
if book.id == cid:
ans = True
break
return ans
def playlists(self):
return self.root.getElementsByTagName(self.prefix+'playlist')
def playlist_items(self):
playlists = self.root.getElementsByTagName(self.prefix+'playlist')
plitems = []
for pl in playlists:
for pl in self.playlists():
plitems.extend(pl.getElementsByTagName(self.prefix+'item'))
return plitems
def purge_corrupted_files(self):
if not self.root:
return []
corrupted = self.root.getElementsByTagName(self.prefix+'corrupted')
paths = []
proot = self.proot if self.proot.endswith('/') else self.proot + '/'
@ -215,8 +186,7 @@ class BookList(list):
def purge_empty_playlists(self):
''' Remove all playlist entries that have no children. '''
playlists = self.root.getElementsByTagName(self.prefix+'playlist')
for pl in playlists:
for pl in self.playlists():
if not pl.getElementsByTagName(self.prefix + 'item'):
pl.parentNode.removeChild(pl)
pl.unlink()
@ -225,10 +195,8 @@ class BookList(list):
nid = node.getAttribute('id')
node.parentNode.removeChild(node)
node.unlink()
for pli in self.playlist_items():
if pli.getAttribute('id') == nid:
pli.parentNode.removeChild(pli)
pli.unlink()
self.remove_from_playlists(nid)
def delete_book(self, cid):
'''
@ -247,11 +215,26 @@ class BookList(list):
Also remove book from any collections it is part of.
'''
for book in self:
if book.path == path:
if path.endswith(book.path):
self.remove(book)
self._delete_book(book.elem)
break
def next_id(self):
return self.document.documentElement.getAttribute('nextID')
def set_next_id(self, id):
self.document.documentElement.setAttribute('nextID', str(id))
def max_id(self):
max = 0
for child in self.root.childNodes:
if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"):
nid = int(child.getAttribute('id'))
if nid > max:
max = nid
return max
def add_book(self, info, name, size, ctime):
""" Add a node into DOM tree representing a book """
node = self.document.createElement(self.prefix + "text")
@ -266,7 +249,6 @@ class BookList(list):
"sourceid":sourceid, "id":str(cid), "date":"", \
"mime":mime, "path":name, "size":str(size)
}
print name
for attr in attrs.keys():
node.setAttributeNode(self.document.createAttribute(attr))
node.setAttribute(attr, attrs[attr])
@ -287,6 +269,63 @@ class BookList(list):
book = Book(node, root=self.proot, prefix=self.prefix)
book.datetime = ctime
self.append(book)
self.set_next_id(cid+1)
if self.prefix: # Playlists only supportted in main memory
self.set_playlists(book.id, info['tags'])
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('sourceid', '0')
pl.setAttribute('id', str(cid))
pl.setAttribute('title', title)
for child in self.root.childNodes:
try:
if child.getAttribute('id') == '1':
self.root.insertBefore(pl, child)
self.set_next_id(cid+1)
break
except AttributeError:
continue
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):
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, id):
ans = []
for pl in self.playlists():
for item in pl.getElementsByTagName(self.prefix+'item'):
if item.getAttribute('id') == str(id):
ans.append(pl)
continue
return ans
def write(self, stream):
""" Write XML representation of DOM tree to C{stream} """

View File

@ -12,6 +12,7 @@
## 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.
from StringIO import StringIO
### End point description for PRS-500 procductId=667
### Endpoint Descriptor:
@ -802,12 +803,12 @@ class PRS500(Device):
else:
self.get_file(self.MEDIA_XML, tfile, end_session=False)
bl = BookList(root=root, sfile=tfile)
paths = bl.purge_corrupted_files()
paths = bl.purge_corrupted_files()
for path in paths:
try:
self.del_file(path, end_session=False)
except PathError: # Incase this is a refetch without a sync in between
continue
continue
return bl
@safe
@ -828,8 +829,9 @@ class PRS500(Device):
@param booklists: A tuple containing the result of calls to
(L{books}(oncard=False), L{books}(oncard=True)).
'''
fix_ids(*booklists)
self.upload_book_list(booklists[0], end_session=False)
if len(booklists[1]):
if booklists[1].root:
self.upload_book_list(booklists[1], end_session=False)
@safe
@ -940,7 +942,7 @@ class PRS500(Device):
raise ArgumentError("Cannot upload list to card as "+\
"card is not present")
path = card + self.CACHE_XML
f = TemporaryFile()
f = StringIO()
booklist.write(f)
f.seek(0)
self.put_file(f, path, replace_file=True, end_session=False)

View File

@ -1230,7 +1230,8 @@ class HTMLConverter(object):
self.process_table(tag, tag_css)
except Exception, err:
print 'WARNING: An error occurred while processing a table:', err
print 'Ignoring table markup'
print 'Ignoring table markup for table:'
print str(tag)[:100]
self.in_table = False
self.process_children(tag, tag_css)
else:

View File

@ -12,6 +12,7 @@
## 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.
from libprs500.gui2 import qstring_to_unicode
import os, textwrap, traceback, time, re, sre_constants
from datetime import timedelta, datetime
from operator import attrgetter
@ -19,7 +20,7 @@ from math import cos, sin, pi
from PyQt4.QtGui import QTableView, QProgressDialog, QAbstractItemView, QColor, \
QItemDelegate, QPainterPath, QLinearGradient, QBrush, \
QPen, QStyle, QPainter, QLineEdit, QApplication, \
QPalette, QItemSelectionModel
QPalette
from PyQt4.QtCore import QAbstractTableModel, QVariant, Qt, QString, \
QCoreApplication, SIGNAL, QObject, QSize, QModelIndex
@ -195,6 +196,7 @@ class BooksModel(QAbstractTableModel):
for row in rows:
row = row.row()
au = self.db.authors(row)
tags = self.db.tags(row)
if not au:
au = 'Unknown'
au = au.split(',')
@ -204,11 +206,17 @@ class BooksModel(QAbstractTableModel):
au = t
else:
au = ' & '.join(au)
if not tags:
tags = []
else:
tags = tags.split(',')
mi = {
'title' : self.db.title(row),
'authors' : au,
'cover' : self.db.cover(row),
'tags' : tags,
}
metadata.append(mi)
return metadata
@ -334,7 +342,8 @@ class BooksView(QTableView):
self.setModel(self._model)
self.setSelectionBehavior(QAbstractItemView.SelectRows)
self.setSortingEnabled(True)
self.setItemDelegateForColumn(4, LibraryDelegate(self))
if self.__class__.__name__ == 'BooksView': # Subclasses may not have rating as col 4
self.setItemDelegateForColumn(4, LibraryDelegate(self))
QObject.connect(self.selectionModel(), SIGNAL('currentRowChanged(QModelIndex, QModelIndex)'),
self._model.current_changed)
# Adding and removing rows should resize rows to contents
@ -398,6 +407,7 @@ class DeviceBooksModel(BooksModel):
self.unknown = str(self.trUtf8('Unknown'))
self.marked_for_deletion = {}
def mark_for_deletion(self, id, rows):
self.marked_for_deletion[id] = self.indices(rows)
for row in rows:
@ -415,16 +425,16 @@ class DeviceBooksModel(BooksModel):
self.emit(SIGNAL('dataChanged(QModelIndex, QModelIndex)'), indices[0], indices[-1])
def path_about_to_be_deleted(self, path):
for row in range(len(self.map)):
for row in range(len(self.map)):
if self.db[self.map[row]].path == path:
print row, path
#print row, path
#print self.rowCount(None)
self.beginRemoveRows(QModelIndex(), row, row)
self.map.pop(row)
self.endRemoveRows()
#print self.rowCount(None)
return
def path_deleted(self):
self.endRemoveRows()
def indices_to_be_deleted(self):
ans = []
for v in self.marked_for_deletion.values():
@ -433,8 +443,12 @@ class DeviceBooksModel(BooksModel):
def flags(self, index):
if self.map[index.row()] in self.indices_to_be_deleted():
return Qt.ItemIsUserCheckable # Can't figure out how to get the disabled flag in python
return BooksModel.flags(self, index)
return Qt.ItemIsUserCheckable # Can't figure out how to get the disabled flag in python
flags = QAbstractTableModel.flags(self, index)
if index.isValid():
if index.column() in [0, 1] or (index.column() == 4 and self.db.supports_tags()):
flags |= Qt.ItemIsEditable
return flags
def search(self, text, refinement, reset=True):
@ -443,7 +457,7 @@ class DeviceBooksModel(BooksModel):
result = []
for i in base:
add = True
q = self.db[i].title + ' ' + self.db[i].authors
q = self.db[i].title + ' ' + self.db[i].authors + ' ' + ', '.join(self.db[i].tags)
for token in tokens:
if not token.search(q):
add = False
@ -478,8 +492,11 @@ class DeviceBooksModel(BooksModel):
def sizecmp(x, y):
x, y = int(self.db[x].size), int(self.db[y].size)
return cmp(x, y)
def tagscmp(x, y):
x, y = ','.join(self.db[x].tags), ','.join(self.db[y].tags)
return cmp(x, y)
fcmp = strcmp('title_sorter') if col == 0 else strcmp('authors') if col == 1 else \
sizecmp if col == 2 else datecmp
sizecmp if col == 2 else datecmp if col == 3 else tagscmp
self.map.sort(cmp=fcmp, reverse=descending)
if len(self.map) == len(self.db):
self.sorted_map = list(self.map)
@ -491,7 +508,7 @@ class DeviceBooksModel(BooksModel):
self.reset()
def columnCount(self, parent):
return 4
return 5
def rowCount(self, parent):
return len(self.map)
@ -516,6 +533,7 @@ class DeviceBooksModel(BooksModel):
dt = datetime(*dt[0:6])
dt = dt - timedelta(seconds=time.timezone) + timedelta(hours=time.daylight)
data['Timestamp'] = dt.ctime()
data['Tags'] = ', '.join(item.tags)
self.emit(SIGNAL('new_bookdisplay_data(PyQt_PyObject)'), data)
def paths(self, rows):
@ -556,30 +574,51 @@ class DeviceBooksModel(BooksModel):
dt = datetime(*dt[0:6])
dt = dt - timedelta(seconds=time.timezone) + timedelta(hours=time.daylight)
return QVariant(dt.strftime(BooksView.TIME_FMT))
elif col == 4:
tags = self.db[self.map[row]].tags
if tags:
return QVariant(', '.join(tags))
elif role == Qt.TextAlignmentRole and index.column() in [2, 3]:
return QVariant(Qt.AlignRight | Qt.AlignVCenter)
elif role == Qt.ToolTipRole and index.isValid():
if self.map[index.row()] in self.indices_to_be_deleted():
return QVariant('Marked for deletion')
if index.column() in [0, 1]:
col = index.column()
if col in [0, 1] or (col == 4 and self.db.supports_tags()):
return QVariant("Double click to <b>edit</b> me<br><br>")
return NONE
def headerData(self, section, orientation, role):
if role != Qt.DisplayRole:
return NONE
text = ""
if orientation == Qt.Horizontal:
if section == 0: text = "Title"
elif section == 1: text = "Author(s)"
elif section == 2: text = "Size (MB)"
elif section == 3: text = "Date"
elif section == 4: text = "Tags"
return QVariant(self.trUtf8(text))
else:
return QVariant(section+1)
def setData(self, index, value, role):
done = False
if role == Qt.EditRole:
row, col = index.row(), index.column()
if col in [2, 3]:
return False
val = unicode(value.toString().toUtf8(), 'utf-8').strip()
val = qstring_to_unicode(value.toString()).strip()
idx = self.map[row]
if col == 0:
self.db[idx].title = val
self.db[idx].title_sorter = val
elif col == 1:
self.db[idx].authors = val
self.emit(SIGNAL("dataChanged(QModelIndex, QModelIndex)"), \
index, index)
elif col == 4:
tags = [i.strip() for i in val.split(',')]
self.db.set_tags(self.db[idx], tags)
self.emit(SIGNAL("dataChanged(QModelIndex, QModelIndex)"), index, index)
self.emit(SIGNAL('booklist_dirtied()'))
if col == self.sorted_on[0]:
self.sort(col, self.sorted_on[1])

View File

@ -61,6 +61,7 @@ class Main(QObject, Ui_MainWindow):
self.device_error_dialog = error_dialog(self.window, 'Error communicating with device', ' ')
self.device_error_dialog.setModal(Qt.NonModal)
self.tb_wrapper = textwrap.TextWrapper(width=40)
self.device_connected = False
####################### Location View ########################
QObject.connect(self.location_view, SIGNAL('location_selected(PyQt_PyObject)'),
self.location_selected)
@ -156,7 +157,9 @@ class Main(QObject, Ui_MainWindow):
self.set_default_thumbnail(cls.THUMBNAIL_HEIGHT)
self.status_bar.showMessage('Device: '+cls.__name__+' detected.', 3000)
self.action_sync.setEnabled(True)
self.device_connected = True
else:
self.device_connected = False
self.job_manager.terminate_device_jobs()
self.device_manager.device_removed()
self.location_view.model().update_devices()
@ -326,15 +329,12 @@ class Main(QObject, Ui_MainWindow):
self.device_job_exception(id, description, exception, formatted_traceback)
return
self.upload_booklists()
if self.delete_memory.has_key(id):
paths, model = self.delete_memory.pop(id)
for path in paths:
model.path_about_to_be_deleted(path)
self.device_manager.remove_books_from_metadata((path,), self.booklists())
model.path_deleted()
self.upload_booklists()
############################################################################
@ -437,6 +437,13 @@ class Main(QObject, Ui_MainWindow):
view.resize_on_select = False
self.status_bar.reset_info()
self.current_view().clearSelection()
if location == 'library':
if self.device_connected:
self.action_sync.setEnabled(True)
self.action_edit.setEnabled(True)
else:
self.action_sync.setEnabled(False)
self.action_edit.setEnabled(False)
def wrap_traceback(self, tb):

View File

@ -12,8 +12,6 @@
## 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.
from libprs500.gui2.dialogs.jobs import JobsDialog
import textwrap
from PyQt4.QtGui import QStatusBar, QMovie, QLabel, QFrame, QHBoxLayout, QPixmap, \
@ -78,29 +76,12 @@ class BookInfoDisplay(QFrame):
self.clear_message()
self.setVisible(True)
class BusyIndicator(QLabel):
def __init__(self, movie, jobs_dialog):
QLabel.__init__(self)
self.setCursor(Qt.PointingHandCursor)
self.setToolTip('Click to see list of active jobs.')
self.setMovie(movie)
movie.start()
movie.setPaused(True)
self.jobs_dialog = jobs_dialog
def mouseReleaseEvent(self, event):
if self.jobs_dialog.isVisible():
self.jobs_dialog.hide()
else:
self.jobs_dialog.show()
class MovieButton(QFrame):
def __init__(self, movie, jobs_dialog):
QFrame.__init__(self)
self.setLayout(QVBoxLayout())
self.movie_widget = BusyIndicator(movie, jobs_dialog)
self.movie_widget = QLabel()
self.movie_widget.setMovie(movie)
self.movie = movie
self.layout().addWidget(self.movie_widget)
self.jobs = QLabel('<b>Jobs: 0')
@ -110,6 +91,18 @@ class MovieButton(QFrame):
self.jobs.setMargin(0)
self.layout().setMargin(0)
self.jobs.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
self.jobs_dialog = jobs_dialog
self.setCursor(Qt.PointingHandCursor)
self.setToolTip('Click to see list of active jobs.')
movie.start()
movie.setPaused(True)
def mouseReleaseEvent(self, event):
if self.jobs_dialog.isVisible():
self.jobs_dialog.hide()
else:
self.jobs_dialog.show()
class StatusBar(QStatusBar):

View File

@ -57,7 +57,7 @@ class LibraryDatabase(object):
Iterator over the books in the old pre 0.4.0 database.
'''
conn = sqlite.connect(path)
cur = conn.execute('select * from books_meta;')
cur = conn.execute('select * from books_meta order by id;')
book = cur.fetchone()
while book:
id = book[0]