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-tab-synchronisation = Synchronization
|
||||
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-timebox-time-limit = Timebox time limit
|
||||
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-complete = Window sizes and locations have been reset.
|
||||
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.
|
||||
|
||||
preferences-basic = Basic
|
||||
preferences-reviewer = Reviewer
|
||||
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
|
||||
size by removing any unwanted decks (optionally exporting them first), and
|
||||
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
|
||||
|
||||
|
|
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."
|
||||
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:
|
||||
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))
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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)}"
|
||||
|
||||
if have_problem:
|
||||
showWarning(text, textFormat="rich", parent=parent)
|
||||
showWarning(text, textFormat="rich", parent=parent, title=title)
|
||||
else:
|
||||
showInfo(text, parent=parent)
|
||||
showInfo(text, parent=parent, title=title)
|
||||
|
||||
|
||||
def download_addons(
|
||||
|
@ -1550,6 +1552,32 @@ def prompt_to_update(
|
|||
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
|
||||
######################################################################
|
||||
|
||||
|
|
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>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QComboBox" name="lang">
|
||||
<item row="1" column="1">
|
||||
<widget class="QComboBox" name="video_driver">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="sizeAdjustPolicy">
|
||||
<enum>QComboBox::AdjustToMinimumContentsLengthWithIcon</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
|
@ -72,17 +69,20 @@
|
|||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QComboBox" name="video_driver">
|
||||
<item row="0" column="1">
|
||||
<widget class="QComboBox" name="lang">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="sizeAdjustPolicy">
|
||||
<enum>QComboBox::AdjustToMinimumContentsLengthWithIcon</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QCheckBox" name="check_for_updates">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||
|
@ -806,6 +806,9 @@
|
|||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
|
@ -856,6 +859,19 @@
|
|||
</property>
|
||||
</spacer>
|
||||
</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>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="1">
|
||||
|
@ -1098,6 +1114,133 @@
|
|||
</item>
|
||||
</layout>
|
||||
</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>
|
||||
</item>
|
||||
<item>
|
||||
|
@ -1125,6 +1268,7 @@
|
|||
<tabstops>
|
||||
<tabstop>lang</tabstop>
|
||||
<tabstop>video_driver</tabstop>
|
||||
<tabstop>check_for_updates</tabstop>
|
||||
<tabstop>theme</tabstop>
|
||||
<tabstop>styleComboBox</tabstop>
|
||||
<tabstop>uiScale</tabstop>
|
||||
|
@ -1164,6 +1308,8 @@
|
|||
<tabstop>weekly_backups</tabstop>
|
||||
<tabstop>monthly_backups</tabstop>
|
||||
<tabstop>tabWidget</tabstop>
|
||||
<tabstop>syncAnkiHubLogout</tabstop>
|
||||
<tabstop>syncAnkiHubLogin</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
<connections>
|
||||
|
|
|
@ -14,6 +14,7 @@ import aqt.operations
|
|||
from anki.collection import OpChanges
|
||||
from anki.utils import is_mac
|
||||
from aqt import AnkiQt
|
||||
from aqt.ankihub import ankihub_login, ankihub_logout
|
||||
from aqt.operations.collection import set_preferences
|
||||
from aqt.profiles import VideoDriver
|
||||
from aqt.qt import *
|
||||
|
@ -213,10 +214,12 @@ class Preferences(QDialog):
|
|||
self.update_login_status()
|
||||
qconnect(self.form.syncLogout.clicked, self.sync_logout)
|
||||
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:
|
||||
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.syncLogout.setVisible(False)
|
||||
else:
|
||||
|
@ -224,6 +227,15 @@ class Preferences(QDialog):
|
|||
self.form.syncLogin.setVisible(False)
|
||||
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:
|
||||
self.mw.media_syncer.show_sync_log()
|
||||
|
||||
|
@ -243,6 +255,16 @@ class Preferences(QDialog):
|
|||
self.mw.col.media.force_resync()
|
||||
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:
|
||||
from aqt import mw
|
||||
|
||||
|
|
|
@ -716,3 +716,15 @@ create table if not exists profiles
|
|||
|
||||
def network_timeout(self) -> int:
|
||||
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
|
||||
|
||||
import functools
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import Future
|
||||
|
@ -298,14 +299,8 @@ def sync_login(
|
|||
username: str = "",
|
||||
password: str = "",
|
||||
) -> 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:
|
||||
auth = fut.result()
|
||||
except SyncError as e:
|
||||
|
@ -324,18 +319,29 @@ def sync_login(
|
|||
|
||||
on_success()
|
||||
|
||||
mw.taskman.with_progress(
|
||||
lambda: mw.col.sync_login(
|
||||
username=username, password=password, endpoint=mw.pm.sync_endpoint()
|
||||
),
|
||||
on_future_done,
|
||||
parent=mw,
|
||||
)
|
||||
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.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(
|
||||
mw: aqt.main.AnkiQt, username: str = "", password: str = ""
|
||||
) -> tuple[str, str]:
|
||||
mw: aqt.main.AnkiQt,
|
||||
callback: Callable[[str, str], None],
|
||||
username: str = "",
|
||||
password: str = "",
|
||||
) -> None:
|
||||
diag = QDialog(mw)
|
||||
diag.setWindowTitle("Anki")
|
||||
disable_help_button(diag)
|
||||
|
@ -371,13 +377,18 @@ def get_id_and_pass_from_user(
|
|||
qconnect(bb.rejected, diag.reject)
|
||||
vbox.addWidget(bb)
|
||||
diag.setLayout(vbox)
|
||||
diag.adjustSize()
|
||||
diag.show()
|
||||
user.setFocus()
|
||||
|
||||
accepted = diag.exec()
|
||||
if not accepted:
|
||||
return ("", "")
|
||||
return (user.text().strip(), passwd.text())
|
||||
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()
|
||||
|
||||
|
||||
# export platform version to syncing code
|
||||
|
|
|
@ -249,6 +249,7 @@ import anki.search_pb2
|
|||
import anki.stats_pb2
|
||||
import anki.sync_pb2
|
||||
import anki.tags_pb2
|
||||
import anki.ankihub_pb2
|
||||
|
||||
class RustBackendGenerated:
|
||||
def _run_command(self, service: int, method: int, input: Any) -> bytes:
|
||||
|
|
|
@ -36,3 +36,4 @@ protobuf!(search, "search");
|
|||
protobuf!(stats, "stats");
|
||||
protobuf!(sync, "sync");
|
||||
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 ankidroid;
|
||||
mod ankihub;
|
||||
mod ankiweb;
|
||||
mod card_rendering;
|
||||
mod collection;
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
pub mod adding;
|
||||
pub(crate) mod ankidroid;
|
||||
pub mod ankihub;
|
||||
pub mod backend;
|
||||
pub mod browser_table;
|
||||
pub mod card;
|
||||
|
|
Loading…
Reference in a new issue