Source code for calibre.ebooks.metadata.book.base

#!/usr/bin/env python
# License: GPLv3 Copyright: 2010, Kovid Goyal <kovid@kovidgoyal.net>

import copy
import traceback
from collections.abc import Iterable, Sequence
from contextlib import suppress
from datetime import date
from typing import TYPE_CHECKING, TypedDict, cast

from calibre import prints
from calibre.constants import DEBUG
from calibre.ebooks.metadata.book import ALL_METADATA_FIELDS, SC_COPYABLE_FIELDS, SC_FIELDS_COPY_NOT_NULL, STANDARD_METADATA_FIELDS, TOP_LEVEL_IDENTIFIERS
from calibre.library.field_metadata import FieldMetadata
from calibre.utils.icu import lower as icu_lower
from calibre.utils.icu import sort_key
from calibre.utils.localization import _, ngettext

if TYPE_CHECKING:
    from collections.abc import Callable, Collection, Iterator, Mapping
    from datetime import datetime

    from calibre.db.lazy import ProxyMetadata
    from calibre.ebooks.metadata.book.formatter import SafeFormat
    from calibre.ebooks.metadata.opf2 import OPF, Guide, Manifest, Spine
    from calibre.ebooks.metadata.toc import TOC
    from calibre.utils.formatter import TemplateCache


# The display dicts of field metadata, mostly used for custom columns, see
# gui2/preferences/create_custom_column.py
class DisplayDict(TypedDict, total=False):
    allow_half_stars: bool
    bools_show_icons: bool
    bools_show_text: bool
    composite_show_in_comments: bool
    composite_sort: str
    composite_store_template_value_in_opf: bool
    composite_template: str
    contains_html: bool
    date_format: str | None
    decimals: int
    default_value: object  # type depends on the datatype of the column
    description: str
    enum_colors: list[str]
    enum_values: list[str]
    heading_position: str
    interpret_as: str
    is_names: bool
    make_category: bool
    number_format: str | None
    sort_alpha: bool
    use_decorations: bool
    web_search_template: str


# The field metadata dicts created by FieldMetadata (see the docstring of
# FieldMetadata for the meaning of the keys). Which keys are present depends
# on the kind of field, hence total=False. The user_metadata dicts on Metadata
# objects additionally store the value of the field under #value# and its
# extra value (series index) under #extra#.
FieldMetadataDict = TypedDict(
    'FieldMetadataDict',
    {
        'table': str | None,
        'column': str | None,
        'datatype': str | None,
        # Empty for fields that are not multiple, otherwise has the keys
        # cache_to_list, ui_to_list and list_to_ui
        'is_multiple': dict[str, str],
        'kind': str,
        'name': str | None,
        'search_terms': list[str],
        'is_custom': bool,
        'is_category': bool,
        'is_csp': bool,
        'is_editable': bool,
        'display': DisplayDict,
        'label': str,
        'colnum': int | None,
        'rec_index': int,
        'category_sort': str | None,
        'link_column': str | None,
        '#value#': object,
        '#extra#': object,
    },
    total=False,
)

# Special sets used to optimize the performance of getting and setting
# attributes on Metadata objects
SIMPLE_GET = frozenset(STANDARD_METADATA_FIELDS - TOP_LEVEL_IDENTIFIERS)
SIMPLE_SET = frozenset(SIMPLE_GET - {'identifiers'})


def human_readable(size: float, precision: int = 2) -> str:
    """Convert a size in bytes into megabytes"""
    ans = size / (1024 * 1024)
    if ans < 0.1:
        return '<0.1 MB'
    return ('%.' + str(precision) + 'f' + ' MB') % ans


NULL_VALUES = {
    'user_metadata': {},
    'cover_data': (None, None),
    'tags': [],
    'identifiers': {},
    'languages': [],
    'device_collections': [],
    'author_sort_map': {},
    'authors': [_('Unknown')],
    'author_sort': _('Unknown'),
    'title': _('Unknown'),
    'user_categories': {},
    'link_maps': {},
    'language': 'und',
}

field_metadata = FieldMetadata()


def reset_field_metadata() -> None:
    global field_metadata
    field_metadata = FieldMetadata()


def ck(typ: str) -> str:
    return icu_lower(typ).strip().replace(':', '').replace(',', '')


def cv(val: str) -> str:
    return val.strip().replace(',', '|')


