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
return True
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 \
the file you're importing. Are you sure?"""), msgfunc=QMessageBox.warning):
return False
# schedule replacement; don't do it immediately as we may have been
# called as part of the startup routine
mw.progress.start(immediate=True)
mw.progress.timer(
100, lambda mw=mw, f=importer.file: replaceWithApkg(mw, f, backup), False)
def replaceWithApkg(mw, file, backup):
# unload collection, which will also trigger a backup
mw.unloadCollection()
mw.unloadCollection(lambda: _replaceWithApkg(mw, file, backup))
def _replaceWithApkg(mw, file, backup):
mw.progress.start(immediate=True)
# overwrite collection
z = zipfile.ZipFile(file)
try:
z.extract("collection.anki2", mw.pm.profileFolder())
except:
mw.progress.finish()
showWarning(_("The provided file is not a valid .apkg file."))
return
# 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)
z.close()
# reload
mw.loadCollection()
if not mw.loadCollection():
mw.progress.finish()
return
if backup:
mw.col.modSchema(check=False)
mw.progress.finish()

View file

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

View file

@ -150,22 +150,7 @@ Not currently enabled; click the sync button in the main window to enable."""))
def setupOptions(self):
self.form.pastePNG.setChecked(self.prof.get("pastePNG", False))
self.form.profilePass.clicked.connect(self.onProfilePass)
def updateOptions(self):
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(
# profile
key=None,
mainWindowGeom=None,
mainWindowState=None,
numBackups=30,
@ -133,13 +132,10 @@ a flash drive.""" % self.base)
self.db.list("select name from profiles")
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)
# some profiles created in python2 may not decode properly
prof = pickle.loads(data, errors="ignore")
if prof['key'] and prof['key'] != self._pwhash(passwd):
self.name = None
return False
if name != "_global":
self.name = name
self.profile = prof
@ -164,6 +160,11 @@ a flash drive.""" % self.base)
self.db.execute("delete from profiles where name = ?", name)
self.db.commit()
def trashCollection(self):
p = self.collectionPath()
if os.path.exists(p):
send2trash(p)
def rename(self, name):
oldName = self.name
oldFolder = self.profileFolder()
@ -319,9 +320,6 @@ please see:
%s
""") % (appHelpSite + "#startupopts"))
def _pwhash(self, passwd):
return checksum(str(self.meta['id'])+str(passwd))
# Default language
######################################################################
# On first run, allow the user to choose the default language

View file

@ -184,16 +184,6 @@
</property>
</spacer>
</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>
</widget>
<widget class="QWidget" name="tab_2">
@ -429,7 +419,6 @@
<tabstop>dayOffset</tabstop>
<tabstop>lrnCutoff</tabstop>
<tabstop>timeLimit</tabstop>
<tabstop>profilePass</tabstop>
<tabstop>syncMedia</tabstop>
<tabstop>syncOnProgramOpen</tabstop>
<tabstop>syncDeauth</tabstop>

View file

@ -1,120 +1,114 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>352</width>
<height>283</height>
<width>423</width>
<height>356</height>
</rect>
</property>
<property name="windowTitle">
<string>Profiles</string>
</property>
<property name="windowIcon">
<iconset resource="icons.qrc">
<normaloff>:/icons/anki.png</normaloff>:/icons/anki.png</iconset>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>Profile:</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QListWidget" name="profiles"/>
</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>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QPushButton" name="login">
<property name="text">
<string>Open</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="add">
<property name="text">
<string>Add</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="rename">
<property name="text">
<string>Rename</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="delete_2">
<property name="text">
<string>Delete</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="quit">
<property name="text">
<string>Quit</string>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
</layout>
</item>
</layout>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QListWidget" name="profiles"/>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QPushButton" name="login">
<property name="text">
<string>Open</string>
</property>
<property name="default">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="add">
<property name="text">
<string>Add</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="rename">
<property name="text">
<string>Rename</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="delete_2">
<property name="text">
<string>Delete</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="quit">
<property name="text">
<string>Quit</string>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="openBackup">
<property name="text">
<string>Open Backup...</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="enabled">
<bool>false</bool>
</property>
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>423</width>
<height>22</height>
</rect>
</property>
</widget>
<widget class="QStatusBar" name="statusbar">
<property name="enabled">
<bool>false</bool>
</property>
</widget>
</widget>
<tabstops>
<tabstop>profiles</tabstop>
<tabstop>passEdit</tabstop>
<tabstop>login</tabstop>
<tabstop>add</tabstop>
<tabstop>rename</tabstop>
<tabstop>delete_2</tabstop>
<tabstop>quit</tabstop>
</tabstops>
<resources>
<include location="icons.qrc"/>
</resources>
<resources/>
<connections/>
</ui>