diff --git a/ftl/core/preferences.ftl b/ftl/core/preferences.ftl
index 1da294b5a..69a9300a0 100644
--- a/ftl/core/preferences.ftl
+++ b/ftl/core/preferences.ftl
@@ -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.
diff --git a/ftl/core/sync.ftl b/ftl/core/sync.ftl
index 4684b6682..76293c87d 100644
--- a/ftl/core/sync.ftl
+++ b/ftl/core/sync.ftl
@@ -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
diff --git a/proto/anki/ankihub.proto b/proto/anki/ankihub.proto
new file mode 100644
index 000000000..bcbdc1778
--- /dev/null
+++ b/proto/anki/ankihub.proto
@@ -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;
+}
diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py
index e2fa0e1d5..164a64091 100644
--- a/pylib/anki/collection.py
+++ b/pylib/anki/collection.py
@@ -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()
diff --git a/qt/aqt/addons.py b/qt/aqt/addons.py
index 890c10c2a..00d68c098 100644
--- a/qt/aqt/addons.py
+++ b/qt/aqt/addons.py
@@ -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"
{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
######################################################################
diff --git a/qt/aqt/ankihub.py b/qt/aqt/ankihub.py
new file mode 100644
index 000000000..4d3b00c8a
--- /dev/null
+++ b/qt/aqt/ankihub.py
@@ -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"
{tr.sync_ankihub_dialog_heading()}
")
+ 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)
diff --git a/qt/aqt/forms/preferences.ui b/qt/aqt/forms/preferences.ui
index 319ff47cb..58db104c7 100644
--- a/qt/aqt/forms/preferences.ui
+++ b/qt/aqt/forms/preferences.ui
@@ -49,17 +49,14 @@
- -
-
+
-
+
0
0
-
- QComboBox::AdjustToMinimumContentsLengthWithIcon
-
-
@@ -72,17 +69,20 @@
- -
-
+
-
+
0
0
+
+ QComboBox::AdjustToMinimumContentsLengthWithIcon
+
- -
+
-
@@ -806,6 +806,9 @@
+
+ true
+
-
@@ -856,6 +859,19 @@
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
-
-
@@ -1098,6 +1114,133 @@
+
+
+ preferences_third_party_services
+
+
+
+ 12
+
+
+ 12
+
+
+ 12
+
+
+ 12
+
+
+ 12
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ preferences_third_party_description
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+ QSizePolicy::Maximum
+
+
+
+ 20
+ 12
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ AnkiHub
+
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+
+
+ true
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ sync_log_out_button
+
+
+ false
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ sync_log_in_button
+
+
+ false
+
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 40
+ 20
+
+
+
+
+
+
-
@@ -1125,6 +1268,7 @@
lang
video_driver
+ check_for_updates
theme
styleComboBox
uiScale
@@ -1164,6 +1308,8 @@
weekly_backups
monthly_backups
tabWidget
+ syncAnkiHubLogout
+ syncAnkiHubLogin
diff --git a/qt/aqt/preferences.py b/qt/aqt/preferences.py
index d0f7b07f4..3676fe169 100644
--- a/qt/aqt/preferences.py
+++ b/qt/aqt/preferences.py
@@ -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
diff --git a/qt/aqt/profiles.py b/qt/aqt/profiles.py
index ceecf50f2..40ee95264 100644
--- a/qt/aqt/profiles.py
+++ b/qt/aqt/profiles.py
@@ -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")
diff --git a/qt/aqt/sync.py b/qt/aqt/sync.py
index 1366ee669..cea64fadc 100644
--- a/qt/aqt/sync.py
+++ b/qt/aqt/sync.py
@@ -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
diff --git a/rslib/proto/python.rs b/rslib/proto/python.rs
index b20b850a4..0ca2c15ea 100644
--- a/rslib/proto/python.rs
+++ b/rslib/proto/python.rs
@@ -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:
diff --git a/rslib/proto/src/lib.rs b/rslib/proto/src/lib.rs
index 85cd528cf..86d7d1580 100644
--- a/rslib/proto/src/lib.rs
+++ b/rslib/proto/src/lib.rs
@@ -36,3 +36,4 @@ protobuf!(search, "search");
protobuf!(stats, "stats");
protobuf!(sync, "sync");
protobuf!(tags, "tags");
+protobuf!(ankihub, "ankihub");
diff --git a/rslib/src/ankihub/http_client/mod.rs b/rslib/src/ankihub/http_client/mod.rs
new file mode 100644
index 000000000..e5e374463
--- /dev/null
+++ b/rslib/src/ankihub/http_client/mod.rs
@@ -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>(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(&self, method: &str, data: &T) -> Result {
+ 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 {
+ self.request("login/", &data).await
+ }
+
+ pub async fn logout(&self) -> Result {
+ self.request("logout/", "").await
+ }
+}
diff --git a/rslib/src/ankihub/login.rs b/rslib/src/ankihub/login.rs
new file mode 100644
index 000000000..42aacec59
--- /dev/null
+++ b/rslib/src/ankihub/login.rs
@@ -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,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub email: Option,
+ pub password: String,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+pub struct LoginResponse {
+ pub token: Option,
+}
+
+pub async fn ankihub_login>(
+ id: S,
+ password: S,
+ client: Client,
+) -> Result {
+ 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::()
+ .await
+ .map_err(|e| e.into())
+}
+
+pub async fn ankihub_logout>(token: S, client: Client) -> Result<()> {
+ let client = HttpAnkiHubClient::new(token, client);
+ client.logout().await?;
+
+ Ok(())
+}
diff --git a/rslib/src/ankihub/mod.rs b/rslib/src/ankihub/mod.rs
new file mode 100644
index 000000000..0a06c5533
--- /dev/null
+++ b/rslib/src/ankihub/mod.rs
@@ -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;
diff --git a/rslib/src/backend/ankihub.rs b/rslib/src/backend/ankihub.rs
new file mode 100644
index 000000000..e48e35e59
--- /dev/null
+++ b/rslib/src/backend/ankihub.rs
@@ -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 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 {
+ 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)
+ }
+}
diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs
index 8b42d20bc..cff86d7e4 100644
--- a/rslib/src/backend/mod.rs
+++ b/rslib/src/backend/mod.rs
@@ -3,6 +3,7 @@
mod adding;
mod ankidroid;
+mod ankihub;
mod ankiweb;
mod card_rendering;
mod collection;
diff --git a/rslib/src/lib.rs b/rslib/src/lib.rs
index f023d1273..8d3251f49 100644
--- a/rslib/src/lib.rs
+++ b/rslib/src/lib.rs
@@ -5,6 +5,7 @@
pub mod adding;
pub(crate) mod ankidroid;
+pub mod ankihub;
pub mod backend;
pub mod browser_table;
pub mod card;