revamp profile manager

- use a main window instead of a dialog, so the menu items of the main
window don't appear while the profile window is active on OS X
- the profile manager now has a button to automatic restoring from
backup, which will prevent old backups from being clobbered
- drop support for profile passwords
- do the right thing when user quits from the menu in profile manager
mode
This commit is contained in:
Damien Elmes 2017-08-16 19:45:39 +10:00
parent 059db539a7
commit a66c5f555f
6 changed files with 205 additions and 201 deletions

View file

@ -370,24 +370,26 @@ def setupApkgImport(mw, importer):
# adding # adding
return True return True
backup = re.match("backup-.*\\.apkg", base) backup = re.match("backup-.*\\.apkg", base)
if not askUser(_("""\ if not mw.restoringBackup and not askUser(_("""\
This will delete your existing collection and replace it with the data in \ This will delete your existing collection and replace it with the data in \
the file you're importing. Are you sure?"""), msgfunc=QMessageBox.warning): the file you're importing. Are you sure?"""), msgfunc=QMessageBox.warning):
return False return False
# schedule replacement; don't do it immediately as we may have been # schedule replacement; don't do it immediately as we may have been
# called as part of the startup routine # called as part of the startup routine
mw.progress.start(immediate=True)
mw.progress.timer( mw.progress.timer(
100, lambda mw=mw, f=importer.file: replaceWithApkg(mw, f, backup), False) 100, lambda mw=mw, f=importer.file: replaceWithApkg(mw, f, backup), False)
def replaceWithApkg(mw, file, backup): def replaceWithApkg(mw, file, backup):
# unload collection, which will also trigger a backup mw.unloadCollection(lambda: _replaceWithApkg(mw, file, backup))
mw.unloadCollection()
def _replaceWithApkg(mw, file, backup):
mw.progress.start(immediate=True)
# overwrite collection # overwrite collection
z = zipfile.ZipFile(file) z = zipfile.ZipFile(file)
try: try:
z.extract("collection.anki2", mw.pm.profileFolder()) z.extract("collection.anki2", mw.pm.profileFolder())
except: except:
mw.progress.finish()
showWarning(_("The provided file is not a valid .apkg file.")) showWarning(_("The provided file is not a valid .apkg file."))
return return
# because users don't have a backup of media, it's safer to import new # because users don't have a backup of media, it's safer to import new
@ -408,7 +410,9 @@ def replaceWithApkg(mw, file, backup):
open(dest, "wb").write(data) open(dest, "wb").write(data)
z.close() z.close()
# reload # reload
mw.loadCollection() if not mw.loadCollection():
mw.progress.finish()
return
if backup: if backup:
mw.col.modSchema(check=False) mw.col.modSchema(check=False)
mw.progress.finish() mw.progress.finish()

View file

@ -24,8 +24,7 @@ import aqt.stats
import aqt.mediasrv import aqt.mediasrv
from aqt.utils import saveGeom, restoreGeom, showInfo, showWarning, \ from aqt.utils import saveGeom, restoreGeom, showInfo, showWarning, \
restoreState, getOnlyText, askUser, applyStyles, showText, tooltip, \ restoreState, getOnlyText, askUser, applyStyles, showText, tooltip, \
openHelp, openLink, checkInvalidFilename openHelp, openLink, checkInvalidFilename, getFile
import anki.db
import sip import sip
class AnkiQt(QMainWindow): class AnkiQt(QMainWindow):
@ -87,18 +86,29 @@ class AnkiQt(QMainWindow):
# Profiles # Profiles
########################################################################## ##########################################################################
class ProfileManager(QMainWindow):
onClose = pyqtSignal()
closeFires = True
def closeEvent(self, evt):
if self.closeFires:
self.onClose.emit()
evt.accept()
def closeWithoutQuitting(self):
self.closeFires = False
self.close()
self.closeFires = True
def setupProfile(self): def setupProfile(self):
self.pendingImport = None self.pendingImport = None
self.restoringBackup = False
# profile not provided on command line? # profile not provided on command line?
if not self.pm.name: if not self.pm.name:
# if there's a single profile, load it automatically # if there's a single profile, load it automatically
profs = self.pm.profiles() profs = self.pm.profiles()
if len(profs) == 1: if len(profs) == 1:
try:
self.pm.load(profs[0]) self.pm.load(profs[0])
except:
# password protected
pass
if not self.pm.name: if not self.pm.name:
self.showProfileManager() self.showProfileManager()
else: else:
@ -107,20 +117,21 @@ class AnkiQt(QMainWindow):
def showProfileManager(self): def showProfileManager(self):
self.pm.profile = None self.pm.profile = None
self.state = "profileManager" self.state = "profileManager"
d = self.profileDiag = QDialog() d = self.profileDiag = self.ProfileManager()
f = self.profileForm = aqt.forms.profiles.Ui_Dialog() f = self.profileForm = aqt.forms.profiles.Ui_MainWindow()
f.setupUi(d) f.setupUi(d)
f.login.clicked.connect(self.onOpenProfile) f.login.clicked.connect(self.onOpenProfile)
f.profiles.itemDoubleClicked.connect(self.onOpenProfile) f.profiles.itemDoubleClicked.connect(self.onOpenProfile)
def onQuit(): f.openBackup.clicked.connect(self.onOpenBackup)
d.close() f.quit.clicked.connect(d.close)
self.cleanupAndExit() d.onClose.connect(self.cleanupAndExit)
f.quit.clicked.connect(onQuit)
f.add.clicked.connect(self.onAddProfile) f.add.clicked.connect(self.onAddProfile)
f.rename.clicked.connect(self.onRenameProfile) f.rename.clicked.connect(self.onRenameProfile)
f.delete_2.clicked.connect(self.onRemProfile) f.delete_2.clicked.connect(self.onRemProfile)
d.rejected.connect(d.close)
f.profiles.currentRowChanged.connect(self.onProfileRowChange) f.profiles.currentRowChanged.connect(self.onProfileRowChange)
f.statusbar.setVisible(False)
# enter key opens profile
QShortcut(QKeySequence("Return"), d, activated=self.onOpenProfile)
self.refreshProfilesList() self.refreshProfilesList()
# raise first, for osx testing # raise first, for osx testing
d.show() d.show()
@ -142,22 +153,14 @@ class AnkiQt(QMainWindow):
return return
name = self.pm.profiles()[n] name = self.pm.profiles()[n]
f = self.profileForm f = self.profileForm
passwd = not self.pm.load(name) self.pm.load(name)
f.passEdit.setVisible(passwd)
f.passLabel.setVisible(passwd)
def openProfile(self): def openProfile(self):
name = self.pm.profiles()[self.profileForm.profiles.currentRow()] name = self.pm.profiles()[self.profileForm.profiles.currentRow()]
passwd = self.profileForm.passEdit.text() return self.pm.load(name)
return self.pm.load(name, passwd)
def onOpenProfile(self): def onOpenProfile(self):
if not self.openProfile(): self.loadProfile(self.profileDiag.closeWithoutQuitting)
showWarning(_("Invalid password."))
return
self.profileDiag.close()
self.loadProfile()
return True
def profileNameOk(self, str): def profileNameOk(self, str):
return not checkInvalidFilename(str) return not checkInvalidFilename(str)
@ -176,8 +179,6 @@ class AnkiQt(QMainWindow):
def onRenameProfile(self): def onRenameProfile(self):
name = getOnlyText(_("New name:"), default=self.pm.name) name = getOnlyText(_("New name:"), default=self.pm.name)
if not self.openProfile():
return showWarning(_("Invalid password."))
if not name: if not name:
return return
if name == self.pm.name: if name == self.pm.name:
@ -193,18 +194,43 @@ class AnkiQt(QMainWindow):
profs = self.pm.profiles() profs = self.pm.profiles()
if len(profs) < 2: if len(profs) < 2:
return showWarning(_("There must be at least one profile.")) return showWarning(_("There must be at least one profile."))
# password correct?
if not self.openProfile():
return
# sure? # sure?
if not askUser(_("""\ if not askUser(_("""\
All cards, notes, and media for this profile will be deleted. \ All cards, notes, and media for this profile will be deleted. \
Are you sure?""")): Are you sure?"""), msgfunc=QMessageBox.warning, defaultno=True):
return return
self.pm.remove(self.pm.name) self.pm.remove(self.pm.name)
self.refreshProfilesList() self.refreshProfilesList()
def loadProfile(self): def onOpenBackup(self):
if not askUser(_("""\
Replace your collection with an earlier backup?"""),
msgfunc=QMessageBox.warning,
defaultno=True):
return
def doOpen(path):
self._openBackup(path)
getFile(self.profileDiag, _("Revert to backup"),
cb=doOpen, filter="*.apkg", dir=self.pm.backupFolder())
def _openBackup(self, path):
try:
# move the existing collection to the trash, as it may not open
self.pm.trashCollection()
except:
showWarning(_("Unable to move existing file to trash - please try restarting your computer."))
return
self.pendingImport = path
self.restoringBackup = True
showInfo(_("""\
Automatic syncing and backups have been disabled while restoring. To enable them again, \
close the profile or restart Anki."""))
self.onOpenProfile()
def loadProfile(self, onsuccess=None):
self.maybeAutoSync() self.maybeAutoSync()
if not self.loadCollection(): if not self.loadCollection():
@ -223,14 +249,11 @@ Are you sure?""")):
# import pending? # import pending?
if self.pendingImport: if self.pendingImport:
if self.pm.profile['key']:
showInfo(_("""\
To import into a password protected profile, please open the profile before attempting to import."""))
else:
self.handleImport(self.pendingImport) self.handleImport(self.pendingImport)
self.pendingImport = None self.pendingImport = None
runHook("profileLoaded") runHook("profileLoaded")
if onsuccess:
onsuccess()
def unloadProfile(self, onsuccess): def unloadProfile(self, onsuccess):
def callback(): def callback():
@ -246,12 +269,13 @@ To import into a password protected profile, please open the profile before atte
self.pm.save() self.pm.save()
self.hide() self.hide()
self.restoringBackup = False
# at this point there should be no windows left # at this point there should be no windows left
self._checkForUnclosedWidgets() self._checkForUnclosedWidgets()
self.maybeAutoSync() self.maybeAutoSync()
self.pm.profile = None
def _checkForUnclosedWidgets(self): def _checkForUnclosedWidgets(self):
for w in self.app.topLevelWidgets(): for w in self.app.topLevelWidgets():
@ -279,8 +303,8 @@ To import into a password protected profile, please open the profile before atte
except Exception as e: except Exception as e:
showWarning(_("""\ showWarning(_("""\
Anki was unable to open your collection file. If problems persist after \ Anki was unable to open your collection file. If problems persist after \
restarting your computer, please see the manual for how to restore from \ restarting your computer, please use the Open Backup button in the profile \
an automatic backup. manager.
Debug info: Debug info:
""")+traceback.format_exc()) """)+traceback.format_exc())
@ -300,7 +324,13 @@ Debug info:
self.closeAllWindows(callback) self.closeAllWindows(callback)
def _unloadCollection(self): def _unloadCollection(self):
self.progress.start(label=_("Backing Up..."), immediate=True) if not self.col:
return
if self.restoringBackup:
label = _("Closing...")
else:
label = _("Backing Up...")
self.progress.start(label=label, immediate=True)
corrupt = False corrupt = False
try: try:
self.maybeOptimize() self.maybeOptimize()
@ -315,11 +345,11 @@ when the collection is stored on a network or cloud drive. Please see \
the manual for information on how to restore from an automatic backup.")) the manual for information on how to restore from an automatic backup."))
try: try:
self.col.close() self.col.close()
except: finally:
pass
self.col = None self.col = None
if not corrupt: if not corrupt and not self.restoringBackup:
self.backup() self.backup()
self.progress.finish() self.progress.finish()
# Backup and auto-optimize # Backup and auto-optimize
@ -534,7 +564,7 @@ title="%s" %s>%s</button>''' % (
self.form.centralwidget.setLayout(self.mainLayout) self.form.centralwidget.setLayout(self.mainLayout)
def closeAllWindows(self, onsuccess): def closeAllWindows(self, onsuccess):
return aqt.dialogs.closeAll(onsuccess) aqt.dialogs.closeAll(onsuccess)
# Components # Components
########################################################################## ##########################################################################
@ -544,6 +574,7 @@ title="%s" %s>%s</button>''' % (
def onSigInt(self, signum, frame): def onSigInt(self, signum, frame):
# interrupt any current transaction and schedule a rollback & quit # interrupt any current transaction and schedule a rollback & quit
if self.col:
self.col.db.interrupt() self.col.db.interrupt()
def quit(): def quit():
self.col.db.rollback() self.col.db.rollback()
@ -596,10 +627,8 @@ title="%s" %s>%s</button>''' % (
def maybeAutoSync(self): def maybeAutoSync(self):
if (not self.pm.profile['syncKey'] if (not self.pm.profile['syncKey']
or not self.pm.profile['autoSync'] or not self.pm.profile['autoSync']
or self.safeMode): or self.safeMode
return or self.restoringBackup):
if self.pendingImport and os.path.basename(
self.pendingImport).startswith("backup-"):
return return
# ok to sync # ok to sync
@ -669,7 +698,12 @@ title="%s" %s>%s</button>''' % (
########################################################################## ##########################################################################
def closeEvent(self, event): def closeEvent(self, event):
"User hit the X button, etc." if self.state == "profileManager":
# if profile manager active, this event may fire via OS X menu bar's
# quit option
self.profileDiag.close()
event.accept()
else:
# ignore the event for now, as we need time to clean up # ignore the event for now, as we need time to clean up
event.ignore() event.ignore()
self.unloadProfileAndExit() self.unloadProfileAndExit()

View file

@ -150,22 +150,7 @@ Not currently enabled; click the sync button in the main window to enable."""))
def setupOptions(self): def setupOptions(self):
self.form.pastePNG.setChecked(self.prof.get("pastePNG", False)) self.form.pastePNG.setChecked(self.prof.get("pastePNG", False))
self.form.profilePass.clicked.connect(self.onProfilePass)
def updateOptions(self): def updateOptions(self):
self.prof['pastePNG'] = self.form.pastePNG.isChecked() self.prof['pastePNG'] = self.form.pastePNG.isChecked()
def onProfilePass(self):
pw, ret = getText(_("""\
Lock account with password, or leave blank:"""))
if not ret:
return
if not pw:
self.prof['key'] = None
return
pw2, ret = getText(_("Confirm password:"))
if not ret:
return
if pw != pw2:
showWarning(_("Passwords didn't match"))
self.prof['key'] = self.mw.pm._pwhash(pw)

View file

@ -37,7 +37,6 @@ metaConf = dict(
profileConf = dict( profileConf = dict(
# profile # profile
key=None,
mainWindowGeom=None, mainWindowGeom=None,
mainWindowState=None, mainWindowState=None,
numBackups=30, numBackups=30,
@ -133,13 +132,10 @@ a flash drive.""" % self.base)
self.db.list("select name from profiles") self.db.list("select name from profiles")
if x != "_global") if x != "_global")
def load(self, name, passwd=None): def load(self, name):
data = self.db.scalar("select cast(data as blob) from profiles where name = ?", name) data = self.db.scalar("select cast(data as blob) from profiles where name = ?", name)
# some profiles created in python2 may not decode properly # some profiles created in python2 may not decode properly
prof = pickle.loads(data, errors="ignore") prof = pickle.loads(data, errors="ignore")
if prof['key'] and prof['key'] != self._pwhash(passwd):
self.name = None
return False
if name != "_global": if name != "_global":
self.name = name self.name = name
self.profile = prof self.profile = prof
@ -164,6 +160,11 @@ a flash drive.""" % self.base)
self.db.execute("delete from profiles where name = ?", name) self.db.execute("delete from profiles where name = ?", name)
self.db.commit() self.db.commit()
def trashCollection(self):
p = self.collectionPath()
if os.path.exists(p):
send2trash(p)
def rename(self, name): def rename(self, name):
oldName = self.name oldName = self.name
oldFolder = self.profileFolder() oldFolder = self.profileFolder()
@ -319,9 +320,6 @@ please see:
%s %s
""") % (appHelpSite + "#startupopts")) """) % (appHelpSite + "#startupopts"))
def _pwhash(self, passwd):
return checksum(str(self.meta['id'])+str(passwd))
# Default language # Default language
###################################################################### ######################################################################
# On first run, allow the user to choose the default language # On first run, allow the user to choose the default language

View file

@ -184,16 +184,6 @@
</property> </property>
</spacer> </spacer>
</item> </item>
<item>
<widget class="QPushButton" name="profilePass">
<property name="text">
<string>Profile Password...</string>
</property>
<property name="autoDefault">
<bool>false</bool>
</property>
</widget>
</item>
</layout> </layout>
</widget> </widget>
<widget class="QWidget" name="tab_2"> <widget class="QWidget" name="tab_2">
@ -429,7 +419,6 @@
<tabstop>dayOffset</tabstop> <tabstop>dayOffset</tabstop>
<tabstop>lrnCutoff</tabstop> <tabstop>lrnCutoff</tabstop>
<tabstop>timeLimit</tabstop> <tabstop>timeLimit</tabstop>
<tabstop>profilePass</tabstop>
<tabstop>syncMedia</tabstop> <tabstop>syncMedia</tabstop>
<tabstop>syncOnProgramOpen</tabstop> <tabstop>syncOnProgramOpen</tabstop>
<tabstop>syncDeauth</tabstop> <tabstop>syncDeauth</tabstop>

View file

@ -1,30 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0"> <ui version="4.0">
<class>Dialog</class> <class>MainWindow</class>
<widget class="QDialog" name="Dialog"> <widget class="QMainWindow" name="MainWindow">
<property name="geometry"> <property name="geometry">
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>352</width> <width>423</width>
<height>283</height> <height>356</height>
</rect> </rect>
</property> </property>
<property name="windowTitle"> <property name="windowTitle">
<string>Profiles</string> <string>Profiles</string>
</property> </property>
<property name="windowIcon"> <widget class="QWidget" name="centralwidget">
<iconset resource="icons.qrc">
<normaloff>:/icons/anki.png</normaloff>:/icons/anki.png</iconset>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3"> <layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>Profile:</string>
</property>
</widget>
</item>
<item> <item>
<layout class="QHBoxLayout" name="horizontalLayout"> <layout class="QHBoxLayout" name="horizontalLayout">
<item> <item>
@ -32,20 +22,6 @@
<item> <item>
<widget class="QListWidget" name="profiles"/> <widget class="QListWidget" name="profiles"/>
</item> </item>
<item>
<widget class="QLabel" name="passLabel">
<property name="text">
<string>Password:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="passEdit">
<property name="echoMode">
<enum>QLineEdit::Password</enum>
</property>
</widget>
</item>
</layout> </layout>
</item> </item>
<item> <item>
@ -55,6 +31,9 @@
<property name="text"> <property name="text">
<string>Open</string> <string>Open</string>
</property> </property>
<property name="default">
<bool>true</bool>
</property>
</widget> </widget>
</item> </item>
<item> <item>
@ -98,23 +77,38 @@
</property> </property>
</spacer> </spacer>
</item> </item>
<item>
<widget class="QPushButton" name="openBackup">
<property name="text">
<string>Open Backup...</string>
</property>
</widget>
</item>
</layout> </layout>
</item> </item>
</layout> </layout>
</item> </item>
</layout> </layout>
</widget> </widget>
<tabstops> <widget class="QMenuBar" name="menubar">
<tabstop>profiles</tabstop> <property name="enabled">
<tabstop>passEdit</tabstop> <bool>false</bool>
<tabstop>login</tabstop> </property>
<tabstop>add</tabstop> <property name="geometry">
<tabstop>rename</tabstop> <rect>
<tabstop>delete_2</tabstop> <x>0</x>
<tabstop>quit</tabstop> <y>0</y>
</tabstops> <width>423</width>
<resources> <height>22</height>
<include location="icons.qrc"/> </rect>
</resources> </property>
</widget>
<widget class="QStatusBar" name="statusbar">
<property name="enabled">
<bool>false</bool>
</property>
</widget>
</widget>
<resources/>
<connections/> <connections/>
</ui> </ui>