## 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. from libprs500.gui2 import qstring_to_unicode import os, textwrap, traceback, time, re, sre_constants from datetime import timedelta, datetime from operator import attrgetter from math import cos, sin, pi from PyQt4.QtGui import QTableView, QProgressDialog, QAbstractItemView, QColor, \ QItemDelegate, QPainterPath, QLinearGradient, QBrush, \ QPen, QStyle, QPainter, QLineEdit, QApplication, \ QPalette from PyQt4.QtCore import QAbstractTableModel, QVariant, Qt, QString, \ QCoreApplication, SIGNAL, QObject, QSize, QModelIndex from libprs500.ptempfile import PersistentTemporaryFile from libprs500.library.database import LibraryDatabase from libprs500.gui2 import NONE, TableView class LibraryDelegate(QItemDelegate): COLOR = QColor("blue") SIZE = 16 PEN = QPen(COLOR, 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) def __init__(self, parent): QItemDelegate.__init__(self, parent) self.star_path = QPainterPath() self.star_path.moveTo(90, 50) for i in range(1, 5): self.star_path.lineTo(50 + 40 * cos(0.8 * i * pi), \ 50 + 40 * sin(0.8 * i * pi)) self.star_path.closeSubpath() self.star_path.setFillRule(Qt.WindingFill) gradient = QLinearGradient(0, 0, 0, 100) gradient.setColorAt(0.0, self.COLOR) gradient.setColorAt(1.0, self.COLOR) self. brush = QBrush(gradient) self.factor = self.SIZE/100. def sizeHint(self, option, index): #num = index.model().data(index, Qt.DisplayRole).toInt()[0] return QSize(5*(self.SIZE), self.SIZE+4) def paint(self, painter, option, index): num = index.model().data(index, Qt.DisplayRole).toInt()[0] def draw_star(): painter.save() painter.scale(self.factor, self.factor) painter.translate(50.0, 50.0) painter.rotate(-20) painter.translate(-50.0, -50.0) painter.drawPath(self.star_path) painter.restore() painter.save() try: if option.state & QStyle.State_Selected: painter.fillRect(option.rect, option.palette.highlight()) painter.setRenderHint(QPainter.Antialiasing) y = option.rect.center().y()-self.SIZE/2. x = option.rect.right() - self.SIZE painter.setPen(self.PEN) painter.setBrush(self.brush) painter.translate(x, y) i = 0 while i < num: draw_star() painter.translate(-self.SIZE, 0) i += 1 except Exception, e: traceback.print_exc(e) painter.restore() def createEditor(self, parent, option, index): sb = QItemDelegate.createEditor(self, parent, option, index) sb.setMinimum(0) sb.setMaximum(5) return sb class BooksModel(QAbstractTableModel): coding = zip( [1000,900,500,400,100,90,50,40,10,9,5,4,1], ["M","CM","D","CD","C","XC","L","XL","X","IX","V","IV","I"] ) @classmethod def roman(cls, num): if num <= 0 or num >= 4000 or int(num) != num: return str(num) result = [] for d, r in cls.coding: while num >= d: result.append(r) num -= d return ''.join(result) def __init__(self, parent): QAbstractTableModel.__init__(self, parent) self.db = None self.cols = ['title', 'authors', 'size', 'date', 'rating', 'publisher', 'series'] self.sorted_on = (3, Qt.AscendingOrder) self.last_search = '' # The last search performed on this model def set_database(self, db): if isinstance(db, (QString, basestring)): if isinstance(db, QString): db = qstring_to_unicode(db) db = LibraryDatabase(os.path.expanduser(qstring_to_unicode(db))) self.db = db def add_books(self, paths, formats, metadata, uris=[]): self.db.add_books(paths, formats, metadata, uris) def row_indices(self, index): ''' Return list indices of all cells in index.row()''' return [ self.index(index.row(), c) for c in range(self.columnCount(None))] def save_to_disk(self, rows, path): rows = [row.row() for row in rows] self.db.export_to_dir(path, rows, self.sorted_on[0] == 1) def delete_books(self, indices): ids = [ self.id(i) for i in indices ] for id in ids: row = self.db.index(id) self.beginRemoveRows(QModelIndex(), row, row) self.db.delete_book(id) self.endRemoveRows() def search_tokens(self, text): tokens = [] quot = re.search('"(.*?)"', text) while quot: tokens.append(quot.group(1)) text = text.replace('"'+quot.group(1)+'"', '') quot = re.search('"(.*?)"', text) tokens += text.split(' ') ans = [] for i in tokens: try: ans.append(re.compile(i, re.IGNORECASE)) except sre_constants.error: continue return ans def search(self, text, refinement, reset=True): tokens = self.search_tokens(text) self.db.filter(tokens, refinement) self.last_search = text if reset: self.reset() def sort(self, col, order, reset=True): if not self.db: return ascending = order == Qt.AscendingOrder self.db.refresh(self.cols[col], ascending) self.research() if reset: self.reset() self.sorted_on = (col, order) def resort(self, reset=True): self.sort(*self.sorted_on, **dict(reset=reset)) def research(self, reset=True): self.search(self.last_search, False, reset=reset) def database_needs_migration(self): path = os.path.expanduser('~/library.db') return self.db.is_empty() and \ os.path.exists(path) and\ LibraryDatabase.sizeof_old_database(path) > 0 def columnCount(self, parent): return len(self.cols) def rowCount(self, parent): return self.db.rows() if self.db else 0 def current_changed(self, current, previous): data = {} idx = current.row() cdata = self.db.cover(idx) if cdata: data['cover'] = cdata tags = self.db.tags(idx) if tags: tags = tags.replace(',', ', ') else: tags = _('None') data[_('Tags')] = tags formats = self.db.formats(idx) if formats: formats = formats.replace(',', ', ') else: formats = _('None') data[_('Formats')] = formats comments = self.db.comments(idx) if not comments: comments = _('None') data[_('Comments')] = comments series = self.db.series(idx) if series: sidx = self.db.series_index(idx) sidx = self.__class__.roman(sidx) data[_('Series')] = _('Book %s of %s.')%(sidx, series) self.emit(SIGNAL('new_bookdisplay_data(PyQt_PyObject)'), data) def get_metadata(self, rows): metadata = [] 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(',') if len(au) > 1: t = ', '.join(au[:-1]) t += ' & ' + au[-1] au = t else: au = ' & '.join(au) if not tags: tags = [] else: tags = tags.split(',') series = self.db.series(row) if series is not None: tags.append(series) mi = { 'title' : self.db.title(row), 'authors' : au, 'cover' : self.db.cover(row), 'tags' : tags, } if series is not None: mi['tag order'] = {series:self.db.books_in_series_of(row)} metadata.append(mi) return metadata def get_preferred_formats(self, rows, formats): ans = [] for row in (row.row() for row in rows): format = None for f in self.db.formats(row).split(','): if f.lower() in formats: format = f break if format: pt = PersistentTemporaryFile(suffix='.'+format) pt.write(self.db.format(row, format)) pt.seek(0) ans.append(pt) else: ans.append(None) return ans def id(self, row): return self.db.id(row.row()) def data(self, index, role): if role == Qt.DisplayRole or role == Qt.EditRole: row, col = index.row(), index.column() if col == 0: text = self.db.title(row) if text: return QVariant(BooksView.wrap(text, width=35)) elif col == 1: au = self.db.authors(row) if au: au = au.split(',') jau = [ BooksView.wrap(a, width=30).strip() for a in au ] return QVariant("\n".join(jau)) elif col == 2: size = self.db.max_size(row) if size: return QVariant(BooksView.human_readable(size)) elif col == 3: dt = self.db.timestamp(row) if dt: dt = dt - timedelta(seconds=time.timezone) + timedelta(hours=time.daylight) return QVariant(dt.strftime(BooksView.TIME_FMT)) elif col == 4: r = self.db.rating(row) r = r/2 if r else 0 return QVariant(r) elif col == 5: pub = self.db.publisher(row) if pub: return QVariant(BooksView.wrap(pub, 20)) elif col == 6: series = self.db.series(row) if series: return QVariant(series) return NONE elif role == Qt.TextAlignmentRole and index.column() in [2, 3, 4]: return QVariant(Qt.AlignRight | Qt.AlignVCenter) elif role == Qt.ToolTipRole and index.isValid(): if index.column() in [0, 1, 4, 5]: return QVariant("Double click to edit me

