diff --git a/src/calibre/db/cli/cmd_add.py b/src/calibre/db/cli/cmd_add.py new file mode 100644 index 0000000000..bc2253e4ea --- /dev/null +++ b/src/calibre/db/cli/cmd_add.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python2 +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2017, Kovid Goyal + +from __future__ import absolute_import, division, print_function, unicode_literals + +import os +import sys +from io import BytesIO +from optparse import OptionGroup, OptionValueError + +from calibre import prints +from calibre.db.adding import compile_rule, recursive_import, import_book_directory, import_book_directory_multiple +from calibre.ebooks.metadata import MetaInformation, string_to_authors +from calibre.ebooks.metadata.book.serialize import read_cover +from calibre.ebooks.metadata.meta import get_metadata +from calibre.srv.changes import books_added +from calibre.utils.localization import canonicalize_lang + +readonly = False + + +def to_stream(data): + ans = BytesIO(data[1]) + ans.name = data[0] + return ans + + +def empty(db, notify_changes, is_remote, args): + mi = args[0] + ids, duplicates = db.add_books([(mi, {})]) + if is_remote: + notify_changes(books_added(ids)) + db.dump_metadata() + return ids, bool(duplicates) + + +def book(db, notify_changes, is_remote, args): + data, fmt, mi, add_duplicates = args + if is_remote: + data = to_stream(data) + ids, duplicates = db.add_books( + [(mi, {fmt: data})], add_duplicates=add_duplicates) + if is_remote: + notify_changes(books_added(ids)) + db.dump_metadata() + return ids, bool(duplicates) + + +def add_books(db, notify_changes, is_remote, args): + books, kwargs = args + if is_remote: + books = [(mi, {k:to_stream(v) for k, v in fmt_map.iteritems()}) for mi, fmt_map in books] + ids, duplicates = db.add_books(books, **kwargs) + if is_remote: + notify_changes(books_added(ids)) + db.dump_metadata() + return ids, [(mi.title, [getattr(x, 'name', '') for x in format_map.itervalues()]) for mi, format_map in duplicates] + + +def implementation(db, notify_changes, action, *args): + is_remote = notify_changes is not None + func = globals()[action] + return func(db, notify_changes, is_remote, args) + + +class DBProxy(object): + # Allows dbctx to be used with the directory adding API that expects a + # normal db object. Fortunately that API only calls one method, + # add_books() + + def __init__(self, dbctx): + self.new_api = self + self.dbctx = dbctx + + def add_books(self, books, **kwargs): + books = [(read_cover(mi), {k:self.dbctx.path(v) for k, v in fmt_map.iteritems()}) for mi, fmt_map in books] + return self.dbctx.run('add', 'add_books', books, kwargs) + + +def do_add_empty( + dbctx, title, authors, isbn, tags, series, series_index, cover, identifiers, + languages +): + mi = MetaInformation(None) + if title is not None: + mi.title = title + if authors: + mi.authors = authors + if identifiers: + mi.set_identifiers(identifiers) + if isbn: + mi.isbn = isbn + if tags: + mi.tags = tags + if series: + mi.series, mi.series_index = series, series_index + if cover: + mi.cover = cover + if languages: + mi.languages = languages + ids, duplicates = dbctx.run('add', 'empty', read_cover(mi)) + prints(_('Added book ids: %s') % ','.join(map(str, ids))) + + +def do_add( + dbctx, paths, one_book_per_directory, recurse, add_duplicates, otitle, oauthors, + oisbn, otags, oseries, oseries_index, ocover, oidentifiers, olanguages, + compiled_rules +): + orig = sys.stdout + try: + files, dirs = [], [] + for path in paths: + path = os.path.abspath(path) + if os.path.isdir(path): + dirs.append(path) + else: + if os.path.exists(path): + files.append(path) + else: + prints(path, 'not found') + + file_duplicates, added_ids = [], set() + for book in files: + fmt = os.path.splitext(book)[1] + fmt = fmt[1:] if fmt else None + if not fmt: + continue + with lopen(book, 'rb') as stream: + mi = get_metadata(stream, stream_type=fmt, use_libprs_metadata=True) + if not mi.title: + mi.title = os.path.splitext(os.path.basename(book))[0] + if not mi.authors: + mi.authors = [_('Unknown')] + if oidentifiers: + ids = mi.get_identifiers() + ids.update(oidentifiers) + mi.set_identifiers(ids) + for x in ('title', 'authors', 'isbn', 'tags', 'series', 'languages'): + val = locals()['o' + x] + if val: + setattr(mi, x, val) + if oseries: + mi.series_index = oseries_index + if ocover: + mi.cover = ocover + mi.cover_data = (None, None) + + ids, dups = dbctx.run( + 'add', 'book', dbctx.path(book), fmt, read_cover(mi), add_duplicates + ) + added_ids |= set(ids) + if dups: + file_duplicates.append((mi.title, book)) + + dir_dups = [] + dbproxy = DBProxy(dbctx) + + for dpath in dirs: + if recurse: + dups = recursive_import( + dbproxy, + dpath, + single_book_per_directory=one_book_per_directory, + added_ids=added_ids, + compiled_rules=compiled_rules, + add_duplicates=add_duplicates + ) or [] + else: + func = import_book_directory if one_book_per_directory else import_book_directory_multiple + dups = func( + dbproxy, + dpath, + added_ids=added_ids, + compiled_rules=compiled_rules, + add_duplicates=add_duplicates + ) or [] + dir_dups.extend(dups) + + sys.stdout = sys.__stdout__ + + if dir_dups or file_duplicates: + prints( + _( + 'The following books were not added as ' + 'they already exist in the database ' + '(see --duplicates option):' + ), + file=sys.stderr + ) + for title, formats in dir_dups: + prints(' ', title, file=sys.stderr) + for path in formats: + prints(' ', path) + if file_duplicates: + for title, path in file_duplicates: + prints(' ', title, file=sys.stderr) + prints(' ', path) + + if added_ids: + prints(_('Added book ids: %s') % (', '.join(map(type(u''), added_ids)))) + finally: + sys.stdout = orig + + +def option_parser(get_parser): + parser = get_parser( + _( + '''\ +%prog add [options] file1 file2 file3 ... + +Add the specified files as books to the database. You can also specify directories, see +the directory related options below. +''' + ) + ) + parser.add_option( + '-d', + '--duplicates', + action='store_true', + default=False, + help=_( + 'Add books to database even if they already exist. Comparison is done based on book titles.' + ) + ) + parser.add_option( + '-e', + '--empty', + action='store_true', + default=False, + help=_('Add an empty book (a book with no formats)') + ) + parser.add_option( + '-t', '--title', default=None, help=_('Set the title of the added book(s)') + ) + parser.add_option( + '-a', + '--authors', + default=None, + help=_('Set the authors of the added book(s)') + ) + parser.add_option( + '-i', '--isbn', default=None, help=_('Set the ISBN of the added book(s)') + ) + parser.add_option( + '-I', + '--identifier', + default=[], + action='append', + help=_('Set the identifiers for this book, for e.g. -I asin:XXX -I isbn:YYY') + ) + parser.add_option( + '-T', '--tags', default=None, help=_('Set the tags of the added book(s)') + ) + parser.add_option( + '-s', + '--series', + default=None, + help=_('Set the series of the added book(s)') + ) + parser.add_option( + '-S', + '--series-index', + default=1.0, + type=float, + help=_('Set the series number of the added book(s)') + ) + parser.add_option( + '-c', + '--cover', + default=None, + help=_('Path to the cover to use for the added book') + ) + parser.add_option( + '-l', + '--languages', + default=None, + help=_( + 'A comma separated list of languages (best to use ISO639 language codes, though some language names may also be recognized)' + ) + ) + + g = OptionGroup( + parser, + _('ADDING FROM DIRECTORIES'), + _( + 'Options to control the adding of books from directories. By default only files that have extensions of known e-book file types are added.' + ) + ) + + def filter_pat(option, opt, value, parser, action): + try: + getattr(parser.values, option.dest).append( + compile_rule({ + 'match_type': 'glob', + 'query': value, + 'action': action + }) + ) + except Exception: + raise OptionValueError('%r is not a valid filename pattern' % value) + + g.add_option( + '-1', + '--one-book-per-directory', + action='store_true', + default=False, + help=_( + 'Assume that each directory has only a single logical book and that all files in it are different e-book formats of that book' + ) + ) + g.add_option( + '-r', + '--recurse', + action='store_true', + default=False, + help=_('Process directories recursively') + ) + + def fadd(opt, action, help): + g.add_option( + opt, + action='callback', + type='string', + nargs=1, + default=[], + callback=filter_pat, + dest='filters', + callback_args=(action, ), + metavar=_('GLOB PATTERN'), + help=help + ) + + fadd( + '--ignore', 'ignore', + _( + 'A filename (glob) pattern, files matching this pattern will be ignored when scanning directories for files.' + ' Can be specified multiple times for multiple patterns. For e.g.: *.pdf will ignore all pdf files' + ) + ) + fadd( + '--add', 'add', + _( + 'A filename (glob) pattern, files matching this pattern will be added when scanning directories for files,' + ' even if they are not of a known e-book file type. Can be specified multiple times for multiple patterns.' + ) + ) + parser.add_option_group(g) + + return parser + + +def main(opts, args, dbctx): + aut = string_to_authors(opts.authors) if opts.authors else [] + tags = [x.strip() for x in opts.tags.split(',')] if opts.tags else [] + lcodes = [canonicalize_lang(x) for x in (opts.languages or '').split(',')] + lcodes = [x for x in lcodes if x] + identifiers = (x.partition(':')[::2] for x in opts.identifier) + identifiers = dict((k.strip(), v.strip()) for k, v in identifiers + if k.strip() and v.strip()) + if opts.empty: + do_add_empty( + dbctx, opts.title, aut, opts.isbn, tags, opts.series, opts.series_index, + opts.cover, identifiers, lcodes + ) + return 0 + if len(args) < 1: + raise SystemExit(_('You must specify at least one file to add')) + do_add( + dbctx, args, opts.one_book_per_directory, opts.recurse, opts.duplicates, + opts.title, aut, opts.isbn, tags, opts.series, opts.series_index, opts.cover, + identifiers, lcodes, opts.filters + ) + return 0 diff --git a/src/calibre/srv/changes.py b/src/calibre/srv/changes.py new file mode 100644 index 0000000000..6dbc48b921 --- /dev/null +++ b/src/calibre/srv/changes.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python2 +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2017, Kovid Goyal + +from __future__ import absolute_import, division, print_function, unicode_literals + + +def books_added(book_ids): + pass + + +def formats_added(formats_map): + pass