diff --git a/src/calibre/db/categories.py b/src/calibre/db/categories.py index d5cfb6754c..bb6df6b17d 100644 --- a/src/calibre/db/categories.py +++ b/src/calibre/db/categories.py @@ -52,6 +52,18 @@ class Tag(object): def __repr__(self): return str(self) + __calibre_serializable__ = True + + def as_dict(self): + return {k: getattr(self, k) for k in self.__slots__} + + @classmethod + def from_dict(cls, d): + ans = cls('') + for k in cls.__slots__: + setattr(ans, k, d[k]) + return ans + def find_categories(field_metadata): for category, cat in field_metadata.iteritems(): @@ -102,6 +114,7 @@ def clean_user_categories(dbcache): pass return new_cats + category_sort_keys = {True:{}, False: {}} category_sort_keys[True]['popularity'] = category_sort_keys[False]['popularity'] = \ lambda x:(-getattr(x, 'count', 0), sort_key(x.sort or x.name)) diff --git a/src/calibre/db/cli/cmd_list_categories.py b/src/calibre/db/cli/cmd_list_categories.py index 2f6f22ae56..1827ca88d7 100644 --- a/src/calibre/db/cli/cmd_list_categories.py +++ b/src/calibre/db/cli/cmd_list_categories.py @@ -4,19 +4,185 @@ from __future__ import absolute_import, division, print_function, unicode_literals -readonly = False +import csv +import sys +from textwrap import TextWrapper +from io import BytesIO + +from calibre import prints + +readonly = True version = 0 # change this if you change signature of implementation() -def implementation(db, notify_changes, *args): - is_remote = notify_changes is not None - is_remote +def implementation(db, notify_changes): + return db.get_categories(), db.field_metadata def option_parser(get_parser, args): - pass + parser = get_parser( + _( + '''\ +%prog list_categories [options] + +Produce a report of the category information in the database. The +information is the equivalent of what is shown in the tags pane. +''' + ) + ) + + parser.add_option( + '-i', + '--item_count', + default=False, + action='store_true', + help=_( + 'Output only the number of items in a category instead of the ' + 'counts per item within the category' + ) + ) + parser.add_option( + '-c', '--csv', default=False, action='store_true', help=_('Output in CSV') + ) + parser.add_option( + '--dialect', + default='excel', + choices=csv.list_dialects(), + help=_('The type of CSV file to produce. Choices: {}') + .format(', '.join(csv.list_dialects())) + ) + parser.add_option( + '-r', + '--categories', + default='', + dest='report', + help=_("Comma-separated list of category lookup names. " + "Default: all") + ) + parser.add_option( + '-w', + '--width', + default=-1, + type=int, + help=_( + 'The maximum width of a single line in the output. ' + 'Defaults to detecting screen size.' + ) + ) + return parser + + +def do_list(fields, data, opts): + from calibre.utils.terminal import geometry, ColoredStream + + separator = ' ' + widths = list(map(lambda x: 0, fields)) + for i in data: + for j, field in enumerate(fields): + widths[j] = max(widths[j], max(len(field), len(unicode(i[field])))) + + screen_width = geometry()[0] + 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%s' % (x - len(separator), y, separator), widths, fields + ) + with ColoredStream(sys.stdout, fg='green'): + prints(''.join(titles)) + + wrappers = map(lambda x: TextWrapper(x - 1), widths) + + for record in data: + text = [ + wrappers[i].wrap(unicode(record[field])) + 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, '') + print(ft.encode('utf-8') + filler.encode('utf-8'), end=separator) + print() + + +def do_csv(fields, data, opts): + buf = BytesIO() + csv_print = csv.writer(buf, opts.dialect) + csv_print.writerow(fields) + for d in data: + row = [d[f] for f in fields] + csv_print.writerow([ + x if isinstance(x, bytes) else unicode(x).encode('utf-8') for x in row + ]) + print(buf.getvalue()) def main(opts, args, dbctx): - raise NotImplementedError('TODO: implement this') + category_data, field_metadata = dbctx.run('list_categories') + data = [] + report_on = [c.strip() for c in opts.report.split(',') if c.strip()] + + def category_metadata(k): + return field_metadata.get(k) + + categories = [ + k for k in category_data.keys() + if category_metadata(k)['kind'] not in ['user', 'search'] and + (not report_on or k in report_on) + ] + + categories.sort( + cmp=lambda x, y: cmp(x if x[0] != '#' else x[1:], y if y[0] != '#' else y[1:]) + ) + + def fmtr(v): + v = v or 0 + ans = '%.1f' % v + if ans.endswith('.0'): + ans = ans[:-2] + return ans + + if not opts.item_count: + for category in categories: + is_rating = category_metadata(category)['datatype'] == 'rating' + for tag in category_data[category]: + if is_rating: + tag.name = unicode(len(tag.name)) + data.append({ + 'category': category, + 'tag_name': tag.name, + 'count': unicode(tag.count), + 'rating': fmtr(tag.avg_rating), + }) + else: + for category in categories: + data.append({ + 'category': category, + 'tag_name': _('CATEGORY ITEMS'), + 'count': unicode(len(category_data[category])), + 'rating': '' + }) + + fields = ['category', 'tag_name', 'count', 'rating'] + + func = do_csv if opts.csv else do_list + func(fields, data, opts) + return 0 diff --git a/src/calibre/utils/serialize.py b/src/calibre/utils/serialize.py index 3b7afa11df..79c75bdc52 100644 --- a/src/calibre/utils/serialize.py +++ b/src/calibre/utils/serialize.py @@ -30,6 +30,7 @@ def encoder(obj, for_json=False): if getattr(obj, '__calibre_serializable__', False): from calibre.ebooks.metadata.book.base import Metadata from calibre.library.field_metadata import FieldMetadata, fm_as_dict + from calibre.db.categories import Tag if isinstance(obj, Metadata): from calibre.ebooks.metadata.book.serialize import metadata_as_dict return encoded( @@ -37,6 +38,8 @@ def encoder(obj, for_json=False): ) elif isinstance(obj, FieldMetadata): return encoded(3, fm_as_dict(obj), for_json) + elif isinstance(obj, Tag): + return encoded(4, obj.as_dict(), for_json) raise TypeError('Cannot serialize objects of type {}'.format(type(obj))) @@ -67,9 +70,14 @@ def decode_field_metadata(x, for_json): return fm_from_dict(x) +def decode_category_tag(x, for_json): + from calibre.db.categories import Tag + return Tag.from_dict(x) + + decoders = ( lambda x, fj: parse_iso8601(x, assume_utc=True), lambda x, fj: set(x), - decode_metadata, decode_field_metadata, + decode_metadata, decode_field_metadata, decode_category_tag )