diff --git a/setup/translations.py b/setup/translations.py index 611b3b2d68..ecb7834b40 100644 --- a/setup/translations.py +++ b/setup/translations.py @@ -291,6 +291,8 @@ class ISO639(Command): by_3t = {} m2to3 = {} m3to2 = {} + m3bto3t = {} + nm = {} codes2, codes3t, codes3b = set([]), set([]), set([]) for x in root.xpath('//iso_639_entry'): name = x.get('name') @@ -304,12 +306,15 @@ class ISO639(Command): m3to2[threeb] = m3to2[threet] = two by_3b[threeb] = name by_3t[threet] = name + if threeb != threet: + m3bto3t[threeb] = threet codes3b.add(x.get('iso_639_2B_code')) codes3t.add(x.get('iso_639_2T_code')) + nm[name.lower().partition(';')[0].strip()] = threet from cPickle import dump x = {'by_2':by_2, 'by_3b':by_3b, 'by_3t':by_3t, 'codes2':codes2, 'codes3b':codes3b, 'codes3t':codes3t, '2to3':m2to3, - '3to2':m3to2} + '3to2':m3to2, '3bto3t':m3bto3t, 'name_map':nm} dump(x, open(dest, 'wb'), -1) diff --git a/src/calibre/ebooks/metadata/book/__init__.py b/src/calibre/ebooks/metadata/book/__init__.py index 50e7b916ee..38a824374c 100644 --- a/src/calibre/ebooks/metadata/book/__init__.py +++ b/src/calibre/ebooks/metadata/book/__init__.py @@ -47,8 +47,7 @@ PUBLICATION_METADATA_FIELDS = frozenset([ # If None, means book 'publication_type', 'uuid', # A UUID usually of type 4 - 'language', # the primary language of this book - 'languages', # ordered list + 'languages', # ordered list of languages in this publication 'publisher', # Simple string, no special semantics # Absolute path to image file encoded in filesystem_encoding 'cover', @@ -109,7 +108,7 @@ STANDARD_METADATA_FIELDS = SOCIAL_METADATA_FIELDS.union( # Metadata fields that smart update must do special processing to copy. SC_FIELDS_NOT_COPIED = frozenset(['title', 'title_sort', 'authors', 'author_sort', 'author_sort_map', - 'cover_data', 'tags', 'language', + 'cover_data', 'tags', 'languages', 'identifiers']) # Metadata fields that smart update should copy only if the source is not None diff --git a/src/calibre/ebooks/metadata/book/base.py b/src/calibre/ebooks/metadata/book/base.py index 7c56dcabb4..93f657f7c9 100644 --- a/src/calibre/ebooks/metadata/book/base.py +++ b/src/calibre/ebooks/metadata/book/base.py @@ -102,6 +102,7 @@ class Metadata(object): @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: self.smart_update(other) @@ -136,6 +137,11 @@ class Metadata(object): _data = object.__getattribute__(self, '_data') if field in TOP_LEVEL_IDENTIFIERS: return _data.get('identifiers').get(field, None) + if field == 'language': + try: + return _data.get('languages', [])[0] + except: + return NULL_VALUES['language'] if field in STANDARD_METADATA_FIELDS: return _data.get(field, None) try: @@ -175,6 +181,11 @@ class Metadata(object): if not val: val = copy.copy(NULL_VALUES.get('identifiers', None)) self.set_identifiers(val) + elif field == 'language': + langs = [] + if val and val.lower() != 'und': + langs = [val] + _data['languages'] = langs elif field in STANDARD_METADATA_FIELDS: if val is None: val = copy.copy(NULL_VALUES.get(field, None)) @@ -553,9 +564,9 @@ class Metadata(object): for attr in TOP_LEVEL_IDENTIFIERS: copy_not_none(self, other, attr) - other_lang = getattr(other, 'language', None) - if other_lang and other_lang.lower() != 'und': - self.language = other_lang + 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 @@ -706,8 +717,8 @@ class Metadata(object): fmt('Tags', u', '.join([unicode(t) for t in self.tags])) if self.series: fmt('Series', self.series + ' #%s'%self.format_series_index()) - if not self.is_null('language'): - fmt('Language', self.language) + if not self.is_null('languages'): + fmt('Languages', ', '.join(self.languages)) if self.rating is not None: fmt('Rating', self.rating) if self.timestamp is not None: @@ -719,6 +730,8 @@ class Metadata(object): if self.identifiers: fmt('Identifiers', u', '.join(['%s:%s'%(k, v) for k, v in self.identifiers.iteritems()])) + if self.languages: + fmt('Languages', u', '.join(self.languages)) if self.comments: fmt('Comments', self.comments) @@ -743,7 +756,7 @@ class Metadata(object): ans += [(_('Tags'), u', '.join([unicode(t) for t in self.tags]))] if self.series: ans += [(_('Series'), unicode(self.series) + ' #%s'%self.format_series_index())] - ans += [(_('Language'), unicode(self.language))] + ans += [(_('Languages'), u', '.join(self.languages))] if self.timestamp is not None: ans += [(_('Timestamp'), unicode(self.timestamp.isoformat(' ')))] if self.pubdate is not None: diff --git a/src/calibre/ebooks/metadata/opf2.py b/src/calibre/ebooks/metadata/opf2.py index 35fd724ddd..6dcda8884b 100644 --- a/src/calibre/ebooks/metadata/opf2.py +++ b/src/calibre/ebooks/metadata/opf2.py @@ -19,7 +19,7 @@ from calibre.ebooks.metadata.toc import TOC from calibre.ebooks.metadata import string_to_authors, MetaInformation, check_isbn from calibre.ebooks.metadata.book.base import Metadata from calibre.utils.date import parse_date, isoformat -from calibre.utils.localization import get_lang +from calibre.utils.localization import get_lang, canonicalize_lang from calibre import prints, guess_type from calibre.utils.cleantext import clean_ascii_chars from calibre.utils.config import tweaks @@ -515,6 +515,7 @@ class OPF(object): # {{{ '(re:match(@opf:scheme, "calibre|libprs500", "i") or re:match(@scheme, "calibre|libprs500", "i"))]') uuid_id_path = XPath('descendant::*[re:match(name(), "identifier", "i") and '+ '(re:match(@opf:scheme, "uuid", "i") or re:match(@scheme, "uuid", "i"))]') + languages_path = XPath('descendant::*[local-name()="language"]') manifest_path = XPath('descendant::*[re:match(name(), "manifest", "i")]/*[re:match(name(), "item", "i")]') manifest_ppath = XPath('descendant::*[re:match(name(), "manifest", "i")]') @@ -523,7 +524,6 @@ class OPF(object): # {{{ title = MetadataField('title', formatter=lambda x: re.sub(r'\s+', ' ', x)) publisher = MetadataField('publisher') - language = MetadataField('language') comments = MetadataField('description') category = MetadataField('type') rights = MetadataField('rights') @@ -930,6 +930,44 @@ class OPF(object): # {{{ return property(fget=fget, fset=fset) + @dynamic_property + def language(self): + + def fget(self): + ans = self.languages + if ans: + return ans[0] + + def fset(self, val): + self.languages = [val] + + return property(fget=fget, fset=fset) + + + @dynamic_property + def languages(self): + + def fget(self): + ans = [] + for match in self.languages_path(self.metadata): + t = self.get_text(match) + if t and t.strip(): + l = canonicalize_lang(t.strip()) + if l: + ans.append(l) + return ans + + def fset(self, val): + matches = self.languages_path(self.metadata) + for x in matches: + x.getparent().remove(x) + + for lang in val: + l = self.create_metadata_element('language') + self.set_text(l, unicode(lang)) + + return property(fget=fget, fset=fset) + @dynamic_property def book_producer(self): @@ -1052,9 +1090,9 @@ class OPF(object): # {{{ val = getattr(mi, attr, None) if val is not None and val != [] and val != (None, None): setattr(self, attr, val) - lang = getattr(mi, 'language', None) - if lang and lang != 'und': - self.language = lang + langs = getattr(mi, 'languages', []) + if langs and langs != ['und']: + self.languages = langs temp = self.to_book_metadata() temp.smart_update(mi, replace_metadata=replace_metadata) self._user_metadata_ = temp.get_all_user_metadata(True) @@ -1202,10 +1240,11 @@ class OPFCreator(Metadata): dc_attrs={'id':__appname__+'_id'})) if getattr(self, 'pubdate', None) is not None: a(DC_ELEM('date', self.pubdate.isoformat())) - lang = self.language - if not lang or lang.lower() == 'und': - lang = get_lang().replace('_', '-') - a(DC_ELEM('language', lang)) + langs = self.languages + if not langs or langs == ['und']: + langs = [get_lang().replace('_', '-').partition('-')[0]] + for lang in langs: + a(DC_ELEM('language', lang)) if self.comments: a(DC_ELEM('description', self.comments)) if self.publisher: @@ -1288,8 +1327,8 @@ def metadata_to_opf(mi, as_string=True): mi.book_producer = __appname__ + ' (%s) '%__version__ + \ '[http://calibre-ebook.com]' - if not mi.language: - mi.language = 'UND' + if not mi.languages: + mi.languages = ['UND'] root = etree.fromstring(textwrap.dedent( ''' @@ -1339,8 +1378,10 @@ def metadata_to_opf(mi, as_string=True): factory(DC('identifier'), val, scheme=icu_upper(key)) if mi.rights: factory(DC('rights'), mi.rights) - factory(DC('language'), mi.language if mi.language and mi.language.lower() - != 'und' else get_lang().replace('_', '-')) + for lang in mi.languages: + if not lang or lang.lower() == 'und': + lang = get_lang().replace('_', '-').partition('-')[0] + factory(DC('language'), lang) if mi.tags: for tag in mi.tags: factory(DC('subject'), tag) diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py index dedec91a1c..fc02ad7fae 100644 --- a/src/calibre/gui2/__init__.py +++ b/src/calibre/gui2/__init__.py @@ -94,7 +94,7 @@ gprefs.defaults['book_display_fields'] = [ ('path', True), ('publisher', False), ('rating', False), ('author_sort', False), ('sort', False), ('timestamp', False), ('uuid', False), ('comments', True), ('id', False), ('pubdate', False), - ('last_modified', False), ('size', False), + ('last_modified', False), ('size', False), ('languages', False), ] gprefs.defaults['default_author_link'] = 'http://en.wikipedia.org/w/index.php?search={author}' gprefs.defaults['preserve_date_on_ctl'] = True diff --git a/src/calibre/gui2/book_details.py b/src/calibre/gui2/book_details.py index d7fb869400..a070b24986 100644 --- a/src/calibre/gui2/book_details.py +++ b/src/calibre/gui2/book_details.py @@ -24,6 +24,7 @@ from calibre.gui2 import (config, open_local_file, open_url, pixmap_to_data, from calibre.utils.icu import sort_key from calibre.utils.formatter import EvalFormatter from calibre.utils.date import is_date_undefined +from calibre.utils.localization import calibre_langcode_to_name def render_html(mi, css, vertical, widget, all_fields=False): # {{{ table = render_data(mi, all_fields=all_fields, @@ -152,6 +153,12 @@ def render_data(mi, use_roman_numbers=True, all_fields=False): authors.append(aut) ans.append((field, u'%s%s'%(name, u' & '.join(authors)))) + elif field == 'languages': + if not mi.languages: + continue + names = filter(None, map(calibre_langcode_to_name, mi.languages)) + ans.append((field, u'%s%s'%(name, + u', '.join(names)))) else: val = mi.format_field(field)[-1] if val is None: diff --git a/src/calibre/gui2/dialogs/metadata_bulk.py b/src/calibre/gui2/dialogs/metadata_bulk.py index 1472107386..6e9dcf5116 100644 --- a/src/calibre/gui2/dialogs/metadata_bulk.py +++ b/src/calibre/gui2/dialogs/metadata_bulk.py @@ -134,7 +134,7 @@ class MyBlockingBusy(QDialog): # {{{ do_autonumber, do_remove_format, remove_format, do_swap_ta, \ do_remove_conv, do_auto_author, series, do_series_restart, \ series_start_value, do_title_case, cover_action, clear_series, \ - pubdate, adddate, do_title_sort = self.args + pubdate, adddate, do_title_sort, languages, clear_languages = self.args # first loop: do author and title. These will commit at the end of each @@ -238,6 +238,12 @@ class MyBlockingBusy(QDialog): # {{{ if do_remove_conv: self.db.delete_conversion_options(id, 'PIPE', commit=False) + + if clear_languages: + self.db.set_languages(id, [], notify=False, commit=False) + elif languages: + self.db.set_languages(id, languages, notify=False, commit=False) + elif self.current_phase == 3: # both of these are fast enough to just do them all for w in self.cc_widgets: @@ -329,6 +335,7 @@ class MetadataBulkDialog(ResizableDialog, Ui_MetadataBulkDialog): geom = gprefs.get('bulk_metadata_window_geometry', None) if geom is not None: self.restoreGeometry(bytes(geom)) + self.languages.setEditText('') self.exec_() def save_state(self, *args): @@ -352,6 +359,7 @@ class MetadataBulkDialog(ResizableDialog, Ui_MetadataBulkDialog): self.do_again = True self.accept() + # S&R {{{ def prepare_search_and_replace(self): self.search_for.initialize('bulk_edit_search_for') self.replace_with.initialize('bulk_edit_replace_with') @@ -796,6 +804,7 @@ class MetadataBulkDialog(ResizableDialog, Ui_MetadataBulkDialog): # permanent. Make sure it really is. self.db.commit() self.model.refresh_ids(list(books_to_refresh)) + # }}} def create_custom_column_editors(self): w = self.central_widget.widget(1) @@ -919,6 +928,8 @@ class MetadataBulkDialog(ResizableDialog, Ui_MetadataBulkDialog): do_auto_author = self.auto_author_sort.isChecked() do_title_case = self.change_title_to_title_case.isChecked() do_title_sort = self.update_title_sort.isChecked() + clear_languages = self.clear_languages.isChecked() + languages = self.languages.lang_codes pubdate = adddate = None if self.apply_pubdate.isChecked(): pubdate = qt_to_dt(self.pubdate.date()) @@ -937,7 +948,7 @@ class MetadataBulkDialog(ResizableDialog, Ui_MetadataBulkDialog): do_autonumber, do_remove_format, remove_format, do_swap_ta, do_remove_conv, do_auto_author, series, do_series_restart, series_start_value, do_title_case, cover_action, clear_series, - pubdate, adddate, do_title_sort) + pubdate, adddate, do_title_sort, languages, clear_languages) bb = MyBlockingBusy(_('Applying changes to %d books.\nPhase {0} {1}%%.') %len(self.ids), args, self.db, self.ids, diff --git a/src/calibre/gui2/dialogs/metadata_bulk.ui b/src/calibre/gui2/dialogs/metadata_bulk.ui index 59a68d6514..c2e6635f98 100644 --- a/src/calibre/gui2/dialogs/metadata_bulk.ui +++ b/src/calibre/gui2/dialogs/metadata_bulk.ui @@ -443,7 +443,7 @@ from the value in the box - + Remove &format: @@ -453,7 +453,7 @@ from the value in the box - + @@ -463,7 +463,7 @@ from the value in the box - + Qt::Vertical @@ -479,7 +479,7 @@ from the value in the box - + @@ -529,7 +529,7 @@ Future conversion of these books will use the default settings. - + Change &cover @@ -559,7 +559,7 @@ Future conversion of these books will use the default settings. - + Qt::Vertical @@ -572,6 +572,29 @@ Future conversion of these books will use the default settings. + + + + &Languages: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + languages + + + + + + + + + + Remove &all + + + @@ -1145,6 +1168,11 @@ not multiple and the destination field is multiple QLineEdit
widgets.h
+ + LanguagesEdit + QComboBox +
calibre/gui2/languages.h
+
authors diff --git a/src/calibre/gui2/languages.py b/src/calibre/gui2/languages.py new file mode 100644 index 0000000000..95b2a0bd5b --- /dev/null +++ b/src/calibre/gui2/languages.py @@ -0,0 +1,62 @@ +#!/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 ' +__docformat__ = 'restructuredtext en' + +from calibre.gui2.complete import MultiCompleteComboBox +from calibre.utils.localization import lang_map +from calibre.utils.icu import sort_key + +class LanguagesEdit(MultiCompleteComboBox): + + def __init__(self, parent=None): + MultiCompleteComboBox.__init__(self, parent) + + self._lang_map = lang_map() + self._rmap = {v:k for k,v in self._lang_map.iteritems()} + + all_items = sorted(self._lang_map.itervalues(), + key=sort_key) + self.update_items_cache(all_items) + for item in all_items: + self.addItem(item) + + @dynamic_property + def lang_codes(self): + + def fget(self): + vals = [x.strip() for x in + unicode(self.lineEdit().text()).split(',')] + ans = [] + for name in vals: + if name: + code = self._rmap.get(name, None) + if code is not None: + ans.append(code) + return ans + + def fset(self, lang_codes): + ans = [] + for lc in lang_codes: + name = self._lang_map.get(lc, None) + if name is not None: + ans.append(name) + self.setEditText(', '.join(ans)) + + return property(fget=fget, fset=fset) + + def validate(self): + vals = [x.strip() for x in + unicode(self.lineEdit().text()).split(',')] + bad = [] + for name in vals: + if name: + code = self._rmap.get(name, None) + if code is None: + bad.append(name) + return bad + diff --git a/src/calibre/gui2/library/delegates.py b/src/calibre/gui2/library/delegates.py index c4ea05d38c..64c94980be 100644 --- a/src/calibre/gui2/library/delegates.py +++ b/src/calibre/gui2/library/delegates.py @@ -23,6 +23,7 @@ from calibre.utils.formatter import validation_formatter from calibre.utils.icu import sort_key from calibre.gui2.dialogs.comments_dialog import CommentsDialog from calibre.gui2.dialogs.template_dialog import TemplateDialog +from calibre.gui2.languages import LanguagesEdit class RatingDelegate(QStyledItemDelegate): # {{{ @@ -155,7 +156,7 @@ class TextDelegate(QStyledItemDelegate): # {{{ def __init__(self, parent): ''' Delegate for text data. If auto_complete_function needs to return a list - of text items to auto-complete with. The funciton is None no + of text items to auto-complete with. If the function is None no auto-complete will be used. ''' QStyledItemDelegate.__init__(self, parent) @@ -229,6 +230,20 @@ class CompleteDelegate(QStyledItemDelegate): # {{{ QStyledItemDelegate.setModelData(self, editor, model, index) # }}} +class LanguagesDelegate(QStyledItemDelegate): # {{{ + + def createEditor(self, parent, option, index): + editor = LanguagesEdit(parent) + ct = index.data(Qt.DisplayRole).toString() + editor.setEditText(ct) + editor.lineEdit().selectAll() + return editor + + def setModelData(self, editor, model, index): + val = ','.join(editor.lang_codes) + model.setData(index, QVariant(val), Qt.EditRole) +# }}} + class CcDateDelegate(QStyledItemDelegate): # {{{ ''' Delegate for custom columns dates. Because this delegate stores the diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index a0c103a33b..a0870b1e8d 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -25,6 +25,7 @@ from calibre.library.caches import (_match, CONTAINS_MATCH, EQUALS_MATCH, from calibre import strftime, isbytestring from calibre.constants import filesystem_encoding, DEBUG from calibre.gui2.library import DEFAULT_SORT +from calibre.utils.localization import calibre_langcode_to_name def human_readable(size, precision=1): """ Convert a size in bytes into megabytes """ @@ -64,6 +65,7 @@ class BooksModel(QAbstractTableModel): # {{{ 'tags' : _("Tags"), 'series' : ngettext("Series", 'Series', 1), 'last_modified' : _('Modified'), + 'languages' : _('Languages'), } def __init__(self, parent=None, buffer=40): @@ -71,7 +73,8 @@ class BooksModel(QAbstractTableModel): # {{{ self.db = None self.book_on_device = None self.editable_cols = ['title', 'authors', 'rating', 'publisher', - 'tags', 'series', 'timestamp', 'pubdate'] + 'tags', 'series', 'timestamp', 'pubdate', + 'languages'] self.default_image = default_image() self.sorted_on = DEFAULT_SORT self.sort_history = [self.sorted_on] @@ -540,6 +543,13 @@ class BooksModel(QAbstractTableModel): # {{{ else: return None + def languages(r, idx=-1): + lc = self.db.data[r][idx] + if lc: + langs = [calibre_langcode_to_name(l.strip()) for l in lc.split(',')] + return QVariant(', '.join(langs)) + return None + def tags(r, idx=-1): tags = self.db.data[r][idx] if tags: @@ -641,6 +651,8 @@ class BooksModel(QAbstractTableModel): # {{{ siix=self.db.field_metadata['series_index']['rec_index']), 'ondevice' : functools.partial(text_type, idx=self.db.field_metadata['ondevice']['rec_index'], mult=None), + 'languages': functools.partial(languages, + idx=self.db.field_metadata['languages']['rec_index']), } self.dc_decorator = { @@ -884,6 +896,9 @@ class BooksModel(QAbstractTableModel): # {{{ if val.isNull() or not val.isValid(): return False self.db.set_pubdate(id, qt_to_dt(val, as_utc=False)) + elif column == 'languages': + val = val.split(',') + self.db.set_languages(id, val) else: books_to_refresh |= self.db.set(row, column, val, allow_case_change=True) diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py index f0f30bdb08..5a62b76c6b 100644 --- a/src/calibre/gui2/library/views.py +++ b/src/calibre/gui2/library/views.py @@ -8,14 +8,14 @@ __docformat__ = 'restructuredtext en' import os from functools import partial -from PyQt4.Qt import QTableView, Qt, QAbstractItemView, QMenu, pyqtSignal, \ - QModelIndex, QIcon, QItemSelection, QMimeData, QDrag, QApplication, \ - QPoint, QPixmap, QUrl, QImage, QPainter, QColor, QRect +from PyQt4.Qt import (QTableView, Qt, QAbstractItemView, QMenu, pyqtSignal, + QModelIndex, QIcon, QItemSelection, QMimeData, QDrag, QApplication, + QPoint, QPixmap, QUrl, QImage, QPainter, QColor, QRect) -from calibre.gui2.library.delegates import RatingDelegate, PubDateDelegate, \ - TextDelegate, DateDelegate, CompleteDelegate, CcTextDelegate, \ - CcBoolDelegate, CcCommentsDelegate, CcDateDelegate, CcTemplateDelegate, \ - CcEnumDelegate, CcNumberDelegate +from calibre.gui2.library.delegates import (RatingDelegate, PubDateDelegate, + TextDelegate, DateDelegate, CompleteDelegate, CcTextDelegate, + CcBoolDelegate, CcCommentsDelegate, CcDateDelegate, CcTemplateDelegate, + CcEnumDelegate, CcNumberDelegate, LanguagesDelegate) from calibre.gui2.library.models import BooksModel, DeviceBooksModel from calibre.utils.config import tweaks, prefs from calibre.gui2 import error_dialog, gprefs @@ -85,6 +85,7 @@ class BooksView(QTableView): # {{{ self.pubdate_delegate = PubDateDelegate(self) self.last_modified_delegate = DateDelegate(self, tweak_name='gui_last_modified_display_format') + self.languages_delegate = LanguagesDelegate(self) self.tags_delegate = CompleteDelegate(self, ',', 'all_tags') self.authors_delegate = CompleteDelegate(self, '&', 'all_author_names', True) self.cc_names_delegate = CompleteDelegate(self, '&', 'all_custom', True) @@ -306,6 +307,7 @@ class BooksView(QTableView): # {{{ state['hidden_columns'] = [cm[i] for i in range(h.count()) if h.isSectionHidden(i) and cm[i] != 'ondevice'] state['last_modified_injected'] = True + state['languages_injected'] = True state['sort_history'] = \ self.cleanup_sort_history(self.model().sort_history) state['column_positions'] = {} @@ -390,7 +392,7 @@ class BooksView(QTableView): # {{{ def get_default_state(self): old_state = { - 'hidden_columns': ['last_modified'], + 'hidden_columns': ['last_modified', 'languages'], 'sort_history':[DEFAULT_SORT], 'column_positions': {}, 'column_sizes': {}, @@ -399,6 +401,7 @@ class BooksView(QTableView): # {{{ 'timestamp':'center', 'pubdate':'center'}, 'last_modified_injected': True, + 'languages_injected': True, } h = self.column_header cm = self.column_map @@ -430,11 +433,20 @@ class BooksView(QTableView): # {{{ if ans is not None: db.prefs[name] = ans else: + injected = False if not ans.get('last_modified_injected', False): + injected = True ans['last_modified_injected'] = True hc = ans.get('hidden_columns', []) if 'last_modified' not in hc: hc.append('last_modified') + if not ans.get('languages_injected', False): + injected = True + ans['languages_injected'] = True + hc = ans.get('hidden_columns', []) + if 'languages' not in hc: + hc.append('languages') + if injected: db.prefs[name] = ans return ans @@ -501,7 +513,7 @@ class BooksView(QTableView): # {{{ for i in range(self.model().columnCount(None)): if self.itemDelegateForColumn(i) in (self.rating_delegate, self.timestamp_delegate, self.pubdate_delegate, - self.last_modified_delegate): + self.last_modified_delegate, self.languages_delegate): self.setItemDelegateForColumn(i, self.itemDelegate()) cm = self.column_map diff --git a/src/calibre/gui2/metadata/basic_widgets.py b/src/calibre/gui2/metadata/basic_widgets.py index 3084738b27..29f6fffa0b 100644 --- a/src/calibre/gui2/metadata/basic_widgets.py +++ b/src/calibre/gui2/metadata/basic_widgets.py @@ -34,6 +34,7 @@ from calibre.library.comments import comments_to_html from calibre.gui2.dialogs.tag_editor import TagEditor from calibre.utils.icu import strcmp from calibre.ptempfile import PersistentTemporaryFile +from calibre.gui2.languages import LanguagesEdit as LE def save_dialog(parent, title, msg, det_msg=''): d = QMessageBox(parent) @@ -1133,6 +1134,43 @@ class TagsEdit(MultiCompleteLineEdit): # {{{ # }}} +class LanguagesEdit(LE): # {{{ + + LABEL = _('&Languages:') + TOOLTIP = _('A comma separated list of languages for this book') + + def __init__(self, *args, **kwargs): + LE.__init__(self, *args, **kwargs) + self.setToolTip(self.TOOLTIP) + + @dynamic_property + def current_val(self): + def fget(self): return self.lang_codes + def fset(self, val): self.lang_codes = val + return property(fget=fget, fset=fset) + + def initialize(self, db, id_): + lc = [] + langs = db.languages(id_, index_is_id=True) + if langs: + lc = [x.strip() for x in langs.split(',')] + self.current_val = self.original_val = lc + + def commit(self, db, id_): + bad = self.validate() + if bad: + error_dialog(self, _('Unknown language'), + ngettext('The language %s is not recognized', + 'The languages %s are not recognized', len(bad))%( + ', '.join(bad)), + show=True) + return False + cv = self.current_val + if cv != self.original_val: + db.set_languages(id_, cv) + return True +# }}} + class IdentifiersEdit(QLineEdit): # {{{ LABEL = _('I&ds:') BASE_TT = _('Edit the identifiers for this book. ' diff --git a/src/calibre/gui2/metadata/single.py b/src/calibre/gui2/metadata/single.py index 998734511c..bfe80f983f 100644 --- a/src/calibre/gui2/metadata/single.py +++ b/src/calibre/gui2/metadata/single.py @@ -20,7 +20,7 @@ from calibre.gui2 import ResizableDialog, error_dialog, gprefs, pixmap_to_data from calibre.gui2.metadata.basic_widgets import (TitleEdit, AuthorsEdit, AuthorSortEdit, TitleSortEdit, SeriesEdit, SeriesIndexEdit, IdentifiersEdit, RatingEdit, PublisherEdit, TagsEdit, FormatsManager, Cover, CommentsEdit, - BuddyLabel, DateEdit, PubdateEdit) + BuddyLabel, DateEdit, PubdateEdit, LanguagesEdit) from calibre.gui2.metadata.single_download import FullFetch from calibre.gui2.custom_column_widgets import populate_metadata_page from calibre.utils.config import tweaks @@ -183,6 +183,9 @@ class MetadataSingleDialogBase(ResizableDialog): self.publisher = PublisherEdit(self) self.basic_metadata_widgets.append(self.publisher) + self.languages = LanguagesEdit(self) + self.basic_metadata_widgets.append(self.languages) + self.timestamp = DateEdit(self) self.pubdate = PubdateEdit(self) self.basic_metadata_widgets.extend([self.timestamp, self.pubdate]) @@ -610,11 +613,13 @@ class MetadataSingleDialog(MetadataSingleDialogBase): # {{{ create_row2(5, self.pubdate, self.pubdate.clear_button) sto(self.pubdate.clear_button, self.publisher) create_row2(6, self.publisher) + sto(self.publisher, self.languages) + create_row2(7, self.languages) self.tabs[0].spc_two = QSpacerItem(10, 10, QSizePolicy.Expanding, QSizePolicy.Expanding) - l.addItem(self.tabs[0].spc_two, 8, 0, 1, 3) - l.addWidget(self.fetch_metadata_button, 9, 0, 1, 2) - l.addWidget(self.config_metadata_button, 9, 2, 1, 1) + l.addItem(self.tabs[0].spc_two, 9, 0, 1, 3) + l.addWidget(self.fetch_metadata_button, 10, 0, 1, 2) + l.addWidget(self.config_metadata_button, 10, 2, 1, 1) self.tabs[0].gb2 = gb = QGroupBox(_('Co&mments'), self) gb.l = l = QVBoxLayout() @@ -717,16 +722,17 @@ class MetadataSingleDialogAlt1(MetadataSingleDialogBase): # {{{ create_row(7, self.rating, self.pubdate) create_row(8, self.pubdate, self.publisher, button=self.pubdate.clear_button, icon='trash.png') - create_row(9, self.publisher, self.timestamp) - create_row(10, self.timestamp, self.identifiers, + create_row(9, self.publisher, self.languages) + create_row(10, self.languages, self.timestamp) + create_row(11, self.timestamp, self.identifiers, button=self.timestamp.clear_button, icon='trash.png') - create_row(11, self.identifiers, self.comments, + create_row(12, self.identifiers, self.comments, button=self.clear_identifiers_button, icon='trash.png') sto(self.clear_identifiers_button, self.swap_title_author_button) sto(self.swap_title_author_button, self.manage_authors_button) sto(self.manage_authors_button, self.paste_isbn_button) tl.addItem(QSpacerItem(1, 1, QSizePolicy.Fixed, QSizePolicy.Expanding), - 12, 1, 1 ,1) + 13, 1, 1 ,1) w = getattr(self, 'custom_metadata_widgets_parent', None) if w is not None: @@ -852,16 +858,17 @@ class MetadataSingleDialogAlt2(MetadataSingleDialogBase): # {{{ create_row(7, self.rating, self.pubdate) create_row(8, self.pubdate, self.publisher, button=self.pubdate.clear_button, icon='trash.png') - create_row(9, self.publisher, self.timestamp) - create_row(10, self.timestamp, self.identifiers, + create_row(9, self.publisher, self.languages) + create_row(10, self.languages, self.timestamp) + create_row(11, self.timestamp, self.identifiers, button=self.timestamp.clear_button, icon='trash.png') - create_row(11, self.identifiers, self.comments, + create_row(12, self.identifiers, self.comments, button=self.clear_identifiers_button, icon='trash.png') sto(self.clear_identifiers_button, self.swap_title_author_button) sto(self.swap_title_author_button, self.manage_authors_button) sto(self.manage_authors_button, self.paste_isbn_button) tl.addItem(QSpacerItem(1, 1, QSizePolicy.Fixed, QSizePolicy.Expanding), - 12, 1, 1 ,1) + 13, 1, 1 ,1) # Custom metadata in col 1 w = getattr(self, 'custom_metadata_widgets_parent', None) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 2fa43dc94c..5f9dca6d23 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -15,6 +15,7 @@ from calibre.utils.config import tweaks, prefs from calibre.utils.date import parse_date, now, UNDEFINED_DATE from calibre.utils.search_query_parser import SearchQueryParser from calibre.utils.pyparsing import ParseException +from calibre.utils.localization import canonicalize_lang from calibre.ebooks.metadata import title_sort, author_to_author_sort from calibre.ebooks.metadata.opf2 import metadata_to_opf from calibre import prints @@ -721,9 +722,13 @@ class ResultCache(SearchQueryParser): # {{{ if loc == db_col['authors']: ### DB stores authors with commas changed to bars, so change query if matchkind == REGEXP_MATCH: - q = query.replace(',', r'\|'); + q = query.replace(',', r'\|') else: - q = query.replace(',', '|'); + q = query.replace(',', '|') + elif loc == db_col['languages']: + q = canonicalize_lang(query) + if q is None: + q = query else: q = query diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 3471d93332..79a441298f 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -39,6 +39,8 @@ from calibre.utils.magick.draw import save_cover_data_to from calibre.utils.recycle_bin import delete_file, delete_tree from calibre.utils.formatter_functions import load_user_template_functions from calibre.db.errors import NoSuchFormat +from calibre.utils.localization import (canonicalize_lang, + calibre_langcode_to_name) copyfile = os.link if hasattr(os, 'link') else shutil.copyfile SPOOL_SIZE = 30*1024*1024 @@ -372,6 +374,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): 'aum_sortconcat(link.id, authors.name, authors.sort, authors.link)'), 'last_modified', '(SELECT identifiers_concat(type, val) FROM identifiers WHERE identifiers.book=books.id) identifiers', + ('languages', 'languages', 'lang_code', + 'sortconcat(link.id, languages.lang_code)'), ] lines = [] for col in columns: @@ -390,7 +394,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): 'size':4, 'rating':5, 'tags':6, 'comments':7, 'series':8, 'publisher':9, 'series_index':10, 'sort':11, 'author_sort':12, 'formats':13, 'path':14, 'pubdate':15, 'uuid':16, 'cover':17, - 'au_map':18, 'last_modified':19, 'identifiers':20} + 'au_map':18, 'last_modified':19, 'identifiers':20, 'languages':21} for k,v in self.FIELD_MAP.iteritems(): self.field_metadata.set_field_record_index(k, v, prefer_custom=False) @@ -469,7 +473,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): 'author_sort', 'authors', 'comment', 'comments', 'publisher', 'rating', 'series', 'series_index', 'tags', 'title', 'timestamp', 'uuid', 'pubdate', 'ondevice', - 'metadata_last_modified', + 'metadata_last_modified', 'languages', ): fm = {'comment':'comments', 'metadata_last_modified': 'last_modified'}.get(prop, prop) @@ -930,6 +934,9 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): tags = row[fm['tags']] if tags: mi.tags = [i.strip() for i in tags.split(',')] + languages = row[fm['languages']] + if languages: + mi.languages = [i.strip() for i in languages.split(',')] mi.series = row[fm['series']] if mi.series: mi.series_index = row[fm['series_index']] @@ -1390,7 +1397,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): ('authors', 'authors', 'author'), ('publishers', 'publishers', 'publisher'), ('tags', 'tags', 'tag'), - ('series', 'series', 'series') + ('series', 'series', 'series'), + ('languages', 'languages', 'lang_code'), ]: doit(ltable, table, ltable_col) @@ -1507,6 +1515,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): 'series' : self.get_series_with_ids, 'publisher': self.get_publishers_with_ids, 'tags' : self.get_tags_with_ids, + 'languages': self.get_languages_with_ids, 'rating' : self.get_ratings_with_ids, } func = funcs.get(category, None) @@ -1521,6 +1530,10 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): for l in list: (id, val, sort_val) = (l[0], l[1], l[2]) tids[category][val] = (id, sort_val) + elif category == 'languages': + for l in list: + id, val = l[0], calibre_langcode_to_name(l[1]) + tids[category][l[1]] = (id, val) elif cat['datatype'] == 'series': for l in list: (id, val) = (l[0], l[1]) @@ -1620,6 +1633,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): item.rt += rating item.rc += 1 except: + prints(tid_cat, val) prints('get_categories: item', val, 'is not in', cat, 'list!') #print 'end phase "books":', time.clock() - last, 'seconds' @@ -1684,6 +1698,10 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): # Clean up the authors strings to human-readable form formatter = (lambda x: x.replace('|', ',')) items = [v for v in tcategories[category].values() if v.c > 0] + elif category == 'languages': + # Use a human readable language string + formatter = calibre_langcode_to_name + items = [v for v in tcategories[category].values() if v.c > 0] else: formatter = (lambda x:unicode(x)) items = [v for v in tcategories[category].values() if v.c > 0] @@ -2043,6 +2061,9 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): if should_replace_field('comments'): doit(self.set_comment, id, mi.comments, notify=False, commit=False) + if should_replace_field('languages'): + doit(self.set_languages, id, mi.languages, notify=False, commit=False) + # Setting series_index to zero is acceptable if mi.series_index is not None: doit(self.set_series_index, id, mi.series_index, notify=False, @@ -2265,6 +2286,37 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): if notify: self.notify('metadata', [id]) + def set_languages(self, book_id, languages, notify=True, commit=True): + self.conn.execute( + 'DELETE FROM books_languages_link WHERE book=?', (book_id,)) + self.conn.execute('''DELETE FROM languages WHERE (SELECT COUNT(id) + FROM books_languages_link WHERE + lang_code=languages.id) < 1''') + + books_to_refresh = set([book_id]) + final_languages = [] + for l in languages: + lc = canonicalize_lang(l) + if not lc or lc in final_languages or lc in ('und', 'zxx', 'mis', + 'mul'): + continue + final_languages.append(lc) + lc_id = self.conn.get('SELECT id FROM languages WHERE lang_code=?', + (lc,), all=False) + if lc_id is None: + lc_id = self.conn.execute('''INSERT INTO languages(lang_code) + VALUES (?)''', (lc,)).lastrowid + self.conn.execute('''INSERT INTO books_languages_link(book, lang_code) + VALUES (?,?)''', (book_id, lc_id)) + self.dirtied(books_to_refresh, commit=False) + if commit: + self.conn.commit() + self.data.set(book_id, self.FIELD_MAP['languages'], + u','.join(final_languages), row_is_id=True) + if notify: + self.notify('metadata', [book_id]) + return books_to_refresh + def set_timestamp(self, id, dt, notify=True, commit=True): if dt: self.conn.execute('UPDATE books SET timestamp=? WHERE id=?', (dt, id)) @@ -2363,6 +2415,12 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): return [] return result + def get_languages_with_ids(self): + result = self.conn.get('SELECT id,lang_code FROM languages') + if not result: + return [] + return result + def rename_tag(self, old_id, new_name): # It is possible that new_name is in fact a set of names. Split it on # comma to find out. If it is, then rename the first one and append the diff --git a/src/calibre/library/field_metadata.py b/src/calibre/library/field_metadata.py index f802ae7f7b..eff3fd1fed 100644 --- a/src/calibre/library/field_metadata.py +++ b/src/calibre/library/field_metadata.py @@ -17,7 +17,7 @@ class TagsIcons(dict): category_icons = ['authors', 'series', 'formats', 'publisher', 'rating', 'news', 'tags', 'custom:', 'user:', 'search', - 'identifiers', 'gst'] + 'identifiers', 'languages', 'gst'] def __init__(self, icon_dict): for a in self.category_icons: if a not in icon_dict: @@ -37,6 +37,7 @@ category_icon_map = { 'search' : 'search.png', 'identifiers': 'identifiers.png', 'gst' : 'catalog.png', + 'languages' : 'languages.png', } @@ -114,6 +115,21 @@ class FieldMetadata(dict): 'is_custom':False, 'is_category':True, 'is_csp': False}), + ('languages', {'table':'languages', + 'column':'lang_code', + 'link_column':'lang_code', + 'category_sort':'lang_code', + 'datatype':'text', + 'is_multiple':{'cache_to_list': ',', + 'ui_to_list': ',', + 'list_to_ui': ', '}, + 'kind':'field', + 'name':_('Languages'), + 'search_terms':['languages', 'language'], + 'is_custom':False, + 'is_category':True, + 'is_csp': False}), + ('series', {'table':'series', 'column':'name', 'link_column':'series', diff --git a/src/calibre/utils/localization.py b/src/calibre/utils/localization.py index 1b3347c5bd..c1dcbad25e 100644 --- a/src/calibre/utils/localization.py +++ b/src/calibre/utils/localization.py @@ -192,6 +192,74 @@ def get_language(lang): ans = iso639['by_3t'].get(lang, ans) return translate(ans) +def calibre_langcode_to_name(lc, localize=True): + iso639 = _load_iso639() + translate = _ if localize else lambda x: x + try: + return translate(iso639['by_3t'][lc]) + except: + pass + return lc + +def canonicalize_lang(raw): + if not raw: + return None + if not isinstance(raw, unicode): + raw = raw.decode('utf-8', 'ignore') + raw = raw.lower().strip() + if not raw: + return None + raw = raw.replace('_', '-').partition('-')[0].strip() + if not raw: + return None + iso639 = _load_iso639() + m2to3 = iso639['2to3'] + + if len(raw) == 2: + ans = m2to3.get(raw, None) + if ans is not None: + return ans + elif len(raw) == 3: + if raw in iso639['by_3t']: + return raw + if raw in iso639['3bto3t']: + return iso639['3bto3t'][raw] + + return iso639['name_map'].get(raw, None) + +_lang_map = None + +def lang_map(): + ' Return mapping of ISO 639 3 letter codes to localized language names ' + iso639 = _load_iso639() + translate = _ + global _lang_map + if _lang_map is None: + _lang_map = {k:translate(v) for k, v in iso639['by_3t'].iteritems()} + return _lang_map + +def langnames_to_langcodes(names): + ''' + Given a list of localized language names return a mapping of the names to 3 + letter ISO 639 language codes. If a name is not recognized, it is mapped to + None. + ''' + iso639 = _load_iso639() + translate = _ + ans = {} + names = set(names) + for k, v in iso639['by_3t'].iteritems(): + tv = translate(v) + if tv in names: + names.remove(tv) + ans[tv] = k + if not names: + break + for x in names: + ans[x] = None + + return ans + _udc = None def get_udc():