mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
Integrate AnkiHub Sign-in (#3232)
* Add AnkiHub section to preferences screen * Add short intro for AnkiWeb and AnkiHub to syncing section * Add AnkiHub login screen * Implement login methods in backend * Set minimum dialog width * Add missing colon * Respect the ANKIHUB_APP_URL env var This is used by the add-on. * Simplify login error reporting * Fix from_prefs_screen not passed to subcall * Add missing ankihub_pb2 import * Install AnkiHub add-on after sign-in * Avoid .exec() * Update ftl/core/sync.ftl Co-authored-by: Damien Elmes <dae@users.noreply.github.com> * Split translation string * Support login by username/email * Fix entered username/email not being passed back to on_done * Remove unused import * Move to 'Third-party services' section * Tweak login dialog's heading * Remove 'third-party' from intro text * Tweak copy * Prefix profile keys * Tweak strings * Remove description from login dialog * Remove signup links * Clear credentials in ankihub_logout() * Call .adjustSize() * Title Case * Add padding to third-party services, and fix tab order from other PR
This commit is contained in:
parent
acf3134e04
commit
520564da11
18 changed files with 623 additions and 33 deletions
|
@ -27,7 +27,6 @@ preferences-show-remaining-card-count = Show remaining card count
|
||||||
preferences-some-settings-will-take-effect-after = Some settings will take effect after you restart Anki.
|
preferences-some-settings-will-take-effect-after = Some settings will take effect after you restart Anki.
|
||||||
preferences-tab-synchronisation = Synchronization
|
preferences-tab-synchronisation = Synchronization
|
||||||
preferences-synchronize-audio-and-images-too = Synchronize audio and images too
|
preferences-synchronize-audio-and-images-too = Synchronize audio and images too
|
||||||
preferences-not-logged-in = Not currently logged in to AnkiWeb.
|
|
||||||
preferences-login-successful-sync-now = Log-in successful. Save preferences and sync now?
|
preferences-login-successful-sync-now = Log-in successful. Save preferences and sync now?
|
||||||
preferences-timebox-time-limit = Timebox time limit
|
preferences-timebox-time-limit = Timebox time limit
|
||||||
preferences-user-interface-size = User interface size
|
preferences-user-interface-size = User interface size
|
||||||
|
@ -78,9 +77,15 @@ preferences-network-timeout = Network timeout
|
||||||
preferences-reset-window-sizes = Reset Window Sizes
|
preferences-reset-window-sizes = Reset Window Sizes
|
||||||
preferences-reset-window-sizes-complete = Window sizes and locations have been reset.
|
preferences-reset-window-sizes-complete = Window sizes and locations have been reset.
|
||||||
preferences-shortcut-placeholder = Enter an unused shortcut key, or leave empty to disable.
|
preferences-shortcut-placeholder = Enter an unused shortcut key, or leave empty to disable.
|
||||||
|
preferences-third-party-services = Third-Party Services
|
||||||
|
preferences-ankihub-not-logged-in = Not currently logged in to AnkiHub.
|
||||||
|
preferences-ankiweb-intro = AnkiWeb is a free service that lets you keep your flashcard data in sync across your devices, and provides a way to recover the data if your device breaks or is lost.
|
||||||
|
preferences-ankihub-intro = AnkiHub provides collaborative deck editing and additional study tools. A paid subscription is required to access certain features.
|
||||||
|
preferences-third-party-description = Third-party services are unaffiliated with and not endorsed by Anki. Use of these services may require payment.
|
||||||
|
|
||||||
## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.
|
## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.
|
||||||
|
|
||||||
preferences-basic = Basic
|
preferences-basic = Basic
|
||||||
preferences-reviewer = Reviewer
|
preferences-reviewer = Reviewer
|
||||||
preferences-media = Media
|
preferences-media = Media
|
||||||
|
preferences-not-logged-in = Not currently logged in to AnkiWeb.
|
||||||
|
|
|
@ -54,6 +54,11 @@ sync-upload-too-large =
|
||||||
Your collection file is too large to send to AnkiWeb. You can reduce its
|
Your collection file is too large to send to AnkiWeb. You can reduce its
|
||||||
size by removing any unwanted decks (optionally exporting them first), and
|
size by removing any unwanted decks (optionally exporting them first), and
|
||||||
then using Check Database to shrink the file size down. ({ $details })
|
then using Check Database to shrink the file size down. ({ $details })
|
||||||
|
sync-sign-in = Sign in
|
||||||
|
sync-ankihub-dialog-heading = AnkiHub Login
|
||||||
|
sync-ankihub-username-label = Username or Email:
|
||||||
|
sync-ankihub-login-failed = Unable to log in to AnkiHub with the provided credentials.
|
||||||
|
sync-ankihub-addon-installation = AnkiHub Add-on Installation
|
||||||
|
|
||||||
## Buttons
|
## Buttons
|
||||||
|
|
||||||
|
|
30
proto/anki/ankihub.proto
Normal file
30
proto/anki/ankihub.proto
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
option java_multiple_files = true;
|
||||||
|
|
||||||
|
import "anki/generic.proto";
|
||||||
|
|
||||||
|
package anki.ankihub;
|
||||||
|
|
||||||
|
service AnkiHubService {}
|
||||||
|
|
||||||
|
service BackendAnkiHubService {
|
||||||
|
rpc AnkihubLogin(LoginRequest) returns (LoginResponse);
|
||||||
|
rpc AnkihubLogout(LogoutRequest) returns (generic.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
message LoginResponse {
|
||||||
|
string token = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LoginRequest {
|
||||||
|
string id = 1;
|
||||||
|
string password = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LogoutRequest {
|
||||||
|
string token = 1;
|
||||||
|
}
|
|
@ -1121,6 +1121,12 @@ class Collection(DeprecatedNamesMixin):
|
||||||
"This will throw if the sync failed with an error."
|
"This will throw if the sync failed with an error."
|
||||||
return self._backend.media_sync_status()
|
return self._backend.media_sync_status()
|
||||||
|
|
||||||
|
def ankihub_login(self, id: str, password: str) -> str:
|
||||||
|
return self._backend.ankihub_login(id=id, password=password)
|
||||||
|
|
||||||
|
def ankihub_logout(self, token: str) -> None:
|
||||||
|
self._backend.ankihub_logout(token=token)
|
||||||
|
|
||||||
def get_preferences(self) -> Preferences:
|
def get_preferences(self) -> Preferences:
|
||||||
return self._backend.get_preferences()
|
return self._backend.get_preferences()
|
||||||
|
|
||||||
|
|
|
@ -1253,7 +1253,9 @@ class DownloaderInstaller(QObject):
|
||||||
self.mgr.mw.progress.single_shot(50, lambda: self.on_done(self.log))
|
self.mgr.mw.progress.single_shot(50, lambda: self.on_done(self.log))
|
||||||
|
|
||||||
|
|
||||||
def show_log_to_user(parent: QWidget, log: list[DownloadLogEntry]) -> None:
|
def show_log_to_user(
|
||||||
|
parent: QWidget, log: list[DownloadLogEntry], title: str = "Anki"
|
||||||
|
) -> None:
|
||||||
have_problem = download_encountered_problem(log)
|
have_problem = download_encountered_problem(log)
|
||||||
|
|
||||||
if have_problem:
|
if have_problem:
|
||||||
|
@ -1263,9 +1265,9 @@ def show_log_to_user(parent: QWidget, log: list[DownloadLogEntry]) -> None:
|
||||||
text += f"<br><br>{download_log_to_html(log)}"
|
text += f"<br><br>{download_log_to_html(log)}"
|
||||||
|
|
||||||
if have_problem:
|
if have_problem:
|
||||||
showWarning(text, textFormat="rich", parent=parent)
|
showWarning(text, textFormat="rich", parent=parent, title=title)
|
||||||
else:
|
else:
|
||||||
showInfo(text, parent=parent)
|
showInfo(text, parent=parent, title=title)
|
||||||
|
|
||||||
|
|
||||||
def download_addons(
|
def download_addons(
|
||||||
|
@ -1550,6 +1552,32 @@ def prompt_to_update(
|
||||||
ChooseAddonsToUpdateDialog(parent, mgr, updated_addons).ask(after_choosing)
|
ChooseAddonsToUpdateDialog(parent, mgr, updated_addons).ask(after_choosing)
|
||||||
|
|
||||||
|
|
||||||
|
def install_or_update_addon(
|
||||||
|
parent: QWidget,
|
||||||
|
mgr: AddonManager,
|
||||||
|
addon_id: int,
|
||||||
|
on_done: Callable[[list[DownloadLogEntry]], None],
|
||||||
|
) -> None:
|
||||||
|
def check() -> list[AddonInfo]:
|
||||||
|
return fetch_update_info([addon_id])
|
||||||
|
|
||||||
|
def update_info_received(future: Future) -> None:
|
||||||
|
try:
|
||||||
|
items = future.result()
|
||||||
|
updated_addons = mgr.get_updated_addons(items)
|
||||||
|
if not updated_addons:
|
||||||
|
on_done([])
|
||||||
|
return
|
||||||
|
client = HttpClient()
|
||||||
|
download_addons(
|
||||||
|
parent, mgr, [addon.id for addon in updated_addons], on_done, client
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
on_done([(addon_id, DownloadError(exception=exc))])
|
||||||
|
|
||||||
|
mgr.mw.taskman.run_in_background(check, update_info_received)
|
||||||
|
|
||||||
|
|
||||||
# Editing config
|
# Editing config
|
||||||
######################################################################
|
######################################################################
|
||||||
|
|
||||||
|
|
157
qt/aqt/ankihub.py
Normal file
157
qt/aqt/ankihub.py
Normal file
|
@ -0,0 +1,157 @@
|
||||||
|
# Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
|
from concurrent.futures import Future
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import aqt
|
||||||
|
import aqt.main
|
||||||
|
from aqt.addons import (
|
||||||
|
AddonManager,
|
||||||
|
DownloadLogEntry,
|
||||||
|
install_or_update_addon,
|
||||||
|
show_log_to_user,
|
||||||
|
)
|
||||||
|
from aqt.qt import (
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QGridLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
Qt,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
qconnect,
|
||||||
|
)
|
||||||
|
from aqt.utils import disable_help_button, showWarning, tr
|
||||||
|
|
||||||
|
|
||||||
|
def ankihub_login(
|
||||||
|
mw: aqt.main.AnkiQt,
|
||||||
|
on_success: Callable[[], None],
|
||||||
|
username: str = "",
|
||||||
|
password: str = "",
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
def on_future_done(fut: Future[str], username: str, password: str) -> None:
|
||||||
|
try:
|
||||||
|
token = fut.result()
|
||||||
|
except Exception as exc:
|
||||||
|
showWarning(str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
showWarning(tr.sync_ankihub_login_failed(), parent=mw)
|
||||||
|
ankihub_login(mw, on_success, username, password)
|
||||||
|
return
|
||||||
|
mw.pm.set_ankihub_token(token)
|
||||||
|
mw.pm.set_ankihub_username(username)
|
||||||
|
install_ankihub_addon(mw, mw.addonManager)
|
||||||
|
on_success()
|
||||||
|
|
||||||
|
def callback(username: str, password: str) -> None:
|
||||||
|
if not username and not password:
|
||||||
|
return
|
||||||
|
if username and password:
|
||||||
|
mw.taskman.with_progress(
|
||||||
|
lambda: mw.col.ankihub_login(id=username, password=password),
|
||||||
|
functools.partial(on_future_done, username=username, password=password),
|
||||||
|
parent=mw,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ankihub_login(mw, on_success, username, password)
|
||||||
|
|
||||||
|
get_id_and_pass_from_user(mw, callback, username, password)
|
||||||
|
|
||||||
|
|
||||||
|
def ankihub_logout(
|
||||||
|
mw: aqt.main.AnkiQt,
|
||||||
|
on_success: Callable[[], None],
|
||||||
|
token: str,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
def logout() -> None:
|
||||||
|
mw.pm.set_ankihub_username(None)
|
||||||
|
mw.pm.set_ankihub_token(None)
|
||||||
|
mw.col.ankihub_logout(token=token)
|
||||||
|
|
||||||
|
mw.taskman.with_progress(
|
||||||
|
logout,
|
||||||
|
# We don't need to wait for the response
|
||||||
|
lambda _: on_success(),
|
||||||
|
parent=mw,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_id_and_pass_from_user(
|
||||||
|
mw: aqt.main.AnkiQt,
|
||||||
|
callback: Callable[[str, str], None],
|
||||||
|
username: str = "",
|
||||||
|
password: str = "",
|
||||||
|
) -> None:
|
||||||
|
diag = QDialog(mw)
|
||||||
|
diag.setWindowTitle("Anki")
|
||||||
|
disable_help_button(diag)
|
||||||
|
diag.setWindowModality(Qt.WindowModality.WindowModal)
|
||||||
|
diag.setMinimumWidth(600)
|
||||||
|
vbox = QVBoxLayout()
|
||||||
|
info_label = QLabel(f"<h1>{tr.sync_ankihub_dialog_heading()}</h1>")
|
||||||
|
info_label.setOpenExternalLinks(True)
|
||||||
|
info_label.setWordWrap(True)
|
||||||
|
vbox.addWidget(info_label)
|
||||||
|
vbox.addSpacing(20)
|
||||||
|
g = QGridLayout()
|
||||||
|
l1 = QLabel(tr.sync_ankihub_username_label())
|
||||||
|
g.addWidget(l1, 0, 0)
|
||||||
|
user = QLineEdit()
|
||||||
|
user.setText(username)
|
||||||
|
g.addWidget(user, 0, 1)
|
||||||
|
l2 = QLabel(tr.sync_password_label())
|
||||||
|
g.addWidget(l2, 1, 0)
|
||||||
|
passwd = QLineEdit()
|
||||||
|
passwd.setText(password)
|
||||||
|
passwd.setEchoMode(QLineEdit.EchoMode.Password)
|
||||||
|
g.addWidget(passwd, 1, 1)
|
||||||
|
vbox.addLayout(g)
|
||||||
|
|
||||||
|
vbox.addSpacing(20)
|
||||||
|
bb = QDialogButtonBox() # type: ignore
|
||||||
|
sign_in_button = QPushButton(tr.sync_sign_in())
|
||||||
|
sign_in_button.setAutoDefault(True)
|
||||||
|
bb.addButton(
|
||||||
|
QPushButton(tr.actions_cancel()),
|
||||||
|
QDialogButtonBox.ButtonRole.RejectRole,
|
||||||
|
)
|
||||||
|
bb.addButton(
|
||||||
|
sign_in_button,
|
||||||
|
QDialogButtonBox.ButtonRole.AcceptRole,
|
||||||
|
)
|
||||||
|
qconnect(bb.accepted, diag.accept)
|
||||||
|
qconnect(bb.rejected, diag.reject)
|
||||||
|
vbox.addWidget(bb)
|
||||||
|
|
||||||
|
diag.setLayout(vbox)
|
||||||
|
diag.adjustSize()
|
||||||
|
diag.show()
|
||||||
|
user.setFocus()
|
||||||
|
|
||||||
|
def on_finished(result: int) -> None:
|
||||||
|
if result == QDialog.DialogCode.Rejected:
|
||||||
|
callback("", "")
|
||||||
|
else:
|
||||||
|
callback(user.text().strip(), passwd.text())
|
||||||
|
|
||||||
|
qconnect(diag.finished, on_finished)
|
||||||
|
diag.open()
|
||||||
|
|
||||||
|
|
||||||
|
def install_ankihub_addon(parent: QWidget, mgr: AddonManager) -> None:
|
||||||
|
def on_done(log: list[DownloadLogEntry]) -> None:
|
||||||
|
if log:
|
||||||
|
show_log_to_user(parent, log, title=tr.sync_ankihub_addon_installation())
|
||||||
|
|
||||||
|
install_or_update_addon(parent, mgr, 1322529746, on_done)
|
|
@ -49,17 +49,14 @@
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item row="0" column="1">
|
<item row="1" column="1">
|
||||||
<widget class="QComboBox" name="lang">
|
<widget class="QComboBox" name="video_driver">
|
||||||
<property name="sizePolicy">
|
<property name="sizePolicy">
|
||||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||||
<horstretch>0</horstretch>
|
<horstretch>0</horstretch>
|
||||||
<verstretch>0</verstretch>
|
<verstretch>0</verstretch>
|
||||||
</sizepolicy>
|
</sizepolicy>
|
||||||
</property>
|
</property>
|
||||||
<property name="sizeAdjustPolicy">
|
|
||||||
<enum>QComboBox::AdjustToMinimumContentsLengthWithIcon</enum>
|
|
||||||
</property>
|
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item row="1" column="0">
|
<item row="1" column="0">
|
||||||
|
@ -72,17 +69,20 @@
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item row="1" column="1">
|
<item row="0" column="1">
|
||||||
<widget class="QComboBox" name="video_driver">
|
<widget class="QComboBox" name="lang">
|
||||||
<property name="sizePolicy">
|
<property name="sizePolicy">
|
||||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||||
<horstretch>0</horstretch>
|
<horstretch>0</horstretch>
|
||||||
<verstretch>0</verstretch>
|
<verstretch>0</verstretch>
|
||||||
</sizepolicy>
|
</sizepolicy>
|
||||||
</property>
|
</property>
|
||||||
|
<property name="sizeAdjustPolicy">
|
||||||
|
<enum>QComboBox::AdjustToMinimumContentsLengthWithIcon</enum>
|
||||||
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item row="2" column="0">
|
||||||
<widget class="QCheckBox" name="check_for_updates">
|
<widget class="QCheckBox" name="check_for_updates">
|
||||||
<property name="sizePolicy">
|
<property name="sizePolicy">
|
||||||
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||||
|
@ -806,6 +806,9 @@
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string/>
|
<string/>
|
||||||
</property>
|
</property>
|
||||||
|
<property name="wordWrap">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
|
@ -856,6 +859,19 @@
|
||||||
</property>
|
</property>
|
||||||
</spacer>
|
</spacer>
|
||||||
</item>
|
</item>
|
||||||
|
<item>
|
||||||
|
<spacer name="verticalSpacer_2">
|
||||||
|
<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>
|
<item>
|
||||||
<layout class="QGridLayout" name="gridLayout_2">
|
<layout class="QGridLayout" name="gridLayout_2">
|
||||||
<item row="0" column="1">
|
<item row="0" column="1">
|
||||||
|
@ -1098,6 +1114,133 @@
|
||||||
</item>
|
</item>
|
||||||
</layout>
|
</layout>
|
||||||
</widget>
|
</widget>
|
||||||
|
<widget class="QWidget" name="tab_3">
|
||||||
|
<attribute name="title">
|
||||||
|
<string>preferences_third_party_services</string>
|
||||||
|
</attribute>
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||||
|
<property name="spacing">
|
||||||
|
<number>12</number>
|
||||||
|
</property>
|
||||||
|
<property name="leftMargin">
|
||||||
|
<number>12</number>
|
||||||
|
</property>
|
||||||
|
<property name="topMargin">
|
||||||
|
<number>12</number>
|
||||||
|
</property>
|
||||||
|
<property name="rightMargin">
|
||||||
|
<number>12</number>
|
||||||
|
</property>
|
||||||
|
<property name="bottomMargin">
|
||||||
|
<number>12</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="label">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>preferences_third_party_description</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<spacer name="verticalSpacer">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Vertical</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeType">
|
||||||
|
<enum>QSizePolicy::Maximum</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>20</width>
|
||||||
|
<height>12</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QGroupBox" name="groupBox_6">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="title">
|
||||||
|
<string notr="true">AnkiHub</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="syncAnkiHubUser">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
<property name="wordWrap">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="syncAnkiHubLogout">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>sync_log_out_button</string>
|
||||||
|
</property>
|
||||||
|
<property name="autoDefault">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="syncAnkiHubLogin">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>sync_log_in_button</string>
|
||||||
|
</property>
|
||||||
|
<property name="autoDefault">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<spacer name="verticalspacer_13">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Vertical</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>40</width>
|
||||||
|
<height>20</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
|
@ -1125,6 +1268,7 @@
|
||||||
<tabstops>
|
<tabstops>
|
||||||
<tabstop>lang</tabstop>
|
<tabstop>lang</tabstop>
|
||||||
<tabstop>video_driver</tabstop>
|
<tabstop>video_driver</tabstop>
|
||||||
|
<tabstop>check_for_updates</tabstop>
|
||||||
<tabstop>theme</tabstop>
|
<tabstop>theme</tabstop>
|
||||||
<tabstop>styleComboBox</tabstop>
|
<tabstop>styleComboBox</tabstop>
|
||||||
<tabstop>uiScale</tabstop>
|
<tabstop>uiScale</tabstop>
|
||||||
|
@ -1164,6 +1308,8 @@
|
||||||
<tabstop>weekly_backups</tabstop>
|
<tabstop>weekly_backups</tabstop>
|
||||||
<tabstop>monthly_backups</tabstop>
|
<tabstop>monthly_backups</tabstop>
|
||||||
<tabstop>tabWidget</tabstop>
|
<tabstop>tabWidget</tabstop>
|
||||||
|
<tabstop>syncAnkiHubLogout</tabstop>
|
||||||
|
<tabstop>syncAnkiHubLogin</tabstop>
|
||||||
</tabstops>
|
</tabstops>
|
||||||
<resources/>
|
<resources/>
|
||||||
<connections>
|
<connections>
|
||||||
|
|
|
@ -14,6 +14,7 @@ import aqt.operations
|
||||||
from anki.collection import OpChanges
|
from anki.collection import OpChanges
|
||||||
from anki.utils import is_mac
|
from anki.utils import is_mac
|
||||||
from aqt import AnkiQt
|
from aqt import AnkiQt
|
||||||
|
from aqt.ankihub import ankihub_login, ankihub_logout
|
||||||
from aqt.operations.collection import set_preferences
|
from aqt.operations.collection import set_preferences
|
||||||
from aqt.profiles import VideoDriver
|
from aqt.profiles import VideoDriver
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
|
@ -213,10 +214,12 @@ class Preferences(QDialog):
|
||||||
self.update_login_status()
|
self.update_login_status()
|
||||||
qconnect(self.form.syncLogout.clicked, self.sync_logout)
|
qconnect(self.form.syncLogout.clicked, self.sync_logout)
|
||||||
qconnect(self.form.syncLogin.clicked, self.sync_login)
|
qconnect(self.form.syncLogin.clicked, self.sync_login)
|
||||||
|
qconnect(self.form.syncAnkiHubLogout.clicked, self.ankihub_sync_logout)
|
||||||
|
qconnect(self.form.syncAnkiHubLogin.clicked, self.ankihub_sync_login)
|
||||||
|
|
||||||
def update_login_status(self) -> None:
|
def update_login_status(self) -> None:
|
||||||
if not self.prof.get("syncKey"):
|
if not self.prof.get("syncKey"):
|
||||||
self.form.syncUser.setText(tr.preferences_not_logged_in())
|
self.form.syncUser.setText(tr.preferences_ankiweb_intro())
|
||||||
self.form.syncLogin.setVisible(True)
|
self.form.syncLogin.setVisible(True)
|
||||||
self.form.syncLogout.setVisible(False)
|
self.form.syncLogout.setVisible(False)
|
||||||
else:
|
else:
|
||||||
|
@ -224,6 +227,15 @@ class Preferences(QDialog):
|
||||||
self.form.syncLogin.setVisible(False)
|
self.form.syncLogin.setVisible(False)
|
||||||
self.form.syncLogout.setVisible(True)
|
self.form.syncLogout.setVisible(True)
|
||||||
|
|
||||||
|
if not self.mw.pm.ankihub_token():
|
||||||
|
self.form.syncAnkiHubUser.setText(tr.preferences_ankihub_intro())
|
||||||
|
self.form.syncAnkiHubLogin.setVisible(True)
|
||||||
|
self.form.syncAnkiHubLogout.setVisible(False)
|
||||||
|
else:
|
||||||
|
self.form.syncAnkiHubUser.setText(self.mw.pm.ankihub_username())
|
||||||
|
self.form.syncAnkiHubLogin.setVisible(False)
|
||||||
|
self.form.syncAnkiHubLogout.setVisible(True)
|
||||||
|
|
||||||
def on_media_log(self) -> None:
|
def on_media_log(self) -> None:
|
||||||
self.mw.media_syncer.show_sync_log()
|
self.mw.media_syncer.show_sync_log()
|
||||||
|
|
||||||
|
@ -243,6 +255,16 @@ class Preferences(QDialog):
|
||||||
self.mw.col.media.force_resync()
|
self.mw.col.media.force_resync()
|
||||||
self.update_login_status()
|
self.update_login_status()
|
||||||
|
|
||||||
|
def ankihub_sync_login(self) -> None:
|
||||||
|
def on_success():
|
||||||
|
if self.mw.pm.ankihub_token():
|
||||||
|
self.update_login_status()
|
||||||
|
|
||||||
|
ankihub_login(self.mw, on_success)
|
||||||
|
|
||||||
|
def ankihub_sync_logout(self) -> None:
|
||||||
|
ankihub_logout(self.mw, self.update_login_status, self.mw.pm.ankihub_token())
|
||||||
|
|
||||||
def confirm_sync_after_login(self) -> None:
|
def confirm_sync_after_login(self) -> None:
|
||||||
from aqt import mw
|
from aqt import mw
|
||||||
|
|
||||||
|
|
|
@ -716,3 +716,15 @@ create table if not exists profiles
|
||||||
|
|
||||||
def network_timeout(self) -> int:
|
def network_timeout(self) -> int:
|
||||||
return self.profile.get("networkTimeout") or 60
|
return self.profile.get("networkTimeout") or 60
|
||||||
|
|
||||||
|
def set_ankihub_token(self, val: str | None) -> None:
|
||||||
|
self.profile["thirdPartyAnkiHubToken"] = val
|
||||||
|
|
||||||
|
def ankihub_token(self) -> str | None:
|
||||||
|
return self.profile.get("thirdPartyAnkiHubToken")
|
||||||
|
|
||||||
|
def set_ankihub_username(self, val: str | None) -> None:
|
||||||
|
self.profile["thirdPartyAnkiHubUsername"] = val
|
||||||
|
|
||||||
|
def ankihub_username(self) -> str | None:
|
||||||
|
return self.profile.get("thirdPartyAnkiHubUsername")
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
import os
|
import os
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from concurrent.futures import Future
|
from concurrent.futures import Future
|
||||||
|
@ -298,14 +299,8 @@ def sync_login(
|
||||||
username: str = "",
|
username: str = "",
|
||||||
password: str = "",
|
password: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
while True:
|
|
||||||
(username, password) = get_id_and_pass_from_user(mw, username, password)
|
|
||||||
if not username and not password:
|
|
||||||
return
|
|
||||||
if username and password:
|
|
||||||
break
|
|
||||||
|
|
||||||
def on_future_done(fut: Future[SyncAuth]) -> None:
|
def on_future_done(fut: Future[SyncAuth], username: str, password: str) -> None:
|
||||||
try:
|
try:
|
||||||
auth = fut.result()
|
auth = fut.result()
|
||||||
except SyncError as e:
|
except SyncError as e:
|
||||||
|
@ -324,18 +319,29 @@ def sync_login(
|
||||||
|
|
||||||
on_success()
|
on_success()
|
||||||
|
|
||||||
mw.taskman.with_progress(
|
def callback(username: str, password: str) -> None:
|
||||||
lambda: mw.col.sync_login(
|
if not username and not password:
|
||||||
username=username, password=password, endpoint=mw.pm.sync_endpoint()
|
return
|
||||||
),
|
if username and password:
|
||||||
on_future_done,
|
mw.taskman.with_progress(
|
||||||
parent=mw,
|
lambda: mw.col.sync_login(
|
||||||
)
|
username=username, password=password, endpoint=mw.pm.sync_endpoint()
|
||||||
|
),
|
||||||
|
functools.partial(on_future_done, username=username, password=password),
|
||||||
|
parent=mw,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sync_login(mw, on_success, username, password)
|
||||||
|
|
||||||
|
get_id_and_pass_from_user(mw, callback, username, password)
|
||||||
|
|
||||||
|
|
||||||
def get_id_and_pass_from_user(
|
def get_id_and_pass_from_user(
|
||||||
mw: aqt.main.AnkiQt, username: str = "", password: str = ""
|
mw: aqt.main.AnkiQt,
|
||||||
) -> tuple[str, str]:
|
callback: Callable[[str, str], None],
|
||||||
|
username: str = "",
|
||||||
|
password: str = "",
|
||||||
|
) -> None:
|
||||||
diag = QDialog(mw)
|
diag = QDialog(mw)
|
||||||
diag.setWindowTitle("Anki")
|
diag.setWindowTitle("Anki")
|
||||||
disable_help_button(diag)
|
disable_help_button(diag)
|
||||||
|
@ -371,13 +377,18 @@ def get_id_and_pass_from_user(
|
||||||
qconnect(bb.rejected, diag.reject)
|
qconnect(bb.rejected, diag.reject)
|
||||||
vbox.addWidget(bb)
|
vbox.addWidget(bb)
|
||||||
diag.setLayout(vbox)
|
diag.setLayout(vbox)
|
||||||
|
diag.adjustSize()
|
||||||
diag.show()
|
diag.show()
|
||||||
user.setFocus()
|
user.setFocus()
|
||||||
|
|
||||||
accepted = diag.exec()
|
def on_finished(result: int) -> None:
|
||||||
if not accepted:
|
if result == QDialog.DialogCode.Rejected:
|
||||||
return ("", "")
|
callback("", "")
|
||||||
return (user.text().strip(), passwd.text())
|
else:
|
||||||
|
callback(user.text().strip(), passwd.text())
|
||||||
|
|
||||||
|
qconnect(diag.finished, on_finished)
|
||||||
|
diag.open()
|
||||||
|
|
||||||
|
|
||||||
# export platform version to syncing code
|
# export platform version to syncing code
|
||||||
|
|
|
@ -249,6 +249,7 @@ import anki.search_pb2
|
||||||
import anki.stats_pb2
|
import anki.stats_pb2
|
||||||
import anki.sync_pb2
|
import anki.sync_pb2
|
||||||
import anki.tags_pb2
|
import anki.tags_pb2
|
||||||
|
import anki.ankihub_pb2
|
||||||
|
|
||||||
class RustBackendGenerated:
|
class RustBackendGenerated:
|
||||||
def _run_command(self, service: int, method: int, input: Any) -> bytes:
|
def _run_command(self, service: int, method: int, input: Any) -> bytes:
|
||||||
|
|
|
@ -36,3 +36,4 @@ protobuf!(search, "search");
|
||||||
protobuf!(stats, "stats");
|
protobuf!(stats, "stats");
|
||||||
protobuf!(sync, "sync");
|
protobuf!(sync, "sync");
|
||||||
protobuf!(tags, "tags");
|
protobuf!(tags, "tags");
|
||||||
|
protobuf!(ankihub, "ankihub");
|
||||||
|
|
62
rslib/src/ankihub/http_client/mod.rs
Normal file
62
rslib/src/ankihub/http_client/mod.rs
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
use reqwest::Client;
|
||||||
|
use reqwest::Response;
|
||||||
|
use reqwest::Result;
|
||||||
|
use reqwest::Url;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::ankihub::login::LoginRequest;
|
||||||
|
|
||||||
|
static API_VERSION: &str = "18.0";
|
||||||
|
static DEFAULT_API_URL: &str = "https://app.ankihub.net/api/";
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HttpAnkiHubClient {
|
||||||
|
pub token: String,
|
||||||
|
pub endpoint: Url,
|
||||||
|
client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpAnkiHubClient {
|
||||||
|
pub fn new<S: Into<String>>(token: S, client: Client) -> HttpAnkiHubClient {
|
||||||
|
let endpoint = match env::var("ANKIHUB_APP_URL") {
|
||||||
|
Ok(url) => {
|
||||||
|
if let Ok(u) = Url::try_from(url.as_str()) {
|
||||||
|
u.join("api/").unwrap().to_string()
|
||||||
|
} else {
|
||||||
|
DEFAULT_API_URL.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => DEFAULT_API_URL.to_string(),
|
||||||
|
};
|
||||||
|
HttpAnkiHubClient {
|
||||||
|
token: token.into(),
|
||||||
|
endpoint: Url::try_from(endpoint.as_str()).unwrap(),
|
||||||
|
client,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request<T: Serialize + ?Sized>(&self, method: &str, data: &T) -> Result<Response> {
|
||||||
|
let url = self.endpoint.join(method).unwrap();
|
||||||
|
let mut builder = self.client.post(url).header(
|
||||||
|
reqwest::header::ACCEPT,
|
||||||
|
format!("application/json; version={API_VERSION}"),
|
||||||
|
);
|
||||||
|
if !self.token.is_empty() {
|
||||||
|
builder = builder.header("Authorization", format!("Token {}", self.token));
|
||||||
|
}
|
||||||
|
builder.json(&data).send().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(&self, data: LoginRequest) -> Result<Response> {
|
||||||
|
self.request("login/", &data).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout(&self) -> Result<Response> {
|
||||||
|
self.request("logout/", "").await
|
||||||
|
}
|
||||||
|
}
|
63
rslib/src/ankihub/login.rs
Normal file
63
rslib/src/ankihub/login.rs
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use regex::Regex;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::ankihub::http_client::HttpAnkiHubClient;
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub username: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct LoginResponse {
|
||||||
|
pub token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ankihub_login<S: Into<String>>(
|
||||||
|
id: S,
|
||||||
|
password: S,
|
||||||
|
client: Client,
|
||||||
|
) -> Result<LoginResponse> {
|
||||||
|
let client = HttpAnkiHubClient::new("", client);
|
||||||
|
lazy_static! {
|
||||||
|
static ref EMAIL_RE: Regex =
|
||||||
|
Regex::new(r"^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$")
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let mut request = LoginRequest {
|
||||||
|
username: None,
|
||||||
|
email: None,
|
||||||
|
password: password.into(),
|
||||||
|
};
|
||||||
|
let id: String = id.into();
|
||||||
|
if EMAIL_RE.is_match(&id) {
|
||||||
|
request.email = Some(id);
|
||||||
|
} else {
|
||||||
|
request.username = Some(id);
|
||||||
|
}
|
||||||
|
client
|
||||||
|
.login(request)
|
||||||
|
.await?
|
||||||
|
.json::<LoginResponse>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ankihub_logout<S: Into<String>>(token: S, client: Client) -> Result<()> {
|
||||||
|
let client = HttpAnkiHubClient::new(token, client);
|
||||||
|
client.logout().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
5
rslib/src/ankihub/mod.rs
Normal file
5
rslib/src/ankihub/mod.rs
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
pub mod http_client;
|
||||||
|
pub mod login;
|
34
rslib/src/backend/ankihub.rs
Normal file
34
rslib/src/backend/ankihub.rs
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
use super::Backend;
|
||||||
|
use crate::ankihub::login::ankihub_login;
|
||||||
|
use crate::ankihub::login::ankihub_logout;
|
||||||
|
use crate::ankihub::login::LoginResponse;
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
impl From<LoginResponse> for anki_proto::ankihub::LoginResponse {
|
||||||
|
fn from(value: LoginResponse) -> Self {
|
||||||
|
anki_proto::ankihub::LoginResponse {
|
||||||
|
token: value.token.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::services::BackendAnkiHubService for Backend {
|
||||||
|
fn ankihub_login(
|
||||||
|
&self,
|
||||||
|
input: anki_proto::ankihub::LoginRequest,
|
||||||
|
) -> Result<anki_proto::ankihub::LoginResponse> {
|
||||||
|
let rt = self.runtime_handle();
|
||||||
|
let fut = ankihub_login(input.id, input.password, self.web_client());
|
||||||
|
|
||||||
|
rt.block_on(fut).map(|a| a.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ankihub_logout(&self, input: anki_proto::ankihub::LogoutRequest) -> Result<()> {
|
||||||
|
let rt = self.runtime_handle();
|
||||||
|
let fut = ankihub_logout(input.token, self.web_client());
|
||||||
|
rt.block_on(fut)
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
mod adding;
|
mod adding;
|
||||||
mod ankidroid;
|
mod ankidroid;
|
||||||
|
mod ankihub;
|
||||||
mod ankiweb;
|
mod ankiweb;
|
||||||
mod card_rendering;
|
mod card_rendering;
|
||||||
mod collection;
|
mod collection;
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
pub mod adding;
|
pub mod adding;
|
||||||
pub(crate) mod ankidroid;
|
pub(crate) mod ankidroid;
|
||||||
|
pub mod ankihub;
|
||||||
pub mod backend;
|
pub mod backend;
|
||||||
pub mod browser_table;
|
pub mod browser_table;
|
||||||
pub mod card;
|
pub mod card;
|
||||||
|
|
Loading…
Reference in a new issue