[docs] class Metadata: ''' A class representing all the metadata for a book. The various standard metadata fields are available as attributes of this object. You can also stick arbitrary attributes onto this object. Metadata from custom columns should be accessed via the get() method, passing in the lookup name for the column, for example: "#mytags". Use the :meth:`is_null` method to test if a field is null. This object also has functions to format fields into strings. The list of standard metadata fields grows with time is in :data:`STANDARD_METADATA_FIELDS`. Please keep the method based API of this class to a minimum. Every method becomes a reserved field name. ''' __calibre_serializable__ = True # The standard fields, see calibre.ebooks.metadata.book. These are stored # in _data and accessed via __getattribute__/__setattr__, the annotations # are for the type checker only. Fields whose null value is not in # NULL_VALUES are None when unset. tags: list[str] rating: float | None comments: str | None series: str | None series_index: float | None identifiers: dict[str, str] title: str title_sort: str | None authors: list[str] author_sort_map: dict[str, str] author_sort: str book_producer: str | None timestamp: datetime | None pubdate: datetime | None last_modified: datetime | None rights: str | None publication_type: str | None uuid: str | None languages: list[str] publisher: str | None cover: str | None cover_data: tuple[str | None, bytes | None] # Thumbnail data, or an object with an image_path attribute thumbnail: object # Book structure, only used by OPFCreator and OPF objects (which are duck # typed as Metadata), None on plain Metadata objects toc: TOC | None spine: Spine | None guide: Guide | None manifest: Manifest | None device_collections: list[str] lpath: str | None size: int | None mime: str | None application_id: int | str | None db_id: int | None formats: Sequence[str] | None # Maps user category names to [item name, category] pairs user_categories: dict[str, list[list[str]]] link_maps: dict[str, dict[str, str]] pages: int | None # Pseudo fields language: str # the first of languages isbn: str | None # identifiers['isbn'] # Arbitrary attributes set by calibre.db.cache.Cache.get_metadata(), # present only on objects created from a calibre library id: int _proxy_metadata: ProxyMetadata # Maps format to file name (without extension), set by the GUI on objects # for display in book details format_files: dict[str, str] def __init__( self, title: str | None, authors: Collection[str] | None = (_('Unknown'),), other: Metadata | OPF | None = None, template_cache: TemplateCache | None = None, formatter: SafeFormat | None = None, ) -> None: """ @param title: title or ``_('Unknown')`` @param authors: List of strings or [] @param other: None or a metadata object """ _data = copy.deepcopy(NULL_VALUES) _data.pop('language') object.__setattr__(self, '_data', _data) if other is not None: # OPF objects are duck typed as Metadata, smart_update() only uses # the attributes they share when replace_metadata is False self.smart_update(cast('Metadata', other)) else: # set() is used rather than attribute assignment so that the type # checker does not infer narrow types for these dynamic fields if title: self.set('title', title) if authors: # List of strings or [] self.set('author', list(authors) if authors else []) # Needed for backward compatibility self.set('authors', list(authors) if authors else []) if formatter is None: from calibre.ebooks.metadata.book.formatter import SafeFormat # Metadata objects are constructed from ebooks when reading # metadata. These are untrusted and thus python templates must be # disallowed self.formatter = SafeFormat() self.formatter.allow_python_templates = False else: self.formatter = formatter self.template_cache = template_cache
[docs] def is_null(self, field: str) -> bool: """ Return True if the value of field is null in this object. 'null' means it is unknown or evaluates to False. So a title of _('Unknown') is null or a language of 'und' is null. Be careful with numeric fields since this will return True for zero as well as None. Also returns True if the field does not exist. """ try: val = getattr(self, field, None) return not val or val == NULL_VALUES.get(field) except Exception: return True
def set_null(self, field: str) -> None: null_val = copy.copy(NULL_VALUES.get(field)) setattr(self, field, null_val) # The standard fields are declared above, everything else (custom columns # and arbitrary attributes stuck onto the object by callers) is dynamic def __getattribute__(self, field: str) -> object: _data = object.__getattribute__(self, '_data') if field in SIMPLE_GET: return _data.get(field, None) if field in TOP_LEVEL_IDENTIFIERS: return _data.get('identifiers').get(field, None) if field == 'language': try: return _data.get('languages', [])[0] except Exception: return NULL_VALUES['language'] try: return object.__getattribute__(self, field) except AttributeError: pass if field in _data['user_metadata']: d = _data['user_metadata'][field] val = d['#value#'] if val is None and d['datatype'] == 'composite': from calibre.utils.formatter import TEMPLATE_ERROR d['#value#'] = 'RECURSIVE_COMPOSITE FIELD (Metadata) ' + field val = d['#value#'] = self.formatter.safe_format( d['display']['composite_template'], self, TEMPLATE_ERROR, self, column_name=field, template_cache=self.template_cache, ).strip() return val if field.startswith('#') and field.endswith('_index'): try: return self.get_extra(field[:-6]) except Exception: pass raise AttributeError('Metadata object has no attribute named: ' + repr(field)) def __setattr__(self, field: str, val: object, extra: object = None) -> None: _data = object.__getattribute__(self, '_data') if field in SIMPLE_SET: if val is None: val = copy.copy(NULL_VALUES.get(field, None)) _data[field] = val elif field in TOP_LEVEL_IDENTIFIERS: field, val = self._clean_identifier(field, cast('str | None', val)) # identifiers are strings identifiers = _data['identifiers'] identifiers.pop(field, None) if val: identifiers[field] = val elif field == 'identifiers': if not val: val = copy.copy(NULL_VALUES.get('identifiers', None)) self.set_identifiers(cast('dict[str, str]', val)) elif field == 'language': langs = [] if val and cast('str', val).lower() != 'und': langs = [val] _data['languages'] = langs elif field in _data['user_metadata']: d = _data['user_metadata'][field] d['#value#'] = val d['#extra#'] = extra else: # You are allowed to stick arbitrary attributes onto this object as # long as they don't conflict with global or user metadata names # Don't abuse this privilege self.__dict__[field] = val def __iter__(self) -> Iterator[str]: return iter(object.__getattribute__(self, '_data')) def has_key(self, key: str) -> bool: return key in STANDARD_METADATA_FIELDS or key in object.__getattribute__(self, '_data')['user_metadata'] def _evaluate_all_composites(self) -> None: custom_fields = object.__getattribute__(self, '_data')['user_metadata'] for field in custom_fields: self._evaluate_composite(field) def _evaluate_composite(self, field: str) -> None: f = object.__getattribute__(self, '_data')['user_metadata'].get(field, None) if f is not None: if f['datatype'] == 'composite' and f['#value#'] is None: self.get(field)
[docs] def deepcopy(self, class_generator: Callable[[], Metadata] = lambda: Metadata(None)) -> Metadata: """Do not use this method unless you know what you are doing, if you want to create a simple clone of this object, use :meth:`deepcopy_metadata` instead. Class_generator must be a function that returns an instance of Metadata or a subclass of it.""" # We don't need to evaluate all the composites here because we # are returning a "real" Metadata instance that has __get_attribute__. m = class_generator() if not isinstance(m, Metadata): return None object.__setattr__(m, '__dict__', copy.deepcopy(self.__dict__)) return m
def deepcopy_metadata(self) -> Metadata: # We don't need to evaluate all the composites here because we # are returning a "real" Metadata instance that has __get_attribute__. m = Metadata(None) object.__setattr__(m, '_data', copy.deepcopy(object.__getattribute__(self, '_data'))) # Also copy these two top-level attributes as they can appear in templates. with suppress(AttributeError): object.__setattr__(m, 'id', copy.copy(self.__getattribute__('id'))) with suppress(AttributeError): object.__setattr__(m, 'has_cover', copy.copy(self.__getattribute__('has_cover'))) return m def get(self, field: str, default: object = None) -> object: try: return self.__getattribute__(field) except AttributeError: return default def get_extra(self, field: str, default: object = None) -> object: # Don't need to evaluate all composites because a composite can't have # an extra value _data = object.__getattribute__(self, '_data') if field in _data['user_metadata']: try: return _data['user_metadata'][field]['#extra#'] except Exception: return default raise AttributeError('Metadata object has no attribute named: ' + repr(field)) def set(self, field: str, val: object, extra: object = None) -> None: self.__setattr__(field, val, extra)
[docs] def get_identifiers(self) -> dict[str, str]: """ Return a copy of the identifiers dictionary. The dict is small, and the penalty for using a reference where a copy is needed is large. Also, we don't want any manipulations of the returned dict to show up in the book. """ ans = object.__getattribute__(self, '_data')['identifiers'] if not ans: ans = {} return copy.deepcopy(ans)
def _clean_identifier(self, typ: str, val: str | None) -> tuple[str, str | None]: if typ: typ = ck(typ) if val: val = cv(val) return typ, val
[docs] def set_identifiers(self, identifiers: dict[str, str]) -> None: """ Set all identifiers. Note that if you previously set ISBN, calling this method will delete it. """ cleaned = {ck(k): cv(v) for k, v in identifiers.items() if k and v} object.__getattribute__(self, '_data')['identifiers'] = cleaned
[docs] def set_identifier(self, typ: str, val: str | None) -> None: "If val is empty, deletes identifier of type typ" typ, val = self._clean_identifier(typ, val) if not typ: return identifiers = object.__getattribute__(self, '_data')['identifiers'] identifiers.pop(typ, None) if val: identifiers[typ] = val
def has_identifier(self, typ: str) -> bool: identifiers = object.__getattribute__(self, '_data')['identifiers'] return typ in identifiers # field-oriented interface. Intended to be the same as in LibraryDatabase
[docs] def standard_field_keys(self) -> frozenset[str]: """ return a list of all possible keys, even if this book doesn't have them """ return STANDARD_METADATA_FIELDS
[docs] def custom_field_keys(self) -> Iterator[str]: """ return a list of the custom fields in this book """ return iter(object.__getattribute__(self, '_data')['user_metadata'])
[docs] def all_field_keys(self) -> frozenset[str]: """ All field keys known by this instance, even if their value is None """ _data = object.__getattribute__(self, '_data') return frozenset(ALL_METADATA_FIELDS.union(frozenset(_data['user_metadata'])))
[docs] def metadata_for_field(self, key: str) -> FieldMetadataDict | None: """ return metadata describing a standard or custom field. """ if key not in self.custom_field_keys(): return self.get_standard_metadata(key, make_copy=False) return self.get_user_metadata(key, make_copy=False)
[docs] def all_non_none_fields(self) -> dict[str, object]: """ Return a dictionary containing all non-None metadata fields, including the custom ones. """ result = {} _data = object.__getattribute__(self, '_data') for attr in STANDARD_METADATA_FIELDS: v = _data.get(attr, None) if v is not None: result[attr] = v # separate these because it uses the self.get(), not _data.get() for attr in TOP_LEVEL_IDENTIFIERS: v = self.get(attr, None) if v is not None: result[attr] = v for attr in _data['user_metadata']: v = self.get(attr, None) if v is not None: result[attr] = v if _data['user_metadata'][attr]['datatype'] == 'series': result[attr + '_index'] = _data['user_metadata'][attr]['#extra#'] return result
# End of field-oriented interface # Extended interfaces. These permit one to get copies of metadata dictionaries, and to # get and set custom field metadata
[docs] def get_standard_metadata(self, field: str, make_copy: bool) -> FieldMetadataDict | None: """ return field metadata from the field if it is there. Otherwise return None. field is the key name, not the label. Return a copy if requested, just in case the user wants to change values in the dict. """ if field in field_metadata and field_metadata[field]['kind'] == 'field': if make_copy: return copy.deepcopy(field_metadata[field]) return field_metadata[field] return None
[docs] def get_all_standard_metadata(self, make_copy: bool) -> FieldMetadata | dict[str, FieldMetadataDict]: """ return a dict containing all the standard field metadata associated with the book. """ if not make_copy: return field_metadata res = {} for k in field_metadata: if field_metadata[k]['kind'] == 'field': res[k] = copy.deepcopy(field_metadata[k]) return res
[docs] def get_all_user_metadata(self, make_copy: bool) -> dict[str, FieldMetadataDict]: """ return a dict containing all the custom field metadata associated with the book. """ # Must evaluate all composites because we are returning a dict, not a # Metadata instance self._evaluate_all_composites() _data = object.__getattribute__(self, '_data') user_metadata = _data['user_metadata'] if not make_copy: return user_metadata res = {} for k in user_metadata: res[k] = copy.deepcopy(user_metadata[k]) return res
[docs] def get_user_metadata(self, field: str, make_copy: bool) -> FieldMetadataDict | None: """ return field metadata from the object if it is there. Otherwise return None. field is the key name, not the label. Return a copy if requested, just in case the user wants to change values in the dict. """ _data = object.__getattribute__(self, '_data')['user_metadata'] if field in _data: # Must evaluate the field because it might be a composite. It won't # be evaluated on demand because we are returning its dict, not a # Metadata instance self._evaluate_composite(field) if make_copy: return copy.deepcopy(_data[field]) return _data[field] return None
[docs] def set_all_user_metadata(self, metadata: Mapping[str, FieldMetadataDict] | None) -> None: """ store custom field metadata into the object. Field is the key name not the label """ if metadata is None: traceback.print_stack() return um = {} for key, meta in metadata.items(): m = meta.copy() if '#value#' not in m: if m['datatype'] == 'text' and m['is_multiple']: m['#value#'] = [] else: m['#value#'] = None um[key] = m _data = object.__getattribute__(self, '_data') _data['user_metadata'] = um
[docs] def set_user_metadata(self, field: str | None, metadata: Mapping[str, object] | None) -> None: """ store custom field metadata for one column into the object. Field is the key name not the label """ if field is not None: if not field.startswith('#'): raise AttributeError(f"Custom field name {field!r} must begin with '#'") if metadata is None: traceback.print_stack() return m = dict(metadata) # Copying the elements should not be necessary. The objects referenced # in the dict should not change. Of course, they can be replaced. # for k,v in metadata.items(): # m[k] = copy.copy(v) if '#value#' not in m: if m['datatype'] == 'text' and m['is_multiple']: m['#value#'] = [] else: m['#value#'] = None _data = object.__getattribute__(self, '_data') _data['user_metadata'][field] = m
[docs] def remove_stale_user_metadata(self, other_mi: Metadata) -> None: """ Remove user metadata keys (custom column keys) if they don't exist in 'other_mi', which must be a metadata object """ me = self.get_all_user_metadata(make_copy=False) other = set(other_mi.custom_field_keys()) new = {} for k, v in me.items(): if k in other: new[k] = v self.set_all_user_metadata(new)
[docs] def template_to_attribute(self, other: Metadata, ops: Iterable[Sequence[str]] | None) -> None: """ Takes a list [(src,dest), (src,dest)], evaluates the template in the context of other, then copies the result to self[dest]. This is on a best-efforts basis. Some assignments can make no sense. """ if not ops: return from calibre.ebooks.metadata.book.formatter import SafeFormat formatter = SafeFormat() formatter.allow_python_templates = False for op in ops: try: src = op[0] dest = op[1] val = formatter.safe_format(src, other, 'PLUGBOARD TEMPLATE ERROR', other) if dest == 'tags': self.set(dest, [f.strip() for f in val.split(',') if f.strip()]) elif dest == 'authors': self.set(dest, [f.strip() for f in val.split('&') if f.strip()]) else: self.set(dest, val) except Exception: if DEBUG: traceback.print_exc()
# Old Metadata API {{{ def print_all_attributes(self) -> None: for x in STANDARD_METADATA_FIELDS: prints(f'{x}:', getattr(self, x, 'None')) for x in self.custom_field_keys(): meta = self.get_user_metadata(x, make_copy=False) if meta is not None: prints(x, meta) prints('--------------')
[docs] def smart_update(self, other: Metadata, replace_metadata: bool = False) -> None: """ Merge the information in `other` into self. In case of conflicts, the information in `other` takes precedence, unless the information in `other` is NULL. """ def copy_not_none(dest: Metadata, src: Metadata, attr: str) -> None: v = getattr(src, attr, None) if v is not None and v != NULL_VALUES.get(attr, None): setattr(dest, attr, copy.deepcopy(v)) unknown = _('Unknown') if other.title and other.title != unknown: self.title = other.title if hasattr(other, 'title_sort'): self.title_sort = other.title_sort if other.authors and ( other.authors[0] != unknown or (not self.authors or (len(self.authors) == 1 and self.authors[0] == unknown and getattr(self, 'author_sort', None) == unknown)) ): self.set('authors', list(other.authors)) # see comment in __init__ if hasattr(other, 'author_sort_map'): self.author_sort_map = dict(other.author_sort_map) if hasattr(other, 'author_sort'): self.author_sort = other.author_sort if replace_metadata: # SPECIAL_FIELDS = frozenset(['lpath', 'size', 'comments', 'thumbnail']) for attr in SC_COPYABLE_FIELDS: setattr(self, attr, getattr(other, attr, 1.0 if attr == 'series_index' else None)) self.tags = other.tags self.cover_data = getattr(other, 'cover_data', cast('tuple[str | None, bytes | None]', NULL_VALUES['cover_data'])) self.set_all_user_metadata(other.get_all_user_metadata(make_copy=True)) for x in SC_FIELDS_COPY_NOT_NULL: copy_not_none(self, other, x) if callable(getattr(other, 'get_identifiers', None)): self.set_identifiers(other.get_identifiers()) # language is handled below else: for attr in SC_COPYABLE_FIELDS: copy_not_none(self, other, attr) for x in SC_FIELDS_COPY_NOT_NULL: copy_not_none(self, other, x) if other.tags: # Case-insensitive but case preserving merging lotags = [t.lower() for t in other.tags] lstags = [t.lower() for t in self.tags] ot, st = map(frozenset, (lotags, lstags)) for t in st.intersection(ot): sidx = lstags.index(t) oidx = lotags.index(t) self.tags[sidx] = other.tags[oidx] self.tags += [t for t in other.tags if t.lower() in ot - st] if getattr(other, 'cover_data', False): other_cover = other.cover_data[-1] self_cover = self.cover_data[-1] if not self_cover: self_cover = b'' if not other_cover: other_cover = b'' if len(other_cover) > len(self_cover): self.cover_data = other.cover_data if callable(getattr(other, 'custom_field_keys', None)): for x in other.custom_field_keys(): meta = other.get_user_metadata(x, make_copy=True) if meta is not None: self_tags = self.get(x, []) if isinstance(self_tags, (str, bytes)): self_tags = [] self.set_user_metadata(x, meta) # get... did the deepcopy other_tags = other.get(x, []) if meta['datatype'] == 'text' and meta['is_multiple']: # The value of a text, is_multiple field is a list of strings assert isinstance(other_tags, Sequence) # Case-insensitive but case preserving merging lotags = [t.lower() for t in other_tags] if isinstance(self_tags, Iterable): lstags = [t.lower() for t in self_tags] else: # Happens if x is not a text, is_multiple field # on self lstags = [] self_tags = [] # self_tags is modified in place below, which only works for lists assert isinstance(self_tags, list) ot, st = map(frozenset, (lotags, lstags)) for t in st.intersection(ot): sidx = lstags.index(t) oidx = lotags.index(t) self_tags[sidx] = other_tags[oidx] self_tags += [t for t in other_tags if t.lower() in ot - st] setattr(self, x, self_tags) my_comments = getattr(self, 'comments', '') other_comments = getattr(other, 'comments', '') if not my_comments: my_comments = '' if not other_comments: other_comments = '' if len(other_comments.strip()) > len(my_comments.strip()): self.comments = other_comments # Copy all the non-none identifiers if callable(getattr(other, 'get_identifiers', None)): d = self.get_identifiers() s = other.get_identifiers() d.update([v for v in s.items() if v[1] is not None]) self.set_identifiers(d) else: # other structure not Metadata. Copy the top-level identifiers for attr in TOP_LEVEL_IDENTIFIERS: copy_not_none(self, other, attr) other_lang = getattr(other, 'languages', []) if other_lang and other_lang != ['und']: self.languages = list(other_lang) if not getattr(self, 'series', None): self.series_index = None
def format_series_index(self, val: object = None) -> str: from calibre.ebooks.metadata import fmt_sidx v = self.series_index if val is None else val try: assert v is not None x = float(v) # ty: ignore[invalid-argument-type] v can be anything, failures are handled below except Exception: x = 1.0 return fmt_sidx(x) def authors_from_string(self, raw: str) -> None: from calibre.ebooks.metadata import string_to_authors self.set('authors', string_to_authors(raw)) # see comment in __init__ def format_authors(self) -> str: from calibre.ebooks.metadata import authors_to_string return authors_to_string(self.authors) def format_tags(self) -> str: return ', '.join([str(t) for t in sorted(self.tags, key=sort_key)]) def format_rating(self, v: float | None = None, divide_by: float = 1) -> str: if v is None: if self.rating is not None: return str(self.rating / divide_by) return 'None' return str(v / divide_by)
[docs] def format_field(self, key: str, series_with_index: bool = True) -> tuple[str | None, str | None]: """ Returns the tuple (display_name, formatted_value) """ name, val, _ign, _ign = self.format_field_extended(key, series_with_index) return name, val
def format_field_extended(self, key: str, series_with_index: bool = True) -> tuple[str | None, str | None, object, FieldMetadataDict | None]: from calibre.ebooks.metadata import authors_to_string ''' returns the tuple (display_name, formatted_value, original_value, field_metadata) ''' from calibre.utils.date import format_date # Handle custom series index if key.startswith('#') and key.endswith('_index'): tkey = key[:-6] # strip the _index cmeta = self.get_user_metadata(tkey, make_copy=False) if cmeta and cmeta['datatype'] == 'series': if self.get(tkey): res = self.get_extra(tkey) return (str(cast('str', cmeta['name']) + '_index'), self.format_series_index(res), res, cmeta) else: return (str(cast('str', cmeta['name']) + '_index'), '', '', cmeta) if key in self.custom_field_keys(): res = self.get(key, None) # get evaluates all necessary composites cmeta = self.get_user_metadata(key, make_copy=False) assert cmeta is not None # key is a custom field key name = str(cmeta['name']) if res is None or res == '': # can't check "not res" because of numeric fields assert res is None or isinstance(res, str) return (name, res, None, None) orig_res = res datatype = cmeta['datatype'] if datatype == 'text' and cmeta['is_multiple']: assert isinstance(res, Iterable) # the value is a list of strings res = cmeta['is_multiple']['list_to_ui'].join(res) elif datatype == 'series' and series_with_index: if self.get_extra(key) is not None: assert isinstance(res, str) # the series name res = res + f' [{self.format_series_index(val=self.get_extra(key))}]' elif datatype == 'datetime': assert isinstance(res, date) res = format_date(res, cmeta['display'].get('date_format', 'dd MMM yyyy')) elif datatype == 'bool': res = _('Yes') if res else _('No') elif datatype == 'rating': assert isinstance(res, (int, float)) res = f'{res / 2:.2g}' elif datatype in ['int', 'float']: try: fmt = cmeta['display'].get('number_format', None) if fmt is not None: res = fmt.format(res) except Exception: pass return (name, str(res), orig_res, cmeta) # convert top-level ids into their value if key in TOP_LEVEL_IDENTIFIERS: fmeta = field_metadata['identifiers'] name = key res = self.get(key, None) assert res is None or isinstance(res, str) # identifiers are strings return (name, res, res, fmeta) # Translate aliases into the standard field name # grouped search terms (which map to lists) are not valid here fmkey = cast('str', field_metadata.search_term_to_field_key(key)) if fmkey in field_metadata and field_metadata[fmkey]['kind'] == 'field': res = self.get(key, None) fmeta = field_metadata[fmkey] name = str(fmeta['name']) if res is None or res == '': assert res is None or isinstance(res, str) return (name, res, None, None) orig_res = res name = str(fmeta['name']) datatype = fmeta['datatype'] if key == 'authors': assert isinstance(res, list) res = authors_to_string(res) elif key == 'series_index': res = self.format_series_index(res) elif datatype == 'text' and fmeta['is_multiple']: if isinstance(res, dict): res = [k + ':' + v for k, v in res.items()] assert isinstance(res, Iterable) # the value is a list of strings res = fmeta['is_multiple']['list_to_ui'].join(sorted(filter(None, res), key=sort_key)) elif datatype == 'series' and series_with_index: assert isinstance(res, str) # the series name res = res + f' [{self.format_series_index()}]' elif datatype == 'datetime': assert isinstance(res, date) res = format_date(res, fmeta['display'].get('date_format', 'dd MMM yyyy')) elif datatype == 'rating': assert isinstance(res, (int, float)) res = f'{res / 2:.2g}' elif key == 'size': assert isinstance(res, (int, float)) res = human_readable(res) elif key == 'pages': match res: case -3: res = _('DRM') case -2: res = _('Error') case -1: res = _('None') return (name, str(res), orig_res, fmeta) if kv := self.get(key, None): return (key, str(kv), kv, None) return (None, None, None, None) def __unicode__representation__(self) -> str: """ A string representation of this object, suitable for printing to console """ from calibre.ebooks.metadata import authors_to_string from calibre.utils.date import isoformat ans = [] def fmt(x: object, y: object) -> None: ans.append(f'{x:<20}: {y}') fmt('Title', self.title) if self.title_sort: fmt('Title sort', self.title_sort) if self.authors: fmt( 'Author(s)', authors_to_string(self.authors) + ((' [' + self.author_sort + ']') if self.author_sort and self.author_sort != _('Unknown') else ''), ) if self.publisher: fmt('Publisher', self.publisher) if getattr(self, 'book_producer', False): fmt('Book Producer', self.book_producer) if self.tags: fmt('Tags', ', '.join([str(t) for t in self.tags])) if self.series: fmt('Series', self.series + f' #{self.format_series_index()}') if not self.is_null('languages'): fmt('Languages', ', '.join(self.languages)) if self.rating is not None: fmt('Rating', (f'{float(self.rating) / 2:.2g}') if self.rating else '') if self.timestamp is not None: fmt('Timestamp', isoformat(self.timestamp)) if self.pubdate is not None: fmt('Published', isoformat(self.pubdate)) if self.rights is not None: fmt('Rights', str(self.rights)) if self.identifiers: fmt('Identifiers', ', '.join([f'{k}:{v}' for k, v in self.identifiers.items()])) if self.comments: fmt('Comments', self.comments) for key in self.custom_field_keys(): val = self.get(key, None) if val: name, val = self.format_field(key) fmt(name, str(val)) return '\n'.join(ans)
[docs] def to_html(self) -> str: """ A HTML representation of this object. """ from calibre.ebooks.metadata import authors_to_string from calibre.utils.date import isoformat ans: list[tuple[str | None, str | None]] = [(_('Title'), str(self.title))] ans += [(_('Author(s)'), (authors_to_string(self.authors) if self.authors else _('Unknown')))] ans += [(_('Publisher'), str(self.publisher))] ans += [(_('Producer'), str(self.book_producer))] ans += [(_('Comments'), str(self.comments))] ans += [('ISBN', str(self.isbn))] ans += [(_('Tags'), ', '.join([str(t) for t in self.tags]))] if self.series: ans += [(ngettext('Series', 'Series', 1), str(self.series) + f' #{self.format_series_index()}')] ans += [(_('Languages'), ', '.join(self.languages))] if self.timestamp is not None: ans += [(_('Timestamp'), str(isoformat(self.timestamp, as_utc=False, sep=' ')))] if self.pubdate is not None: ans += [(_('Published'), str(isoformat(self.pubdate, as_utc=False, sep=' ')))] if self.rights is not None: ans += [(_('Rights'), str(self.rights))] for key in self.custom_field_keys(): val = self.get(key, None) if val: name, val = self.format_field(key) ans += [(name, val)] rows = '\n'.join('<tr><td><b>{}</b></td><td>{}</td></tr>'.format(*x) for x in ans) return f'<table>{rows}</table>'
__str__ = __unicode__representation__ def __nonzero__(self) -> bool: return bool(self.title or self.author or self.comments or self.tags) __bool__ = __nonzero__
# }}} def field_from_string(field: str, raw: str, field_metadata: FieldMetadataDict) -> object: """Parse the string raw to return an object that is suitable for calling set() on a Metadata object.""" dt = field_metadata['datatype'] val = object if dt in {'int', 'float'}: val = int(raw) if dt == 'int' else float(raw) elif dt == 'rating': val = float(raw) * 2 elif dt == 'datetime': from calibre.utils.iso8601 import parse_iso8601 try: val = parse_iso8601(raw, require_aware=True) except Exception: from calibre.utils.date import parse_only_date val = parse_only_date(raw) elif dt == 'bool': if raw.lower() in {'true', 'yes', 'y'}: val = True elif raw.lower() in {'false', 'no', 'n'}: val = False else: raise ValueError(f'Unknown value for {field}: {raw}') elif dt == 'text': ism = field_metadata['is_multiple'] if ism: val = [x.strip() for x in raw.split(ism['ui_to_list'])] if field == 'identifiers': val = {x.partition(':')[0]: x.partition(':')[-1] for x in val} elif field == 'languages': from calibre.utils.localization import canonicalize_lang val = [canonicalize_lang(x) for x in val] val = [x for x in val if x] if val is object: val = raw return val def get_model_metadata_instance() -> Metadata: """ Get a metadata instance that contains all the fields in the current database with the fields to a plausible value. This function must only be used in the GUI thread. """ from calibre.gui2 import is_gui_thread if not is_gui_thread(): raise ValueError('get_model_metadata_instance() must only be used in the GUI thread') mi = Metadata(_('Title'), [_('Author')]) mi.author_sort = _('Author Sort') mi.series = ngettext('Series', 'Series', 1) mi.series_index = 3 mi.rating = 4.0 mi.tags = [_('Tag 1'), _('Tag 2')] mi.languages = ['eng'] mi.id = -1 from calibre.gui2.ui import get_gui from calibre.utils.date import DEFAULT_DATE gui = get_gui() assert gui is not None fm = gui.current_db.new_api.field_metadata mi.set_all_user_metadata(fm.custom_field_metadata()) for col in mi.get_all_user_metadata(False): if fm[col]['datatype'] == 'datetime': mi.set(col, DEFAULT_DATE) elif fm[col]['datatype'] in ('int', 'float', 'rating'): mi.set(col, 2) elif fm[col]['datatype'] == 'bool': mi.set(col, False) elif fm[col]['is_multiple']: mi.set(col, [col]) else: mi.set(col, col, 1) return mi