mirror of
https://github.com/kovidgoyal/calibre.git
synced 2026-05-30 10:35:20 -04:00
526 lines
19 KiB
Python
526 lines
19 KiB
Python
#!/usr/bin/env python
|
|
__license__ = 'GPL v3'
|
|
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
|
|
__docformat__ = 'restructuredtext en'
|
|
|
|
'''
|
|
Command line interface to the calibre database.
|
|
'''
|
|
|
|
import sys, os, cStringIO
|
|
from textwrap import TextWrapper
|
|
|
|
from calibre import terminal_controller, preferred_encoding
|
|
from calibre.utils.config import OptionParser, prefs
|
|
try:
|
|
from calibre.utils.single_qt_application import send_message
|
|
except:
|
|
send_message = None
|
|
from calibre.ebooks.metadata.meta import get_metadata
|
|
from calibre.library.database2 import LibraryDatabase2
|
|
from calibre.library.database import text_to_tokens
|
|
from calibre.ebooks.metadata.opf import OPFCreator, OPFReader
|
|
from calibre.utils.genshi.template import MarkupTemplate
|
|
|
|
FIELDS = set(['title', 'authors', 'publisher', 'rating', 'timestamp', 'size', 'tags', 'comments', 'series', 'series_index', 'formats', 'isbn', 'cover'])
|
|
|
|
XML_TEMPLATE = '''\
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<calibredb xmlns:py="http://genshi.edgewall.org/">
|
|
<py:for each="record in data">
|
|
<record>
|
|
<id>${record['id']}</id>
|
|
<title>${record['title']}</title>
|
|
<authors>
|
|
<py:for each="author in record['authors']">
|
|
<author>$author</author>
|
|
</py:for>
|
|
</authors>
|
|
<publisher>${record['publisher']}</publisher>
|
|
<rating>${record['rating']}</rating>
|
|
<date>${record['timestamp']}</date>
|
|
<size>${record['size']}</size>
|
|
<tags py:if="record['tags']">
|
|
<py:for each="tag in record['tags']">
|
|
<tag>$tag</tag>
|
|
</py:for>
|
|
</tags>
|
|
<comments>${record['comments']}</comments>
|
|
<series py:if="record['series']" index="${record['series_index']}">${record['series']}</series>
|
|
<isbn>${record['isbn']}</isbn>
|
|
<cover py:if="record['cover']">${record['cover']}</cover>
|
|
<formats py:if="record['formats']">
|
|
<py:for each="path in record['formats']">
|
|
<format>$path</format>
|
|
</py:for>
|
|
</formats>
|
|
</record>
|
|
</py:for>
|
|
</calibredb>
|
|
'''
|
|
|
|
STANZA_TEMPLATE='''\
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:py="http://genshi.edgewall.org/">
|
|
<title>calibre Library</title>
|
|
<author>
|
|
<name>calibre</name>
|
|
<uri>http://calibre.kovidgoyal.net</uri>
|
|
</author>
|
|
<subtitle>
|
|
${subtitle}
|
|
</subtitle>
|
|
<py:for each="record in data">
|
|
<entry>
|
|
<title>${record['title']}</title>
|
|
<id>urn:calibre:${record['id']}</id>
|
|
<author><name>${record['authors']}</name></author>
|
|
<updated>${record['timestamp'].strftime('%Y-%m-%dT%H:%M:%S+0000')}</updated>
|
|
<link type="application/epub+zip" href="${record['fmt_epub'].replace(sep, '/')}" />
|
|
<link py:if="record['cover']" rel="x-stanza-cover-image" type="image/png" href="${record['cover'].replace(sep, '/')}" />
|
|
<content py:if="record['comments']" type="xhtml">
|
|
<pre>${record['comments']}</pre>
|
|
</content>
|
|
</entry>
|
|
</py:for>
|
|
</feed>
|
|
'''
|
|
|
|
def get_parser(usage):
|
|
parser = OptionParser(usage)
|
|
go = parser.add_option_group('GLOBAL OPTIONS')
|
|
go.add_option('--library-path', default=None, help=_('Path to the calibre library. Default is to use the path stored in the settings.'))
|
|
|
|
return parser
|
|
|
|
def get_db(dbpath, options):
|
|
if options.library_path is not None:
|
|
dbpath = options.library_path
|
|
dbpath = os.path.abspath(dbpath)
|
|
print _('Using library at'), dbpath
|
|
return LibraryDatabase2(dbpath, row_factory=True)
|
|
|
|
def do_list(db, fields, sort_by, ascending, search_text, line_width, separator,
|
|
prefix, output_format, subtitle='Books in the calibre database'):
|
|
db.refresh(sort_by, ascending)
|
|
if search_text:
|
|
filters, OR = text_to_tokens(search_text)
|
|
db.filter(filters, False, OR)
|
|
authors_to_string = output_format in ['stanza', 'text']
|
|
data = db.get_data_as_dict(prefix, authors_as_string=authors_to_string)
|
|
fields = ['id'] + fields
|
|
if output_format == 'text':
|
|
widths = list(map(lambda x : 0, fields))
|
|
for record in data:
|
|
for f in record.keys():
|
|
record[f] = unicode(record[f])
|
|
record[f] = record[f].replace('\n', ' ')
|
|
for i in data:
|
|
for j, field in enumerate(fields):
|
|
widths[j] = max(widths[j], len(unicode(i[str(field)])))
|
|
|
|
screen_width = terminal_controller.COLS if line_width < 0 else line_width
|
|
if not screen_width:
|
|
screen_width = 80
|
|
field_width = screen_width//len(fields)
|
|
base_widths = map(lambda x: min(x+1, field_width), widths)
|
|
|
|
while sum(base_widths) < screen_width:
|
|
adjusted = False
|
|
for i in range(len(widths)):
|
|
if base_widths[i] < widths[i]:
|
|
base_widths[i] += min(screen_width-sum(base_widths), widths[i]-base_widths[i])
|
|
adjusted = True
|
|
break
|
|
if not adjusted:
|
|
break
|
|
|
|
widths = list(base_widths)
|
|
titles = map(lambda x, y: '%-*s'%(x, y), widths, fields)
|
|
print terminal_controller.GREEN + ''.join(titles)+terminal_controller.NORMAL
|
|
|
|
wrappers = map(lambda x: TextWrapper(x-1), widths)
|
|
o = cStringIO.StringIO()
|
|
|
|
for record in data:
|
|
text = [wrappers[i].wrap(unicode(record[field]).encode('utf-8')) for i, field in enumerate(fields)]
|
|
lines = max(map(len, text))
|
|
for l in range(lines):
|
|
for i, field in enumerate(text):
|
|
ft = text[i][l] if l < len(text[i]) else ''
|
|
filler = '%*s'%(widths[i]-len(ft)-1, '')
|
|
o.write(ft)
|
|
o.write(filler+separator)
|
|
print >>o
|
|
return o.getvalue()
|
|
elif output_format == 'xml':
|
|
template = MarkupTemplate(XML_TEMPLATE)
|
|
return template.generate(data=data).render('xml')
|
|
elif output_format == 'stanza':
|
|
data = [i for i in data if i.has_key('fmt_epub')]
|
|
template = MarkupTemplate(STANZA_TEMPLATE)
|
|
return template.generate(data=data, subtitle=subtitle, sep=os.sep).render('xml')
|
|
|
|
|
|
|
|
def command_list(args, dbpath):
|
|
parser = get_parser(_(
|
|
'''\
|
|
%prog list [options]
|
|
|
|
List the books available in the calibre database.
|
|
'''
|
|
))
|
|
parser.add_option('-f', '--fields', default='title,authors',
|
|
help=_('The fields to display when listing books in the database. Should be a comma separated list of fields.\nAvailable fields: %s\nDefault: %%default. The special field "all" can be used to select all fields. Only has effect in the text output format.')%','.join(FIELDS))
|
|
parser.add_option('--sort-by', default='timestamp',
|
|
help=_('The field by which to sort the results.\nAvailable fields: %s\nDefault: %%default')%','.join(FIELDS))
|
|
parser.add_option('--ascending', default=False, action='store_true',
|
|
help=_('Sort results in ascending order'))
|
|
parser.add_option('-s', '--search', default=None,
|
|
help=_('Filter the results by the search query. For the format of the search query, please see the search related documentation in the User Manual. Default is to do no filtering.'))
|
|
parser.add_option('-w', '--line-width', default=-1, type=int,
|
|
help=_('The maximum width of a single line in the output. Defaults to detecting screen size.'))
|
|
parser.add_option('--separator', default=' ', help=_('The string used to separate fields. Default is a space.'))
|
|
parser.add_option('--prefix', default=None, help=_('The prefix for all file paths. Default is the absolute path to the library folder.'))
|
|
of = ['text', 'xml', 'stanza']
|
|
parser.add_option('--output-format', choices=of, default='text',
|
|
help=_('The format in which to output the data. Available choices: %s. Defaults is text.')%of)
|
|
opts, args = parser.parse_args(sys.argv[:1] + args)
|
|
fields = [str(f.strip().lower()) for f in opts.fields.split(',')]
|
|
if 'all' in fields:
|
|
fields = sorted(list(FIELDS))
|
|
if not set(fields).issubset(FIELDS):
|
|
parser.print_help()
|
|
print
|
|
print >>sys.stderr, _('Invalid fields. Available fields:'), ','.join(FIELDS)
|
|
return 1
|
|
|
|
db = get_db(dbpath, opts)
|
|
if not opts.sort_by in FIELDS:
|
|
parser.print_help()
|
|
print
|
|
print >>sys.stderr, _('Invalid sort field. Available fields:'), ','.join(FIELDS)
|
|
return 1
|
|
|
|
print do_list(db, fields, opts.sort_by, opts.ascending, opts.search, opts.line_width, opts.separator,
|
|
opts.prefix, opts.output_format)
|
|
return 0
|
|
|
|
|
|
class DevNull(object):
|
|
|
|
def write(self, msg):
|
|
pass
|
|
NULL = DevNull()
|
|
|
|
def do_add(db, paths, one_book_per_directory, recurse, add_duplicates):
|
|
orig = sys.stdout
|
|
sys.stdout = NULL
|
|
try:
|
|
files, dirs = [], []
|
|
for path in paths:
|
|
path = os.path.abspath(path)
|
|
if os.path.isdir(path):
|
|
dirs.append(path)
|
|
else:
|
|
files.append(path)
|
|
|
|
formats, metadata = [], []
|
|
for book in files:
|
|
format = os.path.splitext(book)[1]
|
|
format = format[1:] if format else None
|
|
if not format:
|
|
continue
|
|
stream = open(book, 'rb')
|
|
mi = get_metadata(stream, stream_type=format, 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']
|
|
|
|
formats.append(format)
|
|
metadata.append(mi)
|
|
|
|
file_duplicates = db.add_books(files, formats, metadata, add_duplicates=add_duplicates)
|
|
if not file_duplicates[0]:
|
|
file_duplicates = []
|
|
else:
|
|
file_duplicates = file_duplicates[0]
|
|
|
|
dir_dups = []
|
|
for dir in dirs:
|
|
if recurse:
|
|
dir_dups.extend(db.recursive_import(dir, single_book_per_directory=one_book_per_directory))
|
|
else:
|
|
func = db.import_book_directory if one_book_per_directory else db.import_book_directory_multiple
|
|
dups = func(dir)
|
|
if not dups:
|
|
dups = []
|
|
dir_dups.extend(dups)
|
|
|
|
sys.stdout = sys.__stdout__
|
|
|
|
if add_duplicates:
|
|
for mi, formats in dir_dups:
|
|
db.import_book(mi, formats)
|
|
else:
|
|
if dir_dups or file_duplicates:
|
|
print >>sys.stderr, _('The following books were not added as they already exist in the database (see --duplicates option):')
|
|
for mi, formats in dir_dups:
|
|
title = mi.title
|
|
if isinstance(title, unicode):
|
|
title = title.encode(preferred_encoding)
|
|
print >>sys.stderr, '\t', title + ':'
|
|
for path in formats:
|
|
print >>sys.stderr, '\t\t ', path
|
|
if file_duplicates:
|
|
for path, mi in zip(file_duplicates[0], file_duplicates[2]):
|
|
title = mi.title
|
|
if isinstance(title, unicode):
|
|
title = title.encode(preferred_encoding)
|
|
print >>sys.stderr, '\t', title+':'
|
|
print >>sys.stderr, '\t\t ', path
|
|
|
|
if send_message is not None:
|
|
send_message('refreshdb:', 'calibre GUI')
|
|
finally:
|
|
sys.stdout = orig
|
|
|
|
|
|
|
|
def command_add(args, dbpath):
|
|
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('-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'))
|
|
parser.add_option('-r', '--recurse', action='store_true', default=False,
|
|
help=_('Process directories recursively'))
|
|
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.'))
|
|
opts, args = parser.parse_args(sys.argv[:1] + args)
|
|
if len(args) < 2:
|
|
parser.print_help()
|
|
print
|
|
print >>sys.stderr, _('You must specify at least one file to add')
|
|
return 1
|
|
do_add(get_db(dbpath, opts), args[1:], opts.one_book_per_directory, opts.recurse, opts.duplicates)
|
|
return 0
|
|
|
|
def do_remove(db, ids):
|
|
for x in ids:
|
|
if isinstance(x, int):
|
|
db.delete_book(x)
|
|
else:
|
|
for y in x:
|
|
db.delete_book(y)
|
|
|
|
if send_message is not None:
|
|
send_message('refreshdb:', 'calibre GUI')
|
|
|
|
|
|
def command_remove(args, dbpath):
|
|
parser = get_parser(_(
|
|
'''\
|
|
%prog remove ids
|
|
|
|
Remove the books identified by ids from the database. ids should be a comma separated \
|
|
list of id numbers (you can get id numbers by using the list command). For example, \
|
|
23,34,57-85
|
|
'''))
|
|
opts, args = parser.parse_args(sys.argv[:1] + args)
|
|
if len(args) < 2:
|
|
parser.print_help()
|
|
print
|
|
print >>sys.stderr, _('You must specify at least one book to remove')
|
|
return 1
|
|
|
|
ids = []
|
|
for x in args[1].split(','):
|
|
y = x.split('-')
|
|
if len(y) > 1:
|
|
ids.append(range(int(y[0], int(y[1]))))
|
|
else:
|
|
ids.append(int(y[0]))
|
|
|
|
do_remove(get_db(dbpath, opts), ids)
|
|
|
|
return 0
|
|
|
|
def do_add_format(db, id, fmt, buffer):
|
|
db.add_format(id, fmt.upper(), buffer, index_is_id=True)
|
|
|
|
|
|
def command_add_format(args, dbpath):
|
|
parser = get_parser(_(
|
|
'''\
|
|
%prog add_format [options] id ebook_file
|
|
|
|
Add the ebook in ebook_file to the available formats for the logical book identified \
|
|
by id. You can get id by using the list command. If the format already exists, it is replaced.
|
|
'''))
|
|
opts, args = parser.parse_args(sys.argv[:1] + args)
|
|
if len(args) < 3:
|
|
parser.print_help()
|
|
print
|
|
print >>sys.stderr, _('You must specify an id and an ebook file')
|
|
return 1
|
|
|
|
id, file, fmt = int(args[1]), open(args[2], 'rb'), os.path.splitext(args[2])[-1]
|
|
if not fmt:
|
|
print _('ebook file must have an extension')
|
|
do_add_format(get_db(dbpath, opts), id, fmt[1:], file)
|
|
return 0
|
|
|
|
def do_remove_format(db, id, fmt):
|
|
db.remove_format(id, fmt, index_is_id=True)
|
|
|
|
def command_remove_format(args, dbpath):
|
|
parser = get_parser(_(
|
|
'''
|
|
%prog remove_format [options] id fmt
|
|
|
|
Remove the format fmt from the logical book identified by id. \
|
|
You can get id by using the list command. fmt should be a file extension \
|
|
like LRF or TXT or EPUB. If the logical book does not have fmt available, \
|
|
do nothing.
|
|
'''))
|
|
opts, args = parser.parse_args(sys.argv[:1] + args)
|
|
if len(args) < 3:
|
|
parser.print_help()
|
|
print
|
|
print >>sys.stderr, _('You must specify an id and a format')
|
|
return 1
|
|
|
|
id, fmt = int(args[1]), args[2].upper()
|
|
do_remove_format(get_db(dbpath, opts), id, fmt)
|
|
return 0
|
|
|
|
def do_show_metadata(db, id, as_opf):
|
|
if not db.has_id(id):
|
|
raise ValueError('Id #%d is not present in database.'%id)
|
|
mi = db.get_metadata(id, index_is_id=True)
|
|
if as_opf:
|
|
mi = OPFCreator(os.getcwd(), mi)
|
|
mi.render(sys.stdout)
|
|
else:
|
|
print unicode(mi).encode(preferred_encoding)
|
|
|
|
def command_show_metadata(args, dbpath):
|
|
parser = get_parser(_(
|
|
'''
|
|
%prog show_metadata [options] id
|
|
|
|
Show the metadata stored in the calibre database for the book identified by id.
|
|
id is an id number from the list command.
|
|
'''))
|
|
parser.add_option('--as-opf', default=False, action='store_true',
|
|
help=_('Print metadata in OPF form (XML)'))
|
|
opts, args = parser.parse_args(sys.argv[1:]+args)
|
|
if len(args) < 2:
|
|
parser.print_help()
|
|
print
|
|
print >>sys.stderr, _('You must specify an id')
|
|
return 1
|
|
id = int(args[1])
|
|
do_show_metadata(get_db(dbpath, opts), id, opts.as_opf)
|
|
return 0
|
|
|
|
def do_set_metadata(db, id, stream):
|
|
mi = OPFReader(stream)
|
|
db.set_metadata(id, mi)
|
|
do_show_metadata(db, id, False)
|
|
if send_message is not None:
|
|
send_message('refreshdb:', 'calibre GUI')
|
|
|
|
def command_set_metadata(args, dbpath):
|
|
parser = get_parser(_(
|
|
'''
|
|
%prog set_metadata [options] id /path/to/metadata.opf
|
|
|
|
Set the metadata stored in the calibre database for the book identified by id
|
|
from the OPF file metadata.opf. id is an id number from the list command. You
|
|
can get a quick feel for the OPF format by using the --as-opf switch to the
|
|
show_metadata command.
|
|
'''))
|
|
opts, args = parser.parse_args(sys.argv[1:]+args)
|
|
if len(args) < 3:
|
|
parser.print_help()
|
|
print
|
|
print >>sys.stderr, _('You must specify an id and a metadata file')
|
|
return 1
|
|
id, opf = int(args[1]), open(args[2], 'rb')
|
|
do_set_metadata(get_db(dbpath, opts), id, opf)
|
|
return 0
|
|
|
|
def do_export(db, ids, dir, single_dir, by_author):
|
|
if ids is None:
|
|
ids = db.all_ids()
|
|
db.export_to_dir(dir, ids, byauthor=by_author, single_dir=single_dir, index_is_id=True)
|
|
|
|
def command_export(args, dbpath):
|
|
parser = get_parser(_('''\
|
|
%prog export [options] ids
|
|
|
|
Export the books specified by ids (a comma separated list) to the filesystem.
|
|
The export operation saves all formats of the book, its cover and metadata (in
|
|
an opf file). You can get id numbers from the list command.
|
|
'''))
|
|
parser.add_option('--all', default=False, action='store_true',
|
|
help=_('Export all books in database, ignoring the list of ids.'))
|
|
parser.add_option('--to-dir', default='.',
|
|
help=(_('Export books to the specified directory. Default is')+' %default'))
|
|
parser.add_option('--single-dir', default=False, action='store_true',
|
|
help=_('Export all books into a single directory'))
|
|
parser.add_option('--by-author', default=False, action='store_true',
|
|
help=_('Create file names as author - title instead of title - author'))
|
|
opts, args = parser.parse_args(sys.argv[1:]+args)
|
|
if (len(args) < 2 and not opts.all):
|
|
parser.print_help()
|
|
print
|
|
print >>sys.stderr, _('You must specify some ids or the %s option')%'--all'
|
|
return 1
|
|
ids = None if opts.all else map(int, args[1].split(','))
|
|
dir = os.path.abspath(os.path.expanduser(opts.to_dir))
|
|
do_export(get_db(dbpath, opts), ids, dir, opts.single_dir, opts.by_author)
|
|
return 0
|
|
|
|
def main(args=sys.argv):
|
|
commands = ('list', 'add', 'remove', 'add_format', 'remove_format',
|
|
'show_metadata', 'set_metadata', 'export')
|
|
parser = OptionParser(_(
|
|
'''\
|
|
%%prog command [options] [arguments]
|
|
|
|
%%prog is the command line interface to the calibre books database.
|
|
|
|
command is one of:
|
|
%s
|
|
|
|
For help on an individual command: %%prog command --help
|
|
'''
|
|
)%'\n '.join(commands))
|
|
if len(args) < 2:
|
|
parser.print_help()
|
|
return 1
|
|
if args[1] not in commands:
|
|
if args[1] == '--version':
|
|
parser.print_version()
|
|
return 0
|
|
parser.print_help()
|
|
return 1
|
|
|
|
command = eval('command_'+args[1])
|
|
dbpath = prefs['library_path']
|
|
|
|
return command(args[2:], dbpath)
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|