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:
Abdo 2024-08-17 06:58:23 +03:00 committed by GitHub
parent acf3134e04
commit 520564da11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 623 additions and 33 deletions

View file

@ -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.

View file

@ -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
View 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;
}

View file

@ -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()

View file

@ -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
View 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)

View file

@ -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>

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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:

View file

@ -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");

View 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
}
}

View 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
View 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;

View 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)
}
}

View file

@ -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;

View file

@ -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;