mirror of
https://github.com/ankitects/anki.git
synced 2025-09-19 06:22:22 -04:00
start work on more clearly defining backend/protobuf boundaries
- anki._backend stores the protobuf files and rsbackend.py code - pylib modules import protobuf messages directly from the _pb2 files, and explicitly export any will be returned or consumed by public pylib functions, so that calling code can import from pylib - the "rsbackend" no longer imports and re-exports protobuf messages - pylib can just consume them directly. - move errors to errors.py Still todo: - rsbridge - finishing the work on rsbackend, and check what we need to add back to the original file location to avoid breaking add-ons
This commit is contained in:
parent
cd9767be80
commit
9d853bbb03
60 changed files with 420 additions and 367 deletions
|
@ -1,5 +1,5 @@
|
||||||
[settings]
|
[settings]
|
||||||
skip=aqt/forms,backend_pb2.py,backend_pb2.pyi,fluent_pb2.py,fluent_pb2.pyi,rsbackend_gen.py,hooks_gen.py,genbackend.py
|
skip=aqt/forms,backend_pb2.py,backend_pb2.pyi,fluent_pb2.py,fluent_pb2.pyi,rsbackend_gen.py,generated.py,hooks_gen.py,genbackend.py
|
||||||
profile=black
|
profile=black
|
||||||
multi_line_output=3
|
multi_line_output=3
|
||||||
include_trailing_comma=True
|
include_trailing_comma=True
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
load("@bazel_skylib//rules:copy_file.bzl", "copy_file")
|
load("@bazel_skylib//rules:copy_file.bzl", "copy_file")
|
||||||
load("@rules_python//python:defs.bzl", "py_library")
|
load("@rules_python//python:defs.bzl", "py_library")
|
||||||
load("@py_deps//:requirements.bzl", "requirement")
|
load("@py_deps//:requirements.bzl", "requirement")
|
||||||
load("//pylib:protobuf.bzl", "py_proto_library_typed")
|
|
||||||
load("@rules_python//experimental/python:wheel.bzl", "py_package", "py_wheel")
|
load("@rules_python//experimental/python:wheel.bzl", "py_package", "py_wheel")
|
||||||
load("@bazel_skylib//lib:selects.bzl", "selects")
|
load("@bazel_skylib//lib:selects.bzl", "selects")
|
||||||
load("//:defs.bzl", "anki_version")
|
load("//:defs.bzl", "anki_version")
|
||||||
|
@ -13,13 +12,6 @@ copy_file(
|
||||||
out = "buildinfo.txt",
|
out = "buildinfo.txt",
|
||||||
)
|
)
|
||||||
|
|
||||||
genrule(
|
|
||||||
name = "rsbackend_gen",
|
|
||||||
outs = ["rsbackend_gen.py"],
|
|
||||||
cmd = "$(location //pylib/tools:genbackend) > $@",
|
|
||||||
tools = ["//pylib/tools:genbackend"],
|
|
||||||
)
|
|
||||||
|
|
||||||
genrule(
|
genrule(
|
||||||
name = "hooks_gen",
|
name = "hooks_gen",
|
||||||
outs = ["hooks_gen.py"],
|
outs = ["hooks_gen.py"],
|
||||||
|
@ -27,22 +19,6 @@ genrule(
|
||||||
tools = ["//pylib/tools:genhooks"],
|
tools = ["//pylib/tools:genhooks"],
|
||||||
)
|
)
|
||||||
|
|
||||||
py_proto_library_typed(
|
|
||||||
name = "backend_pb2",
|
|
||||||
src = "//rslib:backend.proto",
|
|
||||||
visibility = [
|
|
||||||
"//visibility:public",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
py_proto_library_typed(
|
|
||||||
name = "fluent_pb2",
|
|
||||||
src = "//rslib:fluent.proto",
|
|
||||||
visibility = [
|
|
||||||
"//visibility:public",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
copy_file(
|
copy_file(
|
||||||
name = "rsbridge_unix",
|
name = "rsbridge_unix",
|
||||||
src = "//pylib/rsbridge",
|
src = "//pylib/rsbridge",
|
||||||
|
@ -69,7 +45,6 @@ alias(
|
||||||
_py_srcs = glob(
|
_py_srcs = glob(
|
||||||
["**/*.py"],
|
["**/*.py"],
|
||||||
exclude = [
|
exclude = [
|
||||||
"rsbackend_gen.py",
|
|
||||||
"hooks_gen.py",
|
"hooks_gen.py",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@ -79,12 +54,10 @@ py_library(
|
||||||
srcs = _py_srcs,
|
srcs = _py_srcs,
|
||||||
data = [
|
data = [
|
||||||
"py.typed",
|
"py.typed",
|
||||||
":backend_pb2",
|
|
||||||
":buildinfo",
|
":buildinfo",
|
||||||
":fluent_pb2",
|
|
||||||
":hooks_gen",
|
":hooks_gen",
|
||||||
":rsbackend_gen",
|
|
||||||
":rsbridge",
|
":rsbridge",
|
||||||
|
"//pylib/anki/_backend",
|
||||||
],
|
],
|
||||||
imports = [
|
imports = [
|
||||||
"..",
|
"..",
|
||||||
|
|
50
pylib/anki/_backend/BUILD.bazel
Normal file
50
pylib/anki/_backend/BUILD.bazel
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
load("@rules_python//python:defs.bzl", "py_binary")
|
||||||
|
load("@py_deps//:requirements.bzl", "requirement")
|
||||||
|
load("//pylib:protobuf.bzl", "py_proto_library_typed")
|
||||||
|
|
||||||
|
py_proto_library_typed(
|
||||||
|
name = "backend_pb2",
|
||||||
|
src = "//rslib:backend.proto",
|
||||||
|
visibility = [
|
||||||
|
"//visibility:public",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
py_proto_library_typed(
|
||||||
|
name = "fluent_pb2",
|
||||||
|
src = "//rslib:fluent.proto",
|
||||||
|
visibility = [
|
||||||
|
"//visibility:public",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
py_binary(
|
||||||
|
name = "genbackend",
|
||||||
|
srcs = [
|
||||||
|
"backend_pb2",
|
||||||
|
"genbackend.py",
|
||||||
|
],
|
||||||
|
deps = [
|
||||||
|
requirement("black"),
|
||||||
|
requirement("stringcase"),
|
||||||
|
requirement("protobuf"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
genrule(
|
||||||
|
name = "rsbackend_gen",
|
||||||
|
outs = ["generated.py"],
|
||||||
|
cmd = "$(location genbackend) > $@",
|
||||||
|
tools = ["genbackend"],
|
||||||
|
)
|
||||||
|
|
||||||
|
filegroup(
|
||||||
|
name = "_backend",
|
||||||
|
srcs = [
|
||||||
|
"__init__.py",
|
||||||
|
":backend_pb2",
|
||||||
|
":fluent_pb2",
|
||||||
|
":rsbackend_gen",
|
||||||
|
],
|
||||||
|
visibility = ["//pylib:__subpackages__"],
|
||||||
|
)
|
|
@ -22,19 +22,19 @@ import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Union
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Union
|
||||||
|
|
||||||
|
import anki._backend.backend_pb2 as pb
|
||||||
import anki._rsbridge
|
import anki._rsbridge
|
||||||
import anki.backend_pb2 as pb
|
|
||||||
import anki.buildinfo
|
import anki.buildinfo
|
||||||
from anki import hooks
|
from anki import hooks
|
||||||
|
from anki._backend.generated import RustBackendGenerated
|
||||||
from anki.dbproxy import Row as DBRow
|
from anki.dbproxy import Row as DBRow
|
||||||
from anki.dbproxy import ValueForDB
|
from anki.dbproxy import ValueForDB
|
||||||
from anki.fluent_pb2 import FluentString as TR
|
from anki.errors import backend_exception_to_pylib
|
||||||
from anki.rsbackend_gen import RustBackendGenerated
|
from anki.lang import FormatTimeSpanContext
|
||||||
|
from anki.utils import from_json_bytes, to_json_bytes
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from anki.fluent_pb2 import FluentStringValue as TRValue
|
from anki.lang import FormatTimeSpanContextValue, TRValue
|
||||||
|
|
||||||
FormatTimeSpanContextValue = pb.FormatTimespanIn.ContextValue
|
|
||||||
|
|
||||||
assert anki._rsbridge.buildhash() == anki.buildinfo.buildhash
|
assert anki._rsbridge.buildhash() == anki.buildinfo.buildhash
|
||||||
|
|
||||||
|
@ -48,151 +48,10 @@ BackendNote = pb.Note
|
||||||
Tag = pb.Tag
|
Tag = pb.Tag
|
||||||
TagTreeNode = pb.TagTreeNode
|
TagTreeNode = pb.TagTreeNode
|
||||||
NoteType = pb.NoteType
|
NoteType = pb.NoteType
|
||||||
DeckTreeNode = pb.DeckTreeNode
|
|
||||||
StockNoteType = pb.StockNoteType
|
StockNoteType = pb.StockNoteType
|
||||||
ConcatSeparator = pb.ConcatenateSearchesIn.Separator
|
ConcatSeparator = pb.ConcatenateSearchesIn.Separator
|
||||||
SyncAuth = pb.SyncAuth
|
|
||||||
SyncOutput = pb.SyncCollectionOut
|
|
||||||
SyncStatus = pb.SyncStatusOut
|
|
||||||
CountsForDeckToday = pb.CountsForDeckTodayOut
|
CountsForDeckToday = pb.CountsForDeckTodayOut
|
||||||
|
|
||||||
try:
|
|
||||||
import orjson
|
|
||||||
|
|
||||||
to_json_bytes = orjson.dumps
|
|
||||||
from_json_bytes = orjson.loads
|
|
||||||
except:
|
|
||||||
print("orjson is missing; DB operations will be slower")
|
|
||||||
to_json_bytes = lambda obj: json.dumps(obj).encode("utf8") # type: ignore
|
|
||||||
from_json_bytes = json.loads
|
|
||||||
|
|
||||||
|
|
||||||
class Interrupted(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class StringError(Exception):
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.args[0] # pylint: disable=unsubscriptable-object
|
|
||||||
|
|
||||||
|
|
||||||
NetworkErrorKind = pb.NetworkError.NetworkErrorKind
|
|
||||||
SyncErrorKind = pb.SyncError.SyncErrorKind
|
|
||||||
|
|
||||||
|
|
||||||
class NetworkError(StringError):
|
|
||||||
def kind(self) -> pb.NetworkError.NetworkErrorKindValue:
|
|
||||||
return self.args[1]
|
|
||||||
|
|
||||||
|
|
||||||
class SyncError(StringError):
|
|
||||||
def kind(self) -> pb.SyncError.SyncErrorKindValue:
|
|
||||||
return self.args[1]
|
|
||||||
|
|
||||||
|
|
||||||
class IOError(StringError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class DBError(StringError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class TemplateError(StringError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class NotFoundError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ExistsError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class DeckIsFilteredError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidInput(StringError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def proto_exception_to_native(err: pb.BackendError) -> Exception:
|
|
||||||
val = err.WhichOneof("value")
|
|
||||||
if val == "interrupted":
|
|
||||||
return Interrupted()
|
|
||||||
elif val == "network_error":
|
|
||||||
return NetworkError(err.localized, err.network_error.kind)
|
|
||||||
elif val == "sync_error":
|
|
||||||
return SyncError(err.localized, err.sync_error.kind)
|
|
||||||
elif val == "io_error":
|
|
||||||
return IOError(err.localized)
|
|
||||||
elif val == "db_error":
|
|
||||||
return DBError(err.localized)
|
|
||||||
elif val == "template_parse":
|
|
||||||
return TemplateError(err.localized)
|
|
||||||
elif val == "invalid_input":
|
|
||||||
return InvalidInput(err.localized)
|
|
||||||
elif val == "json_error":
|
|
||||||
return StringError(err.localized)
|
|
||||||
elif val == "not_found_error":
|
|
||||||
return NotFoundError()
|
|
||||||
elif val == "exists":
|
|
||||||
return ExistsError()
|
|
||||||
elif val == "deck_is_filtered":
|
|
||||||
return DeckIsFilteredError()
|
|
||||||
elif val == "proto_error":
|
|
||||||
return StringError(err.localized)
|
|
||||||
else:
|
|
||||||
print("unhandled error type:", val)
|
|
||||||
return StringError(err.localized)
|
|
||||||
|
|
||||||
|
|
||||||
MediaSyncProgress = pb.MediaSyncProgress
|
|
||||||
FullSyncProgress = pb.FullSyncProgress
|
|
||||||
NormalSyncProgress = pb.NormalSyncProgress
|
|
||||||
DatabaseCheckProgress = pb.DatabaseCheckProgress
|
|
||||||
|
|
||||||
FormatTimeSpanContext = pb.FormatTimespanIn.Context
|
|
||||||
|
|
||||||
|
|
||||||
class ProgressKind(enum.Enum):
|
|
||||||
NoProgress = 0
|
|
||||||
MediaSync = 1
|
|
||||||
MediaCheck = 2
|
|
||||||
FullSync = 3
|
|
||||||
NormalSync = 4
|
|
||||||
DatabaseCheck = 5
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Progress:
|
|
||||||
kind: ProgressKind
|
|
||||||
val: Union[
|
|
||||||
MediaSyncProgress,
|
|
||||||
pb.FullSyncProgress,
|
|
||||||
NormalSyncProgress,
|
|
||||||
DatabaseCheckProgress,
|
|
||||||
str,
|
|
||||||
]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_proto(proto: pb.Progress) -> Progress:
|
|
||||||
kind = proto.WhichOneof("value")
|
|
||||||
if kind == "media_sync":
|
|
||||||
return Progress(kind=ProgressKind.MediaSync, val=proto.media_sync)
|
|
||||||
elif kind == "media_check":
|
|
||||||
return Progress(kind=ProgressKind.MediaCheck, val=proto.media_check)
|
|
||||||
elif kind == "full_sync":
|
|
||||||
return Progress(kind=ProgressKind.FullSync, val=proto.full_sync)
|
|
||||||
elif kind == "normal_sync":
|
|
||||||
return Progress(kind=ProgressKind.NormalSync, val=proto.normal_sync)
|
|
||||||
elif kind == "database_check":
|
|
||||||
return Progress(kind=ProgressKind.DatabaseCheck, val=proto.database_check)
|
|
||||||
else:
|
|
||||||
return Progress(kind=ProgressKind.NoProgress, val="")
|
|
||||||
|
|
||||||
|
|
||||||
class RustBackend(RustBackendGenerated):
|
class RustBackend(RustBackendGenerated):
|
||||||
def __init__(
|
def __init__(
|
||||||
|
@ -240,7 +99,7 @@ class RustBackend(RustBackendGenerated):
|
||||||
err_bytes = bytes(e.args[0])
|
err_bytes = bytes(e.args[0])
|
||||||
err = pb.BackendError()
|
err = pb.BackendError()
|
||||||
err.ParseFromString(err_bytes)
|
err.ParseFromString(err_bytes)
|
||||||
raise proto_exception_to_native(err)
|
raise backend_exception_to_pylib(err)
|
||||||
|
|
||||||
def translate(self, key: TRValue, **kwargs: Union[str, int, float]) -> str:
|
def translate(self, key: TRValue, **kwargs: Union[str, int, float]) -> str:
|
||||||
return self.translate_string(translate_string_in(key, **kwargs))
|
return self.translate_string(translate_string_in(key, **kwargs))
|
||||||
|
@ -263,7 +122,7 @@ class RustBackend(RustBackendGenerated):
|
||||||
err_bytes = bytes(e.args[0])
|
err_bytes = bytes(e.args[0])
|
||||||
err = pb.BackendError()
|
err = pb.BackendError()
|
||||||
err.ParseFromString(err_bytes)
|
err.ParseFromString(err_bytes)
|
||||||
raise proto_exception_to_native(err)
|
raise backend_exception_to_pylib(err)
|
||||||
|
|
||||||
|
|
||||||
def translate_string_in(
|
def translate_string_in(
|
1
pylib/anki/_backend/backend_pb2.pyi
Symbolic link
1
pylib/anki/_backend/backend_pb2.pyi
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
../../../bazel-bin/pylib/anki/_backend/backend_pb2.pyi
|
1
pylib/anki/_backend/fluent_pb2.pyi
Symbolic link
1
pylib/anki/_backend/fluent_pb2.pyi
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
../../../bazel-bin/pylib/anki/_backend/fluent_pb2.pyi
|
|
@ -5,7 +5,7 @@ import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import pylib.anki.backend_pb2 as pb
|
import pylib.anki._backend.backend_pb2 as pb
|
||||||
|
|
||||||
import stringcase
|
import stringcase
|
||||||
|
|
||||||
|
@ -168,7 +168,7 @@ col.decks.all_config()
|
||||||
|
|
||||||
from typing import *
|
from typing import *
|
||||||
|
|
||||||
import anki.backend_pb2 as pb
|
import anki._backend.backend_pb2 as pb
|
||||||
|
|
||||||
class RustBackendGenerated:
|
class RustBackendGenerated:
|
||||||
def _run_command(self, method: int, input: Any) -> bytes:
|
def _run_command(self, method: int, input: Any) -> bytes:
|
1
pylib/anki/_backend/generated.py
Symbolic link
1
pylib/anki/_backend/generated.py
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
../../../bazel-bin/pylib/anki/_backend/generated.py
|
|
@ -1 +0,0 @@
|
||||||
../../bazel-bin/pylib/anki/backend_pb2.pyi
|
|
|
@ -8,11 +8,11 @@ import time
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import anki # pylint: disable=unused-import
|
import anki # pylint: disable=unused-import
|
||||||
|
import anki._backend.backend_pb2 as _pb
|
||||||
from anki import hooks
|
from anki import hooks
|
||||||
from anki.consts import *
|
from anki.consts import *
|
||||||
from anki.models import NoteType, Template
|
from anki.models import NoteType, Template
|
||||||
from anki.notes import Note
|
from anki.notes import Note
|
||||||
from anki.rsbackend import BackendCard
|
|
||||||
from anki.sound import AVTag
|
from anki.sound import AVTag
|
||||||
|
|
||||||
# Cards
|
# Cards
|
||||||
|
@ -45,14 +45,14 @@ class Card:
|
||||||
self.load()
|
self.load()
|
||||||
else:
|
else:
|
||||||
# new card with defaults
|
# new card with defaults
|
||||||
self._load_from_backend_card(BackendCard())
|
self._load_from_backend_card(_pb.Card())
|
||||||
|
|
||||||
def load(self) -> None:
|
def load(self) -> None:
|
||||||
c = self.col.backend.get_card(self.id)
|
c = self.col.backend.get_card(self.id)
|
||||||
assert c
|
assert c
|
||||||
self._load_from_backend_card(c)
|
self._load_from_backend_card(c)
|
||||||
|
|
||||||
def _load_from_backend_card(self, c: BackendCard) -> None:
|
def _load_from_backend_card(self, c: _pb.Card) -> None:
|
||||||
self._render_output = None
|
self._render_output = None
|
||||||
self._note = None
|
self._note = None
|
||||||
self.id = c.id
|
self.id = c.id
|
||||||
|
@ -86,7 +86,7 @@ class Card:
|
||||||
self._bugcheck()
|
self._bugcheck()
|
||||||
hooks.card_will_flush(self)
|
hooks.card_will_flush(self)
|
||||||
# mtime & usn are set by backend
|
# mtime & usn are set by backend
|
||||||
card = BackendCard(
|
card = _pb.Card(
|
||||||
id=self.id,
|
id=self.id,
|
||||||
note_id=self.nid,
|
note_id=self.nid,
|
||||||
deck_id=self.did,
|
deck_id=self.did,
|
||||||
|
|
|
@ -4,51 +4,66 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
|
import enum
|
||||||
import os
|
import os
|
||||||
import pprint
|
import pprint
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import weakref
|
import weakref
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Any, List, Optional, Sequence, Tuple, Union
|
from typing import TYPE_CHECKING, Any, List, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
import anki.backend_pb2 as pb
|
import anki._backend.backend_pb2 as _pb
|
||||||
import anki.find
|
import anki.find
|
||||||
import anki.latex # sets up hook
|
import anki.latex # sets up hook
|
||||||
import anki.template
|
import anki.template
|
||||||
from anki import hooks
|
from anki import hooks
|
||||||
from anki.backend_pb2 import SearchTerm
|
from anki._backend import ( # pylint: disable=unused-import
|
||||||
|
ConcatSeparator,
|
||||||
|
FormatTimeSpanContext,
|
||||||
|
RustBackend,
|
||||||
|
)
|
||||||
|
|
||||||
|
# from anki._backend import _SyncStatus as SyncStatus
|
||||||
from anki.cards import Card
|
from anki.cards import Card
|
||||||
from anki.config import ConfigManager
|
from anki.config import ConfigManager
|
||||||
from anki.consts import *
|
from anki.consts import *
|
||||||
from anki.dbproxy import DBProxy
|
from anki.dbproxy import DBProxy
|
||||||
from anki.decks import DeckManager
|
from anki.decks import DeckManager
|
||||||
from anki.errors import AnkiError
|
from anki.errors import AnkiError, DBError
|
||||||
|
from anki.lang import TR
|
||||||
from anki.media import MediaManager, media_paths_from_col_path
|
from anki.media import MediaManager, media_paths_from_col_path
|
||||||
from anki.models import ModelManager
|
from anki.models import ModelManager
|
||||||
from anki.notes import Note
|
from anki.notes import Note
|
||||||
from anki.rsbackend import ( # pylint: disable=unused-import
|
|
||||||
TR,
|
|
||||||
ConcatSeparator,
|
|
||||||
DBError,
|
|
||||||
FormatTimeSpanContext,
|
|
||||||
InvalidInput,
|
|
||||||
Progress,
|
|
||||||
RustBackend,
|
|
||||||
from_json_bytes,
|
|
||||||
pb,
|
|
||||||
)
|
|
||||||
from anki.sched import Scheduler as V1Scheduler
|
from anki.sched import Scheduler as V1Scheduler
|
||||||
from anki.schedv2 import Scheduler as V2Scheduler
|
from anki.schedv2 import Scheduler as V2Scheduler
|
||||||
from anki.tags import TagManager
|
from anki.tags import TagManager
|
||||||
from anki.utils import devMode, ids2str, intTime, splitFields, stripHTMLMedia
|
from anki.utils import (
|
||||||
|
devMode,
|
||||||
|
from_json_bytes,
|
||||||
|
ids2str,
|
||||||
|
intTime,
|
||||||
|
splitFields,
|
||||||
|
stripHTMLMedia,
|
||||||
|
)
|
||||||
|
|
||||||
ConfigBoolKey = pb.ConfigBool.Key # pylint: disable=no-member
|
# public exports
|
||||||
|
SearchTerm = _pb.SearchTerm
|
||||||
|
MediaSyncProgress = _pb.MediaSyncProgress
|
||||||
|
FullSyncProgress = _pb.FullSyncProgress
|
||||||
|
NormalSyncProgress = _pb.NormalSyncProgress
|
||||||
|
DatabaseCheckProgress = _pb.DatabaseCheckProgress
|
||||||
|
ConfigBoolKey = _pb.ConfigBool.Key # pylint: disable=no-member
|
||||||
|
EmptyCardsReport = _pb.EmptyCardsReport
|
||||||
|
NoteWithEmptyCards = _pb.NoteWithEmptyCards
|
||||||
|
GraphPreferences = _pb.GraphPreferences
|
||||||
|
|
||||||
|
# pylint: disable=no-member
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from anki.rsbackend import FormatTimeSpanContextValue, TRValue
|
from anki.lang import FormatTimeSpanContextValue, TRValue
|
||||||
|
|
||||||
ConfigBoolKeyValue = pb.ConfigBool.KeyValue # pylint: disable=no-member
|
ConfigBoolKeyValue = _pb.ConfigBool.KeyValue
|
||||||
|
|
||||||
|
|
||||||
class Collection:
|
class Collection:
|
||||||
|
@ -394,6 +409,9 @@ class Collection:
|
||||||
def set_deck(self, card_ids: List[int], deck_id: int) -> None:
|
def set_deck(self, card_ids: List[int], deck_id: int) -> None:
|
||||||
self.backend.set_deck(card_ids=card_ids, deck_id=deck_id)
|
self.backend.set_deck(card_ids=card_ids, deck_id=deck_id)
|
||||||
|
|
||||||
|
def get_empty_cards(self) -> EmptyCardsReport:
|
||||||
|
return self.backend.get_empty_cards()
|
||||||
|
|
||||||
# legacy
|
# legacy
|
||||||
|
|
||||||
def remCards(self, ids: List[int], notes: bool = True) -> None:
|
def remCards(self, ids: List[int], notes: bool = True) -> None:
|
||||||
|
@ -445,20 +463,20 @@ class Collection:
|
||||||
order: Union[
|
order: Union[
|
||||||
bool,
|
bool,
|
||||||
str,
|
str,
|
||||||
pb.BuiltinSearchOrder.BuiltinSortKindValue, # pylint: disable=no-member
|
_pb.BuiltinSearchOrder.BuiltinSortKindValue, # pylint: disable=no-member
|
||||||
] = False,
|
] = False,
|
||||||
reverse: bool = False,
|
reverse: bool = False,
|
||||||
) -> Sequence[int]:
|
) -> Sequence[int]:
|
||||||
if isinstance(order, str):
|
if isinstance(order, str):
|
||||||
mode = pb.SortOrder(custom=order)
|
mode = _pb.SortOrder(custom=order)
|
||||||
elif isinstance(order, bool):
|
elif isinstance(order, bool):
|
||||||
if order is True:
|
if order is True:
|
||||||
mode = pb.SortOrder(from_config=pb.Empty())
|
mode = _pb.SortOrder(from_config=_pb.Empty())
|
||||||
else:
|
else:
|
||||||
mode = pb.SortOrder(none=pb.Empty())
|
mode = _pb.SortOrder(none=_pb.Empty())
|
||||||
else:
|
else:
|
||||||
mode = pb.SortOrder(
|
mode = _pb.SortOrder(
|
||||||
builtin=pb.BuiltinSearchOrder(kind=order, reverse=reverse)
|
builtin=_pb.BuiltinSearchOrder(kind=order, reverse=reverse)
|
||||||
)
|
)
|
||||||
return self.backend.search_cards(search=query, order=mode)
|
return self.backend.search_cards(search=query, order=mode)
|
||||||
|
|
||||||
|
@ -603,6 +621,19 @@ table.review-log {{ {revlog_style} }}
|
||||||
def studied_today(self) -> str:
|
def studied_today(self) -> str:
|
||||||
return self.backend.studied_today()
|
return self.backend.studied_today()
|
||||||
|
|
||||||
|
def graph_data(self, search: str, days: int) -> bytes:
|
||||||
|
return self.backend.graphs(search=search, days=days)
|
||||||
|
|
||||||
|
def get_graph_preferences(self) -> bytes:
|
||||||
|
return self.backend.get_graph_preferences()
|
||||||
|
|
||||||
|
def set_graph_preferences(self, prefs: GraphPreferences) -> None:
|
||||||
|
self.backend.set_graph_preferences(input=prefs)
|
||||||
|
|
||||||
|
def congrats_info(self) -> bytes:
|
||||||
|
"Don't use this, it will likely go away in the future."
|
||||||
|
return self.backend.congrats_info().SerializeToString()
|
||||||
|
|
||||||
# legacy
|
# legacy
|
||||||
|
|
||||||
def cardStats(self, card: Card) -> str:
|
def cardStats(self, card: Card) -> str:
|
||||||
|
@ -797,5 +828,42 @@ table.review-log {{ {revlog_style} }}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressKind(enum.Enum):
|
||||||
|
NoProgress = 0
|
||||||
|
MediaSync = 1
|
||||||
|
MediaCheck = 2
|
||||||
|
FullSync = 3
|
||||||
|
NormalSync = 4
|
||||||
|
DatabaseCheck = 5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Progress:
|
||||||
|
kind: ProgressKind
|
||||||
|
val: Union[
|
||||||
|
MediaSyncProgress,
|
||||||
|
FullSyncProgress,
|
||||||
|
NormalSyncProgress,
|
||||||
|
DatabaseCheckProgress,
|
||||||
|
str,
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_proto(proto: _pb.Progress) -> Progress:
|
||||||
|
kind = proto.WhichOneof("value")
|
||||||
|
if kind == "media_sync":
|
||||||
|
return Progress(kind=ProgressKind.MediaSync, val=proto.media_sync)
|
||||||
|
elif kind == "media_check":
|
||||||
|
return Progress(kind=ProgressKind.MediaCheck, val=proto.media_check)
|
||||||
|
elif kind == "full_sync":
|
||||||
|
return Progress(kind=ProgressKind.FullSync, val=proto.full_sync)
|
||||||
|
elif kind == "normal_sync":
|
||||||
|
return Progress(kind=ProgressKind.NormalSync, val=proto.normal_sync)
|
||||||
|
elif kind == "database_check":
|
||||||
|
return Progress(kind=ProgressKind.DatabaseCheck, val=proto.database_check)
|
||||||
|
else:
|
||||||
|
return Progress(kind=ProgressKind.NoProgress, val="")
|
||||||
|
|
||||||
|
|
||||||
# legacy name
|
# legacy name
|
||||||
_Collection = Collection
|
_Collection = Collection
|
||||||
|
|
|
@ -23,7 +23,8 @@ import weakref
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import anki
|
import anki
|
||||||
from anki.rsbackend import NotFoundError, from_json_bytes, to_json_bytes
|
from anki.errors import NotFoundError
|
||||||
|
from anki.utils import from_json_bytes, to_json_bytes
|
||||||
|
|
||||||
|
|
||||||
class ConfigManager:
|
class ConfigManager:
|
||||||
|
|
|
@ -6,7 +6,7 @@ from __future__ import annotations
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import anki
|
import anki
|
||||||
from anki.rsbackend import TR
|
from anki.lang import TR
|
||||||
|
|
||||||
# whether new cards should be mixed with reviews, or shown first or last
|
# whether new cards should be mixed with reviews, or shown first or last
|
||||||
NEW_CARDS_DISTRIBUTE = 0
|
NEW_CARDS_DISTRIBUTE = 0
|
||||||
|
|
|
@ -21,7 +21,7 @@ class DBProxy:
|
||||||
# Lifecycle
|
# Lifecycle
|
||||||
###############
|
###############
|
||||||
|
|
||||||
def __init__(self, backend: anki.rsbackend.RustBackend) -> None:
|
def __init__(self, backend: anki._backend.RustBackend) -> None:
|
||||||
self._backend = backend
|
self._backend = backend
|
||||||
self.mod = False
|
self.mod = False
|
||||||
self.last_begin_at = 0
|
self.last_begin_at = 0
|
||||||
|
|
|
@ -8,17 +8,14 @@ import pprint
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
|
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
import anki # pylint: disable=unused-import
|
import anki # pylint: disable=unused-import
|
||||||
import anki.backend_pb2 as pb
|
import anki._backend.backend_pb2 as _pb
|
||||||
from anki.consts import *
|
from anki.consts import *
|
||||||
from anki.errors import DeckRenameError
|
from anki.errors import DeckIsFilteredError, DeckRenameError, NotFoundError
|
||||||
from anki.rsbackend import (
|
from anki.utils import from_json_bytes, ids2str, intTime, to_json_bytes
|
||||||
TR,
|
|
||||||
DeckTreeNode,
|
# public exports
|
||||||
NotFoundError,
|
DeckTreeNode = _pb.DeckTreeNode
|
||||||
from_json_bytes,
|
DeckNameID = _pb.DeckNameID
|
||||||
to_json_bytes,
|
|
||||||
)
|
|
||||||
from anki.utils import ids2str, intTime
|
|
||||||
|
|
||||||
# legacy code may pass this in as the type argument to .id()
|
# legacy code may pass this in as the type argument to .id()
|
||||||
defaultDeck = 0
|
defaultDeck = 0
|
||||||
|
@ -139,7 +136,7 @@ class DeckManager:
|
||||||
|
|
||||||
def all_names_and_ids(
|
def all_names_and_ids(
|
||||||
self, skip_empty_default=False, include_filtered=True
|
self, skip_empty_default=False, include_filtered=True
|
||||||
) -> Sequence[pb.DeckNameID]:
|
) -> Sequence[DeckNameID]:
|
||||||
"A sorted sequence of deck names and IDs."
|
"A sorted sequence of deck names and IDs."
|
||||||
return self.col.backend.get_deck_names(
|
return self.col.backend.get_deck_names(
|
||||||
skip_empty_default=skip_empty_default, include_filtered=include_filtered
|
skip_empty_default=skip_empty_default, include_filtered=include_filtered
|
||||||
|
@ -166,7 +163,7 @@ class DeckManager:
|
||||||
def new_deck_legacy(self, filtered: bool) -> Deck:
|
def new_deck_legacy(self, filtered: bool) -> Deck:
|
||||||
return from_json_bytes(self.col.backend.new_deck_legacy(filtered))
|
return from_json_bytes(self.col.backend.new_deck_legacy(filtered))
|
||||||
|
|
||||||
def deck_tree(self) -> pb.DeckTreeNode:
|
def deck_tree(self) -> DeckTreeNode:
|
||||||
return self.col.backend.deck_tree(top_deck_id=0, now=0)
|
return self.col.backend.deck_tree(top_deck_id=0, now=0)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -250,7 +247,7 @@ class DeckManager:
|
||||||
g["id"] = self.col.backend.add_or_update_deck_legacy(
|
g["id"] = self.col.backend.add_or_update_deck_legacy(
|
||||||
deck=to_json_bytes(g), preserve_usn_and_mtime=preserve_usn
|
deck=to_json_bytes(g), preserve_usn_and_mtime=preserve_usn
|
||||||
)
|
)
|
||||||
except anki.rsbackend.DeckIsFilteredError as exc:
|
except DeckIsFilteredError as exc:
|
||||||
raise DeckRenameError("deck was filtered") from exc
|
raise DeckRenameError("deck was filtered") from exc
|
||||||
|
|
||||||
def rename(self, g: Deck, newName: str) -> None:
|
def rename(self, g: Deck, newName: str) -> None:
|
||||||
|
|
|
@ -1,8 +1,92 @@
|
||||||
# Copyright: Ankitects Pty Ltd and contributors
|
# Copyright: Ankitects Pty Ltd and contributors
|
||||||
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import anki._backend.backend_pb2 as _pb
|
||||||
|
|
||||||
|
# fixme: notfounderror etc need to be in rsbackend.py
|
||||||
|
|
||||||
|
|
||||||
|
class StringError(Exception):
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.args[0] # pylint: disable=unsubscriptable-object
|
||||||
|
|
||||||
|
|
||||||
|
class Interrupted(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkError(StringError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SyncError(StringError):
|
||||||
|
# pylint: disable=no-member
|
||||||
|
def is_auth_error(self) -> bool:
|
||||||
|
return self.args[1] == _pb.SyncError.SyncErrorKind.AUTH_FAILED
|
||||||
|
|
||||||
|
|
||||||
|
class IOError(StringError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DBError(StringError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateError(StringError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ExistsError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DeckIsFilteredError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidInput(StringError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def backend_exception_to_pylib(err: _pb.BackendError) -> Exception:
|
||||||
|
val = err.WhichOneof("value")
|
||||||
|
if val == "interrupted":
|
||||||
|
return Interrupted()
|
||||||
|
elif val == "network_error":
|
||||||
|
return NetworkError(err.localized, err.network_error.kind)
|
||||||
|
elif val == "sync_error":
|
||||||
|
return SyncError(err.localized, err.sync_error.kind)
|
||||||
|
elif val == "io_error":
|
||||||
|
return IOError(err.localized)
|
||||||
|
elif val == "db_error":
|
||||||
|
return DBError(err.localized)
|
||||||
|
elif val == "template_parse":
|
||||||
|
return TemplateError(err.localized)
|
||||||
|
elif val == "invalid_input":
|
||||||
|
return InvalidInput(err.localized)
|
||||||
|
elif val == "json_error":
|
||||||
|
return StringError(err.localized)
|
||||||
|
elif val == "not_found_error":
|
||||||
|
return NotFoundError()
|
||||||
|
elif val == "exists":
|
||||||
|
return ExistsError()
|
||||||
|
elif val == "deck_is_filtered":
|
||||||
|
return DeckIsFilteredError()
|
||||||
|
elif val == "proto_error":
|
||||||
|
return StringError(err.localized)
|
||||||
|
else:
|
||||||
|
print("unhandled error type:", val)
|
||||||
|
return StringError(err.localized)
|
||||||
|
|
||||||
|
|
||||||
class AnkiError(Exception):
|
class AnkiError(Exception):
|
||||||
def __init__(self, type, **data) -> None:
|
def __init__(self, type, **data) -> None:
|
||||||
|
|
|
@ -13,7 +13,7 @@ from zipfile import ZipFile
|
||||||
|
|
||||||
from anki import hooks
|
from anki import hooks
|
||||||
from anki.collection import Collection
|
from anki.collection import Collection
|
||||||
from anki.rsbackend import TR
|
from anki.lang import TR
|
||||||
from anki.utils import ids2str, namedtmp, splitFields, stripHTML
|
from anki.utils import ids2str, namedtmp, splitFields, stripHTML
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
../../bazel-bin/pylib/anki/fluent_pb2.pyi
|
|
|
@ -7,8 +7,7 @@ from anki.importing.csvfile import TextImporter
|
||||||
from anki.importing.mnemo import MnemosyneImporter
|
from anki.importing.mnemo import MnemosyneImporter
|
||||||
from anki.importing.pauker import PaukerImporter
|
from anki.importing.pauker import PaukerImporter
|
||||||
from anki.importing.supermemo_xml import SupermemoXmlImporter # type: ignore
|
from anki.importing.supermemo_xml import SupermemoXmlImporter # type: ignore
|
||||||
from anki.lang import tr_legacyglobal
|
from anki.lang import TR, tr_legacyglobal
|
||||||
from anki.rsbackend import TR
|
|
||||||
|
|
||||||
Importers = (
|
Importers = (
|
||||||
(tr_legacyglobal(TR.IMPORTING_TEXT_SEPARATED_BY_TABS_OR_SEMICOLONS), TextImporter),
|
(tr_legacyglobal(TR.IMPORTING_TEXT_SEPARATED_BY_TABS_OR_SEMICOLONS), TextImporter),
|
||||||
|
|
|
@ -9,7 +9,7 @@ from anki.collection import Collection
|
||||||
from anki.consts import *
|
from anki.consts import *
|
||||||
from anki.decks import DeckManager
|
from anki.decks import DeckManager
|
||||||
from anki.importing.base import Importer
|
from anki.importing.base import Importer
|
||||||
from anki.rsbackend import TR
|
from anki.lang import TR
|
||||||
from anki.utils import intTime, joinFields, splitFields
|
from anki.utils import intTime, joinFields, splitFields
|
||||||
|
|
||||||
GUID = 1
|
GUID = 1
|
||||||
|
|
|
@ -7,7 +7,7 @@ from typing import Any, List, Optional, TextIO, Union
|
||||||
|
|
||||||
from anki.collection import Collection
|
from anki.collection import Collection
|
||||||
from anki.importing.noteimp import ForeignNote, NoteImporter
|
from anki.importing.noteimp import ForeignNote, NoteImporter
|
||||||
from anki.rsbackend import TR
|
from anki.lang import TR
|
||||||
|
|
||||||
|
|
||||||
class TextImporter(NoteImporter):
|
class TextImporter(NoteImporter):
|
||||||
|
|
|
@ -7,7 +7,7 @@ from typing import cast
|
||||||
|
|
||||||
from anki.db import DB
|
from anki.db import DB
|
||||||
from anki.importing.noteimp import ForeignCard, ForeignNote, NoteImporter
|
from anki.importing.noteimp import ForeignCard, ForeignNote, NoteImporter
|
||||||
from anki.rsbackend import TR
|
from anki.lang import TR
|
||||||
from anki.stdmodels import addBasicModel, addClozeModel
|
from anki.stdmodels import addBasicModel, addClozeModel
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ from typing import Dict, List, Optional, Tuple, Union
|
||||||
from anki.collection import Collection
|
from anki.collection import Collection
|
||||||
from anki.consts import NEW_CARDS_RANDOM, STARTING_FACTOR
|
from anki.consts import NEW_CARDS_RANDOM, STARTING_FACTOR
|
||||||
from anki.importing.base import Importer
|
from anki.importing.base import Importer
|
||||||
from anki.rsbackend import TR
|
from anki.lang import TR
|
||||||
from anki.utils import (
|
from anki.utils import (
|
||||||
fieldChecksum,
|
fieldChecksum,
|
||||||
guid64,
|
guid64,
|
||||||
|
|
|
@ -5,9 +5,20 @@ from __future__ import annotations
|
||||||
|
|
||||||
import locale
|
import locale
|
||||||
import re
|
import re
|
||||||
from typing import Optional, Tuple
|
from typing import TYPE_CHECKING, Optional, Tuple
|
||||||
|
|
||||||
import anki
|
import anki
|
||||||
|
import anki._backend.backend_pb2 as _pb
|
||||||
|
import anki._backend.fluent_pb2 as _fluent_pb
|
||||||
|
|
||||||
|
# public exports
|
||||||
|
TR = _fluent_pb.FluentString
|
||||||
|
FormatTimeSpanContext = _pb.FormatTimespanIn.Context # pylint: disable=no-member
|
||||||
|
|
||||||
|
# pylint: disable=no-member
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
TRValue = _fluent_pb.FluentStringValue
|
||||||
|
FormatTimeSpanContextValue = _pb.FormatTimespanIn.ContextValue
|
||||||
|
|
||||||
langs = sorted(
|
langs = sorted(
|
||||||
[
|
[
|
||||||
|
@ -142,7 +153,7 @@ def lang_to_disk_lang(lang: str) -> str:
|
||||||
currentLang = "en"
|
currentLang = "en"
|
||||||
|
|
||||||
# the current Fluent translation instance
|
# the current Fluent translation instance
|
||||||
current_i18n: Optional[anki.rsbackend.RustBackend] = None
|
current_i18n: Optional[anki._backend.RustBackend] = None
|
||||||
|
|
||||||
# path to locale folder
|
# path to locale folder
|
||||||
locale_folder = ""
|
locale_folder = ""
|
||||||
|
@ -169,7 +180,7 @@ def tr_legacyglobal(*args, **kwargs) -> str:
|
||||||
def set_lang(lang: str, locale_dir: str) -> None:
|
def set_lang(lang: str, locale_dir: str) -> None:
|
||||||
global currentLang, current_i18n, locale_folder
|
global currentLang, current_i18n, locale_folder
|
||||||
currentLang = lang
|
currentLang = lang
|
||||||
current_i18n = anki.rsbackend.RustBackend(ftl_folder=locale_folder, langs=[lang])
|
current_i18n = anki._backend.RustBackend(ftl_folder=locale_folder, langs=[lang])
|
||||||
locale_folder = locale_dir
|
locale_folder = locale_dir
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -10,9 +10,10 @@ from dataclasses import dataclass
|
||||||
from typing import Any, List, Optional, Tuple
|
from typing import Any, List, Optional, Tuple
|
||||||
|
|
||||||
import anki
|
import anki
|
||||||
|
import anki._backend.backend_pb2 as _pb
|
||||||
from anki import hooks
|
from anki import hooks
|
||||||
|
from anki.lang import TR
|
||||||
from anki.models import NoteType
|
from anki.models import NoteType
|
||||||
from anki.rsbackend import TR, pb
|
|
||||||
from anki.template import TemplateRenderContext, TemplateRenderOutput
|
from anki.template import TemplateRenderContext, TemplateRenderOutput
|
||||||
from anki.utils import call, isMac, namedtmp, tmpdir
|
from anki.utils import call, isMac, namedtmp, tmpdir
|
||||||
|
|
||||||
|
@ -45,7 +46,7 @@ class ExtractedLatexOutput:
|
||||||
latex: List[ExtractedLatex]
|
latex: List[ExtractedLatex]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_proto(proto: pb.ExtractLatexOut) -> ExtractedLatexOutput:
|
def from_proto(proto: _pb.ExtractLatexOut) -> ExtractedLatexOutput:
|
||||||
return ExtractedLatexOutput(
|
return ExtractedLatexOutput(
|
||||||
html=proto.text,
|
html=proto.text,
|
||||||
latex=[
|
latex=[
|
||||||
|
|
|
@ -14,9 +14,9 @@ import urllib.request
|
||||||
from typing import Any, Callable, List, Optional, Tuple
|
from typing import Any, Callable, List, Optional, Tuple
|
||||||
|
|
||||||
import anki
|
import anki
|
||||||
|
import anki._backend.backend_pb2 as _pb
|
||||||
from anki.consts import *
|
from anki.consts import *
|
||||||
from anki.latex import render_latex, render_latex_returning_errors
|
from anki.latex import render_latex, render_latex_returning_errors
|
||||||
from anki.rsbackend import pb
|
|
||||||
from anki.utils import intTime
|
from anki.utils import intTime
|
||||||
|
|
||||||
|
|
||||||
|
@ -26,6 +26,9 @@ def media_paths_from_col_path(col_path: str) -> Tuple[str, str]:
|
||||||
return (media_folder, media_db)
|
return (media_folder, media_db)
|
||||||
|
|
||||||
|
|
||||||
|
CheckMediaOut = _pb.CheckMediaOut
|
||||||
|
|
||||||
|
|
||||||
# fixme: look into whether we can drop chdir() below
|
# fixme: look into whether we can drop chdir() below
|
||||||
# - need to check aa89d06304fecd3597da4565330a3e55bdbb91fe
|
# - need to check aa89d06304fecd3597da4565330a3e55bdbb91fe
|
||||||
# - and audio handling code
|
# - and audio handling code
|
||||||
|
@ -188,7 +191,7 @@ class MediaManager:
|
||||||
# Checking media
|
# Checking media
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
|
||||||
def check(self) -> pb.CheckMediaOut:
|
def check(self) -> CheckMediaOut:
|
||||||
output = self.col.backend.check_media()
|
output = self.col.backend.check_media()
|
||||||
# files may have been renamed on disk, so an undo at this point could
|
# files may have been renamed on disk, so an undo at this point could
|
||||||
# break file references
|
# break file references
|
||||||
|
|
|
@ -9,17 +9,24 @@ import time
|
||||||
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
import anki # pylint: disable=unused-import
|
import anki # pylint: disable=unused-import
|
||||||
import anki.backend_pb2 as pb
|
import anki._backend.backend_pb2 as _pb
|
||||||
from anki.consts import *
|
from anki.consts import *
|
||||||
from anki.lang import without_unicode_isolation
|
from anki.errors import NotFoundError
|
||||||
from anki.rsbackend import (
|
from anki.lang import TR, without_unicode_isolation
|
||||||
TR,
|
from anki.utils import (
|
||||||
NotFoundError,
|
checksum,
|
||||||
StockNoteType,
|
|
||||||
from_json_bytes,
|
from_json_bytes,
|
||||||
|
ids2str,
|
||||||
|
intTime,
|
||||||
|
joinFields,
|
||||||
|
splitFields,
|
||||||
to_json_bytes,
|
to_json_bytes,
|
||||||
)
|
)
|
||||||
from anki.utils import checksum, ids2str, intTime, joinFields, splitFields
|
|
||||||
|
# public exports
|
||||||
|
NoteTypeNameID = _pb.NoteTypeNameID
|
||||||
|
NoteTypeNameIDUseCount = _pb.NoteTypeNameIDUseCount
|
||||||
|
|
||||||
|
|
||||||
# types
|
# types
|
||||||
NoteType = Dict[str, Any]
|
NoteType = Dict[str, Any]
|
||||||
|
@ -121,10 +128,10 @@ class ModelManager:
|
||||||
# Listing note types
|
# Listing note types
|
||||||
#############################################################
|
#############################################################
|
||||||
|
|
||||||
def all_names_and_ids(self) -> Sequence[pb.NoteTypeNameID]:
|
def all_names_and_ids(self) -> Sequence[NoteTypeNameID]:
|
||||||
return self.col.backend.get_notetype_names()
|
return self.col.backend.get_notetype_names()
|
||||||
|
|
||||||
def all_use_counts(self) -> Sequence[pb.NoteTypeNameIDUseCount]:
|
def all_use_counts(self) -> Sequence[NoteTypeNameIDUseCount]:
|
||||||
return self.col.backend.get_notetype_names_and_counts()
|
return self.col.backend.get_notetype_names_and_counts()
|
||||||
|
|
||||||
# legacy
|
# legacy
|
||||||
|
@ -200,7 +207,7 @@ class ModelManager:
|
||||||
# caller should call save() after modifying
|
# caller should call save() after modifying
|
||||||
nt = from_json_bytes(
|
nt = from_json_bytes(
|
||||||
self.col.backend.get_stock_notetype_legacy(
|
self.col.backend.get_stock_notetype_legacy(
|
||||||
StockNoteType.STOCK_NOTE_TYPE_BASIC
|
_pb.StockNoteType.STOCK_NOTE_TYPE_BASIC
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
nt["flds"] = []
|
nt["flds"] = []
|
||||||
|
@ -293,7 +300,7 @@ class ModelManager:
|
||||||
assert isinstance(name, str)
|
assert isinstance(name, str)
|
||||||
nt = from_json_bytes(
|
nt = from_json_bytes(
|
||||||
self.col.backend.get_stock_notetype_legacy(
|
self.col.backend.get_stock_notetype_legacy(
|
||||||
StockNoteType.STOCK_NOTE_TYPE_BASIC
|
_pb.StockNoteType.STOCK_NOTE_TYPE_BASIC
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
field = nt["flds"][0]
|
field = nt["flds"][0]
|
||||||
|
@ -354,7 +361,7 @@ class ModelManager:
|
||||||
def new_template(self, name: str) -> Template:
|
def new_template(self, name: str) -> Template:
|
||||||
nt = from_json_bytes(
|
nt = from_json_bytes(
|
||||||
self.col.backend.get_stock_notetype_legacy(
|
self.col.backend.get_stock_notetype_legacy(
|
||||||
StockNoteType.STOCK_NOTE_TYPE_BASIC
|
_pb.StockNoteType.STOCK_NOTE_TYPE_BASIC
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
template = nt["tmpls"][0]
|
template = nt["tmpls"][0]
|
||||||
|
@ -508,5 +515,5 @@ and notes.mid = ? and cards.ord = ?""",
|
||||||
self, m: NoteType, flds: str, allowEmpty: bool = True
|
self, m: NoteType, flds: str, allowEmpty: bool = True
|
||||||
) -> List[int]:
|
) -> List[int]:
|
||||||
print("_availClozeOrds() is deprecated; use note.cloze_numbers_in_fields()")
|
print("_availClozeOrds() is deprecated; use note.cloze_numbers_in_fields()")
|
||||||
note = anki.rsbackend.BackendNote(fields=[flds])
|
note = anki._backend.BackendNote(fields=[flds])
|
||||||
return list(self.col.backend.cloze_numbers_in_note(note))
|
return list(self.col.backend.cloze_numbers_in_note(note))
|
||||||
|
|
|
@ -7,9 +7,9 @@ import pprint
|
||||||
from typing import Any, List, Optional, Sequence, Tuple
|
from typing import Any, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
import anki # pylint: disable=unused-import
|
import anki # pylint: disable=unused-import
|
||||||
|
import anki._backend.backend_pb2 as _pb
|
||||||
from anki import hooks
|
from anki import hooks
|
||||||
from anki.models import NoteType
|
from anki.models import NoteType
|
||||||
from anki.rsbackend import BackendNote
|
|
||||||
from anki.utils import joinFields
|
from anki.utils import joinFields
|
||||||
|
|
||||||
|
|
||||||
|
@ -41,7 +41,7 @@ class Note:
|
||||||
assert n
|
assert n
|
||||||
self._load_from_backend_note(n)
|
self._load_from_backend_note(n)
|
||||||
|
|
||||||
def _load_from_backend_note(self, n: BackendNote) -> None:
|
def _load_from_backend_note(self, n: _pb.Note) -> None:
|
||||||
self.id = n.id
|
self.id = n.id
|
||||||
self.guid = n.guid
|
self.guid = n.guid
|
||||||
self.mid = n.notetype_id
|
self.mid = n.notetype_id
|
||||||
|
@ -51,9 +51,9 @@ class Note:
|
||||||
self.fields = list(n.fields)
|
self.fields = list(n.fields)
|
||||||
self._fmap = self.col.models.fieldMap(self.model())
|
self._fmap = self.col.models.fieldMap(self.model())
|
||||||
|
|
||||||
def to_backend_note(self) -> BackendNote:
|
def to_backend_note(self) -> _pb.Note:
|
||||||
hooks.note_will_flush(self)
|
hooks.note_will_flush(self)
|
||||||
return BackendNote(
|
return _pb.Note(
|
||||||
id=self.id,
|
id=self.id,
|
||||||
guid=self.guid,
|
guid=self.guid,
|
||||||
notetype_id=self.mid,
|
notetype_id=self.mid,
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
../../bazel-bin/pylib/anki/rsbackend_gen.py
|
|
|
@ -20,30 +20,25 @@ from typing import (
|
||||||
)
|
)
|
||||||
|
|
||||||
import anki # pylint: disable=unused-import
|
import anki # pylint: disable=unused-import
|
||||||
import anki.backend_pb2 as pb
|
import anki._backend.backend_pb2 as _pb
|
||||||
from anki import hooks
|
from anki import hooks
|
||||||
|
from anki._backend import CountsForDeckToday, FormatTimeSpanContext, SchedTimingToday
|
||||||
from anki.cards import Card
|
from anki.cards import Card
|
||||||
from anki.consts import *
|
from anki.consts import *
|
||||||
from anki.decks import Deck, DeckConfig, DeckManager, QueueConfig
|
from anki.decks import Deck, DeckConfig, DeckManager, DeckTreeNode, QueueConfig
|
||||||
from anki.notes import Note
|
from anki.notes import Note
|
||||||
from anki.rsbackend import (
|
from anki.utils import from_json_bytes, ids2str, intTime
|
||||||
TR,
|
|
||||||
CountsForDeckToday,
|
|
||||||
DeckTreeNode,
|
|
||||||
FormatTimeSpanContext,
|
|
||||||
SchedTimingToday,
|
|
||||||
from_json_bytes,
|
|
||||||
)
|
|
||||||
from anki.utils import ids2str, intTime
|
|
||||||
|
|
||||||
UnburyCurrentDeckMode = pb.UnburyCardsInCurrentDeckIn.Mode # pylint:disable=no-member
|
CongratsInfoOut = anki._backend.backend_pb2.CongratsInfoOut
|
||||||
BuryOrSuspendMode = pb.BuryOrSuspendCardsIn.Mode # pylint:disable=no-member
|
|
||||||
|
UnburyCurrentDeckMode = _pb.UnburyCardsInCurrentDeckIn.Mode # pylint:disable=no-member
|
||||||
|
BuryOrSuspendMode = _pb.BuryOrSuspendCardsIn.Mode # pylint:disable=no-member
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
UnburyCurrentDeckModeValue = (
|
UnburyCurrentDeckModeValue = (
|
||||||
pb.UnburyCardsInCurrentDeckIn.ModeValue # pylint:disable=no-member
|
_pb.UnburyCardsInCurrentDeckIn.ModeValue # pylint:disable=no-member
|
||||||
)
|
)
|
||||||
BuryOrSuspendModeValue = (
|
BuryOrSuspendModeValue = (
|
||||||
pb.BuryOrSuspendCardsIn.ModeValue # pylint:disable=no-member
|
_pb.BuryOrSuspendCardsIn.ModeValue # pylint:disable=no-member
|
||||||
)
|
)
|
||||||
|
|
||||||
# card types: 0=new, 1=lrn, 2=rev, 3=relrn
|
# card types: 0=new, 1=lrn, 2=rev, 3=relrn
|
||||||
|
@ -1241,7 +1236,7 @@ due = (case when odue>0 then odue else due end), odue = 0, odid = 0, usn = ? whe
|
||||||
# Deck finished state
|
# Deck finished state
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
|
||||||
def congratulations_info(self) -> pb.CongratsInfoOut:
|
def congratulations_info(self) -> CongratsInfoOut:
|
||||||
return self.col.backend.congrats_info()
|
return self.col.backend.congrats_info()
|
||||||
|
|
||||||
def finishedMsg(self) -> str:
|
def finishedMsg(self) -> str:
|
||||||
|
|
|
@ -10,7 +10,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
import anki
|
import anki
|
||||||
from anki.consts import *
|
from anki.consts import *
|
||||||
from anki.rsbackend import TR, FormatTimeSpanContext
|
from anki.lang import TR, FormatTimeSpanContext
|
||||||
from anki.utils import ids2str
|
from anki.utils import ids2str
|
||||||
|
|
||||||
# Card stats
|
# Card stats
|
||||||
|
|
|
@ -5,12 +5,15 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Callable, List, Tuple
|
from typing import TYPE_CHECKING, Callable, List, Tuple
|
||||||
|
|
||||||
|
from anki._backend import StockNoteType
|
||||||
from anki.collection import Collection
|
from anki.collection import Collection
|
||||||
from anki.models import NoteType
|
from anki.models import NoteType
|
||||||
from anki.rsbackend import StockNoteType, from_json_bytes
|
from anki.utils import from_json_bytes
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from anki.backend_pb2 import StockNoteTypeValue # pylint: disable=no-name-in-module
|
from anki._backend.backend_pb2 import ( # pylint: disable=no-name-in-module
|
||||||
|
StockNoteTypeValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# add-on authors can add ("note type name", function_like_addBasicModel)
|
# add-on authors can add ("note type name", function_like_addBasicModel)
|
||||||
|
|
|
@ -1,8 +1,15 @@
|
||||||
# Copyright: Ankitects Pty Ltd and contributors
|
# Copyright: Ankitects Pty Ltd and contributors
|
||||||
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
#
|
|
||||||
|
import anki._backend.backend_pb2 as _pb
|
||||||
|
|
||||||
|
# public exports
|
||||||
|
SyncAuth = _pb.SyncAuth
|
||||||
|
SyncOutput = _pb.SyncCollectionOut
|
||||||
|
SyncStatus = _pb.SyncStatusOut
|
||||||
|
|
||||||
|
|
||||||
# Legacy attributes some add-ons may be using
|
# Legacy attributes some add-ons may be using
|
||||||
#
|
|
||||||
|
|
||||||
from .httpclient import HttpClient
|
from .httpclient import HttpClient
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,7 @@ except ImportError as e:
|
||||||
from flask import Response
|
from flask import Response
|
||||||
|
|
||||||
from anki import Collection
|
from anki import Collection
|
||||||
from anki.backend_pb2 import SyncServerMethodIn
|
from anki._backend.backend_pb2 import SyncServerMethodIn
|
||||||
|
|
||||||
Method = SyncServerMethodIn.Method # pylint: disable=no-member
|
Method = SyncServerMethodIn.Method # pylint: disable=no-member
|
||||||
|
|
||||||
|
|
|
@ -16,9 +16,13 @@ import re
|
||||||
from typing import Collection, List, Optional, Sequence, Tuple
|
from typing import Collection, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
import anki # pylint: disable=unused-import
|
import anki # pylint: disable=unused-import
|
||||||
from anki.collection import SearchTerm
|
import anki._backend.backend_pb2 as _pb
|
||||||
|
import anki.collection
|
||||||
from anki.utils import ids2str
|
from anki.utils import ids2str
|
||||||
|
|
||||||
|
# public exports
|
||||||
|
TagTreeNode = _pb.TagTreeNode
|
||||||
|
|
||||||
|
|
||||||
class TagManager:
|
class TagManager:
|
||||||
def __init__(self, col: anki.collection.Collection) -> None:
|
def __init__(self, col: anki.collection.Collection) -> None:
|
||||||
|
@ -37,6 +41,9 @@ class TagManager:
|
||||||
def allItems(self) -> List[Tuple[str, int]]:
|
def allItems(self) -> List[Tuple[str, int]]:
|
||||||
return [(t.name, t.usn) for t in self.col.backend.all_tags()]
|
return [(t.name, t.usn) for t in self.col.backend.all_tags()]
|
||||||
|
|
||||||
|
def tree(self) -> TagTreeNode:
|
||||||
|
return self.col.backend.tag_tree()
|
||||||
|
|
||||||
# Registering and fetching tags
|
# Registering and fetching tags
|
||||||
#############################################################
|
#############################################################
|
||||||
|
|
||||||
|
@ -87,7 +94,7 @@ class TagManager:
|
||||||
|
|
||||||
def rename_tag(self, old: str, new: str) -> int:
|
def rename_tag(self, old: str, new: str) -> int:
|
||||||
"Rename provided tag, returning number of changed notes."
|
"Rename provided tag, returning number of changed notes."
|
||||||
nids = self.col.find_notes(SearchTerm(tag=old))
|
nids = self.col.find_notes(anki.collection.SearchTerm(tag=old))
|
||||||
if not nids:
|
if not nids:
|
||||||
return 0
|
return 0
|
||||||
escaped_name = re.sub(r"[*_\\]", r"\\\g<0>", old)
|
escaped_name = re.sub(r"[*_\\]", r"\\\g<0>", old)
|
||||||
|
|
|
@ -32,13 +32,15 @@ from dataclasses import dataclass
|
||||||
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
import anki
|
import anki
|
||||||
|
import anki._backend.backend_pb2 as _pb
|
||||||
from anki import hooks
|
from anki import hooks
|
||||||
from anki.cards import Card
|
from anki.cards import Card
|
||||||
from anki.decks import DeckManager
|
from anki.decks import DeckManager
|
||||||
|
from anki.errors import TemplateError
|
||||||
from anki.models import NoteType
|
from anki.models import NoteType
|
||||||
from anki.notes import Note
|
from anki.notes import Note
|
||||||
from anki.rsbackend import pb, to_json_bytes
|
|
||||||
from anki.sound import AVTag, SoundOrVideoTag, TTSTag
|
from anki.sound import AVTag, SoundOrVideoTag, TTSTag
|
||||||
|
from anki.utils import to_json_bytes
|
||||||
|
|
||||||
CARD_BLANK_HELP = (
|
CARD_BLANK_HELP = (
|
||||||
"https://anki.tenderapp.com/kb/card-appearance/the-front-of-this-card-is-blank"
|
"https://anki.tenderapp.com/kb/card-appearance/the-front-of-this-card-is-blank"
|
||||||
|
@ -61,7 +63,7 @@ class PartiallyRenderedCard:
|
||||||
anodes: TemplateReplacementList
|
anodes: TemplateReplacementList
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_proto(cls, out: pb.RenderCardOut) -> PartiallyRenderedCard:
|
def from_proto(cls, out: _pb.RenderCardOut) -> PartiallyRenderedCard:
|
||||||
qnodes = cls.nodes_from_proto(out.question_nodes)
|
qnodes = cls.nodes_from_proto(out.question_nodes)
|
||||||
anodes = cls.nodes_from_proto(out.answer_nodes)
|
anodes = cls.nodes_from_proto(out.answer_nodes)
|
||||||
|
|
||||||
|
@ -69,7 +71,7 @@ class PartiallyRenderedCard:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def nodes_from_proto(
|
def nodes_from_proto(
|
||||||
nodes: Sequence[pb.RenderedTemplateNode],
|
nodes: Sequence[_pb.RenderedTemplateNode],
|
||||||
) -> TemplateReplacementList:
|
) -> TemplateReplacementList:
|
||||||
results: TemplateReplacementList = []
|
results: TemplateReplacementList = []
|
||||||
for node in nodes:
|
for node in nodes:
|
||||||
|
@ -86,7 +88,7 @@ class PartiallyRenderedCard:
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def av_tag_to_native(tag: pb.AVTag) -> AVTag:
|
def av_tag_to_native(tag: _pb.AVTag) -> AVTag:
|
||||||
val = tag.WhichOneof("value")
|
val = tag.WhichOneof("value")
|
||||||
if val == "sound_or_video":
|
if val == "sound_or_video":
|
||||||
return SoundOrVideoTag(filename=tag.sound_or_video)
|
return SoundOrVideoTag(filename=tag.sound_or_video)
|
||||||
|
@ -100,7 +102,7 @@ def av_tag_to_native(tag: pb.AVTag) -> AVTag:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def av_tags_to_native(tags: Sequence[pb.AVTag]) -> List[AVTag]:
|
def av_tags_to_native(tags: Sequence[_pb.AVTag]) -> List[AVTag]:
|
||||||
return list(map(av_tag_to_native, tags))
|
return list(map(av_tag_to_native, tags))
|
||||||
|
|
||||||
|
|
||||||
|
@ -206,7 +208,7 @@ class TemplateRenderContext:
|
||||||
def render(self) -> TemplateRenderOutput:
|
def render(self) -> TemplateRenderOutput:
|
||||||
try:
|
try:
|
||||||
partial = self._partially_render()
|
partial = self._partially_render()
|
||||||
except anki.rsbackend.TemplateError as e:
|
except TemplateError as e:
|
||||||
return TemplateRenderOutput(
|
return TemplateRenderOutput(
|
||||||
question_text=str(e),
|
question_text=str(e),
|
||||||
answer_text=str(e),
|
answer_text=str(e),
|
||||||
|
|
|
@ -26,6 +26,17 @@ from anki.dbproxy import DBProxy
|
||||||
|
|
||||||
_tmpdir: Optional[str]
|
_tmpdir: Optional[str]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# pylint: disable=c-extension-no-member
|
||||||
|
import orjson
|
||||||
|
|
||||||
|
to_json_bytes = orjson.dumps
|
||||||
|
from_json_bytes = orjson.loads
|
||||||
|
except:
|
||||||
|
print("orjson is missing; DB operations will be slower")
|
||||||
|
to_json_bytes = lambda obj: json.dumps(obj).encode("utf8") # type: ignore
|
||||||
|
from_json_bytes = json.loads
|
||||||
|
|
||||||
# Time handling
|
# Time handling
|
||||||
##############################################################################
|
##############################################################################
|
||||||
|
|
||||||
|
|
|
@ -5,8 +5,7 @@ import tempfile
|
||||||
|
|
||||||
from anki import Collection as aopen
|
from anki import Collection as aopen
|
||||||
from anki.dbproxy import emulate_named_args
|
from anki.dbproxy import emulate_named_args
|
||||||
from anki.lang import without_unicode_isolation
|
from anki.lang import TR, without_unicode_isolation
|
||||||
from anki.rsbackend import TR
|
|
||||||
from anki.stdmodels import addBasicModel, get_stock_notetypes
|
from anki.stdmodels import addBasicModel, get_stock_notetypes
|
||||||
from anki.utils import isWin
|
from anki.utils import isWin
|
||||||
from tests.shared import assertException, getEmptyCol
|
from tests.shared import assertException, getEmptyCol
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
# coding: utf-8
|
# coding: utf-8
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from anki._backend import BuiltinSortKind
|
||||||
from anki.collection import ConfigBoolKey
|
from anki.collection import ConfigBoolKey
|
||||||
from anki.consts import *
|
from anki.consts import *
|
||||||
from anki.rsbackend import BuiltinSortKind
|
|
||||||
from tests.shared import getEmptyCol, isNearCutoff
|
from tests.shared import getEmptyCol, isNearCutoff
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from anki.consts import MODEL_CLOZE
|
from anki.consts import MODEL_CLOZE
|
||||||
from anki.rsbackend import NotFoundError
|
from anki.errors import NotFoundError
|
||||||
from anki.utils import isWin, stripHTML
|
from anki.utils import isWin, stripHTML
|
||||||
from tests.shared import getEmptyCol
|
from tests.shared import getEmptyCol
|
||||||
|
|
||||||
|
|
|
@ -22,20 +22,6 @@ py_binary(
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
py_binary(
|
|
||||||
name = "genbackend",
|
|
||||||
srcs = [
|
|
||||||
"genbackend.py",
|
|
||||||
"//pylib/anki:backend_pb2",
|
|
||||||
],
|
|
||||||
visibility = ["//pylib:__subpackages__"],
|
|
||||||
deps = [
|
|
||||||
requirement("black"),
|
|
||||||
requirement("stringcase"),
|
|
||||||
requirement("protobuf"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
py_library(
|
py_library(
|
||||||
name = "hookslib",
|
name = "hookslib",
|
||||||
srcs = ["hookslib.py"],
|
srcs = ["hookslib.py"],
|
||||||
|
|
|
@ -14,8 +14,8 @@ from typing import Any, Callable, Dict, Optional, Union
|
||||||
|
|
||||||
import anki.lang
|
import anki.lang
|
||||||
from anki import version as _version
|
from anki import version as _version
|
||||||
|
from anki._backend import RustBackend
|
||||||
from anki.consts import HELP_SITE
|
from anki.consts import HELP_SITE
|
||||||
from anki.rsbackend import RustBackend
|
|
||||||
from anki.utils import checksum, isLin, isMac
|
from anki.utils import checksum, isLin, isMac
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.utils import TR, locale_dir, tr
|
from aqt.utils import TR, locale_dir, tr
|
||||||
|
|
|
@ -13,8 +13,9 @@ from typing import List, Optional, Sequence, Tuple, cast
|
||||||
import aqt
|
import aqt
|
||||||
import aqt.forms
|
import aqt.forms
|
||||||
from anki.cards import Card
|
from anki.cards import Card
|
||||||
from anki.collection import Collection, ConfigBoolKey, InvalidInput, SearchTerm
|
from anki.collection import Collection, ConfigBoolKey, SearchTerm
|
||||||
from anki.consts import *
|
from anki.consts import *
|
||||||
|
from anki.errors import InvalidInput
|
||||||
from anki.lang import without_unicode_isolation
|
from anki.lang import without_unicode_isolation
|
||||||
from anki.models import NoteType
|
from anki.models import NoteType
|
||||||
from anki.notes import Note
|
from anki.notes import Note
|
||||||
|
|
|
@ -9,9 +9,9 @@ from typing import Any, Dict, List, Optional
|
||||||
import aqt
|
import aqt
|
||||||
from anki.cards import Card
|
from anki.cards import Card
|
||||||
from anki.consts import *
|
from anki.consts import *
|
||||||
|
from anki.errors import TemplateError
|
||||||
from anki.lang import without_unicode_isolation
|
from anki.lang import without_unicode_isolation
|
||||||
from anki.notes import Note
|
from anki.notes import Note
|
||||||
from anki.rsbackend import TemplateError
|
|
||||||
from anki.template import TemplateRenderContext
|
from anki.template import TemplateRenderContext
|
||||||
from aqt import AnkiQt, gui_hooks
|
from aqt import AnkiQt, gui_hooks
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import aqt
|
import aqt
|
||||||
from anki.rsbackend import DatabaseCheckProgress, ProgressKind
|
from anki.collection import DatabaseCheckProgress, ProgressKind
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.utils import showText, tooltip
|
from aqt.utils import showText, tooltip
|
||||||
|
|
||||||
|
|
|
@ -9,8 +9,8 @@ from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import aqt
|
import aqt
|
||||||
|
from anki.decks import DeckTreeNode
|
||||||
from anki.errors import DeckRenameError
|
from anki.errors import DeckRenameError
|
||||||
from anki.rsbackend import DeckTreeNode
|
|
||||||
from aqt import AnkiQt, gui_hooks
|
from aqt import AnkiQt, gui_hooks
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.sound import av_player
|
from aqt.sound import av_player
|
||||||
|
|
|
@ -4,7 +4,8 @@
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import aqt
|
import aqt
|
||||||
from anki.collection import InvalidInput, SearchTerm
|
from anki.collection import SearchTerm
|
||||||
|
from anki.errors import InvalidInput
|
||||||
from anki.lang import without_unicode_isolation
|
from anki.lang import without_unicode_isolation
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.utils import (
|
from aqt.utils import (
|
||||||
|
|
|
@ -6,7 +6,7 @@ from __future__ import annotations
|
||||||
import re
|
import re
|
||||||
|
|
||||||
import aqt
|
import aqt
|
||||||
from anki.backend_pb2 import EmptyCardsReport, NoteWithEmptyCards
|
from anki.collection import EmptyCardsReport, NoteWithEmptyCards
|
||||||
from aqt import gui_hooks
|
from aqt import gui_hooks
|
||||||
from aqt.qt import QDialog, QDialogButtonBox, qconnect
|
from aqt.qt import QDialog, QDialogButtonBox, qconnect
|
||||||
from aqt.utils import TR, disable_help_button, restoreGeom, saveGeom, tooltip, tr
|
from aqt.utils import TR, disable_help_button, restoreGeom, saveGeom, tooltip, tr
|
||||||
|
@ -24,7 +24,7 @@ def show_empty_cards(mw: aqt.main.AnkiQt) -> None:
|
||||||
diag = EmptyCardsDialog(mw, report)
|
diag = EmptyCardsDialog(mw, report)
|
||||||
diag.show()
|
diag.show()
|
||||||
|
|
||||||
mw.taskman.run_in_background(mw.col.backend.get_empty_cards, on_done)
|
mw.taskman.run_in_background(mw.col.get_empty_cards, on_done)
|
||||||
|
|
||||||
|
|
||||||
class EmptyCardsDialog(QDialog):
|
class EmptyCardsDialog(QDialog):
|
||||||
|
|
|
@ -3,9 +3,9 @@
|
||||||
|
|
||||||
import aqt
|
import aqt
|
||||||
from anki.consts import *
|
from anki.consts import *
|
||||||
|
from anki.errors import TemplateError
|
||||||
from anki.lang import without_unicode_isolation
|
from anki.lang import without_unicode_isolation
|
||||||
from anki.models import NoteType
|
from anki.models import NoteType
|
||||||
from anki.rsbackend import TemplateError
|
|
||||||
from aqt import AnkiQt, gui_hooks
|
from aqt import AnkiQt, gui_hooks
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.schema_change_tracker import ChangeTracker
|
from aqt.schema_change_tracker import ChangeTracker
|
||||||
|
|
|
@ -26,11 +26,11 @@ import aqt.stats
|
||||||
import aqt.toolbar
|
import aqt.toolbar
|
||||||
import aqt.webview
|
import aqt.webview
|
||||||
from anki import hooks
|
from anki import hooks
|
||||||
|
from anki._backend import RustBackend as _RustBackend
|
||||||
from anki.collection import Collection, SearchTerm
|
from anki.collection import Collection, SearchTerm
|
||||||
from anki.decks import Deck
|
from anki.decks import Deck
|
||||||
from anki.hooks import runHook
|
from anki.hooks import runHook
|
||||||
from anki.lang import without_unicode_isolation
|
from anki.lang import without_unicode_isolation
|
||||||
from anki.rsbackend import RustBackend
|
|
||||||
from anki.sound import AVTag, SoundOrVideoTag
|
from anki.sound import AVTag, SoundOrVideoTag
|
||||||
from anki.utils import devMode, ids2str, intTime, isMac, isWin, splitFields
|
from anki.utils import devMode, ids2str, intTime, isMac, isWin, splitFields
|
||||||
from aqt import gui_hooks
|
from aqt import gui_hooks
|
||||||
|
@ -100,7 +100,7 @@ class AnkiQt(QMainWindow):
|
||||||
self,
|
self,
|
||||||
app: QApplication,
|
app: QApplication,
|
||||||
profileManager: ProfileManagerType,
|
profileManager: ProfileManagerType,
|
||||||
backend: RustBackend,
|
backend: _RustBackend,
|
||||||
opts: Namespace,
|
opts: Namespace,
|
||||||
args: List[Any],
|
args: List[Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
@ -9,8 +9,10 @@ from concurrent.futures import Future
|
||||||
from typing import Iterable, List, Optional, Sequence, TypeVar
|
from typing import Iterable, List, Optional, Sequence, TypeVar
|
||||||
|
|
||||||
import aqt
|
import aqt
|
||||||
from anki.collection import SearchTerm
|
from anki.collection import ProgressKind, SearchTerm
|
||||||
from anki.rsbackend import TR, Interrupted, ProgressKind, pb
|
from anki.errors import Interrupted
|
||||||
|
from anki.lang import TR
|
||||||
|
from anki.media import CheckMediaOut
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.utils import (
|
from aqt.utils import (
|
||||||
askUser,
|
askUser,
|
||||||
|
@ -74,7 +76,7 @@ class MediaChecker:
|
||||||
|
|
||||||
self.mw.taskman.run_on_main(lambda: self.mw.progress.update(progress.val))
|
self.mw.taskman.run_on_main(lambda: self.mw.progress.update(progress.val))
|
||||||
|
|
||||||
def _check(self) -> pb.CheckMediaOut:
|
def _check(self) -> CheckMediaOut:
|
||||||
"Run the check on a background thread."
|
"Run the check on a background thread."
|
||||||
return self.mw.col.media.check()
|
return self.mw.col.media.check()
|
||||||
|
|
||||||
|
@ -87,7 +89,7 @@ class MediaChecker:
|
||||||
if isinstance(exc, Interrupted):
|
if isinstance(exc, Interrupted):
|
||||||
return
|
return
|
||||||
|
|
||||||
output: pb.CheckMediaOut = future.result()
|
output: CheckMediaOut = future.result()
|
||||||
report = output.report
|
report = output.report
|
||||||
|
|
||||||
# show report and offer to delete
|
# show report and offer to delete
|
||||||
|
|
|
@ -18,11 +18,10 @@ import flask_cors # type: ignore
|
||||||
from flask import Response, request
|
from flask import Response, request
|
||||||
from waitress.server import create_server
|
from waitress.server import create_server
|
||||||
|
|
||||||
import anki.backend_pb2 as pb
|
|
||||||
import aqt
|
import aqt
|
||||||
from anki import hooks
|
from anki import hooks
|
||||||
from anki.rsbackend import from_json_bytes
|
from anki.collection import GraphPreferences
|
||||||
from anki.utils import devMode
|
from anki.utils import devMode, from_json_bytes
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.utils import aqt_data_folder
|
from aqt.utils import aqt_data_folder
|
||||||
|
|
||||||
|
@ -253,22 +252,21 @@ def _redirectWebExports(path):
|
||||||
|
|
||||||
def graph_data() -> bytes:
|
def graph_data() -> bytes:
|
||||||
args = from_json_bytes(request.data)
|
args = from_json_bytes(request.data)
|
||||||
return aqt.mw.col.backend.graphs(search=args["search"], days=args["days"])
|
return aqt.mw.col.graph_data(search=args["search"], days=args["days"])
|
||||||
|
|
||||||
|
|
||||||
def graph_preferences() -> bytes:
|
def graph_preferences() -> bytes:
|
||||||
return aqt.mw.col.backend.get_graph_preferences()
|
return aqt.mw.col.get_graph_preferences()
|
||||||
|
|
||||||
|
|
||||||
def set_graph_preferences() -> None:
|
def set_graph_preferences() -> None:
|
||||||
input = pb.GraphPreferences()
|
prefs = GraphPreferences()
|
||||||
input.ParseFromString(request.data)
|
prefs.ParseFromString(request.data)
|
||||||
aqt.mw.col.backend.set_graph_preferences(input=input)
|
aqt.mw.col.set_graph_preferences(prefs)
|
||||||
|
|
||||||
|
|
||||||
def congrats_info() -> bytes:
|
def congrats_info() -> bytes:
|
||||||
info = aqt.mw.col.backend.congrats_info()
|
return aqt.mw.col.congrats_info()
|
||||||
return info.SerializeToString()
|
|
||||||
|
|
||||||
|
|
||||||
post_handlers = {
|
post_handlers = {
|
||||||
|
|
|
@ -9,13 +9,9 @@ from dataclasses import dataclass
|
||||||
from typing import Callable, List, Optional, Union
|
from typing import Callable, List, Optional, Union
|
||||||
|
|
||||||
import aqt
|
import aqt
|
||||||
from anki.rsbackend import (
|
from anki.collection import MediaSyncProgress, ProgressKind
|
||||||
TR,
|
from anki.errors import Interrupted, NetworkError
|
||||||
Interrupted,
|
from anki.lang import TR
|
||||||
MediaSyncProgress,
|
|
||||||
NetworkError,
|
|
||||||
ProgressKind,
|
|
||||||
)
|
|
||||||
from anki.types import assert_exhaustive
|
from anki.types import assert_exhaustive
|
||||||
from anki.utils import intTime
|
from anki.utils import intTime
|
||||||
from aqt import gui_hooks
|
from aqt import gui_hooks
|
||||||
|
|
|
@ -6,11 +6,9 @@ from typing import Any, List, Optional, Sequence
|
||||||
|
|
||||||
import aqt.clayout
|
import aqt.clayout
|
||||||
from anki import stdmodels
|
from anki import stdmodels
|
||||||
from anki.backend_pb2 import NoteTypeNameIDUseCount
|
|
||||||
from anki.lang import without_unicode_isolation
|
from anki.lang import without_unicode_isolation
|
||||||
from anki.models import NoteType
|
from anki.models import NoteType, NoteTypeNameIDUseCount
|
||||||
from anki.notes import Note
|
from anki.notes import Note
|
||||||
from anki.rsbackend import pb
|
|
||||||
from aqt import AnkiQt, gui_hooks
|
from aqt import AnkiQt, gui_hooks
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.utils import (
|
from aqt.utils import (
|
||||||
|
@ -51,7 +49,7 @@ class Models(QDialog):
|
||||||
self.form.buttonBox.helpRequested,
|
self.form.buttonBox.helpRequested,
|
||||||
lambda: openHelp(HelpPage.ADDING_A_NOTE_TYPE),
|
lambda: openHelp(HelpPage.ADDING_A_NOTE_TYPE),
|
||||||
)
|
)
|
||||||
self.models: List[pb.NoteTypeNameIDUseCount] = []
|
self.models: List[NoteTypeNameIDUseCount] = []
|
||||||
self.setupModels()
|
self.setupModels()
|
||||||
restoreGeom(self, "models")
|
restoreGeom(self, "models")
|
||||||
self.exec_()
|
self.exec_()
|
||||||
|
@ -111,7 +109,7 @@ class Models(QDialog):
|
||||||
self.saveAndRefresh(nt)
|
self.saveAndRefresh(nt)
|
||||||
|
|
||||||
def saveAndRefresh(self, nt: NoteType) -> None:
|
def saveAndRefresh(self, nt: NoteType) -> None:
|
||||||
def save() -> Sequence[pb.NoteTypeNameIDUseCount]:
|
def save() -> Sequence[NoteTypeNameIDUseCount]:
|
||||||
self.mm.save(nt)
|
self.mm.save(nt)
|
||||||
return self.col.models.all_use_counts()
|
return self.col.models.all_use_counts()
|
||||||
|
|
||||||
|
@ -161,7 +159,7 @@ class Models(QDialog):
|
||||||
|
|
||||||
nt = self.current_notetype()
|
nt = self.current_notetype()
|
||||||
|
|
||||||
def save() -> Sequence[pb.NoteTypeNameIDUseCount]:
|
def save() -> Sequence[NoteTypeNameIDUseCount]:
|
||||||
self.mm.rem(nt)
|
self.mm.rem(nt)
|
||||||
return self.col.models.all_use_counts()
|
return self.col.models.all_use_counts()
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ import aqt.sound
|
||||||
from anki import Collection
|
from anki import Collection
|
||||||
from anki.db import DB
|
from anki.db import DB
|
||||||
from anki.lang import without_unicode_isolation
|
from anki.lang import without_unicode_isolation
|
||||||
from anki.rsbackend import SyncAuth
|
from anki.sync import SyncAuth
|
||||||
from anki.utils import intTime, isMac, isWin
|
from anki.utils import intTime, isMac, isWin
|
||||||
from aqt import appHelpSite
|
from aqt import appHelpSite
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
|
|
|
@ -9,9 +9,10 @@ from enum import Enum
|
||||||
from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Sequence, Tuple, cast
|
from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Sequence, Tuple, cast
|
||||||
|
|
||||||
import aqt
|
import aqt
|
||||||
from anki.collection import ConfigBoolKey, InvalidInput, SearchTerm
|
from anki.collection import ConfigBoolKey, SearchTerm
|
||||||
from anki.errors import DeckRenameError
|
from anki.decks import DeckTreeNode
|
||||||
from anki.rsbackend import DeckTreeNode, TagTreeNode
|
from anki.errors import DeckRenameError, InvalidInput
|
||||||
|
from anki.tags import TagTreeNode
|
||||||
from aqt import gui_hooks
|
from aqt import gui_hooks
|
||||||
from aqt.main import ResetReason
|
from aqt.main import ResetReason
|
||||||
from aqt.models import Models
|
from aqt.models import Models
|
||||||
|
@ -524,7 +525,7 @@ class SidebarTreeView(QTreeView):
|
||||||
newhead = head + node.name + "::"
|
newhead = head + node.name + "::"
|
||||||
render(item, node.children, newhead)
|
render(item, node.children, newhead)
|
||||||
|
|
||||||
tree = self.col.backend.tag_tree()
|
tree = self.col.tags.tree()
|
||||||
root = self._section_root(
|
root = self._section_root(
|
||||||
root=root,
|
root=root,
|
||||||
name=TR.BROWSING_SIDEBAR_TAGS,
|
name=TR.BROWSING_SIDEBAR_TAGS,
|
||||||
|
|
|
@ -8,18 +8,10 @@ import os
|
||||||
from typing import Callable, Tuple
|
from typing import Callable, Tuple
|
||||||
|
|
||||||
import aqt
|
import aqt
|
||||||
from anki.lang import without_unicode_isolation
|
from anki.collection import FullSyncProgress, NormalSyncProgress, ProgressKind
|
||||||
from anki.rsbackend import (
|
from anki.errors import Interrupted, SyncError
|
||||||
TR,
|
from anki.lang import TR, without_unicode_isolation
|
||||||
FullSyncProgress,
|
from anki.sync import SyncOutput, SyncStatus
|
||||||
Interrupted,
|
|
||||||
NormalSyncProgress,
|
|
||||||
ProgressKind,
|
|
||||||
SyncError,
|
|
||||||
SyncErrorKind,
|
|
||||||
SyncOutput,
|
|
||||||
SyncStatus,
|
|
||||||
)
|
|
||||||
from anki.utils import platDesc
|
from anki.utils import platDesc
|
||||||
from aqt.qt import (
|
from aqt.qt import (
|
||||||
QDialog,
|
QDialog,
|
||||||
|
@ -69,7 +61,7 @@ def get_sync_status(mw: aqt.main.AnkiQt, callback: Callable[[SyncStatus], None])
|
||||||
|
|
||||||
def handle_sync_error(mw: aqt.main.AnkiQt, err: Exception):
|
def handle_sync_error(mw: aqt.main.AnkiQt, err: Exception):
|
||||||
if isinstance(err, SyncError):
|
if isinstance(err, SyncError):
|
||||||
if err.kind() == SyncErrorKind.AUTH_FAILED:
|
if err.is_auth_error():
|
||||||
mw.pm.clear_sync_auth()
|
mw.pm.clear_sync_auth()
|
||||||
elif isinstance(err, Interrupted):
|
elif isinstance(err, Interrupted):
|
||||||
# no message to show
|
# no message to show
|
||||||
|
@ -249,7 +241,7 @@ def sync_login(
|
||||||
try:
|
try:
|
||||||
auth = fut.result()
|
auth = fut.result()
|
||||||
except SyncError as e:
|
except SyncError as e:
|
||||||
if e.kind() == SyncErrorKind.AUTH_FAILED:
|
if e.is_auth_error():
|
||||||
showWarning(str(e))
|
showWarning(str(e))
|
||||||
sync_login(mw, on_success, username, password)
|
sync_login(mw, on_success, username, password)
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -6,7 +6,7 @@ from __future__ import annotations
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import aqt
|
import aqt
|
||||||
from anki.rsbackend import SyncStatus
|
from anki.sync import SyncStatus
|
||||||
from aqt import gui_hooks
|
from aqt import gui_hooks
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.sync import get_sync_status
|
from aqt.sync import get_sync_status
|
||||||
|
|
|
@ -14,13 +14,14 @@ from markdown import markdown
|
||||||
|
|
||||||
import anki
|
import anki
|
||||||
import aqt
|
import aqt
|
||||||
from anki.rsbackend import TR, InvalidInput # pylint: disable=unused-import
|
from anki.errors import InvalidInput
|
||||||
|
from anki.lang import TR # pylint: disable=unused-import
|
||||||
from anki.utils import invalidFilename, isMac, isWin, noBundledLibs, versionWithBuild
|
from anki.utils import invalidFilename, isMac, isWin, noBundledLibs, versionWithBuild
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.theme import theme_manager
|
from aqt.theme import theme_manager
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from anki.rsbackend import TRValue
|
from anki.lang import TRValue
|
||||||
|
|
||||||
TextFormat = Union[Literal["plain", "rich"]]
|
TextFormat = Union[Literal["plain", "rich"]]
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import anki.lang
|
import anki.lang
|
||||||
from anki.rsbackend import TR
|
from anki.lang import TR
|
||||||
|
|
||||||
|
|
||||||
def test_no_collection_i18n():
|
def test_no_collection_i18n():
|
||||||
|
|
Loading…
Reference in a new issue