%PDF- %PDF-
| Direktori : /usr/lib/calibre/calibre/gui2/library/ |
| Current File : //usr/lib/calibre/calibre/gui2/library/models.py |
#!/usr/bin/env python3
__license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
import errno
import functools
import numbers
import os
import re
import time
import traceback
from collections import defaultdict, namedtuple
from itertools import groupby
from qt.core import (
QAbstractTableModel, QApplication, QColor, QDateTime, QFont, QIcon, QImage,
QModelIndex, QPainter, QPixmap, Qt, pyqtSignal
)
from calibre import (
fit_image, force_unicode, human_readable, isbytestring, prepare_string_for_xml,
strftime
)
from calibre.constants import DEBUG, config_dir, filesystem_encoding
from calibre.db.search import CONTAINS_MATCH, EQUALS_MATCH, REGEXP_MATCH, _match
from calibre.ebooks.metadata import authors_to_string, fmt_sidx, string_to_authors
from calibre.ebooks.metadata.book.formatter import SafeFormat
from calibre.gui2 import error_dialog
from calibre.gui2.library import DEFAULT_SORT
from calibre.library.caches import force_to_bool
from calibre.library.coloring import color_row_key
from calibre.library.save_to_disk import find_plugboard
from calibre.ptempfile import PersistentTemporaryFile
from calibre.utils.config import device_prefs, prefs, tweaks
from calibre.utils.date import (
UNDEFINED_DATE, as_local_time, dt_factory, is_date_undefined, qt_to_dt
)
from calibre.utils.icu import sort_key
from calibre.utils.localization import calibre_langcode_to_name
from calibre.utils.search_query_parser import ParseException, SearchQueryParser
from polyglot.builtins import iteritems, itervalues, string_or_bytes
Counts = namedtuple('Counts', 'library_total total current')
TIME_FMT = '%d %b %Y'
ALIGNMENT_MAP = {'left': Qt.AlignmentFlag.AlignLeft, 'right': Qt.AlignmentFlag.AlignRight, 'center':
Qt.AlignmentFlag.AlignHCenter}
_default_image = None
def default_image():
global _default_image
if _default_image is None:
_default_image = QImage(I('default_cover.png'))
return _default_image
def group_numbers(numbers):
for k, g in groupby(enumerate(sorted(numbers)), lambda i_x:i_x[0] - i_x[1]):
first = None
for last in g:
if first is None:
first = last[1]
yield first, last[1]
class ColumnColor: # {{{
def __init__(self, formatter):
self.mi = None
self.formatter = formatter
def __call__(self, id_, key, fmt, db, color_cache, template_cache):
key += str(hash(fmt))
if id_ in color_cache and key in color_cache[id_]:
self.mi = None
color = color_cache[id_][key]
if color.isValid():
return color
return None
try:
if self.mi is None:
self.mi = db.new_api.get_proxy_metadata(id_)
color = QColor(self.formatter.safe_format(fmt, self.mi, '', self.mi,
column_name=key,
template_cache=template_cache))
color_cache[id_][key] = color
if color.isValid():
self.mi = None
return color
except:
pass
# }}}
class ColumnIcon: # {{{
def __init__(self, formatter, model):
self.mi = None
self.formatter = formatter
self.model = model
self.dpr = QApplication.instance().devicePixelRatio()
def __call__(self, id_, fmts, cache_index, db, icon_cache, icon_bitmap_cache,
template_cache):
if id_ in icon_cache and cache_index in icon_cache[id_]:
self.mi = None
return icon_cache[id_][cache_index]
try:
if self.mi is None:
self.mi = db.new_api.get_proxy_metadata(id_)
icons = []
for dex, (kind, fmt) in enumerate(fmts):
rule_icons = self.formatter.safe_format(fmt, self.mi, '', self.mi,
column_name=cache_index+str(dex),
template_cache=template_cache)
if not rule_icons:
continue
icon_list = [ic.strip() for ic in rule_icons.split(':') if ic.strip()]
icons.extend(icon_list)
if icon_list and not kind.endswith('_composed'):
break
if icons:
icon_string = ':'.join(icons)
if icon_string in icon_bitmap_cache:
icon_bitmap = icon_bitmap_cache[icon_string]
icon_cache[id_][cache_index] = icon_bitmap
return icon_bitmap
icon_bitmaps = []
total_width = 0
rh = max(2, self.model.row_height - 4)
dim = int(self.dpr * rh)
for icon in icons:
d = os.path.join(config_dir, 'cc_icons', icon)
if (os.path.exists(d)):
bm = QPixmap(d)
scaled, nw, nh = fit_image(bm.width(), bm.height(), bm.width(), dim)
bm = bm.scaled(int(nw), int(nh), aspectRatioMode=Qt.AspectRatioMode.IgnoreAspectRatio,
transformMode=Qt.TransformationMode.SmoothTransformation)
bm.setDevicePixelRatio(self.dpr)
icon_bitmaps.append(bm)
total_width += bm.width()
if len(icon_bitmaps) > 1:
i = len(icon_bitmaps)
result = QPixmap(total_width + ((i-1)*2), dim)
result.setDevicePixelRatio(self.dpr)
result.fill(Qt.GlobalColor.transparent)
painter = QPainter(result)
x = 0
for bm in icon_bitmaps:
painter.drawPixmap(x, 0, bm)
x += int(bm.width() / self.dpr) + 2
painter.end()
else:
result = icon_bitmaps[0]
icon_cache[id_][cache_index] = result
icon_bitmap_cache[icon_string] = result
self.mi = None
return result
except:
pass
# }}}
class BooksModel(QAbstractTableModel): # {{{
about_to_be_sorted = pyqtSignal(object, name='aboutToBeSorted')
sorting_done = pyqtSignal(object, name='sortingDone')
database_changed = pyqtSignal(object, name='databaseChanged')
new_bookdisplay_data = pyqtSignal(object)
count_changed_signal = pyqtSignal(int)
searched = pyqtSignal(object)
search_done = pyqtSignal()
def __init__(self, parent=None, buffer=40):
QAbstractTableModel.__init__(self, parent)
base_font = parent.font() if parent else QApplication.instance().font()
self.bold_font = QFont(base_font)
self.bold_font.setBold(True)
self.italic_font = QFont(base_font)
self.italic_font.setItalic(True)
self.bi_font = QFont(self.bold_font)
self.bi_font.setItalic(True)
self.styled_columns = {}
self.orig_headers = {
'title' : _("Title"),
'ondevice' : _("On Device"),
'authors' : _("Author(s)"),
'size' : _("Size (MB)"),
'timestamp' : _("Date"),
'pubdate' : _('Published'),
'rating' : _('Rating'),
'publisher' : _("Publisher"),
'tags' : _("Tags"),
'series' : ngettext("Series", 'Series', 1),
'last_modified' : _('Modified'),
'languages' : _('Languages'),
}
self.db = None
self.formatter = SafeFormat()
self._clear_caches()
self.column_color = ColumnColor(self.formatter)
self.column_icon = ColumnIcon(self.formatter, self)
self.book_on_device = None
self.editable_cols = ['title', 'authors', 'rating', 'publisher',
'tags', 'series', 'timestamp', 'pubdate',
'languages']
self.default_image = default_image()
self.sorted_on = DEFAULT_SORT
self.sort_history = [self.sorted_on]
self.last_search = '' # The last search performed on this model
self.column_map = []
self.headers = {}
self.alignment_map = {}
self.buffer_size = buffer
self.metadata_backup = None
icon_height = (parent.fontMetrics() if hasattr(parent, 'fontMetrics') else QApplication.instance().fontMetrics()).lineSpacing()
self.bool_yes_icon = QIcon(I('ok.png')).pixmap(icon_height)
self.bool_no_icon = QIcon(I('list_remove.png')).pixmap(icon_height)
self.bool_blank_icon = QIcon(I('blank.png')).pixmap(icon_height)
# Qt auto-scales marked icon correctly, so we dont need to do it (and
# remember that the cover grid view needs a larger version of the icon,
# anyway)
self.marked_icon = QIcon(I('marked.png'))
self.bool_blank_icon_as_icon = QIcon(self.bool_blank_icon)
self.row_decoration = None
self.device_connected = False
self.ids_to_highlight = []
self.ids_to_highlight_set = set()
self.current_highlighted_idx = None
self.highlight_only = False
self.row_height = 0
self.read_config()
def _clear_caches(self):
self.color_cache = defaultdict(dict)
self.icon_cache = defaultdict(dict)
self.icon_bitmap_cache = {}
self.cover_grid_emblem_cache = defaultdict(dict)
self.cover_grid_bitmap_cache = {}
self.color_row_fmt_cache = None
self.color_template_cache = {}
self.icon_template_cache = {}
self.cover_grid_template_cache = {}
def set_row_height(self, height):
self.row_height = height
def set_row_decoration(self, current_marked):
self.row_decoration = self.bool_blank_icon_as_icon if current_marked else None
def change_alignment(self, colname, alignment):
if colname in self.column_map and alignment in ('left', 'right', 'center'):
old = self.alignment_map.get(colname, 'left')
if old == alignment:
return
self.alignment_map.pop(colname, None)
if alignment != 'left':
self.alignment_map[colname] = alignment
col = self.column_map.index(colname)
for row in range(self.rowCount(QModelIndex())):
self.dataChanged.emit(self.index(row, col), self.index(row,
col))
def change_column_font(self, colname, font_type):
if colname in self.column_map and font_type in ('normal', 'bold', 'italic', 'bi'):
db = self.db.new_api
old = db.pref('styled_columns', {})
old.pop(colname, None)
self.styled_columns.pop(colname, None)
if font_type != 'normal':
self.styled_columns[colname] = getattr(self, f'{font_type}_font')
old[colname] = font_type
self.db.new_api.set_pref('styled_columns', old)
col = self.column_map.index(colname)
for row in range(self.rowCount(QModelIndex())):
self.dataChanged.emit(self.index(row, col), self.index(row,
col))
def is_custom_column(self, cc_label):
try:
return cc_label in self.custom_columns
except AttributeError:
return False
def read_config(self):
pass
def set_device_connected(self, is_connected):
self.device_connected = is_connected
def refresh_ondevice(self):
self.db.refresh_ondevice()
self.resort()
self.research()
def set_book_on_device_func(self, func):
self.book_on_device = func
def set_database(self, db):
self.ids_to_highlight = []
if db:
style_map = {'bold': self.bold_font, 'bi': self.bi_font, 'italic': self.italic_font}
self.styled_columns = {k: style_map.get(v, None) for k, v in iteritems(db.new_api.pref('styled_columns', {}))}
self.alignment_map = {}
self.ids_to_highlight_set = set()
self.current_highlighted_idx = None
self.db = db
self.custom_columns = self.db.field_metadata.custom_field_metadata()
self.column_map = list(self.orig_headers.keys()) + \
list(self.custom_columns)
def col_idx(name):
if name == 'ondevice':
return -1
if name not in self.db.field_metadata:
return 100000
return self.db.field_metadata[name]['rec_index']
self.column_map.sort(key=lambda x: col_idx(x))
for col in self.column_map:
if col in self.orig_headers:
self.headers[col] = self.orig_headers[col]
elif col in self.custom_columns:
self.headers[col] = self.custom_columns[col]['name']
self.build_data_convertors()
self.beginResetModel(), self.endResetModel()
self.database_changed.emit(db)
self.stop_metadata_backup()
self.start_metadata_backup()
def start_metadata_backup(self):
from calibre.db.backup import MetadataBackup
self.metadata_backup = MetadataBackup(self.db)
self.metadata_backup.start()
def stop_metadata_backup(self):
if getattr(self, 'metadata_backup', None) is not None:
self.metadata_backup.stop()
# Would like to to a join here, but the thread might be waiting to
# do something on the GUI thread. Deadlock.
def refresh_ids(self, ids, current_row=-1):
self._clear_caches()
rows = self.db.refresh_ids(ids)
if rows:
self.refresh_rows(rows, current_row=current_row)
def refresh_rows(self, rows, current_row=-1):
self._clear_caches()
cc = self.columnCount(QModelIndex()) - 1
for first_row, last_row in group_numbers(rows):
self.dataChanged.emit(self.index(first_row, 0), self.index(last_row, cc))
if current_row >= 0 and first_row <= current_row <= last_row:
self.new_bookdisplay_data.emit(self.get_book_display_info(current_row))
def close(self):
if self.db is not None:
self.db.close()
self.db = None
self.beginResetModel(), self.endResetModel()
def add_books(self, paths, formats, metadata, add_duplicates=False,
return_ids=False):
ret = self.db.add_books(paths, formats, metadata,
add_duplicates=add_duplicates, return_ids=return_ids)
self.count_changed()
return ret
def add_news(self, path, arg):
ret = self.db.add_news(path, arg)
self.count_changed()
return ret
def add_catalog(self, path, title):
ret = self.db.add_catalog(path, title)
self.count_changed()
return ret
def count_changed(self, *args):
self._clear_caches()
self.count_changed_signal.emit(self.db.count())
def counts(self):
library_total = total = self.db.count()
if self.db.data.search_restriction_applied():
total = self.db.data.get_search_restriction_book_count()
return Counts(library_total, total, self.count())
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))]
@property
def by_author(self):
return self.sorted_on[0] == 'authors'
def books_deleted(self):
self.count_changed()
self.beginResetModel(), self.endResetModel()
def delete_books(self, indices, permanent=False):
ids = list(map(self.id, indices))
self.delete_books_by_id(ids, permanent=permanent)
return ids
def delete_books_by_id(self, ids, permanent=False):
self.db.new_api.remove_books(ids, permanent=permanent)
self.ids_deleted(ids)
def ids_deleted(self, ids):
self.db.data.books_deleted(tuple(ids))
self.db.notify('delete', list(ids))
self.books_deleted()
def books_added(self, num):
if num > 0:
self.beginInsertRows(QModelIndex(), 0, num-1)
self.endInsertRows()
self.count_changed()
def set_highlight_only(self, toWhat):
self.highlight_only = toWhat
def get_current_highlighted_id(self):
if len(self.ids_to_highlight) == 0 or self.current_highlighted_idx is None:
return None
try:
return self.ids_to_highlight[self.current_highlighted_idx]
except:
return None
def get_next_highlighted_id(self, current_row, forward):
if len(self.ids_to_highlight) == 0 or self.current_highlighted_idx is None:
return None
if current_row is None:
row_ = self.current_highlighted_idx
else:
row_ = current_row
while True:
row_ += 1 if forward else -1
if row_ < 0:
row_ = self.count() - 1
elif row_ >= self.count():
row_ = 0
if self.id(row_) in self.ids_to_highlight_set:
break
try:
self.current_highlighted_idx = self.ids_to_highlight.index(self.id(row_))
except:
# This shouldn't happen ...
return None
return self.get_current_highlighted_id()
def highlight_ids(self, ids_to_highlight):
self.ids_to_highlight = ids_to_highlight
self.ids_to_highlight_set = set(self.ids_to_highlight)
if self.ids_to_highlight:
self.current_highlighted_idx = 0
else:
self.current_highlighted_idx = None
self.beginResetModel(), self.endResetModel()
def search(self, text, reset=True):
try:
if self.highlight_only:
self.db.search('')
if not text:
self.ids_to_highlight = []
self.ids_to_highlight_set = set()
self.current_highlighted_idx = None
else:
self.ids_to_highlight = self.db.search(text, return_matches=True)
self.ids_to_highlight_set = set(self.ids_to_highlight)
if self.ids_to_highlight:
self.current_highlighted_idx = 0
else:
self.current_highlighted_idx = None
else:
self.ids_to_highlight = []
self.ids_to_highlight_set = set()
self.current_highlighted_idx = None
self.db.search(text)
except ParseException as e:
self.searched.emit(e.msg)
return
self.last_search = text
if reset:
self.beginResetModel(), self.endResetModel()
if self.last_search:
# Do not issue search done for the null search. It is used to clear
# the search and count records for restrictions
self.searched.emit(True)
self.search_done.emit()
def sort(self, col, order=Qt.SortOrder.AscendingOrder, reset=True):
if not self.db:
return
if not isinstance(order, bool):
order = order == Qt.SortOrder.AscendingOrder
label = self.column_map[col]
self._sort(label, order, reset)
def sort_by_named_field(self, field, order, reset=True):
if field in list(self.db.field_metadata.keys()):
self._sort(field, order, reset)
def _sort(self, label, order, reset):
self.about_to_be_sorted.emit(self.db.id)
self.db.data.incremental_sort([(label, order)])
if reset:
self.beginResetModel(), self.endResetModel()
self.sorted_on = (label, order)
self.sort_history.insert(0, self.sorted_on)
self.sorting_done.emit(self.db.index)
def refresh(self, reset=True):
self.db.refresh(field=None)
self.resort(reset=reset)
def beginResetModel(self):
self._clear_caches()
QAbstractTableModel.beginResetModel(self)
def reset(self):
self.beginResetModel(), self.endResetModel()
def resort(self, reset=True):
if not self.db:
return
self.db.multisort(self.sort_history[:tweaks['maximum_resort_levels']])
if reset:
self.beginResetModel(), self.endResetModel()
def research(self, reset=True):
self.search(self.last_search, reset=reset)
def columnCount(self, parent):
if parent and parent.isValid():
return 0
return len(self.column_map)
def rowCount(self, parent):
if parent and parent.isValid():
return 0
return len(self.db.data) if self.db else 0
def count(self):
return self.rowCount(None)
def get_book_display_info(self, idx):
mi = self.db.get_metadata(idx)
mi.size = mi._proxy_metadata.book_size
mi.cover_data = ('jpg', self.cover(idx))
mi.id = self.db.id(idx)
mi.field_metadata = self.db.field_metadata
mi.path = self.db.abspath(idx, create_dirs=False)
mi.format_files = self.db.new_api.format_files(self.db.data.index_to_id(idx))
mi.row_number = idx
try:
mi.marked = self.db.data.get_marked(idx, index_is_id=False)
except:
mi.marked = None
return mi
def current_changed(self, current, previous, emit_signal=True):
if current.isValid():
idx = current.row()
data = self.get_book_display_info(idx)
if emit_signal:
self.new_bookdisplay_data.emit(data)
else:
return data
def get_book_info(self, index):
if isinstance(index, numbers.Integral):
index = self.index(index, 0)
# If index is not valid returns None
data = self.current_changed(index, None, False)
return data
def metadata_for(self, ids, get_cover=True):
'''
WARNING: if get_cover=True temp files are created for mi.cover.
Remember to delete them once you are done with them.
'''
ans = []
for id in ids:
mi = self.db.get_metadata(id, index_is_id=True, get_cover=get_cover)
ans.append(mi)
return ans
def get_metadata(self, rows, rows_are_ids=False, full_metadata=False):
metadata, _full_metadata = [], []
if not rows_are_ids:
rows = [self.db.id(row.row()) for row in rows]
for id in rows:
mi = self.db.get_metadata(id, index_is_id=True)
_full_metadata.append(mi)
au = authors_to_string(mi.authors if mi.authors else [_('Unknown')])
tags = mi.tags if mi.tags else []
if mi.series is not None:
tags.append(mi.series)
info = {
'title' : mi.title,
'authors' : au,
'author_sort' : mi.author_sort,
'cover' : self.db.cover(id, index_is_id=True),
'tags' : tags,
'comments': mi.comments,
}
if mi.series is not None:
info['tag order'] = {
mi.series:self.db.books_in_series_of(id, index_is_id=True)
}
metadata.append(info)
if full_metadata:
return metadata, _full_metadata
else:
return metadata
def get_preferred_formats_from_ids(self, ids, formats,
set_metadata=False, specific_format=None,
exclude_auto=False, mode='r+b',
use_plugboard=None, plugboard_formats=None):
from calibre.ebooks.metadata.meta import set_metadata as _set_metadata
ans = []
need_auto = []
if specific_format is not None:
formats = [specific_format.lower()]
for id in ids:
format = None
fmts = self.db.formats(id, index_is_id=True)
if not fmts:
fmts = ''
db_formats = set(fmts.lower().split(','))
available_formats = {f.lower() for f in formats}
u = available_formats.intersection(db_formats)
for f in formats:
if f.lower() in u:
format = f
break
if format is not None:
pt = PersistentTemporaryFile(suffix='caltmpfmt.'+format)
self.db.copy_format_to(id, format, pt, index_is_id=True)
pt.seek(0)
if set_metadata:
try:
mi = self.db.get_metadata(id, get_cover=True,
index_is_id=True,
cover_as_data=True)
newmi = None
if use_plugboard and format.lower() in plugboard_formats:
plugboards = self.db.new_api.pref('plugboards', {})
cpb = find_plugboard(use_plugboard, format.lower(),
plugboards)
if cpb:
newmi = mi.deepcopy_metadata()
newmi.template_to_attribute(mi, cpb)
if newmi is not None:
_set_metadata(pt, newmi, format)
else:
_set_metadata(pt, mi, format)
except:
traceback.print_exc()
pt.close()
def to_uni(x):
if isbytestring(x):
x = x.decode(filesystem_encoding)
return x
ans.append(to_uni(os.path.abspath(pt.name)))
else:
need_auto.append(id)
if not exclude_auto:
ans.append(None)
return ans, need_auto
def get_preferred_formats(self, rows, formats, paths=False,
set_metadata=False, specific_format=None,
exclude_auto=False):
from calibre.ebooks.metadata.meta import set_metadata as _set_metadata
ans = []
need_auto = []
if specific_format is not None:
formats = [specific_format.lower()]
for row in (row.row() for row in rows):
format = None
fmts = self.db.formats(row)
if not fmts:
fmts = ''
db_formats = set(fmts.lower().split(','))
available_formats = {f.lower() for f in formats}
u = available_formats.intersection(db_formats)
for f in formats:
if f.lower() in u:
format = f
break
if format is not None:
pt = PersistentTemporaryFile(suffix='.'+format)
self.db.copy_format_to(id, format, pt, index_is_id=True)
pt.seek(0)
if set_metadata:
_set_metadata(pt, self.db.get_metadata(row, get_cover=True,
cover_as_data=True), format)
pt.close() if paths else pt.seek(0)
ans.append(pt)
else:
need_auto.append(row)
if not exclude_auto:
ans.append(None)
return ans, need_auto
def id(self, row):
return self.db.id(getattr(row, 'row', lambda:row)())
def authors(self, row_number):
return self.db.authors(row_number)
def title(self, row_number):
return self.db.title(row_number)
def rating(self, row_number):
ans = self.db.rating(row_number)
ans = ans/2 if ans else 0
return int(ans)
def cover(self, row_number):
data = None
try:
data = self.db.cover(row_number)
except IndexError: # Happens if database has not yet been refreshed
pass
except MemoryError:
raise ValueError(_('The cover for the book %s is too large, cannot load it.'
' Resize or delete it.') % self.db.title(row_number))
if not data:
return self.default_image
img = QImage()
img.loadFromData(data)
if img.isNull():
img = self.default_image
return img
def build_data_convertors(self):
rating_fields = {}
def renderer(field, decorator=False):
idfunc = self.db.id
fffunc = self.db.new_api.fast_field_for
field_obj = self.db.new_api.fields[field]
m = field_obj.metadata.copy()
if 'display' not in m:
m['display'] = {}
dt = m['datatype']
if decorator == 'bool':
bt = self.db.new_api.pref('bools_are_tristate')
bn = self.bool_no_icon
by = self.bool_yes_icon
if dt != 'bool':
def func(idx):
val = fffunc(field_obj, idfunc(idx))
if val is None:
return None
val = force_to_bool(val)
if val is None:
return None
return by if val else bn
else:
def func(idx):
val = force_to_bool(fffunc(field_obj, idfunc(idx)))
if val is None:
return None if bt else bn
return by if val else bn
elif field == 'size':
sz_mult = 1/(1024**2)
def func(idx):
val = fffunc(field_obj, idfunc(idx), default_value=0) or 0
if val == 0:
return None
ans = '%.1f' % (val * sz_mult)
return ('<0.1' if ans == '0.0' else ans)
elif field == 'languages':
def func(idx):
return (', '.join(calibre_langcode_to_name(x) for x in fffunc(field_obj, idfunc(idx))))
elif field == 'ondevice' and decorator:
by = self.bool_yes_icon
bb = self.bool_blank_icon
def func(idx):
return by if fffunc(field_obj, idfunc(idx)) else bb
elif dt in {'text', 'comments', 'composite', 'enumeration'}:
if m['is_multiple']:
jv = m['is_multiple']['list_to_ui']
do_sort = '&' not in jv
if field_obj.is_composite:
if do_sort:
sv = m['is_multiple']['cache_to_list']
def func(idx):
val = fffunc(field_obj, idfunc(idx), default_value='') or ''
return (jv.join(sorted((x.strip() for x in val.split(sv)), key=sort_key)))
else:
def func(idx):
return (fffunc(field_obj, idfunc(idx), default_value=''))
else:
if do_sort:
def func(idx):
return (jv.join(sorted(fffunc(field_obj, idfunc(idx), default_value=()), key=sort_key)))
else:
def func(idx):
return (jv.join(fffunc(field_obj, idfunc(idx), default_value=())))
else:
if dt in {'text', 'composite', 'enumeration'} and m['display'].get('use_decorations', False):
def func(idx):
text = fffunc(field_obj, idfunc(idx))
return (text) if force_to_bool(text) is None else None
else:
def func(idx):
return (fffunc(field_obj, idfunc(idx), default_value=''))
elif dt == 'datetime':
def func(idx):
val = fffunc(field_obj, idfunc(idx), default_value=UNDEFINED_DATE)
return None if is_date_undefined(val) else QDateTime(as_local_time(val))
elif dt == 'rating':
rating_fields[field] = m['display'].get('allow_half_stars', False)
def func(idx):
return int(fffunc(field_obj, idfunc(idx), default_value=0))
elif dt == 'series':
sidx_field = self.db.new_api.fields[field + '_index']
def func(idx):
book_id = idfunc(idx)
series = fffunc(field_obj, book_id, default_value=False)
if series:
return (f'{series} [{fmt_sidx(fffunc(sidx_field, book_id, default_value=1.0))}]')
return None
elif dt in {'int', 'float'}:
fmt = m['display'].get('number_format', None)
def func(idx):
val = fffunc(field_obj, idfunc(idx))
if val is None:
return None
if fmt:
try:
return (fmt.format(val))
except (TypeError, ValueError, AttributeError, IndexError, KeyError):
pass
return (val)
else:
def func(idx):
return None
return func
self.dc = {f:renderer(f) for f in 'title authors size timestamp pubdate last_modified rating publisher tags series ondevice languages'.split()}
self.dc_decorator = {f:renderer(f, True) for f in ('ondevice',)}
for col in self.custom_columns:
self.dc[col] = renderer(col)
m = self.custom_columns[col]
dt = m['datatype']
mult = m['is_multiple']
if dt in {'text', 'composite', 'enumeration'} and not mult and m['display'].get('use_decorations', False):
self.dc_decorator[col] = renderer(col, 'bool')
elif dt == 'bool':
self.dc_decorator[col] = renderer(col, 'bool')
tc = self.dc.copy()
def stars_tooltip(func, allow_half=True):
def f(idx):
ans = val = int(func(idx))
ans = str(val // 2)
if allow_half and val % 2:
ans += '.5'
return _('%s stars') % ans
return f
for f, allow_half in iteritems(rating_fields):
tc[f] = stars_tooltip(self.dc[f], allow_half)
# build a index column to data converter map, to remove the string lookup in the data loop
self.column_to_dc_map = [self.dc[col] for col in self.column_map]
self.column_to_tc_map = [tc[col] for col in self.column_map]
self.column_to_dc_decorator_map = [self.dc_decorator.get(col, None) for col in self.column_map]
def data(self, index, role):
col = index.column()
# in obscure cases where custom columns are both edited and added, for a time
# the column map does not accurately represent the screen. In these cases,
# we will get asked to display columns we don't know about. Must test for this.
if col >= len(self.column_to_dc_map) or col < 0:
return None
if role == Qt.ItemDataRole.DisplayRole:
rules = self.db.new_api.pref('column_icon_rules')
if rules:
key = self.column_map[col]
id_ = None
fmts = []
for kind, k, fmt in rules:
if k == key and kind in {'icon_only', 'icon_only_composed'}:
if id_ is None:
id_ = self.id(index)
self.column_icon.mi = None
fmts.append((kind, fmt))
if fmts:
cache_index = key + ':DisplayRole'
ccicon = self.column_icon(id_, fmts, cache_index, self.db,
self.icon_cache, self.icon_bitmap_cache,
self.icon_template_cache)
if ccicon is not None:
return None
self.icon_cache[id_][cache_index] = None
return self.column_to_dc_map[col](index.row())
elif role == Qt.ItemDataRole.ToolTipRole:
return self.column_to_tc_map[col](index.row())
elif role == Qt.ItemDataRole.EditRole:
return self.column_to_dc_map[col](index.row())
elif role == Qt.ItemDataRole.BackgroundRole:
if self.id(index) in self.ids_to_highlight_set:
return QColor('#027524') if QApplication.instance().is_dark_theme else QColor('#b4ecb4')
elif role == Qt.ItemDataRole.ForegroundRole:
key = self.column_map[col]
id_ = self.id(index)
self.column_color.mi = None
for k, fmt in self.db.new_api.pref('column_color_rules', ()):
if k == key:
ccol = self.column_color(id_, key, fmt, self.db,
self.color_cache, self.color_template_cache)
if ccol is not None:
return ccol
if self.is_custom_column(key) and \
self.custom_columns[key]['datatype'] == 'enumeration':
cc = self.custom_columns[self.column_map[col]]['display']
colors = cc.get('enum_colors', [])
values = cc.get('enum_values', [])
txt = str(index.data(Qt.ItemDataRole.DisplayRole) or '')
if len(colors) > 0 and txt in values:
try:
color = QColor(colors[values.index(txt)])
if color.isValid():
self.column_color.mi = None
return (color)
except:
pass
if self.color_row_fmt_cache is None:
self.color_row_fmt_cache = tuple(fmt for key, fmt in
self.db.new_api.pref('column_color_rules', ()) if key == color_row_key)
for fmt in self.color_row_fmt_cache:
ccol = self.column_color(id_, color_row_key, fmt, self.db,
self.color_cache, self.color_template_cache)
if ccol is not None:
return ccol
self.column_color.mi = None
return None
elif role == Qt.ItemDataRole.DecorationRole:
default_icon = None
if self.column_to_dc_decorator_map[col] is not None:
default_icon = self.column_to_dc_decorator_map[index.column()](index.row())
rules = self.db.new_api.pref('column_icon_rules')
if rules:
key = self.column_map[col]
id_ = None
need_icon_with_text = False
fmts = []
for kind, k, fmt in rules:
if k == key and kind.startswith('icon'):
if id_ is None:
id_ = self.id(index)
self.column_icon.mi = None
fmts.append((kind, fmt))
if kind in ('icon', 'icon_composed'):
need_icon_with_text = True
if fmts:
cache_index = key + ':DecorationRole'
ccicon = self.column_icon(id_, fmts, cache_index, self.db,
self.icon_cache, self.icon_bitmap_cache,
self.icon_template_cache)
if ccicon is not None:
return ccicon
if need_icon_with_text and default_icon is None:
self.icon_cache[id_][cache_index] = self.bool_blank_icon
return self.bool_blank_icon
self.icon_cache[id_][cache_index] = None
return default_icon
elif role == Qt.ItemDataRole.TextAlignmentRole:
cname = self.column_map[index.column()]
ans = Qt.AlignmentFlag.AlignVCenter | ALIGNMENT_MAP[self.alignment_map.get(cname,
'left')]
return (ans)
elif role == Qt.ItemDataRole.FontRole and self.styled_columns:
cname = self.column_map[index.column()]
return self.styled_columns.get(cname)
# elif role == Qt.ItemDataRole.ToolTipRole and index.isValid():
# if self.column_map[index.column()] in self.editable_cols:
# return (_("Double click to <b>edit</b> me<br><br>"))
return None
def headerData(self, section, orientation, role):
if orientation == Qt.Orientation.Horizontal:
if section >= len(self.column_map): # same problem as in data, the column_map can be wrong
return None
if role == Qt.ItemDataRole.ToolTipRole:
ht = self.column_map[section]
title = self.headers[ht]
fm = self.db.field_metadata[self.column_map[section]]
if ht == 'timestamp': # change help text because users know this field as 'date'
ht = 'date'
if fm['is_category']:
is_cat = '<br><br>' + prepare_string_for_xml(_('Click in this column and press Q to Quickview books with the same "%s"') % ht)
else:
is_cat = ''
cust_desc = ''
if fm['is_custom']:
cust_desc = fm['display'].get('description', '')
if cust_desc:
cust_desc = ('<br><b>{}</b>'.format(_('Description:')) +
'<span style="white-space:pre-wrap"> ' +
prepare_string_for_xml(cust_desc) + '</span>')
return '<b>{}</b>: {}'.format(
prepare_string_for_xml(title),
_('The lookup/search name is <i>{0}</i>').format(ht) + cust_desc + is_cat
)
if role == Qt.ItemDataRole.DisplayRole:
return (self.headers[self.column_map[section]])
return None
if DEBUG and role == Qt.ItemDataRole.ToolTipRole and orientation == Qt.Orientation.Vertical:
col = self.db.field_metadata['uuid']['rec_index']
return (_('This book\'s UUID is "{0}"').format(self.db.data[section][col]))
if role == Qt.ItemDataRole.DisplayRole: # orientation is vertical
return (section+1)
if role == Qt.ItemDataRole.DecorationRole:
try:
return self.marked_icon if self.db.data.get_marked(self.db.data.index_to_id(section)) else self.row_decoration
except (ValueError, IndexError):
pass
return None
def flags(self, index):
flags = QAbstractTableModel.flags(self, index)
if index.isValid():
colhead = self.column_map[index.column()]
if colhead in self.editable_cols:
flags |= Qt.ItemFlag.ItemIsEditable
elif self.is_custom_column(colhead):
if self.custom_columns[colhead]['is_editable']:
flags |= Qt.ItemFlag.ItemIsEditable
return flags
def set_custom_column_data(self, row, colhead, value):
cc = self.custom_columns[colhead]
typ = cc['datatype']
label=self.db.field_metadata.key_to_label(colhead)
s_index = None
if typ in ('text', 'comments'):
val = str(value or '').strip()
val = val if val else None
elif typ == 'enumeration':
val = str(value or '').strip()
if not val:
val = None
elif typ == 'bool':
val = value if value is None else bool(value)
elif typ == 'rating':
val = max(0, min(int(value or 0), 10))
elif typ in ('int', 'float'):
if value == 0:
val = '0'
else:
val = str(value or '').strip()
if not val:
val = None
elif typ == 'datetime':
val = value
if val is None:
val = None
else:
if not val.isValid():
return False
val = qt_to_dt(val, as_utc=False)
elif typ == 'series':
val = str(value or '').strip()
if val:
pat = re.compile(r'\[([.0-9]+)\]')
match = pat.search(val)
if match is not None:
s_index = float(match.group(1))
val = pat.sub('', val).strip()
elif val:
# it is OK to leave s_index == None when using 'no_change'
if tweaks['series_index_auto_increment'] != 'const' and \
tweaks['series_index_auto_increment'] != 'no_change':
s_index = self.db.get_next_cc_series_num_for(val,
label=label, num=None)
elif typ == 'composite':
tmpl = str(value or '').strip()
disp = cc['display']
disp['composite_template'] = tmpl
self.db.set_custom_column_metadata(cc['colnum'], display=disp,
update_last_modified=True)
self.refresh(reset=False)
self.research(reset=True)
return True
id = self.db.id(row)
books_to_refresh = {id}
books_to_refresh |= self.db.set_custom(id, val, extra=s_index,
label=label, num=None, append=False, notify=True,
allow_case_change=True)
self.refresh_ids(list(books_to_refresh), current_row=row)
return True
def setData(self, index, value, role):
from calibre.gui2.ui import get_gui
if get_gui().shutting_down:
return False
if role == Qt.ItemDataRole.EditRole:
from calibre.gui2.ui import get_gui
try:
return self._set_data(index, value)
except OSError as err:
import traceback
if getattr(err, 'errno', None) == errno.EACCES: # Permission denied
fname = getattr(err, 'filename', None)
p = 'Locked file: %s\n\n'%force_unicode(fname if fname else '')
error_dialog(get_gui(), _('Permission denied'),
_('Could not change the on disk location of this'
' book. Is it open in another program?'),
det_msg=p+force_unicode(traceback.format_exc()), show=True)
return False
error_dialog(get_gui(), _('Failed to set data'),
_('Could not set data, click "Show details" to see why.'),
det_msg=traceback.format_exc(), show=True)
except:
import traceback
traceback.print_exc()
error_dialog(get_gui(), _('Failed to set data'),
_('Could not set data, click "Show details" to see why.'),
det_msg=traceback.format_exc(), show=True)
return False
def _set_data(self, index, value):
row, col = index.row(), index.column()
column = self.column_map[col]
if self.is_custom_column(column):
if not self.set_custom_column_data(row, column, value):
return False
else:
if column not in self.editable_cols:
return False
val = (int(value) if column == 'rating' else
value if column in ('timestamp', 'pubdate')
else re.sub(r'\s', ' ', str(value or '').strip()))
id = self.db.id(row)
books_to_refresh = {id}
if column == 'rating':
val = max(0, min(int(val or 0), 10))
self.db.set_rating(id, val)
elif column == 'series':
val = val.strip()
if not val:
books_to_refresh |= self.db.set_series(id, val,
allow_case_change=True)
self.db.set_series_index(id, 1.0)
else:
pat = re.compile(r'\[([.0-9]+)\]')
match = pat.search(val)
if match is not None:
self.db.set_series_index(id, float(match.group(1)))
val = pat.sub('', val).strip()
elif val:
if tweaks['series_index_auto_increment'] != 'const' and \
tweaks['series_index_auto_increment'] != 'no_change':
ni = self.db.get_next_series_num_for(val)
if ni != 1:
self.db.set_series_index(id, ni)
if val:
books_to_refresh |= self.db.set_series(id, val,
allow_case_change=True)
elif column == 'timestamp':
if val is None or not val.isValid():
return False
self.db.set_timestamp(id, qt_to_dt(val, as_utc=False))
elif column == 'pubdate':
if val is None or not val.isValid():
return False
self.db.set_pubdate(id, qt_to_dt(val, as_utc=False))
elif column == 'languages':
val = val.split(',')
self.db.set_languages(id, val)
else:
if column == 'authors' and val:
val = authors_to_string(string_to_authors(val))
books_to_refresh |= self.db.set(row, column, val,
allow_case_change=True)
self.refresh_ids(list(books_to_refresh), row)
self.dataChanged.emit(index, index)
return True
# }}}
class OnDeviceSearch(SearchQueryParser): # {{{
USABLE_LOCATIONS = [
'all',
'author',
'authors',
'collections',
'format',
'formats',
'title',
'inlibrary',
'tags',
'search'
]
def __init__(self, model):
SearchQueryParser.__init__(self, locations=self.USABLE_LOCATIONS)
self.model = model
def universal_set(self):
return set(range(0, len(self.model.db)))
def get_matches(self, location, query):
location = location.lower().strip()
if location == 'authors':
location = 'author'
matchkind = CONTAINS_MATCH
if len(query) > 1:
if query.startswith('\\'):
query = query[1:]
elif query.startswith('='):
matchkind = EQUALS_MATCH
query = query[1:]
elif query.startswith('~'):
matchkind = REGEXP_MATCH
query = query[1:]
if matchkind != REGEXP_MATCH: # leave case in regexps because it can be significant e.g. \S \W \D
query = query.lower()
if location not in self.USABLE_LOCATIONS:
return set()
matches = set()
all_locs = set(self.USABLE_LOCATIONS) - {'all', 'tags', 'search'}
locations = all_locs if location == 'all' else [location]
q = {
'title' : lambda x : getattr(x, 'title').lower(),
'author': lambda x: ' & '.join(getattr(x, 'authors')).lower(),
'collections':lambda x: ','.join(getattr(x, 'device_collections')).lower(),
'format':lambda x: os.path.splitext(x.path)[1].lower(),
'inlibrary':lambda x : getattr(x, 'in_library'),
'tags':lambda x : getattr(x, 'tags', [])
}
for x in ('author', 'format'):
q[x+'s'] = q[x]
upf = prefs['use_primary_find_in_search']
for index, row in enumerate(self.model.db):
for locvalue in locations:
accessor = q[locvalue]
if query == 'true':
if accessor(row):
matches.add(index)
continue
if query == 'false':
if not accessor(row):
matches.add(index)
continue
if locvalue == 'inlibrary':
continue # this is bool, so can't match below
try:
# Can't separate authors because comma is used for name sep and author sep
# Exact match might not get what you want. For that reason, turn author
# exactmatch searches into contains searches.
if locvalue == 'author' and matchkind == EQUALS_MATCH:
m = CONTAINS_MATCH
else:
m = matchkind
vals = accessor(row)
if vals is None:
vals = ''
if isinstance(vals, string_or_bytes):
vals = vals.split(',') if locvalue == 'collections' else [vals]
if _match(query, vals, m, use_primary_find_in_search=upf):
matches.add(index)
break
except ValueError: # Unicode errors
traceback.print_exc()
return matches
# }}}
class DeviceDBSortKeyGen: # {{{
def __init__(self, attr, keyfunc, db):
self.attr = attr
self.db = db
self.keyfunc = keyfunc
def __call__(self, x):
try:
ans = self.keyfunc(getattr(self.db[x], self.attr))
except Exception:
ans = ''
return ans
# }}}
class DeviceBooksModel(BooksModel): # {{{
booklist_dirtied = pyqtSignal()
upload_collections = pyqtSignal(object)
resize_rows = pyqtSignal()
def __init__(self, parent):
BooksModel.__init__(self, parent)
self.db = []
self.map = []
self.sorted_map = []
self.sorted_on = DEFAULT_SORT
self.sort_history = [self.sorted_on]
self.unknown = _('Unknown')
self.column_map = ['inlibrary', 'title', 'authors', 'timestamp', 'size',
'collections']
self.headers = {
'inlibrary' : _('In Library'),
'title' : _('Title'),
'authors' : _('Author(s)'),
'timestamp' : _('Date'),
'size' : _('Size'),
'collections' : _('Collections')
}
self.marked_for_deletion = {}
self.search_engine = OnDeviceSearch(self)
self.editable = ['title', 'authors', 'collections']
self.book_in_library = None
self.sync_icon = QIcon(I('sync.png'))
def counts(self):
return Counts(len(self.db), len(self.db), len(self.map))
def count_changed(self, *args):
self.count_changed_signal.emit(len(self.db))
def mark_for_deletion(self, job, rows, rows_are_ids=False):
db_indices = rows if rows_are_ids else self.indices(rows)
db_items = [self.db[i] for i in db_indices if -1 < i < len(self.db)]
self.marked_for_deletion[job] = db_items
if rows_are_ids:
self.beginResetModel(), self.endResetModel()
else:
for row in rows:
indices = self.row_indices(row)
self.dataChanged.emit(indices[0], indices[-1])
def find_item_in_db(self, item):
idx = None
try:
idx = self.db.index(item)
except:
path = getattr(item, 'path', None)
if path:
for i, x in enumerate(self.db):
if getattr(x, 'path', None) == path:
idx = i
break
return idx
def deletion_done(self, job, succeeded=True):
db_items = self.marked_for_deletion.pop(job, [])
rows = []
for item in db_items:
idx = self.find_item_in_db(item)
if idx is not None:
try:
rows.append(self.map.index(idx))
except ValueError:
pass
for row in rows:
if not succeeded:
indices = self.row_indices(self.index(row, 0))
self.dataChanged.emit(indices[0], indices[-1])
self.count_changed()
def paths_deleted(self, paths):
self.map = list(range(0, len(self.db)))
self.resort(False)
self.research(True)
self.count_changed()
def is_row_marked_for_deletion(self, row):
try:
item = self.db[self.map[row]]
except IndexError:
return False
path = getattr(item, 'path', None)
for items in itervalues(self.marked_for_deletion):
for x in items:
if x is item or (path and path == getattr(x, 'path', None)):
return True
return False
def clear_ondevice(self, db_ids, to_what=None):
for data in self.db:
if data is None:
continue
app_id = getattr(data, 'application_id', None)
if app_id is not None and app_id in db_ids:
data.in_library = to_what
self.beginResetModel(), self.endResetModel()
def flags(self, index):
if self.is_row_marked_for_deletion(index.row()):
return Qt.ItemFlag.NoItemFlags
flags = QAbstractTableModel.flags(self, index)
if index.isValid():
cname = self.column_map[index.column()]
if cname in self.editable and \
(cname != 'collections' or
(callable(getattr(self.db, 'supports_collections', None)) and
self.db.supports_collections() and
device_prefs['manage_device_metadata']=='manual')):
flags |= Qt.ItemFlag.ItemIsEditable
return flags
def search(self, text, reset=True):
# This should not be here, but since the DeviceBooksModel does not
# implement count_changed and I am too lazy to fix that, this kludge
# will have to do
self.resize_rows.emit()
if not text or not text.strip():
self.map = list(range(len(self.db)))
else:
try:
matches = self.search_engine.parse(text)
except ParseException:
self.searched.emit(False)
return
self.map = []
for i in range(len(self.db)):
if i in matches:
self.map.append(i)
self.resort(reset=False)
if reset:
self.beginResetModel(), self.endResetModel()
self.last_search = text
if self.last_search:
self.searched.emit(True)
self.count_changed()
def research(self, reset=True):
self.search(self.last_search, reset)
def sort(self, col, order, reset=True):
descending = order != Qt.SortOrder.AscendingOrder
cname = self.column_map[col]
def author_key(x):
try:
ax = self.db[x].author_sort
if not ax:
raise Exception('')
except:
try:
ax = authors_to_string(self.db[x].authors)
except:
ax = ''
try:
return sort_key(ax)
except:
return ax
keygen = {
'title': ('title_sorter', lambda x: sort_key(x) if x else ''),
'authors' : author_key,
'size' : ('size', int),
'timestamp': ('datetime', functools.partial(dt_factory, assume_utc=True)),
'collections': ('device_collections', lambda x:sorted(x,
key=sort_key)),
'inlibrary': ('in_library', lambda x: x or ''),
}[cname]
keygen = keygen if callable(keygen) else DeviceDBSortKeyGen(
keygen[0], keygen[1], self.db)
self.map.sort(key=keygen, 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(key=keygen, reverse=descending)
self.sorted_on = (self.column_map[col], order)
self.sort_history.insert(0, self.sorted_on)
if hasattr(keygen, 'db'):
keygen.db = None
if reset:
self.beginResetModel(), self.endResetModel()
def resort(self, reset=True):
if self.sorted_on:
self.sort(self.column_map.index(self.sorted_on[0]),
self.sorted_on[1], reset=False)
if reset:
self.beginResetModel(), self.endResetModel()
def columnCount(self, parent):
if parent and parent.isValid():
return 0
return len(self.column_map)
def rowCount(self, parent):
if parent and parent.isValid():
return 0
return len(self.map)
def set_database(self, db):
self.custom_columns = {}
self.db = db
self.map = list(range(0, len(db)))
self.research(reset=False)
self.resort()
self.count_changed()
def cover(self, row):
item = self.db[self.map[row]]
cdata = item.thumbnail
img = QImage()
if cdata is not None:
if hasattr(cdata, 'image_path'):
img.load(cdata.image_path)
elif cdata:
if isinstance(cdata, (tuple, list)):
img.loadFromData(cdata[-1])
else:
img.loadFromData(cdata)
if img.isNull():
img = self.default_image
return img
def get_book_display_info(self, idx):
from calibre.ebooks.metadata.book.base import Metadata
item = self.db[self.map[idx]]
cover = self.cover(idx)
if cover is self.default_image:
cover = None
title = item.title
if not title:
title = _('Unknown')
au = item.authors
if not au:
au = [_('Unknown')]
mi = Metadata(title, au)
mi.cover_data = ('jpg', cover)
fmt = _('Unknown')
ext = os.path.splitext(item.path)[1]
if ext:
fmt = ext[1:].lower()
mi.formats = [fmt]
mi.path = (item.path if item.path else None)
dt = dt_factory(item.datetime, assume_utc=True)
mi.timestamp = dt
mi.device_collections = list(item.device_collections)
mi.tags = list(getattr(item, 'tags', []))
mi.comments = getattr(item, 'comments', None)
series = getattr(item, 'series', None)
if series:
sidx = getattr(item, 'series_index', 0)
mi.series = series
mi.series_index = sidx
return mi
def current_changed(self, current, previous, emit_signal=True):
if current.isValid():
idx = current.row()
data = self.get_book_display_info(idx)
if emit_signal:
self.new_bookdisplay_data.emit(data)
else:
return data
def paths(self, rows):
return [self.db[self.map[r.row()]].path for r in rows]
def paths_for_db_ids(self, db_ids, as_map=False):
res = defaultdict(list) if as_map else []
for r,b in enumerate(self.db):
if b.application_id in db_ids:
if as_map:
res[b.application_id].append(b)
else:
res.append((r,b))
return res
def get_collections_with_ids(self):
collections = set()
for book in self.db:
if book.device_collections is not None:
collections.update(set(book.device_collections))
self.collections = []
result = []
for i,collection in enumerate(collections):
result.append((i, collection))
self.collections.append(collection)
return result
def rename_collection(self, old_id, new_name):
old_name = self.collections[old_id]
for book in self.db:
if book.device_collections is None:
continue
if old_name in book.device_collections:
book.device_collections.remove(old_name)
if new_name not in book.device_collections:
book.device_collections.append(new_name)
def delete_collection_using_id(self, old_id):
old_name = self.collections[old_id]
for book in self.db:
if book.device_collections is None:
continue
if old_name in book.device_collections:
book.device_collections.remove(old_name)
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):
row, col = index.row(), index.column()
cname = self.column_map[col]
if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole:
if cname == 'title':
text = self.db[self.map[row]].title
if not text:
text = self.unknown
return (text)
elif cname == 'authors':
au = self.db[self.map[row]].authors
if not au:
au = [_('Unknown')]
return (authors_to_string(au))
elif cname == 'size':
size = self.db[self.map[row]].size
if not isinstance(size, numbers.Number):
size = 0
return (human_readable(size))
elif cname == 'timestamp':
dt = self.db[self.map[row]].datetime
try:
dt = dt_factory(dt, assume_utc=True, as_utc=False)
except OverflowError:
dt = dt_factory(time.gmtime(), assume_utc=True,
as_utc=False)
return (strftime(TIME_FMT, dt.timetuple()))
elif cname == 'collections':
tags = self.db[self.map[row]].device_collections
if tags:
tags.sort(key=sort_key)
return (', '.join(tags))
elif DEBUG and cname == 'inlibrary':
return (self.db[self.map[row]].in_library)
elif role == Qt.ItemDataRole.ToolTipRole and index.isValid():
if col == 0 and hasattr(self.db[self.map[row]], 'in_library_waiting'):
return (_('Waiting for metadata to be updated'))
if self.is_row_marked_for_deletion(row):
return (_('Marked for deletion'))
if cname in ['title', 'authors'] or (
cname == 'collections' and (
callable(getattr(self.db, 'supports_collections', None)) and self.db.supports_collections())
):
return (_("Double click to <b>edit</b> me<br><br>"))
elif role == Qt.ItemDataRole.DecorationRole and cname == 'inlibrary':
if hasattr(self.db[self.map[row]], 'in_library_waiting'):
return (self.sync_icon)
elif self.db[self.map[row]].in_library:
return (self.bool_yes_icon)
elif self.db[self.map[row]].in_library is not None:
return (self.bool_no_icon)
elif role == Qt.ItemDataRole.TextAlignmentRole:
cname = self.column_map[index.column()]
ans = Qt.AlignmentFlag.AlignVCenter | ALIGNMENT_MAP[self.alignment_map.get(cname,
'left')]
return (ans)
return None
def headerData(self, section, orientation, role):
if role == Qt.ItemDataRole.ToolTipRole and orientation == Qt.Orientation.Horizontal:
cname = self.column_map[section]
text = self.headers[cname]
return '<b>{}</b>: {}'.format(
prepare_string_for_xml(text),
prepare_string_for_xml(_('The lookup/search name is')) + f' <i>{self.column_map[section]}</i>')
if DEBUG and role == Qt.ItemDataRole.ToolTipRole and orientation == Qt.Orientation.Vertical:
return (_('This book\'s UUID is "{0}"').format(self.db[self.map[section]].uuid))
if role != Qt.ItemDataRole.DisplayRole:
return None
if orientation == Qt.Orientation.Horizontal:
cname = self.column_map[section]
text = self.headers[cname]
return (text)
else:
return (section+1)
def setData(self, index, value, role):
from calibre.gui2.ui import get_gui
if get_gui().shutting_down:
return False
done = False
if role == Qt.ItemDataRole.EditRole:
row, col = index.row(), index.column()
cname = self.column_map[col]
if cname in ('size', 'timestamp', 'inlibrary'):
return False
val = str(value or '').strip()
idx = self.map[row]
if cname == 'collections':
tags = [i.strip() for i in val.split(',')]
tags = [t for t in tags if t]
self.db[idx].device_collections = tags
self.dataChanged.emit(index, index)
self.upload_collections.emit(self.db)
return True
if cname == 'title' :
self.db[idx].title = val
elif cname == 'authors':
self.db[idx].authors = string_to_authors(val)
self.dataChanged.emit(index, index)
self.booklist_dirtied.emit()
done = True
return done
def set_editable(self, editable):
# Cannot edit if metadata is sent on connect. Reason: changes will
# revert to what is in the library on next connect.
if isinstance(editable, list):
self.editable = editable
elif editable:
self.editable = ['title', 'authors', 'collections']
else:
self.editable = []
if device_prefs['manage_device_metadata']=='on_connect':
self.editable = []
# }}}