") 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 = _("Rating") elif section == 5: text = _("Publisher") elif section == 6: text = _("Series") return QVariant(text) else: return QVariant(section+1) def flags(self, index): flags = QAbstractTableModel.flags(self, index) if index.isValid(): if index.column() not in [2, 3]: flags |= Qt.ItemIsEditable return flags 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() if col != 4 else \ int(value.toInt()[0]) if col == 4: val = 0 if val < 0 else 5 if val > 5 else val val *= 2 column = self.cols[col] self.db.set(row, column, val) self.emit(SIGNAL("dataChanged(QModelIndex, QModelIndex)"), \ index, index) if col == self.sorted_on[0]: self.sort(col, self.sorted_on[1]) done = True return done class BooksView(TableView): TIME_FMT = '%d %b %Y' wrapper = textwrap.TextWrapper(width=20) @classmethod def wrap(cls, s, width=20): cls.wrapper.width = width return cls.wrapper.fill(s) @classmethod def human_readable(cls, size, precision=1): """ Convert a size in bytes into megabytes """ return ('%.'+str(precision)+'f') % ((size/(1024.*1024.)),) def __init__(self, parent, modelcls=BooksModel): TableView.__init__(self, parent) self.display_parent = parent self._model = modelcls(self) self.setModel(self._model) self.setSelectionBehavior(QAbstractItemView.SelectRows) self.setSortingEnabled(True) 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 QObject.connect(self.model(), SIGNAL('rowsRemoved(QModelIndex, int, int)'), self.resizeRowsToContents) QObject.connect(self.model(), SIGNAL('rowsInserted(QModelIndex, int, int)'), self.resizeRowsToContents) # Resetting the model should resize rows (model is reset after search and sort operations) QObject.connect(self.model(), SIGNAL('modelReset()'), self.resizeRowsToContents) def set_database(self, db): self._model.set_database(db) def migrate_database(self): if self.model().database_needs_migration(): print 'Migrating database from pre 0.4.0 version' path = os.path.abspath(os.path.expanduser('~/library.db')) progress = QProgressDialog('Upgrading database from pre 0.4.0 version.
'+\ 'The new database is stored in the file '+self._model.db.dbpath, QString(), 0, LibraryDatabase.sizeof_old_database(path), self) progress.setModal(True) progress.setValue(0) app = QCoreApplication.instance() def meter(count): progress.setValue(count) app.processEvents() progress.setWindowTitle('Upgrading database') progress.show() LibraryDatabase.import_old_database(path, self._model.db.conn, meter) def connect_to_search_box(self, sb): QObject.connect(sb, SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'), self._model.search) def connect_to_book_display(self, bd): QObject.connect(self._model, SIGNAL('new_bookdisplay_data(PyQt_PyObject)'), bd) class DeviceBooksView(BooksView): def __init__(self, parent): BooksView.__init__(self, parent, DeviceBooksModel) self.columns_resized = False self.resize_on_select = False def resizeColumnsToContents(self): QTableView.resizeColumnsToContents(self) self.columns_resized = True def connect_dirtied_signal(self, slot): QObject.connect(self._model, SIGNAL('booklist_dirtied()'), slot) class DeviceBooksModel(BooksModel): def __init__(self, parent): BooksModel.__init__(self, parent) self.db = [] self.map = [] self.sorted_map = [] 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: indices = self.row_indices(row) self.emit(SIGNAL('dataChanged(QModelIndex, QModelIndex)'), indices[0], indices[-1]) def deletion_done(self, id, succeeded=True): if not self.marked_for_deletion.has_key(id): return rows = self.marked_for_deletion.pop(id) for row in rows: if not succeeded: indices = self.row_indices(self.index(row, 0)) self.emit(SIGNAL('dataChanged(QModelIndex, QModelIndex)'), indices[0], indices[-1]) def paths_deleted(self, paths): self.map = list(range(0, len(self.db))) self.resort(False) self.research(True) def indices_to_be_deleted(self): ans = [] for v in self.marked_for_deletion.values(): ans.extend(v) return ans 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 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): tokens = self.search_tokens(text) base = self.map if refinement else self.sorted_map result = [] for i in base: add = True 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 break if add: result.append(i) self.map = result if reset: self.reset() self.last_search = text def sort(self, col, order, reset=True): descending = order != Qt.AscendingOrder def strcmp(attr): ag = attrgetter(attr) def _strcmp(x, y): x = ag(self.db[x]) y = ag(self.db[y]) if x == None: x = '' if y == None: y = '' x, y = x.strip().lower(), y.strip().lower() return cmp(x, y) return _strcmp def datecmp(x, y): x = self.db[x].datetime y = self.db[y].datetime return cmp(datetime(*x[0:6]), datetime(*y[0:6])) 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 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) else: self.sorted_map = list(range(len(self.db))) self.sorted_map.sort(cmp=fcmp, reverse=descending) self.sorted_on = (col, order) if reset: self.reset() def columnCount(self, parent): return 5 def rowCount(self, parent): return len(self.map) def set_database(self, db): self.db = db self.map = list(range(0, len(db))) def current_changed(self, current, previous): data = {} item = self.db[self.map[current.row()]] cdata = item.thumbnail if cdata: data['cover'] = cdata type = _('Unknown') ext = os.path.splitext(item.path)[1] if ext: type = ext[1:].lower() data[_('Format')] = type data[_('Path')] = item.path dt = item.datetime 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): return [self.db[self.map[r.row()]].path for r in rows ] def indices(self, rows): ''' Return indices into underlying database from rows ''' return [ self.map[r.row()] for r in rows] def data(self, index, role): if role == Qt.DisplayRole or role == Qt.EditRole: row, col = index.row(), index.column() if col == 0: text = self.db[self.map[row]].title if not text: text = self.unknown return QVariant(text) elif col == 1: au = self.db[self.map[row]].authors if not au: au = self.unknown if role == Qt.EditRole: return QVariant(au) au = au.split(',') authors = [] for i in au: authors += i.strip().split('&') jau = [ a.strip() for a in authors ] return QVariant("\n".join(jau)) elif col == 2: size = self.db[self.map[row]].size return QVariant(BooksView.human_readable(size)) elif col == 3: dt = self.db[self.map[row]].datetime 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') col = index.column() if col in [0, 1] or (col == 4 and self.db.supports_tags()): return QVariant("Double click to edit me

") 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(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 = 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 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()')) if col == self.sorted_on[0]: self.sort(col, self.sorted_on[1]) done = True return done class SearchBox(QLineEdit): INTERVAL = 1000 #: Time to wait before emitting search signal def __init__(self, parent): QLineEdit.__init__(self, parent) self.help_text = _('Search by title, author, publisher, tags, series and comments') self.initial_state = True self.default_palette = QApplication.palette(self) self.gray = QPalette(self.default_palette) self.gray.setBrush(QPalette.Text, QBrush(QColor('gray'))) self.prev_search = '' self.timer = None self.clear_to_help() QObject.connect(self, SIGNAL('textEdited(QString)'), self.text_edited_slot) def normalize_state(self): self.setText('') self.setPalette(self.default_palette) def clear_to_help(self): self.setPalette(self.gray) self.setText(self.help_text) self.home(False) self.initial_state = True def keyPressEvent(self, event): if self.initial_state: self.normalize_state() self.initial_state = False QLineEdit.keyPressEvent(self, event) def mouseReleaseEvent(self, event): if self.initial_state: self.normalize_state() self.initial_state = False QLineEdit.mouseReleaseEvent(self, event) def text_edited_slot(self, text): text = str(text) self.prev_text = text self.timer = self.startTimer(self.__class__.INTERVAL) def timerEvent(self, event): self.killTimer(event.timerId()) if event.timerId() == self.timer: text = qstring_to_unicode(self.text()) refinement = text.startswith(self.prev_search) self.prev_search = text self.emit(SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'), text, refinement)