calibre/src/calibre/gui2/actions/choose_library.py
Kovid Goyal 239407d73b Switch to newdb
Remove the use_newdb_tweak and various bits of code used to support
dual use.
2013-08-19 11:16:15 +05:30

602 lines
23 KiB
Python

#!/usr/bin/env python
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
__license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
import os, posixpath, weakref
from functools import partial
from PyQt4.Qt import (QMenu, Qt, QInputDialog, QToolButton, QDialog,
QDialogButtonBox, QGridLayout, QLabel, QLineEdit, QIcon, QSize,
QCoreApplication, pyqtSignal, QVBoxLayout, QTimer)
from calibre import isbytestring, sanitize_file_name_unicode
from calibre.constants import (filesystem_encoding, iswindows,
get_portable_base)
from calibre.utils.config import prefs, tweaks
from calibre.utils.icu import sort_key
from calibre.gui2 import (gprefs, warning_dialog, Dispatcher, error_dialog,
question_dialog, info_dialog, open_local_file, choose_dir)
from calibre.gui2.actions import InterfaceAction
def db_class():
from calibre.db.legacy import LibraryDatabase
return LibraryDatabase
class LibraryUsageStats(object): # {{{
def __init__(self):
self.stats = {}
self.read_stats()
base = get_portable_base()
if base is not None:
lp = prefs['library_path']
if lp:
# Rename the current library. Renaming of other libraries is
# handled by the switch function
q = os.path.basename(lp)
for loc in list(self.stats.iterkeys()):
bn = posixpath.basename(loc)
if bn.lower() == q.lower():
self.rename(loc, lp)
def read_stats(self):
stats = gprefs.get('library_usage_stats', {})
self.stats = stats
def write_stats(self):
locs = list(self.stats.keys())
locs.sort(cmp=lambda x, y: cmp(self.stats[x], self.stats[y]),
reverse=True)
for key in locs[500:]:
self.stats.pop(key)
gprefs.set('library_usage_stats', self.stats)
def remove(self, location):
self.stats.pop(location, None)
self.write_stats()
def canonicalize_path(self, lpath):
if isbytestring(lpath):
lpath = lpath.decode(filesystem_encoding)
lpath = lpath.replace(os.sep, '/')
return lpath
def library_used(self, db):
lpath = self.canonicalize_path(db.library_path)
if lpath not in self.stats:
self.stats[lpath] = 0
self.stats[lpath] += 1
self.write_stats()
return self.pretty(lpath)
def locations(self, db):
lpath = self.canonicalize_path(db.library_path)
locs = list(self.stats.keys())
if lpath in locs:
locs.remove(lpath)
limit = tweaks['many_libraries']
key = sort_key if len(locs) > limit else lambda x:self.stats[x]
locs.sort(key=key, reverse=len(locs)<=limit)
for loc in locs:
yield self.pretty(loc), loc
def pretty(self, loc):
if loc.endswith('/'):
loc = loc[:-1]
return loc.split('/')[-1]
def rename(self, location, newloc):
newloc = self.canonicalize_path(newloc)
stats = self.stats.pop(location, None)
if stats is not None:
self.stats[newloc] = stats
self.write_stats()
# }}}
class MovedDialog(QDialog): # {{{
def __init__(self, stats, location, parent=None):
QDialog.__init__(self, parent)
self.setWindowTitle(_('No library found'))
self._l = l = QGridLayout(self)
self.setLayout(l)
self.stats, self.location = stats, location
loc = self.oldloc = location.replace('/', os.sep)
self.header = QLabel(_('No existing calibre library was found at %s. '
'If the library was moved, select its new location below. '
'Otherwise calibre will forget this library.')%loc)
self.header.setWordWrap(True)
ncols = 2
l.addWidget(self.header, 0, 0, 1, ncols)
self.cl = QLabel('<br><b>'+_('New location of this library:'))
l.addWidget(self.cl, 1, 0, 1, ncols)
self.loc = QLineEdit(loc, self)
l.addWidget(self.loc, 2, 0, 1, 1)
self.cd = QToolButton(self)
self.cd.setIcon(QIcon(I('document_open.png')))
self.cd.clicked.connect(self.choose_dir)
l.addWidget(self.cd, 2, 1, 1, 1)
self.bb = QDialogButtonBox(QDialogButtonBox.Abort)
b = self.bb.addButton(_('Library moved'), self.bb.AcceptRole)
b.setIcon(QIcon(I('ok.png')))
b = self.bb.addButton(_('Forget library'), self.bb.RejectRole)
b.setIcon(QIcon(I('edit-clear.png')))
b.clicked.connect(self.forget_library)
self.bb.accepted.connect(self.accept)
self.bb.rejected.connect(self.reject)
l.addWidget(self.bb, 3, 0, 1, ncols)
self.resize(self.sizeHint() + QSize(100, 50))
def choose_dir(self):
d = choose_dir(self, 'library moved choose new loc',
_('New library location'), default_dir=self.oldloc)
if d is not None:
self.loc.setText(d)
def forget_library(self):
self.stats.remove(self.location)
def accept(self):
newloc = unicode(self.loc.text())
if not db_class.exists_at(newloc):
error_dialog(self, _('No library found'),
_('No existing calibre library found at %s')%newloc,
show=True)
return
self.stats.rename(self.location, newloc)
self.newloc = newloc
QDialog.accept(self)
# }}}
class BackupStatus(QDialog): # {{{
def __init__(self, gui):
QDialog.__init__(self, gui)
self.l = l = QVBoxLayout(self)
self.msg = QLabel('')
self.msg.setWordWrap(True)
l.addWidget(self.msg)
self.bb = bb = QDialogButtonBox(QDialogButtonBox.Close)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)
b = bb.addButton(_('Queue &all books for backup'), bb.ActionRole)
b.clicked.connect(self.mark_all_dirty)
b.setIcon(QIcon(I('lt.png')))
l.addWidget(bb)
self.db = weakref.ref(gui.current_db)
self.setResult(9)
self.setWindowTitle(_('Backup status'))
self.update()
self.resize(self.sizeHint() + QSize(50, 15))
def update(self):
db = self.db()
if db is None:
return
if self.result() != 9:
return
dirty_text = 'no'
try:
dirty_text = '%s' % db.dirty_queue_length()
except:
dirty_text = _('none')
self.msg.setText('<p>' +
_('Book metadata files remaining to be written: %s') % dirty_text)
QTimer.singleShot(1000, self.update)
def mark_all_dirty(self):
db = self.db()
if hasattr(db, 'new_api'):
db.new_api.mark_as_dirty(db.new_api.all_book_ids())
else:
db.dirtied(list(db.data.iterallids()))
# }}}
class ChooseLibraryAction(InterfaceAction):
name = 'Choose Library'
action_spec = (_('Choose Library'), 'lt.png',
_('Choose calibre library to work with'), None)
dont_add_to = frozenset(['context-menu-device'])
action_add_menu = True
action_menu_clone_qaction = _('Switch/create library...')
restore_view_state = pyqtSignal(object)
def genesis(self):
self.count_changed(0)
self.action_choose = self.menuless_qaction
self.stats = LibraryUsageStats()
self.popup_type = (QToolButton.InstantPopup if len(self.stats.stats) > 1 else
QToolButton.MenuButtonPopup)
if len(self.stats.stats) > 1:
self.action_choose.triggered.connect(self.choose_library)
else:
self.qaction.triggered.connect(self.choose_library)
self.choose_menu = self.qaction.menu()
ac = self.create_action(spec=(_('Pick a random book'), 'random.png',
None, None), attr='action_pick_random')
ac.triggered.connect(self.pick_random)
if not os.environ.get('CALIBRE_OVERRIDE_DATABASE_PATH', None):
self.choose_menu.addAction(self.action_choose)
self.quick_menu = QMenu(_('Quick switch'))
self.quick_menu_action = self.choose_menu.addMenu(self.quick_menu)
self.rename_menu = QMenu(_('Rename library'))
self.rename_menu_action = self.choose_menu.addMenu(self.rename_menu)
self.choose_menu.addAction(ac)
self.delete_menu = QMenu(_('Remove library'))
self.delete_menu_action = self.choose_menu.addMenu(self.delete_menu)
else:
self.choose_menu.addAction(ac)
self.rename_separator = self.choose_menu.addSeparator()
self.switch_actions = []
for i in range(5):
ac = self.create_action(spec=('', None, None, None),
attr='switch_action%d'%i)
self.switch_actions.append(ac)
ac.setVisible(False)
ac.triggered.connect(partial(self.qs_requested, i),
type=Qt.QueuedConnection)
self.choose_menu.addAction(ac)
self.rename_separator = self.choose_menu.addSeparator()
self.maintenance_menu = QMenu(_('Library Maintenance'))
ac = self.create_action(spec=(_('Library metadata backup status'),
'lt.png', None, None), attr='action_backup_status')
ac.triggered.connect(self.backup_status, type=Qt.QueuedConnection)
self.maintenance_menu.addAction(ac)
ac = self.create_action(spec=(_('Check library'), 'lt.png',
None, None), attr='action_check_library')
ac.triggered.connect(self.check_library, type=Qt.QueuedConnection)
self.maintenance_menu.addAction(ac)
ac = self.create_action(spec=(_('Restore database'), 'lt.png',
None, None),
attr='action_restore_database')
ac.triggered.connect(self.restore_database, type=Qt.QueuedConnection)
self.maintenance_menu.addAction(ac)
self.choose_menu.addMenu(self.maintenance_menu)
self.view_state_map = {}
self.restore_view_state.connect(self._restore_view_state,
type=Qt.QueuedConnection)
@property
def preserve_state_on_switch(self):
ans = getattr(self, '_preserve_state_on_switch', None)
if ans is None:
self._preserve_state_on_switch = ans = \
self.gui.library_view.preserve_state(require_selected_ids=False)
return ans
def pick_random(self, *args):
self.gui.iactions['Pick Random Book'].pick_random()
def library_name(self):
db = self.gui.library_view.model().db
path = db.library_path
if isbytestring(path):
path = path.decode(filesystem_encoding)
path = path.replace(os.sep, '/')
return self.stats.pretty(path)
def update_tooltip(self, count):
tooltip = self.action_spec[2] + '\n\n' + _('{0} [{1} books]').format(
getattr(self, 'last_lname', ''), count)
a = self.qaction
a.setToolTip(tooltip)
a.setStatusTip(tooltip)
a.setWhatsThis(tooltip)
def library_changed(self, db):
lname = self.stats.library_used(db)
self.last_lname = lname
if len(lname) > 16:
lname = lname[:16] + u''
a = self.qaction
a.setText(lname)
self.update_tooltip(db.count())
self.build_menus()
state = self.view_state_map.get(self.stats.canonicalize_path(
db.library_path), None)
if state is not None:
self.restore_view_state.emit(state)
def _restore_view_state(self, state):
self.preserve_state_on_switch.state = state
def initialization_complete(self):
self.library_changed(self.gui.library_view.model().db)
def build_menus(self):
if os.environ.get('CALIBRE_OVERRIDE_DATABASE_PATH', None):
return
db = self.gui.library_view.model().db
locations = list(self.stats.locations(db))
for ac in self.switch_actions:
ac.setVisible(False)
self.quick_menu.clear()
self.qs_locations = [i[1] for i in locations]
self.rename_menu.clear()
self.delete_menu.clear()
quick_actions, rename_actions, delete_actions = [], [], []
for name, loc in locations:
ac = self.quick_menu.addAction(name, Dispatcher(partial(self.switch_requested,
loc)))
quick_actions.append(ac)
ac = self.rename_menu.addAction(name, Dispatcher(partial(self.rename_requested,
name, loc)))
rename_actions.append(ac)
ac = self.delete_menu.addAction(name, Dispatcher(partial(self.delete_requested,
name, loc)))
delete_actions.append(ac)
qs_actions = []
for i, x in enumerate(locations[:len(self.switch_actions)]):
name, loc = x
ac = self.switch_actions[i]
ac.setText(name)
ac.setVisible(True)
qs_actions.append(ac)
self.quick_menu_action.setVisible(bool(locations))
self.rename_menu_action.setVisible(bool(locations))
self.delete_menu_action.setVisible(bool(locations))
self.gui.location_manager.set_switch_actions(quick_actions,
rename_actions, delete_actions, qs_actions,
self.action_choose)
def location_selected(self, loc):
enabled = loc == 'library'
self.qaction.setEnabled(enabled)
def rename_requested(self, name, location):
LibraryDatabase = db_class()
loc = location.replace('/', os.sep)
base = os.path.dirname(loc)
newname, ok = QInputDialog.getText(self.gui, _('Rename') + ' ' + name,
'<p>'+_('Choose a new name for the library <b>%s</b>. ')%name +
'<p>'+_('Note that the actual library folder will be renamed.'),
text=name)
newname = sanitize_file_name_unicode(unicode(newname))
if not ok or not newname or newname == name:
return
newloc = os.path.join(base, newname)
if os.path.exists(newloc):
return error_dialog(self.gui, _('Already exists'),
_('The folder %s already exists. Delete it first.') %
newloc, show=True)
if (iswindows and len(newloc) >
LibraryDatabase.WINDOWS_LIBRARY_PATH_LIMIT):
return error_dialog(self.gui, _('Too long'),
_('Path to library too long. Must be less than'
' %d characters.')%LibraryDatabase.WINDOWS_LIBRARY_PATH_LIMIT,
show=True)
if not os.path.exists(loc):
error_dialog(self.gui, _('Not found'),
_('Cannot rename as no library was found at %s. '
'Try switching to this library first, then switch back '
'and retry the renaming.')%loc, show=True)
return
try:
os.rename(loc, newloc)
except:
import traceback
det_msg = 'Location: %r New Location: %r\n%s'%(loc, newloc,
traceback.format_exc())
error_dialog(self.gui, _('Rename failed'),
_('Failed to rename the library at %s. '
'The most common cause for this is if one of the files'
' in the library is open in another program.') % loc,
det_msg=det_msg, show=True)
return
self.stats.rename(location, newloc)
self.build_menus()
self.gui.iactions['Copy To Library'].build_menus()
def delete_requested(self, name, location):
loc = location.replace('/', os.sep)
self.stats.remove(location)
self.build_menus()
self.gui.iactions['Copy To Library'].build_menus()
info_dialog(self.gui, _('Library removed'),
_('The library %s has been removed from calibre. '
'The files remain on your computer, if you want '
'to delete them, you will have to do so manually.') % loc,
show=True)
if os.path.exists(loc):
open_local_file(loc)
def backup_status(self, location):
self.__backup_status_dialog = d = BackupStatus(self.gui)
d.show()
def mark_dirty(self):
db = self.gui.library_view.model().db
db.dirtied(list(db.data.iterallids()))
info_dialog(self.gui, _('Backup metadata'),
_('Metadata will be backed up while calibre is running, at the '
'rate of approximately 1 book every three seconds.'), show=True)
def restore_database(self):
LibraryDatabase = db_class()
m = self.gui.library_view.model()
db = m.db
if (iswindows and len(db.library_path) >
LibraryDatabase.WINDOWS_LIBRARY_PATH_LIMIT):
return error_dialog(self.gui, _('Too long'),
_('Path to library too long. Must be less than'
' %d characters. Move your library to a location with'
' a shorter path using Windows Explorer, then point'
' calibre to the new location and try again.')%
LibraryDatabase.WINDOWS_LIBRARY_PATH_LIMIT,
show=True)
from calibre.gui2.dialogs.restore_library import restore_database
m = self.gui.library_view.model()
m.stop_metadata_backup()
db = m.db
db.prefs.disable_setting = True
if restore_database(db, self.gui):
self.gui.library_moved(db.library_path, call_close=False)
def check_library(self):
from calibre.gui2.dialogs.check_library import CheckLibraryDialog, DBCheck, DBCheckNew
self.gui.library_view.save_state()
m = self.gui.library_view.model()
m.stop_metadata_backup()
db = m.db
db.prefs.disable_setting = True
if hasattr(db, 'new_api'):
d = DBCheckNew(self.gui, db)
else:
d = DBCheck(self.gui, db)
d.start()
try:
d.conn.close()
except:
pass
d.break_cycles()
self.gui.library_moved(db.library_path, call_close=not
d.closed_orig_conn)
if d.rejected:
return
if d.error is None:
if not question_dialog(self.gui, _('Success'),
_('Found no errors in your calibre library database.'
' Do you want calibre to check if the files in your '
' library match the information in the database?')):
return
else:
return error_dialog(self.gui, _('Failed'),
_('Database integrity check failed, click Show details'
' for details.'), show=True, det_msg=d.error[1])
self.gui.status_bar.show_message(
_('Starting library scan, this may take a while'))
try:
QCoreApplication.processEvents()
d = CheckLibraryDialog(self.gui, m.db)
if not d.do_exec():
info_dialog(self.gui, _('No problems found'),
_('The files in your library match the information '
'in the database.'), show=True)
finally:
self.gui.status_bar.clear_message()
def look_for_portable_lib(self, db, location):
base = get_portable_base()
if base is None:
return False, None
loc = location.replace('/', os.sep)
candidate = os.path.join(base, os.path.basename(loc))
if db.exists_at(candidate):
newloc = candidate.replace(os.sep, '/')
self.stats.rename(location, newloc)
return True, newloc
return False, None
def switch_requested(self, location):
if not self.change_library_allowed():
return
db = self.gui.library_view.model().db
current_lib = self.stats.canonicalize_path(db.library_path)
self.view_state_map[current_lib] = self.preserve_state_on_switch.state
loc = location.replace('/', os.sep)
exists = db.exists_at(loc)
if not exists:
exists, new_location = self.look_for_portable_lib(db, location)
if exists:
location = new_location
loc = location.replace('/', os.sep)
if not exists:
d = MovedDialog(self.stats, location, self.gui)
ret = d.exec_()
self.build_menus()
self.gui.iactions['Copy To Library'].build_menus()
if ret == d.Accepted:
loc = d.newloc.replace('/', os.sep)
else:
return
# from calibre.utils.mem import memory
# import weakref
# from PyQt4.Qt import QTimer
# self.dbref = weakref.ref(self.gui.library_view.model().db)
# self.before_mem = memory()
self.gui.library_moved(loc, allow_rebuild=True)
# QTimer.singleShot(5000, self.debug_leak)
def debug_leak(self):
import gc
from calibre.utils.mem import memory
ref = self.dbref
for i in xrange(3):
gc.collect()
if ref() is not None:
print 'DB object alive:', ref()
for r in gc.get_referrers(ref())[:10]:
print r
print
print 'before:', self.before_mem
print 'after:', memory()
print
self.dbref = self.before_mem = None
def qs_requested(self, idx, *args):
self.switch_requested(self.qs_locations[idx])
def count_changed(self, new_count):
self.update_tooltip(new_count)
def choose_library(self, *args):
if not self.change_library_allowed():
return
from calibre.gui2.dialogs.choose_library import ChooseLibrary
self.gui.library_view.save_state()
db = self.gui.library_view.model().db
location = self.stats.canonicalize_path(db.library_path)
self.pre_choose_dialog_location = location
c = ChooseLibrary(db, self.choose_library_callback, self.gui)
c.exec_()
self.choose_dialog_library_renamed = getattr(c, 'library_renamed', False)
def choose_library_callback(self, newloc, copy_structure=False):
self.gui.library_moved(newloc, copy_structure=copy_structure,
allow_rebuild=True)
if getattr(self, 'choose_dialog_library_renamed', False):
self.stats.rename(self.pre_choose_dialog_location, prefs['library_path'])
self.build_menus()
self.gui.iactions['Copy To Library'].build_menus()
def change_library_allowed(self):
if os.environ.get('CALIBRE_OVERRIDE_DATABASE_PATH', None):
warning_dialog(self.gui, _('Not allowed'),
_('You cannot change libraries while using the environment'
' variable CALIBRE_OVERRIDE_DATABASE_PATH.'), show=True)
return False
if self.gui.job_manager.has_jobs():
warning_dialog(self.gui, _('Not allowed'),
_('You cannot change libraries while jobs'
' are running.'), show=True)
return False
return True