calibre/src/calibre/db/cache.py
Kovid Goyal c9a88a1a80 When bulk deleting formats use a single temp dir
When bulk deleting formats, use a single temporary directory for the
deleted files. This makes restoring them from the recycle bin a little
cleaner. Also might fix the reported issue with windows choking on
creating a large number of folders.
2013-10-22 18:16:34 +05:30

1787 lines
72 KiB
Python

#!/usr/bin/env python
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
from __future__ import (unicode_literals, division, absolute_import,
print_function)
__license__ = 'GPL v3'
__copyright__ = '2011, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
import os, traceback, random, shutil, re, operator
from io import BytesIO
from collections import defaultdict
from functools import wraps
from future_builtins import zip
from calibre import isbytestring
from calibre.constants import iswindows, preferred_encoding
from calibre.customize.ui import run_plugins_on_import, run_plugins_on_postimport
from calibre.db import SPOOL_SIZE, _get_next_series_num_for_list
from calibre.db.categories import get_categories
from calibre.db.locking import create_locks, DowngradeLockError, SafeReadLock
from calibre.db.errors import NoSuchFormat
from calibre.db.fields import create_field, IDENTITY, InvalidLinkTable
from calibre.db.search import Search
from calibre.db.tables import VirtualTable
from calibre.db.write import get_series_values, uniq
from calibre.db.lazy import FormatMetadata, FormatsList, ProxyMetadata
from calibre.ebooks import check_ebook_format
from calibre.ebooks.metadata import string_to_authors, author_to_author_sort, get_title_sort_pat
from calibre.ebooks.metadata.book.base import Metadata
from calibre.ebooks.metadata.opf2 import metadata_to_opf
from calibre.ptempfile import (base_dir, PersistentTemporaryFile,
SpooledTemporaryFile)
from calibre.utils.config import prefs, tweaks
from calibre.utils.date import now as nowf, utcnow, UNDEFINED_DATE
from calibre.utils.icu import sort_key
def api(f):
f.is_cache_api = True
return f
def read_api(f):
f = api(f)
f.is_read_api = True
return f
def write_api(f):
f = api(f)
f.is_read_api = False
return f
def wrap_simple(lock, func):
@wraps(func)
def call_func_with_lock(*args, **kwargs):
try:
with lock:
return func(*args, **kwargs)
except DowngradeLockError:
# We already have an exclusive lock, no need to acquire a shared
# lock. See the safe_read_lock properties' documentation for why
# this is necessary.
return func(*args, **kwargs)
return call_func_with_lock
def run_import_plugins(path_or_stream, fmt):
fmt = fmt.lower()
if hasattr(path_or_stream, 'seek'):
path_or_stream.seek(0)
pt = PersistentTemporaryFile('_import_plugin.'+fmt)
shutil.copyfileobj(path_or_stream, pt, 1024**2)
pt.close()
path = pt.name
else:
path = path_or_stream
return run_plugins_on_import(path, fmt)
def _add_newbook_tag(mi):
tags = prefs['new_book_tags']
if tags:
for tag in [t.strip() for t in tags]:
if tag:
if not mi.tags:
mi.tags = [tag]
elif tag not in mi.tags:
mi.tags.append(tag)
class Cache(object):
def __init__(self, backend):
self.backend = backend
self.fields = {}
self.composites = {}
self.read_lock, self.write_lock = create_locks()
self.format_metadata_cache = defaultdict(dict)
self.formatter_template_cache = {}
self.dirtied_cache = {}
self.dirtied_sequence = 0
self.cover_caches = set()
# Implement locking for all simple read/write API methods
# An unlocked version of the method is stored with the name starting
# with a leading underscore. Use the unlocked versions when the lock
# has already been acquired.
for name in dir(self):
func = getattr(self, name)
ira = getattr(func, 'is_read_api', None)
if ira is not None:
# Save original function
setattr(self, '_'+name, func)
# Wrap it in a lock
lock = self.read_lock if ira else self.write_lock
setattr(self, name, wrap_simple(lock, func))
self._search_api = Search(self, 'saved_searches', self.field_metadata.get_search_terms())
self.initialize_dynamic()
@property
def safe_read_lock(self):
''' A safe read lock is a lock that does nothing if the thread already
has a write lock, otherwise it acquires a read lock. This is necessary
to prevent DowngradeLockErrors, which can happen when updating the
search cache in the presence of composite columns. Updating the search
cache holds an exclusive lock, but searching a composite column
involves reading field values via ProxyMetadata which tries to get a
shared lock. There may be other scenarios that trigger this as well.
This property returns a new lock object on every access. This lock
object is not recursive (for performance) and must only be used in a
with statement as ``with cache.safe_read_lock:`` otherwise bad things
will happen.'''
return SafeReadLock(self.read_lock)
@write_api
def initialize_dynamic(self):
# Reconstruct the user categories, putting them into field_metadata
# Assumption is that someone else will fix them if they change.
self.field_metadata.remove_dynamic_categories()
for user_cat in sorted(self._pref('user_categories', {}).iterkeys(), key=sort_key):
cat_name = '@' + user_cat # add the '@' to avoid name collision
self.field_metadata.add_user_category(label=cat_name, name=user_cat)
# add grouped search term user categories
muc = frozenset(self._pref('grouped_search_make_user_categories', []))
for cat in sorted(self._pref('grouped_search_terms', {}).iterkeys(), key=sort_key):
if cat in muc:
# There is a chance that these can be duplicates of an existing
# user category. Print the exception and continue.
try:
self.field_metadata.add_user_category(label=u'@' + cat, name=cat)
except:
traceback.print_exc()
if len(self._search_api.saved_searches.names()) > 0:
self.field_metadata.add_search_category(label='search', name=_('Searches'))
self.field_metadata.add_grouped_search_terms(
self._pref('grouped_search_terms', {}))
self._search_api.change_locations(self.field_metadata.get_search_terms())
self.dirtied_cache = {x:i for i, (x,) in enumerate(
self.backend.conn.execute('SELECT book FROM metadata_dirtied'))}
if self.dirtied_cache:
self.dirtied_sequence = max(self.dirtied_cache.itervalues())+1
@write_api
def initialize_template_cache(self):
self.formatter_template_cache = {}
@write_api
def clear_composite_caches(self, book_ids=None):
for field in self.composites.itervalues():
field.clear_caches(book_ids=book_ids)
@write_api
def clear_search_caches(self, book_ids=None):
self._search_api.update_or_clear(self, book_ids)
@write_api
def clear_caches(self, book_ids=None, template_cache=True, search_cache=True):
if template_cache:
self._initialize_template_cache() # Clear the formatter template cache
for field in self.fields.itervalues():
if hasattr(field, 'clear_caches'):
field.clear_caches(book_ids=book_ids) # Clear the composite cache and ondevice caches
if book_ids:
for book_id in book_ids:
self.format_metadata_cache.pop(book_id, None)
else:
self.format_metadata_cache.clear()
if search_cache:
self._clear_search_caches(book_ids)
@write_api
def reload_from_db(self, clear_caches=True):
if clear_caches:
self._clear_caches()
self.backend.prefs.load_from_db()
self._search_api.saved_searches.load_from_db()
for field in self.fields.itervalues():
if hasattr(field, 'table'):
field.table.read(self.backend) # Reread data from metadata.db
@property
def field_metadata(self):
return self.backend.field_metadata
def _get_metadata(self, book_id, get_user_categories=True): # {{{
mi = Metadata(None, template_cache=self.formatter_template_cache)
mi._proxy_metadata = ProxyMetadata(self, book_id, formatter=mi.formatter)
author_ids = self._field_ids_for('authors', book_id)
adata = self._author_data(author_ids)
aut_list = [adata[i] for i in author_ids]
aum = []
aus = {}
aul = {}
for rec in aut_list:
aut = rec['name']
aum.append(aut)
aus[aut] = rec['sort']
aul[aut] = rec['link']
mi.title = self._field_for('title', book_id,
default_value=_('Unknown'))
mi.authors = aum
mi.author_sort = self._field_for('author_sort', book_id,
default_value=_('Unknown'))
mi.author_sort_map = aus
mi.author_link_map = aul
mi.comments = self._field_for('comments', book_id)
mi.publisher = self._field_for('publisher', book_id)
n = utcnow()
mi.timestamp = self._field_for('timestamp', book_id, default_value=n)
mi.pubdate = self._field_for('pubdate', book_id, default_value=n)
mi.uuid = self._field_for('uuid', book_id,
default_value='dummy')
mi.title_sort = self._field_for('sort', book_id,
default_value=_('Unknown'))
mi.last_modified = self._field_for('last_modified', book_id,
default_value=n)
formats = self._field_for('formats', book_id)
mi.format_metadata = {}
mi.languages = list(self._field_for('languages', book_id))
if not formats:
good_formats = None
else:
mi.format_metadata = FormatMetadata(self, book_id, formats)
good_formats = FormatsList(sorted(formats), mi.format_metadata)
# These three attributes are returned by the db2 get_metadata(),
# however, we dont actually use them anywhere other than templates, so
# they have been removed, to avoid unnecessary overhead. The templates
# all use _proxy_metadata.
# mi.book_size = self._field_for('size', book_id, default_value=0)
# mi.ondevice_col = self._field_for('ondevice', book_id, default_value='')
# mi.db_approx_formats = formats
mi.formats = good_formats
mi.has_cover = _('Yes') if self._field_for('cover', book_id,
default_value=False) else ''
mi.tags = list(self._field_for('tags', book_id, default_value=()))
mi.series = self._field_for('series', book_id)
if mi.series:
mi.series_index = self._field_for('series_index', book_id,
default_value=1.0)
mi.rating = self._field_for('rating', book_id)
mi.set_identifiers(self._field_for('identifiers', book_id,
default_value={}))
mi.application_id = book_id
mi.id = book_id
composites = []
for key, meta in self.field_metadata.custom_iteritems():
mi.set_user_metadata(key, meta)
if meta['datatype'] == 'composite':
composites.append(key)
else:
val = self._field_for(key, book_id)
if isinstance(val, tuple):
val = list(val)
extra = self._field_for(key+'_index', book_id)
mi.set(key, val=val, extra=extra)
for key in composites:
mi.set(key, val=self._composite_for(key, book_id, mi))
user_cat_vals = {}
if get_user_categories:
user_cats = self.backend.prefs['user_categories']
for ucat in user_cats:
res = []
for name,cat,ign in user_cats[ucat]:
v = mi.get(cat, None)
if isinstance(v, list):
if name in v:
res.append([name,cat])
elif name == v:
res.append([name,cat])
user_cat_vals[ucat] = res
mi.user_categories = user_cat_vals
return mi
# }}}
# Cache Layer API {{{
@api
def init(self):
'''
Initialize this cache with data from the backend.
'''
with self.write_lock:
self.backend.read_tables()
bools_are_tristate = self.backend.prefs['bools_are_tristate']
for field, table in self.backend.tables.iteritems():
self.fields[field] = create_field(field, table, bools_are_tristate)
if table.metadata['datatype'] == 'composite':
self.composites[field] = self.fields[field]
self.fields['ondevice'] = create_field('ondevice',
VirtualTable('ondevice'), bools_are_tristate)
for name, field in self.fields.iteritems():
if name[0] == '#' and name.endswith('_index'):
field.series_field = self.fields[name[:-len('_index')]]
self.fields[name[:-len('_index')]].index_field = field
elif name == 'series_index':
field.series_field = self.fields['series']
self.fields['series'].index_field = field
elif name == 'authors':
field.author_sort_field = self.fields['author_sort']
elif name == 'title':
field.title_sort_field = self.fields['sort']
if self.backend.prefs['update_all_last_mod_dates_on_start']:
self.update_last_modified(self.all_book_ids())
self.backend.prefs.set('update_all_last_mod_dates_on_start', False)
@read_api
def field_for(self, name, book_id, default_value=None):
'''
Return the value of the field ``name`` for the book identified by
``book_id``. If no such book exists or it has no defined value for the
field ``name`` or no such field exists, then ``default_value`` is returned.
default_value is not used for title, title_sort, authors, author_sort
and series_index. This is because these always have values in the db.
default_value is used for all custom columns.
The returned value for is_multiple fields are always tuples, even when
no values are found (in other words, default_value is ignored). The
exception is identifiers for which the returned value is always a dict.
WARNING: For is_multiple fields this method returns tuples, the old
interface generally returned lists.
WARNING: For is_multiple fields the order of items is always in link
order (order in which they were entered), whereas the old db had them
in random order for fields other than author.
'''
if self.composites and name in self.composites:
return self.composite_for(name, book_id,
default_value=default_value)
try:
field = self.fields[name]
except KeyError:
return default_value
if field.is_multiple:
default_value = field.default_value
try:
return field.for_book(book_id, default_value=default_value)
except (KeyError, IndexError):
return default_value
@read_api
def fast_field_for(self, field_obj, book_id, default_value=None):
' Same as field_for, except that it avoids the extra lookup to get the field object '
if field_obj.is_composite:
return field_obj.get_value_with_cache(book_id, self._get_proxy_metadata)
if field_obj.is_multiple:
default_value = field_obj.default_value
try:
return field_obj.for_book(book_id, default_value=default_value)
except (KeyError, IndexError):
return default_value
@read_api
def all_field_for(self, field, book_ids, default_value=None):
' Same as field_for, except that it operates on multiple books at once '
field_obj = self.fields[field]
return {book_id:self._fast_field_for(field_obj, book_id, default_value=default_value) for book_id in book_ids}
@read_api
def composite_for(self, name, book_id, mi=None, default_value=''):
try:
f = self.fields[name]
except KeyError:
return default_value
if mi is None:
return f.get_value_with_cache(book_id, self._get_proxy_metadata)
else:
return f.render_composite(book_id, mi)
@read_api
def field_ids_for(self, name, book_id):
'''
Return the ids (as a tuple) for the values that the field ``name`` has on the book
identified by ``book_id``. If there are no values, or no such book, or
no such field, an empty tuple is returned.
'''
try:
return self.fields[name].ids_for_book(book_id)
except (KeyError, IndexError):
return ()
@read_api
def books_for_field(self, name, item_id):
'''
Return all the books associated with the item identified by
``item_id``, where the item belongs to the field ``name``.
Returned value is a set of book ids, or the empty set if the item
or the field does not exist.
'''
try:
return self.fields[name].books_for(item_id)
except (KeyError, IndexError):
return set()
@read_api
def all_book_ids(self, type=frozenset):
'''
Frozen set of all known book ids.
'''
return type(self.fields['uuid'].table.book_col_map)
@read_api
def all_field_ids(self, name):
'''
Frozen set of ids for all values in the field ``name``.
'''
return frozenset(iter(self.fields[name]))
@read_api
def all_field_names(self, field):
''' Frozen set of all fields names (should only be used for many-one and many-many fields) '''
if field == 'formats':
return frozenset(self.fields[field].table.col_book_map)
try:
return frozenset(self.fields[field].table.id_map.itervalues())
except AttributeError:
raise ValueError('%s is not a many-one or many-many field' % field)
@read_api
def get_usage_count_by_id(self, field):
try:
return {k:len(v) for k, v in self.fields[field].table.col_book_map.iteritems()}
except AttributeError:
raise ValueError('%s is not a many-one or many-many field' % field)
@read_api
def get_id_map(self, field):
try:
return self.fields[field].table.id_map.copy()
except AttributeError:
if field == 'title':
return self.fields[field].table.book_col_map.copy()
raise ValueError('%s is not a many-one or many-many field' % field)
@read_api
def get_item_name(self, field, item_id):
return self.fields[field].table.id_map[item_id]
@read_api
def get_item_id(self, field, item_name):
' Return the item id for item_name (case-insensitive) '
rmap = {icu_lower(v) if isinstance(v, unicode) else v:k for k, v in self.fields[field].table.id_map.iteritems()}
return rmap.get(icu_lower(item_name) if isinstance(item_name, unicode) else item_name, None)
@read_api
def get_item_ids(self, field, item_names):
' Return the item id for item_name (case-insensitive) '
rmap = {icu_lower(v) if isinstance(v, unicode) else v:k for k, v in self.fields[field].table.id_map.iteritems()}
return {name:rmap.get(icu_lower(name) if isinstance(name, unicode) else name, None) for name in item_names}
@read_api
def author_data(self, author_ids=None):
'''
Return author data as a dictionary with keys: name, sort, link
If no authors with the specified ids are found an empty dictionary is
returned. If author_ids is None, data for all authors is returned.
'''
af = self.fields['authors']
if author_ids is None:
author_ids = tuple(af.table.id_map)
return {aid:af.author_data(aid) for aid in author_ids if aid in af.table.id_map}
@read_api
def format_hash(self, book_id, fmt):
try:
name = self.fields['formats'].format_fname(book_id, fmt)
path = self._field_for('path', book_id).replace('/', os.sep)
except:
raise NoSuchFormat('Record %d has no fmt: %s'%(book_id, fmt))
return self.backend.format_hash(book_id, fmt, name, path)
@api
def format_metadata(self, book_id, fmt, allow_cache=True, update_db=False):
if not fmt:
return {}
fmt = fmt.upper()
if allow_cache:
x = self.format_metadata_cache[book_id].get(fmt, None)
if x is not None:
return x
with self.safe_read_lock:
try:
name = self.fields['formats'].format_fname(book_id, fmt)
path = self._field_for('path', book_id).replace('/', os.sep)
except:
return {}
ans = {}
if path and name:
ans = self.backend.format_metadata(book_id, fmt, name, path)
self.format_metadata_cache[book_id][fmt] = ans
if update_db and 'size' in ans:
with self.write_lock:
max_size = self.fields['formats'].table.update_fmt(book_id, fmt, name, ans['size'], self.backend)
self.fields['size'].table.update_sizes({book_id: max_size})
return ans
@read_api
def format_files(self, book_id):
field = self.fields['formats']
fmts = field.table.book_col_map.get(book_id, ())
return {fmt:field.format_fname(book_id, fmt) for fmt in fmts}
@read_api
def pref(self, name, default=None):
return self.backend.prefs.get(name, default)
@write_api
def set_pref(self, name, val):
self.backend.prefs.set(name, val)
if name == 'grouped_search_terms':
self._clear_search_caches()
@api
def get_metadata(self, book_id,
get_cover=False, get_user_categories=True, cover_as_data=False):
'''
Return metadata for the book identified by book_id as a :class:`Metadata` object.
Note that the list of formats is not verified. If get_cover is True,
the cover is returned, either a path to temp file as mi.cover or if
cover_as_data is True then as mi.cover_data.
'''
with self.safe_read_lock:
mi = self._get_metadata(book_id, get_user_categories=get_user_categories)
if get_cover:
if cover_as_data:
cdata = self.cover(book_id)
if cdata:
mi.cover_data = ('jpeg', cdata)
else:
mi.cover = self.cover(book_id, as_path=True)
return mi
@read_api
def get_proxy_metadata(self, book_id):
return ProxyMetadata(self, book_id)
@api
def cover(self, book_id,
as_file=False, as_image=False, as_path=False):
'''
Return the cover image or None. By default, returns the cover as a
bytestring.
WARNING: Using as_path will copy the cover to a temp file and return
the path to the temp file. You should delete the temp file when you are
done with it.
:param as_file: If True return the image as an open file object (a SpooledTemporaryFile)
:param as_image: If True return the image as a QImage object
:param as_path: If True return the image as a path pointing to a
temporary file
'''
if as_file:
ret = SpooledTemporaryFile(SPOOL_SIZE)
if not self.copy_cover_to(book_id, ret):
return
ret.seek(0)
elif as_path:
pt = PersistentTemporaryFile('_dbcover.jpg')
with pt:
if not self.copy_cover_to(book_id, pt):
return
ret = pt.name
else:
buf = BytesIO()
if not self.copy_cover_to(book_id, buf):
return
ret = buf.getvalue()
if as_image:
from PyQt4.Qt import QImage
i = QImage()
i.loadFromData(ret)
ret = i
return ret
@read_api
def cover_or_cache(self, book_id, timestamp):
try:
path = self._field_for('path', book_id).replace('/', os.sep)
except AttributeError:
return False, None, None
return self.backend.cover_or_cache(path, timestamp)
@read_api
def cover_last_modified(self, book_id):
try:
path = self._field_for('path', book_id).replace('/', os.sep)
except AttributeError:
return
return self.backend.cover_last_modified(path)
@read_api
def copy_cover_to(self, book_id, dest, use_hardlink=False):
'''
Copy the cover to the file like object ``dest``. Returns False
if no cover exists or dest is the same file as the current cover.
dest can also be a path in which case the cover is
copied to it iff the path is different from the current path (taking
case sensitivity into account).
'''
try:
path = self._field_for('path', book_id).replace('/', os.sep)
except AttributeError:
return False
return self.backend.copy_cover_to(path, dest,
use_hardlink=use_hardlink)
@read_api
def copy_format_to(self, book_id, fmt, dest, use_hardlink=False):
'''
Copy the format ``fmt`` to the file like object ``dest``. If the
specified format does not exist, raises :class:`NoSuchFormat` error.
dest can also be a path, in which case the format is copied to it, iff
the path is different from the current path (taking case sensitivity
into account).
'''
fmt = (fmt or '').upper()
try:
name = self.fields['formats'].format_fname(book_id, fmt)
path = self._field_for('path', book_id).replace('/', os.sep)
except (KeyError, AttributeError):
raise NoSuchFormat('Record %d has no %s file'%(book_id, fmt))
return self.backend.copy_format_to(book_id, fmt, name, path, dest,
use_hardlink=use_hardlink)
@read_api
def format_abspath(self, book_id, fmt):
'''
Return absolute path to the ebook file of format `format`
Currently used only in calibredb list, the viewer and the catalogs (via
get_data_as_dict()).
Apart from the viewer, I don't believe any of the others do any file
I/O with the results of this call.
'''
fmt = (fmt or '').upper()
try:
name = self.fields['formats'].format_fname(book_id, fmt)
path = self._field_for('path', book_id).replace('/', os.sep)
except:
return None
if name and path:
return self.backend.format_abspath(book_id, fmt, name, path)
@read_api
def has_format(self, book_id, fmt):
'Return True iff the format exists on disk'
fmt = (fmt or '').upper()
try:
name = self.fields['formats'].format_fname(book_id, fmt)
path = self._field_for('path', book_id).replace('/', os.sep)
except:
return False
return self.backend.has_format(book_id, fmt, name, path)
@api
def save_original_format(self, book_id, fmt):
fmt = fmt.upper()
if 'ORIGINAL' in fmt:
raise ValueError('Cannot save original of an original fmt')
fmtfile = self.format(book_id, fmt, as_file=True)
if fmtfile is None:
return False
with fmtfile:
nfmt = 'ORIGINAL_'+fmt
return self.add_format(book_id, nfmt, fmtfile, run_hooks=False)
@api
def restore_original_format(self, book_id, original_fmt):
original_fmt = original_fmt.upper()
fmtfile = self.format(book_id, original_fmt, as_file=True)
if fmtfile is not None:
fmt = original_fmt.partition('_')[2]
with fmtfile:
self.add_format(book_id, fmt, fmtfile, run_hooks=False)
self.remove_formats({book_id:(original_fmt,)})
return True
return False
@read_api
def formats(self, book_id, verify_formats=True):
'''
Return tuple of all formats for the specified book. If verify_formats
is True, verifies that the files exist on disk.
'''
ans = self.field_for('formats', book_id)
if verify_formats and ans:
try:
path = self._field_for('path', book_id).replace('/', os.sep)
except:
return ()
def verify(fmt):
try:
name = self.fields['formats'].format_fname(book_id, fmt)
except:
return False
return self.backend.has_format(book_id, fmt, name, path)
ans = tuple(x for x in ans if verify(x))
return ans
@api
def format(self, book_id, fmt, as_file=False, as_path=False, preserve_filename=False):
'''
Return the ebook format as a bytestring or `None` if the format doesn't exist,
or we don't have permission to write to the ebook file.
:param as_file: If True the ebook format is returned as a file object. Note
that the file object is a SpooledTemporaryFile, so if what you want to
do is copy the format to another file, use :method:`copy_format_to`
instead for performance.
:param as_path: Copies the format file to a temp file and returns the
path to the temp file
:param preserve_filename: If True and returning a path the filename is
the same as that used in the library. Note that using
this means that repeated calls yield the same
temp file (which is re-created each time)
'''
fmt = (fmt or '').upper()
ext = ('.'+fmt.lower()) if fmt else ''
if as_path:
if preserve_filename:
with self.safe_read_lock:
try:
fname = self.fields['formats'].format_fname(book_id, fmt)
except:
return None
fname += ext
bd = base_dir()
d = os.path.join(bd, 'format_abspath')
try:
os.makedirs(d)
except:
pass
ret = os.path.join(d, fname)
try:
self.copy_format_to(book_id, fmt, ret)
except NoSuchFormat:
return None
else:
with PersistentTemporaryFile(ext) as pt:
try:
self.copy_format_to(book_id, fmt, pt)
except NoSuchFormat:
return None
ret = pt.name
elif as_file:
with self.safe_read_lock:
try:
fname = self.fields['formats'].format_fname(book_id, fmt)
except:
return None
fname += ext
ret = SpooledTemporaryFile(SPOOL_SIZE)
try:
self.copy_format_to(book_id, fmt, ret)
except NoSuchFormat:
return None
ret.seek(0)
# Various bits of code try to use the name as the default
# title when reading metadata, so set it
ret.name = fname
else:
buf = BytesIO()
try:
self.copy_format_to(book_id, fmt, buf)
except NoSuchFormat:
return None
ret = buf.getvalue()
return ret
@read_api
def multisort(self, fields, ids_to_sort=None, virtual_fields=None):
'''
Return a list of sorted book ids. If ids_to_sort is None, all book ids
are returned.
fields must be a list of 2-tuples of the form (field_name,
ascending=True or False). The most significant field is the first
2-tuple.
'''
ids_to_sort = self._all_book_ids() if ids_to_sort is None else ids_to_sort
get_metadata = self._get_proxy_metadata
lang_map = self.fields['languages'].book_value_map
virtual_fields = virtual_fields or {}
fm = {'title':'sort', 'authors':'author_sort'}
def sort_key_func(field):
'Handle series type fields, virtual fields and the id field'
idx = field + '_index'
is_series = idx in self.fields
try:
func = self.fields[fm.get(field, field)].sort_keys_for_books(get_metadata, lang_map)
except KeyError:
if field == 'id':
return IDENTITY
else:
return virtual_fields[fm.get(field, field)].sort_keys_for_books(get_metadata, lang_map)
if is_series:
idx_func = self.fields[idx].sort_keys_for_books(get_metadata, lang_map)
def skf(book_id):
return (func(book_id), idx_func(book_id))
return skf
return func
# Sort only once on any given field
fields = uniq(fields, operator.itemgetter(0))
if len(fields) == 1:
return sorted(ids_to_sort, key=sort_key_func(fields[0][0]),
reverse=not fields[0][1])
sort_key_funcs = tuple(sort_key_func(field) for field, order in fields)
orders = tuple(1 if order else -1 for _, order in fields)
Lazy = object() # Lazy load the sort keys for sub-sort fields
class SortKey(object):
__slots__ = ('book_id', 'sort_key')
def __init__(self, book_id):
self.book_id = book_id
# Calculate only the first sub-sort key since that will always be used
self.sort_key = [key(book_id) if i == 0 else Lazy for i, key in enumerate(sort_key_funcs)]
def __cmp__(self, other):
for i, (order, self_key, other_key) in enumerate(zip(orders, self.sort_key, other.sort_key)):
if self_key is Lazy:
self_key = self.sort_key[i] = sort_key_funcs[i](self.book_id)
if other_key is Lazy:
other_key = other.sort_key[i] = sort_key_funcs[i](other.book_id)
ans = cmp(self_key, other_key)
if ans != 0:
return ans * order
return 0
return sorted(ids_to_sort, key=SortKey)
@read_api
def search(self, query, restriction='', virtual_fields=None, book_ids=None):
return self._search_api(self, query, restriction, virtual_fields=virtual_fields, book_ids=book_ids)
@api
def get_categories(self, sort='name', book_ids=None, icon_map=None, already_fixed=None):
try:
with self.safe_read_lock:
return get_categories(self, sort=sort, book_ids=book_ids, icon_map=icon_map)
except InvalidLinkTable as err:
bad_field = err.field_name
if bad_field == already_fixed:
raise
with self.write_lock:
self.fields[bad_field].table.fix_link_table(self.backend)
return self.get_categories(sort=sort, book_ids=book_ids, icon_map=icon_map, already_fixed=bad_field)
@write_api
def update_last_modified(self, book_ids, now=None):
if book_ids:
if now is None:
now = nowf()
f = self.fields['last_modified']
f.writer.set_books({book_id:now for book_id in book_ids}, self.backend)
if self.composites:
self._clear_composite_caches(book_ids)
self._clear_search_caches(book_ids)
@write_api
def mark_as_dirty(self, book_ids):
self._update_last_modified(book_ids)
already_dirtied = set(self.dirtied_cache).intersection(book_ids)
new_dirtied = book_ids - already_dirtied
already_dirtied = {book_id:self.dirtied_sequence+i for i, book_id in enumerate(already_dirtied)}
if already_dirtied:
self.dirtied_sequence = max(already_dirtied.itervalues()) + 1
self.dirtied_cache.update(already_dirtied)
if new_dirtied:
self.backend.conn.executemany('INSERT OR IGNORE INTO metadata_dirtied (book) VALUES (?)',
((x,) for x in new_dirtied))
new_dirtied = {book_id:self.dirtied_sequence+i for i, book_id in enumerate(new_dirtied)}
self.dirtied_sequence = max(new_dirtied.itervalues()) + 1
self.dirtied_cache.update(new_dirtied)
@write_api
def commit_dirty_cache(self):
book_ids = [(x,) for x in self.dirtied_cache]
if book_ids:
self.backend.conn.executemany('INSERT OR IGNORE INTO metadata_dirtied (book) VALUES (?)', book_ids)
@write_api
def set_field(self, name, book_id_to_val_map, allow_case_change=True, do_path_update=True):
f = self.fields[name]
is_series = f.metadata['datatype'] == 'series'
update_path = name in {'title', 'authors'}
if update_path and iswindows:
paths = (x for x in (self._field_for('path', book_id) for book_id in book_id_to_val_map) if x)
self.backend.windows_check_if_files_in_use(paths)
if is_series:
bimap, simap = {}, {}
for k, v in book_id_to_val_map.iteritems():
if isinstance(v, basestring):
v, sid = get_series_values(v)
else:
v = sid = None
if name.startswith('#') and sid is None:
sid = 1.0 # The value will be set to 1.0 in the db table
bimap[k] = v
if sid is not None:
simap[k] = sid
book_id_to_val_map = bimap
dirtied = f.writer.set_books(
book_id_to_val_map, self.backend, allow_case_change=allow_case_change)
if is_series and simap:
sf = self.fields[f.name+'_index']
dirtied |= sf.writer.set_books(simap, self.backend, allow_case_change=False)
if dirtied and update_path and do_path_update:
self._update_path(dirtied, mark_as_dirtied=False)
self._mark_as_dirty(dirtied)
return dirtied
@write_api
def update_path(self, book_ids, mark_as_dirtied=True):
for book_id in book_ids:
title = self._field_for('title', book_id, default_value=_('Unknown'))
author = self._field_for('authors', book_id, default_value=(_('Unknown'),))[0]
self.backend.update_path(book_id, title, author, self.fields['path'], self.fields['formats'])
if mark_as_dirtied:
self._mark_as_dirty(book_ids)
@read_api
def get_a_dirtied_book(self):
if self.dirtied_cache:
return random.choice(tuple(self.dirtied_cache.iterkeys()))
return None
@read_api
def get_metadata_for_dump(self, book_id):
mi = None
# get the current sequence number for this book to pass back to the
# backup thread. This will avoid double calls in the case where the
# thread has not done the work between the put and the get_metadata
sequence = self.dirtied_cache.get(book_id, None)
if sequence is not None:
try:
# While a book is being created, the path is empty. Don't bother to
# try to write the opf, because it will go to the wrong folder.
if self._field_for('path', book_id):
mi = self._get_metadata(book_id)
# Always set cover to cover.jpg. Even if cover doesn't exist,
# no harm done. This way no need to call dirtied when
# cover is set/removed
mi.cover = 'cover.jpg'
except:
# This almost certainly means that the book has been deleted while
# the backup operation sat in the queue.
pass
return mi, sequence
@write_api
def clear_dirtied(self, book_id, sequence):
'''
Clear the dirtied indicator for the books. This is used when fetching
metadata, creating an OPF, and writing a file are separated into steps.
The last step is clearing the indicator
'''
dc_sequence = self.dirtied_cache.get(book_id, None)
if dc_sequence is None or sequence is None or dc_sequence == sequence:
self.backend.conn.execute('DELETE FROM metadata_dirtied WHERE book=?',
(book_id,))
self.dirtied_cache.pop(book_id, None)
@write_api
def write_backup(self, book_id, raw):
try:
path = self._field_for('path', book_id).replace('/', os.sep)
except:
return
self.backend.write_backup(path, raw)
@read_api
def dirty_queue_length(self):
return len(self.dirtied_cache)
@read_api
def read_backup(self, book_id):
''' Return the OPF metadata backup for the book as a bytestring or None
if no such backup exists. '''
try:
path = self._field_for('path', book_id).replace('/', os.sep)
except:
return
try:
return self.backend.read_backup(path)
except EnvironmentError:
return None
@write_api
def dump_metadata(self, book_ids=None, remove_from_dirtied=True,
callback=None):
'''
Write metadata for each record to an individual OPF file. If callback
is not None, it is called once at the start with the number of book_ids
being processed. And once for every book_id, with arguments (book_id,
mi, ok).
'''
if book_ids is None:
book_ids = set(self.dirtied_cache)
if callback is not None:
callback(len(book_ids), True, False)
for book_id in book_ids:
if self._field_for('path', book_id) is None:
if callback is not None:
callback(book_id, None, False)
continue
mi, sequence = self._get_metadata_for_dump(book_id)
if mi is None:
if callback is not None:
callback(book_id, mi, False)
continue
try:
raw = metadata_to_opf(mi)
self._write_backup(book_id, raw)
if remove_from_dirtied:
self._clear_dirtied(book_id, sequence)
except:
pass
if callback is not None:
callback(book_id, mi, True)
@write_api
def set_cover(self, book_id_data_map):
''' Set the cover for this book. data can be either a QImage,
QPixmap, file object or bytestring. It can also be None, in which
case any existing cover is removed. '''
for book_id, data in book_id_data_map.iteritems():
try:
path = self._field_for('path', book_id).replace('/', os.sep)
except AttributeError:
self._update_path((book_id,))
path = self._field_for('path', book_id).replace('/', os.sep)
self.backend.set_cover(book_id, path, data)
for cc in self.cover_caches:
cc.invalidate(book_id_data_map)
return self._set_field('cover', {
book_id:(0 if data is None else 1) for book_id, data in book_id_data_map.iteritems()})
@write_api
def add_cover_cache(self, cover_cache):
if not callable(cover_cache.invalidate):
raise ValueError('Cover caches must have an invalidate method')
self.cover_caches.add(cover_cache)
@write_api
def remove_cover_cache(self, cover_cache):
self.cover_caches.discard(cover_cache)
@write_api
def set_metadata(self, book_id, mi, ignore_errors=False, force_changes=False,
set_title=True, set_authors=True, allow_case_change=False):
'''
Set metadata for the book `id` from the `Metadata` object `mi`
Setting force_changes=True will force set_metadata to update fields even
if mi contains empty values. In this case, 'None' is distinguished from
'empty'. If mi.XXX is None, the XXX is not replaced, otherwise it is.
The tags, identifiers, and cover attributes are special cases. Tags and
identifiers cannot be set to None so then will always be replaced if
force_changes is true. You must ensure that mi contains the values you
want the book to have. Covers are always changed if a new cover is
provided, but are never deleted. Also note that force_changes has no
effect on setting title or authors.
'''
try:
# Handle code passing in an OPF object instead of a Metadata object
mi = mi.to_book_metadata()
except (AttributeError, TypeError):
pass
def set_field(name, val):
self._set_field(name, {book_id:val}, do_path_update=False, allow_case_change=allow_case_change)
path_changed = False
if set_title and mi.title:
path_changed = True
set_field('title', mi.title)
if set_authors:
path_changed = True
if not mi.authors:
mi.authors = [_('Unknown')]
authors = []
for a in mi.authors:
authors += string_to_authors(a)
set_field('authors', authors)
if path_changed:
self._update_path({book_id})
def protected_set_field(name, val, **kwargs):
try:
set_field(name, val, **kwargs)
except:
if ignore_errors:
traceback.print_exc()
else:
raise
# force_changes has no effect on cover manipulation
try:
cdata = mi.cover_data[1]
if cdata is None and isinstance(mi.cover, basestring) and mi.cover and os.access(mi.cover, os.R_OK):
with lopen(mi.cover, 'rb') as f:
cdata = f.read() or None
if cdata is not None:
self._set_cover({book_id: cdata})
except:
if ignore_errors:
traceback.print_exc()
else:
raise
try:
with self.backend.conn: # Speed up set_metadata by not operating in autocommit mode
for field in ('rating', 'series_index', 'timestamp'):
val = getattr(mi, field)
if val is not None:
protected_set_field(field, val)
for field in ('author_sort', 'publisher', 'series', 'tags', 'comments',
'languages', 'pubdate'):
val = mi.get(field, None)
if (force_changes and val is not None) or not mi.is_null(field):
protected_set_field(field, val)
val = mi.get('title_sort', None)
if (force_changes and val is not None) or not mi.is_null('title_sort'):
protected_set_field('sort', val)
# identifiers will always be replaced if force_changes is True
mi_idents = mi.get_identifiers()
if force_changes:
protected_set_field('identifiers', mi_idents)
elif mi_idents:
identifiers = self._field_for('identifiers', book_id, default_value={})
for key, val in mi_idents.iteritems():
if val and val.strip(): # Don't delete an existing identifier
identifiers[icu_lower(key)] = val
protected_set_field('identifiers', identifiers)
user_mi = mi.get_all_user_metadata(make_copy=False)
fm = self.field_metadata
for key in user_mi.iterkeys():
if (key in fm and
user_mi[key]['datatype'] == fm[key]['datatype'] and
(user_mi[key]['datatype'] != 'text' or
user_mi[key]['is_multiple'] == fm[key]['is_multiple'])):
val = mi.get(key, None)
if force_changes or val is not None:
protected_set_field(key, val)
idx = key + '_index'
if idx in self.fields:
extra = mi.get_extra(key)
if extra is not None or force_changes:
protected_set_field(idx, extra)
except:
# sqlite will rollback the entire transaction, thanks to the with
# statement, so we have to re-read everything form the db to ensure
# the db and Cache are in sync
self._reload_from_db()
raise
@api
def add_format(self, book_id, fmt, stream_or_path, replace=True, run_hooks=True, dbapi=None):
if run_hooks:
# Run import plugins, the write lock is not held to cater for
# broken plugins that might spin the event loop by popping up a
# message in the GUI during the processing.
npath = run_import_plugins(stream_or_path, fmt)
fmt = os.path.splitext(npath)[-1].lower().replace('.', '').upper()
stream_or_path = lopen(npath, 'rb')
fmt = check_ebook_format(stream_or_path, fmt)
with self.write_lock:
fmt = (fmt or '').upper()
self.format_metadata_cache[book_id].pop(fmt, None)
try:
name = self.fields['formats'].format_fname(book_id, fmt)
except:
name = None
if name and not replace:
return False
path = self._field_for('path', book_id).replace('/', os.sep)
title = self._field_for('title', book_id, default_value=_('Unknown'))
author = self._field_for('authors', book_id, default_value=(_('Unknown'),))[0]
stream = stream_or_path if hasattr(stream_or_path, 'read') else lopen(stream_or_path, 'rb')
size, fname = self.backend.add_format(book_id, fmt, stream, title, author, path, name)
del stream
max_size = self.fields['formats'].table.update_fmt(book_id, fmt, fname, size, self.backend)
self.fields['size'].table.update_sizes({book_id: max_size})
self._update_last_modified((book_id,))
if run_hooks:
# Run post import plugins, the write lock is released so the plugin
# can call api without a locking violation.
run_plugins_on_postimport(dbapi or self, book_id, fmt)
stream_or_path.close()
return True
@write_api
def remove_formats(self, formats_map, db_only=False):
table = self.fields['formats'].table
formats_map = {book_id:frozenset((f or '').upper() for f in fmts) for book_id, fmts in formats_map.iteritems()}
for book_id, fmts in formats_map.iteritems():
for fmt in fmts:
self.format_metadata_cache[book_id].pop(fmt, None)
if not db_only:
removes = defaultdict(set)
for book_id, fmts in formats_map.iteritems():
try:
path = self._field_for('path', book_id).replace('/', os.sep)
except:
continue
for fmt in fmts:
try:
name = self.fields['formats'].format_fname(book_id, fmt)
except:
continue
if name and path:
removes[book_id].add((fmt, name, path))
if removes:
self.backend.remove_formats(removes)
size_map = table.remove_formats(formats_map, self.backend)
self.fields['size'].table.update_sizes(size_map)
self._update_last_modified(tuple(formats_map.iterkeys()))
@read_api
def get_next_series_num_for(self, series, field='series', current_indices=False):
books = ()
sf = self.fields[field]
if series:
q = icu_lower(series)
for val, book_ids in sf.iter_searchable_values(self._get_proxy_metadata, frozenset(self._all_book_ids())):
if q == icu_lower(val):
books = book_ids
break
idf = sf.index_field
index_map = {book_id:self._fast_field_for(idf, book_id, default_value=1.0) for book_id in books}
if current_indices:
return index_map
series_indices = sorted(index_map.itervalues())
return _get_next_series_num_for_list(tuple(series_indices), unwrap=False)
@read_api
def author_sort_from_authors(self, authors):
'''Given a list of authors, return the author_sort string for the authors,
preferring the author sort associated with the author over the computed
string. '''
table = self.fields['authors'].table
result = []
rmap = {icu_lower(v):k for k, v in table.id_map.iteritems()}
for aut in authors:
aid = rmap.get(icu_lower(aut), None)
result.append(author_to_author_sort(aut) if aid is None else table.asort_map[aid])
return ' & '.join(filter(None, result))
@read_api
def has_book(self, mi):
title = mi.title
if title:
if isbytestring(title):
title = title.decode(preferred_encoding, 'replace')
q = icu_lower(title)
for title in self.fields['title'].table.book_col_map.itervalues():
if q == icu_lower(title):
return True
return False
@read_api
def has_id(self, book_id):
return book_id in self.fields['title'].table.book_col_map
@write_api
def create_book_entry(self, mi, cover=None, add_duplicates=True, force_id=None, apply_import_tags=True, preserve_uuid=False):
if mi.tags:
mi.tags = list(mi.tags)
if apply_import_tags:
_add_newbook_tag(mi)
if not add_duplicates and self._has_book(mi):
return
series_index = (self._get_next_series_num_for(mi.series) if mi.series_index is None else mi.series_index)
if not mi.authors:
mi.authors = (_('Unknown'),)
aus = mi.author_sort if mi.author_sort else self._author_sort_from_authors(mi.authors)
mi.title = mi.title or _('Unknown')
if isbytestring(aus):
aus = aus.decode(preferred_encoding, 'replace')
if isbytestring(mi.title):
mi.title = mi.title.decode(preferred_encoding, 'replace')
conn = self.backend.conn
if force_id is None:
conn.execute('INSERT INTO books(title, series_index, author_sort) VALUES (?, ?, ?)',
(mi.title, series_index, aus))
else:
conn.execute('INSERT INTO books(id, title, series_index, author_sort) VALUES (?, ?, ?, ?)',
(force_id, mi.title, series_index, aus))
book_id = conn.last_insert_rowid()
mi.timestamp = utcnow() if mi.timestamp is None else mi.timestamp
mi.pubdate = UNDEFINED_DATE if mi.pubdate is None else mi.pubdate
if cover is not None:
mi.cover, mi.cover_data = None, (None, cover)
self._set_metadata(book_id, mi, ignore_errors=True)
if preserve_uuid and mi.uuid:
self._set_field('uuid', {book_id:mi.uuid})
# Update the caches for fields from the books table
self.fields['size'].table.book_col_map[book_id] = 0
row = next(conn.execute('SELECT sort, series_index, author_sort, uuid, has_cover FROM books WHERE id=?', (book_id,)))
for field, val in zip(('sort', 'series_index', 'author_sort', 'uuid', 'cover'), row):
if field == 'cover':
val = bool(val)
elif field == 'uuid':
self.fields[field].table.uuid_to_id_map[val] = book_id
self.fields[field].table.book_col_map[book_id] = val
return book_id
@api
def add_books(self, books, add_duplicates=True, apply_import_tags=True, preserve_uuid=False, run_hooks=True, dbapi=None):
duplicates, ids = [], []
for mi, format_map in books:
book_id = self.create_book_entry(mi, add_duplicates=add_duplicates, apply_import_tags=apply_import_tags, preserve_uuid=preserve_uuid)
if book_id is None:
duplicates.append((mi, format_map))
else:
ids.append(book_id)
for fmt, stream_or_path in format_map.iteritems():
self.add_format(book_id, fmt, stream_or_path, dbapi=dbapi, run_hooks=run_hooks)
return ids, duplicates
@write_api
def remove_books(self, book_ids, permanent=False):
path_map = {}
for book_id in book_ids:
try:
path = self._field_for('path', book_id).replace('/', os.sep)
except:
path = None
path_map[book_id] = path
if iswindows:
paths = (x.replace(os.sep, '/') for x in path_map.itervalues() if x)
self.backend.windows_check_if_files_in_use(paths)
self.backend.remove_books(path_map, permanent=permanent)
for field in self.fields.itervalues():
try:
table = field.table
except AttributeError:
continue # Some fields like ondevice do not have tables
else:
table.remove_books(book_ids, self.backend)
self._search_api.discard_books(book_ids)
self._clear_caches(book_ids=book_ids, template_cache=False, search_cache=False)
for cc in self.cover_caches:
cc.invalidate(book_ids)
@read_api
def author_sort_strings_for_books(self, book_ids):
val_map = {}
for book_id in book_ids:
authors = self._field_ids_for('authors', book_id)
adata = self._author_data(authors)
val_map[book_id] = tuple(adata[aid]['sort'] for aid in authors)
return val_map
@write_api
def rename_items(self, field, item_id_to_new_name_map, change_index=True):
f = self.fields[field]
try:
func = f.table.rename_item
except AttributeError:
raise ValueError('Cannot rename items for one-one fields: %s' % field)
affected_books = set()
moved_books = set()
id_map = {}
try:
sv = f.metadata['is_multiple']['ui_to_list']
except (TypeError, KeyError, AttributeError):
sv = None
for item_id, new_name in item_id_to_new_name_map.iteritems():
new_names = tuple(x.strip() for x in new_name.split(sv)) if sv else (new_name,)
books, new_id = func(item_id, new_names[0], self.backend)
affected_books.update(books)
id_map[item_id] = new_id
if new_id != item_id:
moved_books.update(books)
if len(new_names) > 1:
# Add the extra items to the books
extra = new_names[1:]
self._set_field(field, {book_id:self._fast_field_for(f, book_id) + extra for book_id in books})
if affected_books:
if field == 'authors':
self._set_field('author_sort',
{k:' & '.join(v) for k, v in self._author_sort_strings_for_books(affected_books).iteritems()})
self._update_path(affected_books, mark_as_dirtied=False)
elif change_index and hasattr(f, 'index_field') and tweaks['series_index_auto_increment'] != 'no_change':
for book_id in moved_books:
self._set_field(f.index_field.name, {book_id:self._get_next_series_num_for(self._fast_field_for(f, book_id), field=field)})
self._mark_as_dirty(affected_books)
return affected_books, id_map
@write_api
def remove_items(self, field, item_ids):
''' Delete all items in the specified field with the specified ids. Returns the set of affected book ids. '''
field = self.fields[field]
affected_books = field.table.remove_items(item_ids, self.backend)
if affected_books:
if hasattr(field, 'index_field'):
self._set_field(field.index_field.name, {bid:1.0 for bid in affected_books})
else:
self._mark_as_dirty(affected_books)
return affected_books
@write_api
def add_custom_book_data(self, name, val_map, delete_first=False):
''' Add data for name where val_map is a map of book_ids to values. If
delete_first is True, all previously stored data for name will be
removed. '''
missing = frozenset(val_map) - self._all_book_ids()
if missing:
raise ValueError('add_custom_book_data: no such book_ids: %d'%missing)
self.backend.add_custom_data(name, val_map, delete_first)
@read_api
def get_custom_book_data(self, name, book_ids=(), default=None):
''' Get data for name. By default returns data for all book_ids, pass
in a list of book ids if you only want some data. Returns a map of
book_id to values. If a particular value could not be decoded, uses
default for it. '''
return self.backend.get_custom_book_data(name, book_ids, default)
@write_api
def delete_custom_book_data(self, name, book_ids=()):
''' Delete data for name. By default deletes all data, if you only want
to delete data for some book ids, pass in a list of book ids. '''
self.backend.delete_custom_book_data(name, book_ids)
@read_api
def get_ids_for_custom_book_data(self, name):
''' Return the set of book ids for which name has data. '''
return self.backend.get_ids_for_custom_book_data(name)
@read_api
def conversion_options(self, book_id, fmt='PIPE'):
return self.backend.conversion_options(book_id, fmt)
@read_api
def has_conversion_options(self, ids, fmt='PIPE'):
return self.backend.has_conversion_options(ids, fmt)
@write_api
def delete_conversion_options(self, book_ids, fmt='PIPE'):
return self.backend.delete_conversion_options(book_ids, fmt)
@write_api
def set_conversion_options(self, options, fmt='PIPE'):
''' options must be a map of the form {book_id:conversion_options} '''
return self.backend.set_conversion_options(options, fmt)
@write_api
def refresh_format_cache(self):
self.fields['formats'].table.read(self.backend)
self.format_metadata_cache.clear()
@write_api
def refresh_ondevice(self):
self.fields['ondevice'].clear_caches()
self.clear_search_caches()
self.clear_composite_caches()
@read_api
def tags_older_than(self, tag, delta=None, must_have_tag=None, must_have_authors=None):
'''
Return the ids of all books having the tag ``tag`` that are older than
than the specified time. tag comparison is case insensitive.
:param delta: A timedelta object or None. If None, then all ids with
the tag are returned.
:param must_have_tag: If not None the list of matches will be
restricted to books that have this tag
:param must_have_authors: A list of authors. If not None the list of
matches will be restricted to books that have these authors (case
insensitive).
'''
tag_map = {icu_lower(v):k for k, v in self._get_id_map('tags').iteritems()}
tag = icu_lower(tag.strip())
mht = icu_lower(must_have_tag.strip()) if must_have_tag else None
tag_id, mht_id = tag_map.get(tag, None), tag_map.get(mht, None)
ans = set()
if mht_id is None and mht:
return ans
if tag_id is not None:
tagged_books = self._books_for_field('tags', tag_id)
if mht_id is not None and tagged_books:
tagged_books = tagged_books.intersection(self._books_for_field('tags', mht_id))
if tagged_books:
if must_have_authors is not None:
amap = {icu_lower(v):k for k, v in self._get_id_map('authors').iteritems()}
books = None
for author in must_have_authors:
abooks = self._books_for_field('authors', amap.get(icu_lower(author), None))
books = abooks if books is None else books.intersection(abooks)
if not books:
break
tagged_books = tagged_books.intersection(books or set())
if delta is None:
ans = tagged_books
else:
now = nowf()
for book_id in tagged_books:
ts = self._field_for('timestamp', book_id)
if (now - ts) > delta:
ans.add(book_id)
return ans
@write_api
def set_sort_for_authors(self, author_id_to_sort_map, update_books=True):
sort_map = self.fields['authors'].table.set_sort_names(author_id_to_sort_map, self.backend)
changed_books = set()
if update_books:
val_map = {}
for author_id in sort_map:
books = self._books_for_field('authors', author_id)
changed_books |= books
for book_id in books:
authors = self._field_ids_for('authors', book_id)
adata = self._author_data(authors)
sorts = [adata[x]['sort'] for x in authors]
val_map[book_id] = ' & '.join(sorts)
if val_map:
self._set_field('author_sort', val_map)
if changed_books:
self._mark_as_dirty(changed_books)
return changed_books
@write_api
def set_link_for_authors(self, author_id_to_link_map):
link_map = self.fields['authors'].table.set_links(author_id_to_link_map, self.backend)
changed_books = set()
for author_id in link_map:
changed_books |= self._books_for_field('authors', author_id)
if changed_books:
self._mark_as_dirty(changed_books)
return changed_books
@read_api
def lookup_by_uuid(self, uuid):
return self.fields['uuid'].table.lookup_by_uuid(uuid)
@write_api
def delete_custom_column(self, label=None, num=None):
self.backend.delete_custom_column(label, num)
@write_api
def create_custom_column(self, label, name, datatype, is_multiple, editable=True, display={}):
self.backend.create_custom_column(label, name, datatype, is_multiple, editable=editable, display=display)
@write_api
def set_custom_column_metadata(self, num, name=None, label=None, is_editable=None,
display=None, update_last_modified=False):
changed = self.backend.set_custom_column_metadata(num, name=name, label=label, is_editable=is_editable, display=display)
if changed:
if update_last_modified:
self._update_last_modified(self._all_book_ids())
else:
self.backend.prefs.set('update_all_last_mod_dates_on_start', True)
return changed
@read_api
def get_books_for_category(self, category, item_id_or_composite_value):
f = self.fields[category]
if hasattr(f, 'get_books_for_val'):
# Composite field
return f.get_books_for_val(item_id_or_composite_value, self._get_proxy_metadata, self._all_book_ids())
return self._books_for_field(f.name, int(item_id_or_composite_value))
@read_api
def find_identical_books(self, mi, search_restriction='', book_ids=None):
''' Finds books that have a superset of the authors in mi and the same
title (title is fuzzy matched) '''
fuzzy_title_patterns = [(re.compile(pat, re.IGNORECASE) if
isinstance(pat, basestring) else pat, repl) for pat, repl in
[
(r'[\[\](){}<>\'";,:#]', ''),
(get_title_sort_pat(), ''),
(r'[-._]', ' '),
(r'\s+', ' ')
]
]
def fuzzy_title(title):
title = icu_lower(title.strip())
for pat, repl in fuzzy_title_patterns:
title = pat.sub(repl, title)
return title
identical_book_ids = set()
if mi.authors:
try:
quathors = mi.authors[:20] # Too many authors causes parsing of
# the search expression to fail
query = ' and '.join('authors:"=%s"'%(a.replace('"', '')) for a in quathors)
qauthors = mi.authors[20:]
except ValueError:
return identical_book_ids
try:
book_ids = self._search(query, restriction=search_restriction, book_ids=book_ids)
except:
traceback.print_exc()
return identical_book_ids
if qauthors and book_ids:
matches = set()
qauthors = {icu_lower(x) for x in qauthors}
for book_id in book_ids:
aut = self._field_for('authors', book_id)
if aut:
aut = {icu_lower(x) for x in aut}
if aut.issuperset(qauthors):
matches.add(book_id)
book_ids = matches
for book_id in book_ids:
fbook_title = self._field_for('title', book_id)
fbook_title = fuzzy_title(fbook_title)
mbook_title = fuzzy_title(mi.title)
if fbook_title == mbook_title:
identical_book_ids.add(book_id)
return identical_book_ids
@read_api
def get_top_level_move_items(self):
all_paths = {self._field_for('path', book_id).partition('/')[0] for book_id in self._all_book_ids()}
return self.backend.get_top_level_move_items(all_paths)
@write_api
def move_library_to(self, newloc, progress=None):
if progress is None:
progress = lambda x:x
all_paths = {self._field_for('path', book_id).partition('/')[0] for book_id in self._all_book_ids()}
self.backend.move_library_to(all_paths, newloc, progress=progress)
@read_api
def saved_search_names(self):
return self._search_api.saved_searches.names()
@read_api
def saved_search_lookup(self, name):
return self._search_api.saved_searches.lookup(name)
@write_api
def saved_search_set_all(self, smap):
self._search_api.saved_searches.set_all(smap)
self._clear_search_caches()
@write_api
def saved_search_delete(self, name):
self._search_api.saved_searches.delete(name)
self._clear_search_caches()
@write_api
def saved_search_add(self, name, val):
self._search_api.saved_searches.add(name, val)
@write_api
def saved_search_rename(self, old_name, new_name):
self._search_api.saved_searches.rename(old_name, new_name)
self._clear_search_caches()
@write_api
def change_search_locations(self, newlocs):
self._search_api.change_locations(newlocs)
@write_api
def dump_and_restore(self, callback=None, sql=None):
return self.backend.dump_and_restore(callback=callback, sql=sql)
@write_api
def vacuum(self):
self.backend.vacuum()
@write_api
def close(self):
self.backend.close()
@write_api
def restore_book(self, book_id, mi, last_modified, path, formats):
''' Restore the book entry in the database for a book that already exists on the filesystem '''
cover = mi.cover
mi.cover = None
self._create_book_entry(mi, add_duplicates=True,
force_id=book_id, apply_import_tags=False, preserve_uuid=True)
self._update_last_modified((book_id,), last_modified)
if cover and os.path.exists(cover):
self._set_field('cover', {book_id:1})
self.backend.restore_book(book_id, path, formats)
@read_api
def virtual_libraries_for_books(self, book_ids):
libraries = self._pref('virtual_libraries', {})
ans = {book_id:[] for book_id in book_ids}
for lib, expr in libraries.iteritems():
books = self._search(expr) # We deliberately dont use book_ids as we want to use the search cache
for book in book_ids:
if book in books:
ans[book].append(lib)
return {k:tuple(sorted(v, key=sort_key)) for k, v in ans.iteritems()}
# }}}