2
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
4
import os, textwrap, traceback, re, shutil
5
from operator import attrgetter
6
from math import cos, sin, pi
7
from contextlib import closing
9
from PyQt4.QtGui import QTableView, QAbstractItemView, QColor, \
10
QPainterPath, QLinearGradient, QBrush, \
11
QPen, QStyle, QPainter, QStyleOptionViewItemV4, \
13
QStyledItemDelegate, QCompleter
14
from PyQt4.QtCore import QAbstractTableModel, QVariant, Qt, pyqtSignal, \
15
SIGNAL, QObject, QSize, QModelIndex, QDate
17
from calibre import strftime
18
from calibre.ptempfile import PersistentTemporaryFile
19
from calibre.utils.pyparsing import ParseException
20
from calibre.library.caches import _match, CONTAINS_MATCH, EQUALS_MATCH, REGEXP_MATCH
21
from calibre.gui2 import NONE, TableView, qstring_to_unicode, config, \
23
from calibre.gui2.widgets import EnLineEdit, TagsLineEdit
24
from calibre.utils.search_query_parser import SearchQueryParser
25
from calibre.ebooks.metadata.meta import set_metadata as _set_metadata
26
from calibre.ebooks.metadata import string_to_authors, fmt_sidx, \
28
from calibre.utils.config import tweaks
29
from calibre.utils.date import dt_factory, qt_to_dt, isoformat
31
class LibraryDelegate(QStyledItemDelegate):
32
COLOR = QColor("blue")
34
PEN = QPen(COLOR, 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
36
def __init__(self, parent):
37
QStyledItemDelegate.__init__(self, parent)
39
self.dummy = QModelIndex()
40
self.star_path = QPainterPath()
41
self.star_path.moveTo(90, 50)
43
self.star_path.lineTo(50 + 40 * cos(0.8 * i * pi), \
44
50 + 40 * sin(0.8 * i * pi))
45
self.star_path.closeSubpath()
46
self.star_path.setFillRule(Qt.WindingFill)
47
gradient = QLinearGradient(0, 0, 0, 100)
48
gradient.setColorAt(0.0, self.COLOR)
49
gradient.setColorAt(1.0, self.COLOR)
50
self.brush = QBrush(gradient)
51
self.factor = self.SIZE/100.
53
def sizeHint(self, option, index):
54
#num = index.model().data(index, Qt.DisplayRole).toInt()[0]
55
return QSize(5*(self.SIZE), self.SIZE+4)
57
def paint(self, painter, option, index):
58
style = self._parent.style()
59
option = QStyleOptionViewItemV4(option)
60
self.initStyleOption(option, self.dummy)
61
num = index.model().data(index, Qt.DisplayRole).toInt()[0]
64
painter.scale(self.factor, self.factor)
65
painter.translate(50.0, 50.0)
67
painter.translate(-50.0, -50.0)
68
painter.drawPath(self.star_path)
72
if hasattr(QStyle, 'CE_ItemViewItem'):
73
style.drawControl(QStyle.CE_ItemViewItem, option,
74
painter, self._parent)
75
elif option.state & QStyle.State_Selected:
76
painter.fillRect(option.rect, option.palette.highlight())
78
painter.setRenderHint(QPainter.Antialiasing)
79
painter.setClipRect(option.rect)
80
y = option.rect.center().y()-self.SIZE/2.
81
x = option.rect.right() - self.SIZE
82
painter.setPen(self.PEN)
83
painter.setBrush(self.brush)
84
painter.translate(x, y)
88
painter.translate(-self.SIZE, 0)
94
def createEditor(self, parent, option, index):
95
sb = QStyledItemDelegate.createEditor(self, parent, option, index)
100
class DateDelegate(QStyledItemDelegate):
102
def displayText(self, val, locale):
104
return d.toString('dd MMM yyyy')
106
def createEditor(self, parent, option, index):
107
qde = QStyledItemDelegate.createEditor(self, parent, option, index)
108
stdformat = unicode(qde.displayFormat())
109
if 'yyyy' not in stdformat:
110
stdformat = stdformat.replace('yy', 'yyyy')
111
qde.setDisplayFormat(stdformat)
112
qde.setMinimumDate(QDate(101,1,1))
113
qde.setCalendarPopup(True)
116
class PubDateDelegate(QStyledItemDelegate):
118
def displayText(self, val, locale):
119
return val.toDate().toString('MMM yyyy')
121
def createEditor(self, parent, option, index):
122
qde = QStyledItemDelegate.createEditor(self, parent, option, index)
123
qde.setDisplayFormat('MM yyyy')
124
qde.setMinimumDate(QDate(101,1,1))
125
qde.setCalendarPopup(True)
128
class TextDelegate(QStyledItemDelegate):
130
def __init__(self, parent):
132
Delegate for text data. If auto_complete_function needs to return a list
133
of text items to auto-complete with. The funciton is None no
134
auto-complete will be used.
136
QStyledItemDelegate.__init__(self, parent)
137
self.auto_complete_function = None
139
def set_auto_complete_function(self, f):
140
self.auto_complete_function = f
142
def createEditor(self, parent, option, index):
143
editor = EnLineEdit(parent)
144
if self.auto_complete_function:
145
complete_items = [i[1] for i in self.auto_complete_function()]
146
completer = QCompleter(complete_items, self)
147
completer.setCaseSensitivity(Qt.CaseInsensitive)
148
completer.setCompletionMode(QCompleter.InlineCompletion)
149
editor.setCompleter(completer)
152
class TagsDelegate(QStyledItemDelegate):
154
def __init__(self, parent):
155
QStyledItemDelegate.__init__(self, parent)
158
def set_database(self, db):
161
def createEditor(self, parent, option, index):
163
editor = TagsLineEdit(parent, self.db.all_tags())
165
editor = EnLineEdit(parent)
168
class BooksModel(QAbstractTableModel):
170
about_to_be_sorted = pyqtSignal(object, name='aboutToBeSorted')
171
sorting_done = pyqtSignal(object, name='sortingDone')
174
'title' : _("Title"),
175
'authors' : _("Author(s)"),
176
'size' : _("Size (MB)"),
177
'timestamp' : _("Date"),
178
'pubdate' : _('Published'),
179
'rating' : _('Rating'),
180
'publisher' : _("Publisher"),
182
'series' : _("Series"),
185
def __init__(self, parent=None, buffer=40):
186
QAbstractTableModel.__init__(self, parent)
188
self.column_map = config['column_map']
189
self.column_map = [x for x in self.column_map if x in self.headers]
190
self.editable_cols = ['title', 'authors', 'rating', 'publisher',
191
'tags', 'series', 'timestamp', 'pubdate']
192
self.default_image = QImage(I('book.svg'))
193
self.sorted_on = ('timestamp', Qt.AscendingOrder)
194
self.last_search = '' # The last search performed on this model
196
self.buffer_size = buffer
197
self.cover_cache = None
199
def clear_caches(self):
201
self.cover_cache.clear_cache()
203
def read_config(self):
204
self.use_roman_numbers = config['use_roman_numerals_for_series_number']
205
cols = config['column_map']
206
cols = [x for x in cols if x in self.headers]
207
if cols != self.column_map:
208
self.column_map = cols
210
self.emit(SIGNAL('columns_sorted()'))
212
def set_database(self, db):
214
self.build_data_convertors()
216
def refresh_ids(self, ids, current_row=-1):
217
rows = self.db.refresh_ids(ids)
219
self.refresh_rows(rows, current_row=current_row)
221
def refresh_cover_cache(self, ids):
223
self.cover_cache.refresh(ids)
225
def refresh_rows(self, rows, current_row=-1):
229
self.cover_cache.refresh([id])
230
if row == current_row:
231
self.emit(SIGNAL('new_bookdisplay_data(PyQt_PyObject)'),
232
self.get_book_display_info(row))
233
self.emit(SIGNAL('dataChanged(QModelIndex,QModelIndex)'),
234
self.index(row, 0), self.index(row, self.columnCount(QModelIndex())-1))
241
def add_books(self, paths, formats, metadata, add_duplicates=False):
242
ret = self.db.add_books(paths, formats, metadata,
243
add_duplicates=add_duplicates)
247
def add_news(self, path, arg):
248
ret = self.db.add_news(path, arg)
252
def add_catalog(self, path, title):
253
ret = self.db.add_catalog(path, title)
257
def count_changed(self, *args):
258
self.emit(SIGNAL('count_changed(int)'), self.db.count())
260
def row_indices(self, index):
261
''' Return list indices of all cells in index.row()'''
262
return [ self.index(index.row(), c) for c in range(self.columnCount(None))]
266
return self.sorted_on[0] == 'authors'
268
def delete_books(self, indices):
269
ids = map(self.id, indices)
271
self.db.delete_book(id, notify=False)
277
def delete_books_by_id(self, ids):
280
row = self.db.row(id)
284
self.beginRemoveRows(QModelIndex(), row, row)
285
self.db.delete_book(id)
291
def books_added(self, num):
293
self.beginInsertRows(QModelIndex(), 0, num-1)
297
def search(self, text, refinement, reset=True):
300
except ParseException:
301
self.emit(SIGNAL('searched(PyQt_PyObject)'), False)
303
self.last_search = text
308
self.emit(SIGNAL('searched(PyQt_PyObject)'), True)
311
def sort(self, col, order, reset=True):
314
self.about_to_be_sorted.emit(self.db.id)
315
ascending = order == Qt.AscendingOrder
316
self.db.sort(self.column_map[col], ascending)
320
self.sorted_on = (self.column_map[col], order)
321
self.sorting_done.emit(self.db.index)
323
def refresh(self, reset=True):
325
col = self.column_map.index(self.sorted_on[0])
328
self.db.refresh(field=self.column_map[col],
329
ascending=self.sorted_on[1]==Qt.AscendingOrder)
333
def resort(self, reset=True):
335
col = self.column_map.index(self.sorted_on[0])
338
self.sort(col, self.sorted_on[1], reset=reset)
340
def research(self, reset=True):
341
self.search(self.last_search, False, reset=reset)
343
def columnCount(self, parent):
344
if parent and parent.isValid():
346
return len(self.column_map)
348
def rowCount(self, parent):
349
if parent and parent.isValid():
351
return len(self.db.data) if self.db else 0
354
return self.rowCount(None)
356
def get_book_display_info(self, idx):
358
cdata = self.cover(idx)
360
data['cover'] = cdata
361
tags = self.db.tags(idx)
363
tags = tags.replace(',', ', ')
366
data[_('Tags')] = tags
367
formats = self.db.formats(idx)
369
formats = formats.replace(',', ', ')
372
data[_('Formats')] = formats
373
data[_('Path')] = self.db.abspath(idx)
374
comments = self.db.comments(idx)
377
data[_('Comments')] = comments
378
series = self.db.series(idx)
380
sidx = self.db.series_index(idx)
381
sidx = fmt_sidx(sidx, use_roman = self.use_roman_numbers)
382
data[_('Series')] = _('Book <font face="serif">%s</font> of %s.')%(sidx, series)
386
def set_cache(self, idx):
387
l, r = 0, self.count()-1
389
l = max(l, idx-self.buffer_size)
390
r = min(r, idx+self.buffer_size)
391
k = min(r-idx, idx-l)
393
for i in range(1, k):
394
ids.extend([idx-i, idx+i])
395
ids = ids + [i for i in range(l, r, 1) if i not in ids]
397
ids = [self.db.id(i) for i in ids]
400
self.cover_cache.set_cache(ids)
402
def current_changed(self, current, previous, emit_signal=True):
405
data = self.get_book_display_info(idx)
407
self.emit(SIGNAL('new_bookdisplay_data(PyQt_PyObject)'), data)
411
def get_book_info(self, index):
412
if isinstance(index, int):
413
index = self.index(index, 0)
414
data = self.current_changed(index, None, False)
416
data[_('Title')] = self.db.title(row)
417
au = self.db.authors(row)
420
au = ', '.join([a.strip() for a in au.split(',')])
421
data[_('Author(s)')] = au
424
def metadata_for(self, ids):
427
mi = self.db.get_metadata(id, index_is_id=True, get_cover=True)
428
if mi.series is not None:
429
mi.tag_order = { mi.series: self.db.books_in_series_of(id,
434
def get_metadata(self, rows, rows_are_ids=False, full_metadata=False):
435
metadata, _full_metadata = [], []
437
rows = [self.db.id(row.row()) for row in rows]
439
mi = self.db.get_metadata(id, index_is_id=True)
440
_full_metadata.append(mi)
441
au = authors_to_string(mi.authors if mi.authors else [_('Unknown')])
442
tags = mi.tags if mi.tags else []
443
if mi.series is not None:
444
tags.append(mi.series)
448
'author_sort' : mi.author_sort,
449
'cover' : self.db.cover(id, index_is_id=True),
451
'comments': mi.comments,
453
if mi.series is not None:
454
info['tag order'] = {
455
mi.series:self.db.books_in_series_of(id, index_is_id=True)
458
metadata.append(info)
460
return metadata, _full_metadata
464
def get_preferred_formats_from_ids(self, ids, formats, paths=False,
465
set_metadata=False, specific_format=None,
466
exclude_auto=False, mode='r+b'):
469
if specific_format is not None:
470
formats = [specific_format.lower()]
473
fmts = self.db.formats(id, index_is_id=True)
476
db_formats = set(fmts.lower().split(','))
477
available_formats = set([f.lower() for f in formats])
478
u = available_formats.intersection(db_formats)
483
if format is not None:
484
pt = PersistentTemporaryFile(suffix='.'+format)
485
with closing(self.db.format(id, format, index_is_id=True,
486
as_file=True)) as src:
487
shutil.copyfileobj(src, pt)
491
_set_metadata(pt, self.db.get_metadata(id, get_cover=True, index_is_id=True),
493
pt.close() if paths else pt.seek(0)
499
return ans, need_auto
501
def get_preferred_formats(self, rows, formats, paths=False,
502
set_metadata=False, specific_format=None,
506
if specific_format is not None:
507
formats = [specific_format.lower()]
508
for row in (row.row() for row in rows):
510
fmts = self.db.formats(row)
513
db_formats = set(fmts.lower().split(','))
514
available_formats = set([f.lower() for f in formats])
515
u = available_formats.intersection(db_formats)
520
if format is not None:
521
pt = PersistentTemporaryFile(suffix='.'+format)
522
with closing(self.db.format(row, format, as_file=True)) as src:
523
shutil.copyfileobj(src, pt)
527
_set_metadata(pt, self.db.get_metadata(row, get_cover=True),
529
pt.close() if paths else pt.seek(0)
532
need_auto.append(row)
535
return ans, need_auto
538
return self.db.id(getattr(row, 'row', lambda:row)())
540
def title(self, row_number):
541
return self.db.title(row_number)
543
def cover(self, row_number):
546
id = self.db.id(row_number)
548
img = self.cover_cache.cover(id)
551
img = self.default_image
554
data = self.db.cover(row_number)
555
except IndexError: # Happens if database has not yet been refreshed
559
return self.default_image
561
img.loadFromData(data)
563
img = self.default_image
566
def build_data_convertors(self):
568
tidx = self.db.FIELD_MAP['title']
569
aidx = self.db.FIELD_MAP['authors']
570
sidx = self.db.FIELD_MAP['size']
571
ridx = self.db.FIELD_MAP['rating']
572
pidx = self.db.FIELD_MAP['publisher']
573
tmdx = self.db.FIELD_MAP['timestamp']
574
pddx = self.db.FIELD_MAP['pubdate']
575
srdx = self.db.FIELD_MAP['series']
576
tgdx = self.db.FIELD_MAP['tags']
577
siix = self.db.FIELD_MAP['series_index']
580
au = self.db.data[r][aidx]
582
au = [a.strip().replace('|', ',') for a in au.split(',')]
583
return ' & '.join(au)
586
dt = self.db.data[r][tmdx]
588
return QDate(dt.year, dt.month, dt.day)
591
dt = self.db.data[r][pddx]
593
return QDate(dt.year, dt.month, dt.day)
596
r = self.db.data[r][ridx]
601
pub = self.db.data[r][pidx]
606
tags = self.db.data[r][tgdx]
608
return ', '.join(sorted(tags.split(',')))
611
series = self.db.data[r][srdx]
613
idx = fmt_sidx(self.db.data[r][siix])
614
return series + ' [%s]'%idx
616
size = self.db.data[r][sidx]
618
return '%.1f'%(float(size)/(1024*1024))
621
'title' : lambda r : self.db.data[r][tidx],
624
'timestamp': timestamp,
627
'publisher': publisher,
632
def data(self, index, role):
633
if role in (Qt.DisplayRole, Qt.EditRole):
634
ans = self.dc[self.column_map[index.column()]](index.row())
635
return NONE if ans is None else QVariant(ans)
636
#elif role == Qt.TextAlignmentRole and self.column_map[index.column()] in ('size', 'timestamp'):
637
# return QVariant(Qt.AlignVCenter | Qt.AlignCenter)
638
#elif role == Qt.ToolTipRole and index.isValid():
639
# if self.column_map[index.column()] in self.editable_cols:
640
# return QVariant(_("Double click to <b>edit</b> me<br><br>"))
643
def headerData(self, section, orientation, role):
644
if role != Qt.DisplayRole:
646
if orientation == Qt.Horizontal:
647
return QVariant(self.headers[self.column_map[section]])
649
return QVariant(section+1)
651
def flags(self, index):
652
flags = QAbstractTableModel.flags(self, index)
654
if self.column_map[index.column()] in self.editable_cols:
655
flags |= Qt.ItemIsEditable
658
def setData(self, index, value, role):
659
if role == Qt.EditRole:
660
row, col = index.row(), index.column()
661
column = self.column_map[col]
662
if column not in self.editable_cols:
664
val = int(value.toInt()[0]) if column == 'rating' else \
665
value.toDate() if column in ('timestamp', 'pubdate') else \
666
unicode(value.toString())
668
if column == 'rating':
669
val = 0 if val < 0 else 5 if val > 5 else val
671
self.db.set_rating(id, val)
672
elif column == 'series':
674
pat = re.compile(r'\[([.0-9]+)\]')
675
match = pat.search(val)
676
if match is not None:
677
self.db.set_series_index(id, float(match.group(1)))
678
val = pat.sub('', val).strip()
680
if tweaks['series_index_auto_increment'] == 'next':
681
ni = self.db.get_next_series_num_for(val)
683
self.db.set_series_index(id, ni)
685
self.db.set_series(id, val)
686
elif column == 'timestamp':
687
if val.isNull() or not val.isValid():
689
self.db.set_timestamp(id, qt_to_dt(val, as_utc=False))
690
elif column == 'pubdate':
691
if val.isNull() or not val.isValid():
693
self.db.set_pubdate(id, qt_to_dt(val, as_utc=False))
695
self.db.set(row, column, val)
696
self.emit(SIGNAL("dataChanged(QModelIndex, QModelIndex)"), \
698
#if column == self.sorted_on[0]:
703
class BooksView(TableView):
704
TIME_FMT = '%d %b %Y'
705
wrapper = textwrap.TextWrapper(width=20)
708
def wrap(cls, s, width=20):
709
cls.wrapper.width = width
710
return cls.wrapper.fill(s)
713
def human_readable(cls, size, precision=1):
714
""" Convert a size in bytes into megabytes """
715
return ('%.'+str(precision)+'f') % ((size/(1024.*1024.)),)
717
def __init__(self, parent, modelcls=BooksModel):
718
TableView.__init__(self, parent)
719
self.rating_delegate = LibraryDelegate(self)
720
self.timestamp_delegate = DateDelegate(self)
721
self.pubdate_delegate = PubDateDelegate(self)
722
self.tags_delegate = TagsDelegate(self)
723
self.authors_delegate = TextDelegate(self)
724
self.series_delegate = TextDelegate(self)
725
self.publisher_delegate = TextDelegate(self)
726
self.display_parent = parent
727
self._model = modelcls(self)
728
self.setModel(self._model)
729
self.setSelectionBehavior(QAbstractItemView.SelectRows)
730
self.setSortingEnabled(True)
732
self.setItemDelegateForColumn(i, TextDelegate(self))
733
self.columns_sorted()
734
QObject.connect(self.selectionModel(), SIGNAL('currentRowChanged(QModelIndex, QModelIndex)'),
735
self._model.current_changed)
736
self.connect(self._model, SIGNAL('columns_sorted()'),
737
self.columns_sorted, Qt.QueuedConnection)
738
hv = self.verticalHeader()
739
hv.setClickable(True)
740
hv.setCursor(Qt.PointingHandCursor)
741
self.selected_ids = []
742
self._model.about_to_be_sorted.connect(self.about_to_be_sorted)
743
self._model.sorting_done.connect(self.sorting_done)
745
def about_to_be_sorted(self, idc):
746
selected_rows = [r.row() for r in self.selectionModel().selectedRows()]
747
self.selected_ids = [idc(r) for r in selected_rows]
749
def sorting_done(self, indexc):
750
if self.selected_ids:
751
indices = [self.model().index(indexc(i), 0) for i in
753
sm = self.selectionModel()
755
sm.select(idx, sm.Select|sm.Rows)
756
self.selected_ids = []
758
def columns_sorted(self):
759
for i in range(self.model().columnCount(None)):
760
if self.itemDelegateForColumn(i) in (self.rating_delegate,
761
self.timestamp_delegate, self.pubdate_delegate):
762
self.setItemDelegateForColumn(i, self.itemDelegate())
764
cm = self._model.column_map
767
self.setItemDelegateForColumn(cm.index('rating'), self.rating_delegate)
768
if 'timestamp' in cm:
769
self.setItemDelegateForColumn(cm.index('timestamp'), self.timestamp_delegate)
771
self.setItemDelegateForColumn(cm.index('pubdate'), self.pubdate_delegate)
773
self.setItemDelegateForColumn(cm.index('tags'), self.tags_delegate)
775
self.setItemDelegateForColumn(cm.index('authors'), self.authors_delegate)
776
if 'publisher' in cm:
777
self.setItemDelegateForColumn(cm.index('publisher'), self.publisher_delegate)
779
self.setItemDelegateForColumn(cm.index('series'), self.series_delegate)
781
def set_context_menu(self, edit_metadata, send_to_device, convert, view,
782
save, open_folder, book_details, delete, similar_menu=None):
783
self.setContextMenuPolicy(Qt.DefaultContextMenu)
784
self.context_menu = QMenu(self)
785
if edit_metadata is not None:
786
self.context_menu.addAction(edit_metadata)
787
if send_to_device is not None:
788
self.context_menu.addAction(send_to_device)
789
if convert is not None:
790
self.context_menu.addAction(convert)
791
self.context_menu.addAction(view)
792
self.context_menu.addAction(save)
793
if open_folder is not None:
794
self.context_menu.addAction(open_folder)
795
if delete is not None:
796
self.context_menu.addAction(delete)
797
if book_details is not None:
798
self.context_menu.addAction(book_details)
799
if similar_menu is not None:
800
self.context_menu.addMenu(similar_menu)
802
def contextMenuEvent(self, event):
803
self.context_menu.popup(event.globalPos())
806
def sortByColumn(self, colname, order):
808
idx = self._model.column_map.index(colname)
811
TableView.sortByColumn(self, idx, order)
814
def paths_from_event(cls, event):
816
Accept a drop event and return a list of paths that can be read from
817
and represent files with extensions.
819
if event.mimeData().hasFormat('text/uri-list'):
820
urls = [qstring_to_unicode(u.toLocalFile()) for u in event.mimeData().urls()]
821
return [u for u in urls if os.path.splitext(u)[1] and os.access(u, os.R_OK)]
823
def dragEnterEvent(self, event):
824
if int(event.possibleActions() & Qt.CopyAction) + \
825
int(event.possibleActions() & Qt.MoveAction) == 0:
827
paths = self.paths_from_event(event)
830
event.acceptProposedAction()
832
def dragMoveEvent(self, event):
833
event.acceptProposedAction()
835
def dropEvent(self, event):
836
paths = self.paths_from_event(event)
837
event.setDropAction(Qt.CopyAction)
839
self.emit(SIGNAL('files_dropped(PyQt_PyObject)'), paths)
842
def set_database(self, db):
843
self._model.set_database(db)
844
self.tags_delegate.set_database(db)
845
self.authors_delegate.set_auto_complete_function(db.all_authors)
846
self.series_delegate.set_auto_complete_function(db.all_series)
847
self.publisher_delegate.set_auto_complete_function(db.all_publishers)
852
def set_editable(self, editable):
853
self._model.set_editable(editable)
855
def connect_to_search_box(self, sb, search_done):
856
QObject.connect(sb, SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'),
858
self._search_done = search_done
859
self.connect(self._model, SIGNAL('searched(PyQt_PyObject)'),
862
def connect_to_book_display(self, bd):
863
QObject.connect(self._model, SIGNAL('new_bookdisplay_data(PyQt_PyObject)'),
866
def search_done(self, ok):
867
self._search_done(self, ok)
870
return self._model.count()
873
class DeviceBooksView(BooksView):
875
def __init__(self, parent):
876
BooksView.__init__(self, parent, DeviceBooksModel)
877
self.columns_resized = False
878
self.resize_on_select = False
879
self.rating_delegate = None
881
self.setItemDelegateForColumn(i, TextDelegate(self))
882
self.setDragDropMode(self.NoDragDrop)
883
self.setAcceptDrops(False)
885
def set_database(self, db):
886
self._model.set_database(db)
888
def resizeColumnsToContents(self):
889
QTableView.resizeColumnsToContents(self)
890
self.columns_resized = True
892
def connect_dirtied_signal(self, slot):
893
QObject.connect(self._model, SIGNAL('booklist_dirtied()'), slot)
895
def sortByColumn(self, col, order):
896
TableView.sortByColumn(self, col, order)
898
def dropEvent(self, *args):
899
error_dialog(self, _('Not allowed'),
900
_('Dropping onto a device is not supported. First add the book to the calibre library.')).exec_()
902
class OnDeviceSearch(SearchQueryParser):
904
def __init__(self, model):
905
SearchQueryParser.__init__(self)
908
def universal_set(self):
909
return set(range(0, len(self.model.db)))
911
def get_matches(self, location, query):
912
location = location.lower().strip()
914
matchkind = CONTAINS_MATCH
916
if query.startswith('\\'):
918
elif query.startswith('='):
919
matchkind = EQUALS_MATCH
921
elif query.startswith('~'):
922
matchkind = REGEXP_MATCH
924
if matchkind != REGEXP_MATCH: ### leave case in regexps because it can be significant e.g. \S \W \D
925
query = query.lower()
927
if location not in ('title', 'author', 'tag', 'all', 'format'):
930
locations = ['title', 'author', 'tag', 'format'] if location == 'all' else [location]
932
'title' : lambda x : getattr(x, 'title').lower(),
933
'author': lambda x: getattr(x, 'authors').lower(),
934
'tag':lambda x: ','.join(getattr(x, 'tags')).lower(),
935
'format':lambda x: os.path.splitext(x.path)[1].lower()
937
for index, row in enumerate(self.model.db):
938
for locvalue in locations:
939
accessor = q[locvalue]
941
### Can't separate authors because comma is used for name sep and author sep
942
### Exact match might not get what you want. For that reason, turn author
943
### exactmatch searches into contains searches.
944
if locvalue == 'author' and matchkind == EQUALS_MATCH:
949
if locvalue == 'tag':
950
vals = accessor(row).split(',')
952
vals = [accessor(row)]
953
if _match(query, vals, m):
956
except ValueError: # Unicode errors
958
traceback.print_exc()
962
class DeviceBooksModel(BooksModel):
964
def __init__(self, parent):
965
BooksModel.__init__(self, parent)
969
self.unknown = _('Unknown')
970
self.marked_for_deletion = {}
971
self.search_engine = OnDeviceSearch(self)
974
def mark_for_deletion(self, job, rows):
975
self.marked_for_deletion[job] = self.indices(rows)
977
indices = self.row_indices(row)
978
self.emit(SIGNAL('dataChanged(QModelIndex, QModelIndex)'), indices[0], indices[-1])
980
def deletion_done(self, job, succeeded=True):
981
if not self.marked_for_deletion.has_key(job):
983
rows = self.marked_for_deletion.pop(job)
986
indices = self.row_indices(self.index(row, 0))
987
self.emit(SIGNAL('dataChanged(QModelIndex, QModelIndex)'), indices[0], indices[-1])
989
def paths_deleted(self, paths):
990
self.map = list(range(0, len(self.db)))
994
def indices_to_be_deleted(self):
996
for v in self.marked_for_deletion.values():
1000
def flags(self, index):
1001
if self.map[index.row()] in self.indices_to_be_deleted():
1002
return Qt.ItemIsUserCheckable # Can't figure out how to get the disabled flag in python
1003
flags = QAbstractTableModel.flags(self, index)
1004
if index.isValid() and self.editable:
1005
if index.column() in [0, 1] or (index.column() == 4 and self.db.supports_tags()):
1006
flags |= Qt.ItemIsEditable
1010
def search(self, text, refinement, reset=True):
1011
if not text or not text.strip():
1012
self.map = list(range(len(self.db)))
1015
matches = self.search_engine.parse(text)
1016
except ParseException:
1017
self.emit(SIGNAL('searched(PyQt_PyObject)'), False)
1021
for i in range(len(self.db)):
1024
self.resort(reset=False)
1027
self.last_search = text
1028
if self.last_search:
1029
self.emit(SIGNAL('searched(PyQt_PyObject)'), True)
1032
def resort(self, reset):
1033
self.sort(self.sorted_on[0], self.sorted_on[1], reset=reset)
1035
def sort(self, col, order, reset=True):
1036
descending = order != Qt.AscendingOrder
1038
ag = attrgetter(attr)
1046
x, y = x.strip().lower(), y.strip().lower()
1050
x = self.db[x].datetime
1051
y = self.db[y].datetime
1052
return cmp(dt_factory(x, assume_utc=True), dt_factory(y,
1055
x, y = int(self.db[x].size), int(self.db[y].size)
1058
x, y = ','.join(self.db[x].tags), ','.join(self.db[y].tags)
1060
fcmp = strcmp('title_sorter') if col == 0 else strcmp('authors') if col == 1 else \
1061
sizecmp if col == 2 else datecmp if col == 3 else tagscmp
1062
self.map.sort(cmp=fcmp, reverse=descending)
1063
if len(self.map) == len(self.db):
1064
self.sorted_map = list(self.map)
1066
self.sorted_map = list(range(len(self.db)))
1067
self.sorted_map.sort(cmp=fcmp, reverse=descending)
1068
self.sorted_on = (col, order)
1072
def columnCount(self, parent):
1073
if parent and parent.isValid():
1077
def rowCount(self, parent):
1078
if parent and parent.isValid():
1080
return len(self.map)
1082
def set_database(self, db):
1084
self.map = list(range(0, len(db)))
1086
def current_changed(self, current, previous):
1088
item = self.db[self.map[current.row()]]
1089
cdata = item.thumbnail
1092
img.loadFromData(cdata)
1094
img = self.default_image
1097
ext = os.path.splitext(item.path)[1]
1099
type = ext[1:].lower()
1100
data[_('Format')] = type
1101
data[_('Path')] = item.path
1102
dt = dt_factory(item.datetime, assume_utc=True)
1103
data[_('Timestamp')] = isoformat(dt, sep=' ', as_utc=False)
1104
data[_('Tags')] = ', '.join(item.tags)
1105
self.emit(SIGNAL('new_bookdisplay_data(PyQt_PyObject)'), data)
1107
def paths(self, rows):
1108
return [self.db[self.map[r.row()]].path for r in rows ]
1110
def indices(self, rows):
1112
Return indices into underlying database from rows
1114
return [ self.map[r.row()] for r in rows]
1117
def data(self, index, role):
1118
if role == Qt.DisplayRole or role == Qt.EditRole:
1119
row, col = index.row(), index.column()
1121
text = self.db[self.map[row]].title
1124
return QVariant(text)
1126
au = self.db[self.map[row]].authors
1129
if role == Qt.EditRole:
1131
authors = string_to_authors(au)
1132
return QVariant("\n".join(authors))
1134
size = self.db[self.map[row]].size
1135
return QVariant(BooksView.human_readable(size))
1137
dt = self.db[self.map[row]].datetime
1138
dt = dt_factory(dt, assume_utc=True, as_utc=False)
1139
return QVariant(strftime(BooksView.TIME_FMT, dt.timetuple()))
1141
tags = self.db[self.map[row]].tags
1143
return QVariant(', '.join(tags))
1144
elif role == Qt.TextAlignmentRole and index.column() in [2, 3]:
1145
return QVariant(Qt.AlignRight | Qt.AlignVCenter)
1146
elif role == Qt.ToolTipRole and index.isValid():
1147
if self.map[index.row()] in self.indices_to_be_deleted():
1148
return QVariant('Marked for deletion')
1149
col = index.column()
1150
if col in [0, 1] or (col == 4 and self.db.supports_tags()):
1151
return QVariant(_("Double click to <b>edit</b> me<br><br>"))
1154
def headerData(self, section, orientation, role):
1155
if role != Qt.DisplayRole:
1158
if orientation == Qt.Horizontal:
1159
if section == 0: text = _("Title")
1160
elif section == 1: text = _("Author(s)")
1161
elif section == 2: text = _("Size (MB)")
1162
elif section == 3: text = _("Date")
1163
elif section == 4: text = _("Tags")
1164
return QVariant(text)
1166
return QVariant(section+1)
1168
def setData(self, index, value, role):
1170
if role == Qt.EditRole:
1171
row, col = index.row(), index.column()
1174
val = qstring_to_unicode(value.toString()).strip()
1177
self.db[idx].title = val
1178
self.db[idx].title_sorter = val
1180
self.db[idx].authors = val
1182
tags = [i.strip() for i in val.split(',')]
1183
tags = [t for t in tags if t]
1184
self.db.set_tags(self.db[idx], tags)
1185
self.emit(SIGNAL("dataChanged(QModelIndex, QModelIndex)"), index, index)
1186
self.emit(SIGNAL('booklist_dirtied()'))
1187
if col == self.sorted_on[0]:
1188
self.sort(col, self.sorted_on[1])
1192
def set_editable(self, editable):
1193
self.editable = editable