mirror of
https://github.com/ankitects/anki.git
synced 2025-09-22 07:52:24 -04:00
Merge branch 'main' into custom-qt-controls
This commit is contained in:
commit
123e2377de
53 changed files with 876 additions and 305 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -6,5 +6,6 @@ target
|
||||||
.dmypy.json
|
.dmypy.json
|
||||||
node_modules
|
node_modules
|
||||||
/.idea/
|
/.idea/
|
||||||
|
/.vscode/
|
||||||
/.bazel
|
/.bazel
|
||||||
/windows.bazelrc
|
/windows.bazelrc
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
# Editing/IDEs
|
# Editing/IDEs
|
||||||
|
|
||||||
Visual Studio Code is recommended, since it provides decent support for all the languages
|
Visual Studio Code is recommended, since it provides decent support for all the languages
|
||||||
Anki uses. If you open the root of this repo in VS Code, it will suggest some extensions
|
Anki uses. To set up the recommended workspace settings for VS Code, please see below.
|
||||||
for you to install.
|
|
||||||
|
|
||||||
For editing Python, PyCharm/IntelliJ's type checking/completion is a bit nicer than
|
For editing Python, PyCharm/IntelliJ's type checking/completion is a bit nicer than
|
||||||
VS Code, but VS Code has improved considerably in a short span of time.
|
VS Code, but VS Code has improved considerably in a short span of time.
|
||||||
|
@ -36,6 +35,20 @@ Code completion partly depends on files that are generated as part of the
|
||||||
regular build process, so for things to work correctly, use './run' or
|
regular build process, so for things to work correctly, use './run' or
|
||||||
'tools/build' prior to using code completion.
|
'tools/build' prior to using code completion.
|
||||||
|
|
||||||
|
## Visual Studio Code
|
||||||
|
|
||||||
|
### Setting up Recommended Workspace Settings
|
||||||
|
|
||||||
|
To start off with some default workspace settings that are optimized for Anki development, please head to the project root and then run:
|
||||||
|
|
||||||
|
```
|
||||||
|
cp -r .vscode.dist .vscode
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installing Recommended Extensions
|
||||||
|
|
||||||
|
Once the workspace settings are set up, open the root of the repo in VS Code to see and install a number of recommended extensions.
|
||||||
|
|
||||||
## PyCharm/IntelliJ
|
## PyCharm/IntelliJ
|
||||||
|
|
||||||
If you decide to use PyCharm instead of VS Code, there are somethings to be aware of.
|
If you decide to use PyCharm instead of VS Code, there are somethings to be aware of.
|
||||||
|
|
122
docs/protobuf.md
Normal file
122
docs/protobuf.md
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
# Protocol Buffers
|
||||||
|
|
||||||
|
Anki uses [different implementations of Protocol Buffers](./architecture.md#protobuf)
|
||||||
|
and each has its own pecularities. This document highlights some aspects relevant
|
||||||
|
to Anki and hopefully helps to avoid some common pitfalls.
|
||||||
|
|
||||||
|
For information about Protobuf's types and syntax, please see the official [language guide](https://developers.google.com/protocol-buffers/docs/proto3).
|
||||||
|
|
||||||
|
## General Notes
|
||||||
|
|
||||||
|
### Names
|
||||||
|
|
||||||
|
Generated code follows the naming conventions of the targeted language. So to access
|
||||||
|
the message field `foo_bar` you need to use `fooBar` in Typescript and the
|
||||||
|
namespace created by the message `FooBar` is called `foo_bar` in Rust.
|
||||||
|
|
||||||
|
### Optional Values
|
||||||
|
|
||||||
|
In Python and Typescript, unset optional values will contain the type's default
|
||||||
|
value rather than `None`, `null` or `undefined`. Here's an example:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message Foo {
|
||||||
|
optional string name = 1;
|
||||||
|
optional int32 number = 2;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
message = Foo()
|
||||||
|
assert message.number == 0
|
||||||
|
assert message name == ""
|
||||||
|
```
|
||||||
|
|
||||||
|
In Python, we can use the message's `HasField()` method to check whether a field is
|
||||||
|
actually set:
|
||||||
|
|
||||||
|
```python
|
||||||
|
message = Foo(name="")
|
||||||
|
assert message.HasField("name")
|
||||||
|
assert not message.HasField("number")
|
||||||
|
```
|
||||||
|
|
||||||
|
In Typescript, this is even less ergonomic and it can be easier to avoid using
|
||||||
|
the default values in active fields. E.g. the `CsvMetadata` message uses 1-based
|
||||||
|
indices instead of optional 0-based ones to avoid ambiguity when an index is `0`.
|
||||||
|
|
||||||
|
### Oneofs
|
||||||
|
|
||||||
|
All fields in a oneof are implicitly optional, so the caveats [above](#optional-values)
|
||||||
|
apply just as much to a message like this:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message Foo {
|
||||||
|
oneof bar {
|
||||||
|
string name = 1;
|
||||||
|
int32 number = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In addition to `HasField()`, `WhichOneof()` can be used to get the name of the set
|
||||||
|
field:
|
||||||
|
|
||||||
|
```python
|
||||||
|
message = Foo(name="")
|
||||||
|
assert message.WhichOneof("bar") == "name"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backwards Compatibility
|
||||||
|
|
||||||
|
The official [language guide](https://developers.google.com/protocol-buffers/docs/proto3)
|
||||||
|
makes a lot of notes about backwards compatibility, but as Anki usually doesn't
|
||||||
|
use Protobuf to communicate between different clients, things like shuffling around
|
||||||
|
field numbers are usually not a concern.
|
||||||
|
|
||||||
|
However, there are some messages, like `Deck`, which get stored in the database.
|
||||||
|
If these are modified in an incompatible way, this can lead to serious issues if
|
||||||
|
clients with a different protocol try to read them. Such modifications are only
|
||||||
|
safe to make as part of a schema upgrade, because schema 11 (the targeted schema
|
||||||
|
when choosing _Downgrade_), does not make use of Protobuf messages.
|
||||||
|
|
||||||
|
### Field Numbers
|
||||||
|
|
||||||
|
Field numbers larger than 15 need an additional byte to encode, so `repeated` fields
|
||||||
|
should preferrably be assigned a number between 1 and 15. If a message contains
|
||||||
|
`reserved` fields, this is usually to accomodate potential future `repeated` fields.
|
||||||
|
|
||||||
|
## Implementation-Specific Notes
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
Protobuf has an official Python implementation with an extensive [reference](https://developers.google.com/protocol-buffers/docs/reference/python-generated).
|
||||||
|
|
||||||
|
- Every message used in aqt or pylib must be added to the respective `.pylintrc`
|
||||||
|
to avoid failing type checks. The unqualified protobuf message's name must be
|
||||||
|
used, not an alias from `collection.py` for example. This should be taken into
|
||||||
|
account when choosing a message name in order to prevent skipping typechecking
|
||||||
|
a Python class of the same name.
|
||||||
|
|
||||||
|
### Typescript
|
||||||
|
|
||||||
|
Anki uses [protobuf.js](https://protobufjs.github.io/protobuf.js/), which offers
|
||||||
|
some documentation.
|
||||||
|
|
||||||
|
- If using a message `Foo` as a type, make sure not to use the generated interface
|
||||||
|
`IFoo` instead. Their definitions are very similar, but the interface requires
|
||||||
|
null checks for every field.
|
||||||
|
|
||||||
|
### Rust
|
||||||
|
|
||||||
|
Anki uses the [prost crate](https://docs.rs/prost/latest/prost/).
|
||||||
|
Its documentation has some useful hints, but for working with the generated code,
|
||||||
|
there is a better option: From within `anki/rslib` run `cargo doc --open --document-private-items`.
|
||||||
|
Inside the `pb` module you will find all generated Rust types and their implementations.
|
||||||
|
|
||||||
|
- Given an enum field `Foo foo = 1;`, `message.foo` is an `i32`. Use the accessor
|
||||||
|
`message.foo()` instead to avoid having to manually convert to a `Foo`.
|
||||||
|
- Protobuf does not guarantee any oneof field to be set or an enum field to contain
|
||||||
|
a valid variant, so the Rust code needs to deal with a lot of `Option`s. As we
|
||||||
|
don't expect other parts of Anki to send invalid messages, using an `InvalidInput`
|
||||||
|
error or `unwrap_or_default()` is usually fine.
|
|
@ -53,6 +53,7 @@ editing-text-color = Text color
|
||||||
editing-text-highlight-color = Text highlight color
|
editing-text-highlight-color = Text highlight color
|
||||||
editing-to-make-a-cloze-deletion-on = To make a cloze deletion on an existing note, you need to change it to a cloze type first, via 'Notes>Change Note Type'
|
editing-to-make-a-cloze-deletion-on = To make a cloze deletion on an existing note, you need to change it to a cloze type first, via 'Notes>Change Note Type'
|
||||||
editing-toggle-html-editor = Toggle HTML Editor
|
editing-toggle-html-editor = Toggle HTML Editor
|
||||||
|
editing-toggle-visual-editor = Toggle Visual Editor
|
||||||
editing-toggle-sticky = Toggle sticky
|
editing-toggle-sticky = Toggle sticky
|
||||||
editing-expand-field = Expand field
|
editing-expand-field = Expand field
|
||||||
editing-collapse-field = Collapse field
|
editing-collapse-field = Collapse field
|
||||||
|
|
|
@ -10,6 +10,7 @@ fields-font = Font:
|
||||||
fields-new-position-1 = New position (1...{ $val }):
|
fields-new-position-1 = New position (1...{ $val }):
|
||||||
fields-notes-require-at-least-one-field = Notes require at least one field.
|
fields-notes-require-at-least-one-field = Notes require at least one field.
|
||||||
fields-reverse-text-direction-rtl = Reverse text direction (RTL)
|
fields-reverse-text-direction-rtl = Reverse text direction (RTL)
|
||||||
|
fields-collapse-by-default = Collapse by default
|
||||||
fields-html-by-default = Use HTML editor by default
|
fields-html-by-default = Use HTML editor by default
|
||||||
fields-size = Size:
|
fields-size = Size:
|
||||||
fields-sort-by-this-field-in-the = Sort by this field in the browser
|
fields-sort-by-this-field-in-the = Sort by this field in the browser
|
||||||
|
|
|
@ -44,6 +44,7 @@ message Card {
|
||||||
int64 original_deck_id = 16;
|
int64 original_deck_id = 16;
|
||||||
uint32 flags = 17;
|
uint32 flags = 17;
|
||||||
optional uint32 original_position = 18;
|
optional uint32 original_position = 18;
|
||||||
|
string custom_data = 19;
|
||||||
}
|
}
|
||||||
|
|
||||||
message UpdateCardsRequest {
|
message UpdateCardsRequest {
|
||||||
|
|
|
@ -73,6 +73,7 @@ message Notetype {
|
||||||
uint32 font_size = 4;
|
uint32 font_size = 4;
|
||||||
string description = 5;
|
string description = 5;
|
||||||
bool plain_text = 6;
|
bool plain_text = 6;
|
||||||
|
bool collapsed = 7;
|
||||||
|
|
||||||
bytes other = 255;
|
bytes other = 255;
|
||||||
}
|
}
|
||||||
|
|
|
@ -240,6 +240,7 @@ message CardAnswer {
|
||||||
Rating rating = 4;
|
Rating rating = 4;
|
||||||
int64 answered_at_millis = 5;
|
int64 answered_at_millis = 5;
|
||||||
uint32 milliseconds_taken = 6;
|
uint32 milliseconds_taken = 6;
|
||||||
|
string custom_data = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CustomStudyRequest {
|
message CustomStudyRequest {
|
||||||
|
@ -303,3 +304,9 @@ message RepositionDefaultsResponse {
|
||||||
bool random = 1;
|
bool random = 1;
|
||||||
bool shift = 2;
|
bool shift = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Data required to support the v3 scheduler's custom scheduling feature
|
||||||
|
message CustomScheduling {
|
||||||
|
NextCardStates states = 1;
|
||||||
|
string custom_data = 2;
|
||||||
|
}
|
||||||
|
|
|
@ -92,6 +92,7 @@ class Card(DeprecatedNamesMixin):
|
||||||
self.original_position = (
|
self.original_position = (
|
||||||
card.original_position if card.HasField("original_position") else None
|
card.original_position if card.HasField("original_position") else None
|
||||||
)
|
)
|
||||||
|
self.custom_data = card.custom_data
|
||||||
|
|
||||||
def _to_backend_card(self) -> cards_pb2.Card:
|
def _to_backend_card(self) -> cards_pb2.Card:
|
||||||
# mtime & usn are set by backend
|
# mtime & usn are set by backend
|
||||||
|
@ -111,9 +112,8 @@ class Card(DeprecatedNamesMixin):
|
||||||
original_due=self.odue,
|
original_due=self.odue,
|
||||||
original_deck_id=self.odid,
|
original_deck_id=self.odid,
|
||||||
flags=self.flags,
|
flags=self.flags,
|
||||||
original_position=self.original_position
|
original_position=self.original_position,
|
||||||
if self.original_position is not None
|
custom_data=self.custom_data,
|
||||||
else None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def flush(self) -> None:
|
def flush(self) -> None:
|
||||||
|
|
|
@ -10,7 +10,22 @@ if TYPE_CHECKING:
|
||||||
import anki.collection
|
import anki.collection
|
||||||
|
|
||||||
|
|
||||||
class LocalizedError(Exception):
|
class AnkiException(Exception):
|
||||||
|
"""
|
||||||
|
General Anki exception that all custom exceptions raised by Anki should
|
||||||
|
inherit from. Allows add-ons to easily identify Anki-native exceptions.
|
||||||
|
|
||||||
|
When inheriting from a Python built-in exception other than `Exception`,
|
||||||
|
please supply `AnkiException` as an additional inheritance:
|
||||||
|
|
||||||
|
```
|
||||||
|
class MyNewAnkiException(ValueError, AnkiException):
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class LocalizedError(AnkiException):
|
||||||
"An error with a localized description."
|
"An error with a localized description."
|
||||||
|
|
||||||
def __init__(self, localized: str) -> None:
|
def __init__(self, localized: str) -> None:
|
||||||
|
@ -29,7 +44,7 @@ class DocumentedError(LocalizedError):
|
||||||
super().__init__(localized)
|
super().__init__(localized)
|
||||||
|
|
||||||
|
|
||||||
class Interrupted(Exception):
|
class Interrupted(AnkiException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@ -68,7 +83,7 @@ class TemplateError(LocalizedError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class NotFoundError(Exception):
|
class NotFoundError(AnkiException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@ -76,11 +91,11 @@ class DeletedError(LocalizedError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ExistsError(Exception):
|
class ExistsError(AnkiException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class UndoEmpty(Exception):
|
class UndoEmpty(AnkiException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@ -96,7 +111,7 @@ class SearchError(LocalizedError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AbortSchemaModification(Exception):
|
class AbortSchemaModification(AnkiException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,18 @@ from anki.utils import call, is_mac, namedtmp, tmpdir
|
||||||
|
|
||||||
pngCommands = [
|
pngCommands = [
|
||||||
["latex", "-interaction=nonstopmode", "tmp.tex"],
|
["latex", "-interaction=nonstopmode", "tmp.tex"],
|
||||||
["dvipng", "-D", "200", "-T", "tight", "tmp.dvi", "-o", "tmp.png"],
|
[
|
||||||
|
"dvipng",
|
||||||
|
"-bg",
|
||||||
|
"Transparent",
|
||||||
|
"-D",
|
||||||
|
"200",
|
||||||
|
"-T",
|
||||||
|
"tight",
|
||||||
|
"tmp.dvi",
|
||||||
|
"-o",
|
||||||
|
"tmp.png",
|
||||||
|
],
|
||||||
]
|
]
|
||||||
|
|
||||||
svgCommands = [
|
svgCommands = [
|
||||||
|
|
|
@ -31,6 +31,7 @@ QueuedCards = scheduler_pb2.QueuedCards
|
||||||
SchedulingState = scheduler_pb2.SchedulingState
|
SchedulingState = scheduler_pb2.SchedulingState
|
||||||
NextStates = scheduler_pb2.NextCardStates
|
NextStates = scheduler_pb2.NextCardStates
|
||||||
CardAnswer = scheduler_pb2.CardAnswer
|
CardAnswer = scheduler_pb2.CardAnswer
|
||||||
|
CustomScheduling = scheduler_pb2.CustomScheduling
|
||||||
|
|
||||||
|
|
||||||
class Scheduler(SchedulerBaseWithLegacy):
|
class Scheduler(SchedulerBaseWithLegacy):
|
||||||
|
@ -61,7 +62,12 @@ class Scheduler(SchedulerBaseWithLegacy):
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
|
||||||
def build_answer(
|
def build_answer(
|
||||||
self, *, card: Card, states: NextStates, rating: CardAnswer.Rating.V
|
self,
|
||||||
|
*,
|
||||||
|
card: Card,
|
||||||
|
states: NextStates,
|
||||||
|
custom_data: str,
|
||||||
|
rating: CardAnswer.Rating.V,
|
||||||
) -> CardAnswer:
|
) -> CardAnswer:
|
||||||
"Build input for answer_card()."
|
"Build input for answer_card()."
|
||||||
if rating == CardAnswer.AGAIN:
|
if rating == CardAnswer.AGAIN:
|
||||||
|
@ -79,6 +85,7 @@ class Scheduler(SchedulerBaseWithLegacy):
|
||||||
card_id=card.id,
|
card_id=card.id,
|
||||||
current_state=states.current,
|
current_state=states.current,
|
||||||
new_state=new_state,
|
new_state=new_state,
|
||||||
|
custom_data=custom_data,
|
||||||
rating=rating,
|
rating=rating,
|
||||||
answered_at_millis=int_time(1000),
|
answered_at_millis=int_time(1000),
|
||||||
milliseconds_taken=card.time_taken(capped=False),
|
milliseconds_taken=card.time_taken(capped=False),
|
||||||
|
@ -163,7 +170,9 @@ class Scheduler(SchedulerBaseWithLegacy):
|
||||||
|
|
||||||
states = self.col._backend.get_next_card_states(card.id)
|
states = self.col._backend.get_next_card_states(card.id)
|
||||||
changes = self.answer_card(
|
changes = self.answer_card(
|
||||||
self.build_answer(card=card, states=states, rating=rating)
|
self.build_answer(
|
||||||
|
card=card, states=states, custom_data=card.custom_data, rating=rating
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# tests assume card will be mutated, so we need to reload it
|
# tests assume card will be mutated, so we need to reload it
|
||||||
|
|
|
@ -18,8 +18,8 @@ pyqt6-sip==13.4.0 \
|
||||||
--hash=sha256:2694ae67811cefb6ea3ee0e9995755b45e4952f4dcadec8c04300fd828f91c75 \
|
--hash=sha256:2694ae67811cefb6ea3ee0e9995755b45e4952f4dcadec8c04300fd828f91c75 \
|
||||||
--hash=sha256:3486914137f5336cff6e10a5e9d52c1e60ff883473938b45f267f794daeacb2f \
|
--hash=sha256:3486914137f5336cff6e10a5e9d52c1e60ff883473938b45f267f794daeacb2f \
|
||||||
--hash=sha256:3ac7e0800180202dcc0c7035ff88c2a6f4a0f5acb20c4a19f71d807d0f7857b7 \
|
--hash=sha256:3ac7e0800180202dcc0c7035ff88c2a6f4a0f5acb20c4a19f71d807d0f7857b7 \
|
||||||
|
--hash=sha256:3de18c4a32f717a351d560a39f528af24077f5135aacfa8890a2f2d79f0633da \
|
||||||
--hash=sha256:6d87a3ee5872d7511b76957d68a32109352caf3b7a42a01d9ee20032b350d979 \
|
--hash=sha256:6d87a3ee5872d7511b76957d68a32109352caf3b7a42a01d9ee20032b350d979 \
|
||||||
--hash=sha256:7b9bbb5fb880440a3a8e7fa3dff70473aa1128aaf7dc9fb6e30512eed4fd38f6 \
|
|
||||||
--hash=sha256:802b0cfed19900183220c46895c2635f0dd062f2d275a25506423f911ef74db4 \
|
--hash=sha256:802b0cfed19900183220c46895c2635f0dd062f2d275a25506423f911ef74db4 \
|
||||||
--hash=sha256:83b446d247a92d119d507dbc94fc1f47389d8118a5b6232a2859951157319a30 \
|
--hash=sha256:83b446d247a92d119d507dbc94fc1f47389d8118a5b6232a2859951157319a30 \
|
||||||
--hash=sha256:9c5231536e6153071b22175e46e368045fd08d772a90d772a0977d1166c7822c \
|
--hash=sha256:9c5231536e6153071b22175e46e368045fd08d772a90d772a0977d1166c7822c \
|
||||||
|
|
|
@ -66,6 +66,7 @@ except AttributeError:
|
||||||
|
|
||||||
appVersion = _version
|
appVersion = _version
|
||||||
appWebsite = "https://apps.ankiweb.net/"
|
appWebsite = "https://apps.ankiweb.net/"
|
||||||
|
appWebsiteDownloadSection = "https://apps.ankiweb.net/#download"
|
||||||
appDonate = "https://apps.ankiweb.net/support/"
|
appDonate = "https://apps.ankiweb.net/support/"
|
||||||
appShared = "https://ankiweb.net/shared/"
|
appShared = "https://ankiweb.net/shared/"
|
||||||
appUpdate = "https://ankiweb.net/update/desktop"
|
appUpdate = "https://ankiweb.net/update/desktop"
|
||||||
|
|
|
@ -21,6 +21,7 @@ from aqt.qt import *
|
||||||
from aqt.sound import av_player
|
from aqt.sound import av_player
|
||||||
from aqt.utils import (
|
from aqt.utils import (
|
||||||
HelpPage,
|
HelpPage,
|
||||||
|
add_close_shortcut,
|
||||||
askUser,
|
askUser,
|
||||||
downArrow,
|
downArrow,
|
||||||
openHelp,
|
openHelp,
|
||||||
|
@ -48,6 +49,7 @@ class AddCards(QMainWindow):
|
||||||
self.setup_choosers()
|
self.setup_choosers()
|
||||||
self.setupEditor()
|
self.setupEditor()
|
||||||
self.setupButtons()
|
self.setupButtons()
|
||||||
|
add_close_shortcut(self)
|
||||||
self._load_new_note()
|
self._load_new_note()
|
||||||
self.history: list[NoteId] = []
|
self.history: list[NoteId] = []
|
||||||
self._last_added_note: Optional[Note] = None
|
self._last_added_note: Optional[Note] = None
|
||||||
|
|
|
@ -501,6 +501,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
|
||||||
]
|
]
|
||||||
|
|
||||||
flds = self.note.note_type()["flds"]
|
flds = self.note.note_type()["flds"]
|
||||||
|
collapsed = [fld["collapsed"] for fld in flds]
|
||||||
plain_texts = [fld.get("plainText", False) for fld in flds]
|
plain_texts = [fld.get("plainText", False) for fld in flds]
|
||||||
descriptions = [fld.get("description", "") for fld in flds]
|
descriptions = [fld.get("description", "") for fld in flds]
|
||||||
|
|
||||||
|
@ -524,6 +525,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
|
||||||
|
|
||||||
js = """
|
js = """
|
||||||
setFields({});
|
setFields({});
|
||||||
|
setCollapsed({});
|
||||||
setPlainTexts({});
|
setPlainTexts({});
|
||||||
setDescriptions({});
|
setDescriptions({});
|
||||||
setFonts({});
|
setFonts({});
|
||||||
|
@ -534,6 +536,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
|
||||||
setMathjaxEnabled({});
|
setMathjaxEnabled({});
|
||||||
""".format(
|
""".format(
|
||||||
json.dumps(data),
|
json.dumps(data),
|
||||||
|
json.dumps(collapsed),
|
||||||
json.dumps(plain_texts),
|
json.dumps(plain_texts),
|
||||||
json.dumps(descriptions),
|
json.dumps(descriptions),
|
||||||
json.dumps(self.fonts()),
|
json.dumps(self.fonts()),
|
||||||
|
|
|
@ -244,6 +244,7 @@ class FieldDialog(QDialog):
|
||||||
f.sortField.setChecked(self.model["sortf"] == fld["ord"])
|
f.sortField.setChecked(self.model["sortf"] == fld["ord"])
|
||||||
f.rtl.setChecked(fld["rtl"])
|
f.rtl.setChecked(fld["rtl"])
|
||||||
f.plainTextByDefault.setChecked(fld["plainText"])
|
f.plainTextByDefault.setChecked(fld["plainText"])
|
||||||
|
f.collapseByDefault.setChecked(fld["collapsed"])
|
||||||
f.fieldDescription.setText(fld.get("description", ""))
|
f.fieldDescription.setText(fld.get("description", ""))
|
||||||
|
|
||||||
def saveField(self) -> None:
|
def saveField(self) -> None:
|
||||||
|
@ -269,6 +270,9 @@ class FieldDialog(QDialog):
|
||||||
if fld["plainText"] != plain_text:
|
if fld["plainText"] != plain_text:
|
||||||
fld["plainText"] = plain_text
|
fld["plainText"] = plain_text
|
||||||
self.change_tracker.mark_basic()
|
self.change_tracker.mark_basic()
|
||||||
|
collapsed = f.collapseByDefault.isChecked()
|
||||||
|
if fld["collapsed"] != collapsed:
|
||||||
|
fld["collapsed"] = collapsed
|
||||||
desc = f.fieldDescription.text()
|
desc = f.fieldDescription.text()
|
||||||
if fld.get("description", "") != desc:
|
if fld.get("description", "") != desc:
|
||||||
fld["description"] = desc
|
fld["description"] = desc
|
||||||
|
|
|
@ -6,8 +6,8 @@
|
||||||
<rect>
|
<rect>
|
||||||
<x>0</x>
|
<x>0</x>
|
||||||
<y>0</y>
|
<y>0</y>
|
||||||
<width>598</width>
|
<width>567</width>
|
||||||
<height>378</height>
|
<height>438</height>
|
||||||
</rect>
|
</rect>
|
||||||
</property>
|
</property>
|
||||||
<property name="windowTitle">
|
<property name="windowTitle">
|
||||||
|
@ -84,44 +84,6 @@
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<layout class="QGridLayout" name="_2">
|
<layout class="QGridLayout" name="_2">
|
||||||
<item row="3" column="1">
|
|
||||||
<widget class="QCheckBox" name="rtl">
|
|
||||||
<property name="text">
|
|
||||||
<string>fields_reverse_text_direction_rtl</string>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item row="1" column="1">
|
|
||||||
<widget class="QFontComboBox" name="fontFamily">
|
|
||||||
<property name="minimumSize">
|
|
||||||
<size>
|
|
||||||
<width>0</width>
|
|
||||||
<height>25</height>
|
|
||||||
</size>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item row="1" column="0">
|
|
||||||
<widget class="QLabel" name="label_font">
|
|
||||||
<property name="text">
|
|
||||||
<string>fields_editing_font</string>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item row="0" column="1" colspan="2">
|
|
||||||
<widget class="QLineEdit" name="fieldDescription">
|
|
||||||
<property name="placeholderText">
|
|
||||||
<string>fields_description_placeholder</string>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item row="2" column="0">
|
|
||||||
<widget class="QLabel" name="label_sort">
|
|
||||||
<property name="text">
|
|
||||||
<string>actions_options</string>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item row="0" column="0">
|
<item row="0" column="0">
|
||||||
<widget class="QLabel" name="label_description">
|
<widget class="QLabel" name="label_description">
|
||||||
<property name="sizePolicy">
|
<property name="sizePolicy">
|
||||||
|
@ -135,6 +97,13 @@
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
<item row="1" column="0">
|
||||||
|
<widget class="QLabel" name="label_font">
|
||||||
|
<property name="text">
|
||||||
|
<string>fields_editing_font</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
<item row="1" column="2">
|
<item row="1" column="2">
|
||||||
<widget class="QSpinBox" name="fontSize">
|
<widget class="QSpinBox" name="fontSize">
|
||||||
<property name="minimum">
|
<property name="minimum">
|
||||||
|
@ -145,10 +114,27 @@
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item row="2" column="1">
|
<item row="2" column="0">
|
||||||
<widget class="QRadioButton" name="sortField">
|
<widget class="QLabel" name="label_sort">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>fields_sort_by_this_field_in_the</string>
|
<string>actions_options</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="1">
|
||||||
|
<widget class="QFontComboBox" name="fontFamily">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>0</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="3" column="1">
|
||||||
|
<widget class="QCheckBox" name="rtl">
|
||||||
|
<property name="text">
|
||||||
|
<string>fields_reverse_text_direction_rtl</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
@ -162,6 +148,30 @@
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
<item row="0" column="1" colspan="2">
|
||||||
|
<widget class="QLineEdit" name="fieldDescription">
|
||||||
|
<property name="placeholderText">
|
||||||
|
<string>fields_description_placeholder</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="2" column="1">
|
||||||
|
<widget class="QRadioButton" name="sortField">
|
||||||
|
<property name="text">
|
||||||
|
<string>fields_sort_by_this_field_in_the</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="5" column="1">
|
||||||
|
<widget class="QCheckBox" name="collapseByDefault">
|
||||||
|
<property name="enabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>fields_collapse_by_default</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
</layout>
|
</layout>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
|
|
|
@ -27,7 +27,7 @@ from anki import hooks
|
||||||
from anki._vendor import stringcase
|
from anki._vendor import stringcase
|
||||||
from anki.collection import OpChanges
|
from anki.collection import OpChanges
|
||||||
from anki.decks import DeckConfigsForUpdate, UpdateDeckConfigs
|
from anki.decks import DeckConfigsForUpdate, UpdateDeckConfigs
|
||||||
from anki.scheduler.v3 import NextStates
|
from anki.scheduler.v3 import CustomScheduling
|
||||||
from anki.utils import dev_mode
|
from anki.utils import dev_mode
|
||||||
from aqt.changenotetype import ChangeNotetypeDialog
|
from aqt.changenotetype import ChangeNotetypeDialog
|
||||||
from aqt.deckoptions import DeckOptionsDialog
|
from aqt.deckoptions import DeckOptionsDialog
|
||||||
|
@ -412,18 +412,18 @@ def update_deck_configs() -> bytes:
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
|
|
||||||
def next_card_states() -> bytes:
|
def get_custom_scheduling() -> bytes:
|
||||||
if states := aqt.mw.reviewer.get_next_states():
|
if scheduling := aqt.mw.reviewer.get_custom_scheduling():
|
||||||
return states.SerializeToString()
|
return scheduling.SerializeToString()
|
||||||
else:
|
else:
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
|
|
||||||
def set_next_card_states() -> bytes:
|
def set_custom_scheduling() -> bytes:
|
||||||
key = request.headers.get("key", "")
|
key = request.headers.get("key", "")
|
||||||
input = NextStates()
|
input = CustomScheduling()
|
||||||
input.ParseFromString(request.data)
|
input.ParseFromString(request.data)
|
||||||
aqt.mw.reviewer.set_next_states(key, input)
|
aqt.mw.reviewer.set_custom_scheduling(key, input)
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
@ -455,8 +455,8 @@ post_handler_list = [
|
||||||
congrats_info,
|
congrats_info,
|
||||||
get_deck_configs_for_update,
|
get_deck_configs_for_update,
|
||||||
update_deck_configs,
|
update_deck_configs,
|
||||||
next_card_states,
|
get_custom_scheduling,
|
||||||
set_next_card_states,
|
set_custom_scheduling,
|
||||||
change_notetype,
|
change_notetype,
|
||||||
import_csv,
|
import_csv,
|
||||||
]
|
]
|
||||||
|
|
|
@ -17,7 +17,7 @@ from anki import hooks
|
||||||
from anki.cards import Card, CardId
|
from anki.cards import Card, CardId
|
||||||
from anki.collection import Config, OpChanges, OpChangesWithCount
|
from anki.collection import Config, OpChanges, OpChangesWithCount
|
||||||
from anki.scheduler.base import ScheduleCardsAsNew
|
from anki.scheduler.base import ScheduleCardsAsNew
|
||||||
from anki.scheduler.v3 import CardAnswer, NextStates, QueuedCards
|
from anki.scheduler.v3 import CardAnswer, CustomScheduling, NextStates, QueuedCards
|
||||||
from anki.scheduler.v3 import Scheduler as V3Scheduler
|
from anki.scheduler.v3 import Scheduler as V3Scheduler
|
||||||
from anki.tags import MARKED_TAG
|
from anki.tags import MARKED_TAG
|
||||||
from anki.types import assert_exhaustive
|
from anki.types import assert_exhaustive
|
||||||
|
@ -82,11 +82,14 @@ class V3CardInfo:
|
||||||
|
|
||||||
queued_cards: QueuedCards
|
queued_cards: QueuedCards
|
||||||
next_states: NextStates
|
next_states: NextStates
|
||||||
|
custom_data: str
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_queue(queued_cards: QueuedCards) -> V3CardInfo:
|
def from_queue(queued_cards: QueuedCards) -> V3CardInfo:
|
||||||
return V3CardInfo(
|
return V3CardInfo(
|
||||||
queued_cards=queued_cards, next_states=queued_cards.cards[0].next_states
|
queued_cards=queued_cards,
|
||||||
|
next_states=queued_cards.cards[0].next_states,
|
||||||
|
custom_data=queued_cards.cards[0].card.custom_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
def top_card(self) -> QueuedCards.QueuedCard:
|
def top_card(self) -> QueuedCards.QueuedCard:
|
||||||
|
@ -230,8 +233,7 @@ class Reviewer:
|
||||||
self.mw.moveToState("overview")
|
self.mw.moveToState("overview")
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._reps is None or self._reps % 100 == 0:
|
if self._reps is None:
|
||||||
# we recycle the webview periodically so webkit can free memory
|
|
||||||
self._initWeb()
|
self._initWeb()
|
||||||
|
|
||||||
self._showQuestion()
|
self._showQuestion()
|
||||||
|
@ -260,23 +262,24 @@ class Reviewer:
|
||||||
self.card = Card(self.mw.col, backend_card=self._v3.top_card().card)
|
self.card = Card(self.mw.col, backend_card=self._v3.top_card().card)
|
||||||
self.card.start_timer()
|
self.card.start_timer()
|
||||||
|
|
||||||
def get_next_states(self) -> NextStates | None:
|
def get_custom_scheduling(self) -> CustomScheduling | None:
|
||||||
if v3 := self._v3:
|
if v3 := self._v3:
|
||||||
return v3.next_states
|
return CustomScheduling(states=v3.next_states, custom_data=v3.custom_data)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def set_next_states(self, key: str, states: NextStates) -> None:
|
def set_custom_scheduling(self, key: str, scheduling: CustomScheduling) -> None:
|
||||||
if key != self._state_mutation_key:
|
if key != self._state_mutation_key:
|
||||||
return
|
return
|
||||||
|
|
||||||
if v3 := self._v3:
|
if v3 := self._v3:
|
||||||
v3.next_states = states
|
v3.next_states = scheduling.states
|
||||||
|
v3.custom_data = scheduling.custom_data
|
||||||
|
|
||||||
def _run_state_mutation_hook(self) -> None:
|
def _run_state_mutation_hook(self) -> None:
|
||||||
if self._v3 and (js := self._state_mutation_js):
|
if self._v3 and (js := self._state_mutation_js):
|
||||||
self.web.eval(
|
self.web.eval(
|
||||||
f"anki.mutateNextCardStates('{self._state_mutation_key}', (states) => {{ {js} }})"
|
f"anki.mutateNextCardStates('{self._state_mutation_key}', (states, customData) => {{ {js} }})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Audio
|
# Audio
|
||||||
|
@ -432,7 +435,10 @@ class Reviewer:
|
||||||
|
|
||||||
if (v3 := self._v3) and (sched := cast(V3Scheduler, self.mw.col.sched)):
|
if (v3 := self._v3) and (sched := cast(V3Scheduler, self.mw.col.sched)):
|
||||||
answer = sched.build_answer(
|
answer = sched.build_answer(
|
||||||
card=self.card, states=v3.next_states, rating=v3.rating_from_ease(ease)
|
card=self.card,
|
||||||
|
states=v3.next_states,
|
||||||
|
custom_data=v3.custom_data,
|
||||||
|
rating=v3.rating_from_ease(ease),
|
||||||
)
|
)
|
||||||
|
|
||||||
def after_answer(changes: OpChanges) -> None:
|
def after_answer(changes: OpChanges) -> None:
|
||||||
|
|
|
@ -95,6 +95,8 @@ def sync_collection(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
|
||||||
|
|
||||||
def on_future_done(fut: Future) -> None:
|
def on_future_done(fut: Future) -> None:
|
||||||
mw.col.db.begin()
|
mw.col.db.begin()
|
||||||
|
# scheduler version may have changed
|
||||||
|
mw.col._load_scheduler()
|
||||||
timer.stop()
|
timer.stop()
|
||||||
try:
|
try:
|
||||||
out: SyncOutput = fut.result()
|
out: SyncOutput = fut.result()
|
||||||
|
|
|
@ -144,8 +144,8 @@ class Toolbar:
|
||||||
self.link_handlers[label] = self._syncLinkHandler
|
self.link_handlers[label] = self._syncLinkHandler
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
<a class=hitem tabindex="-1" aria-label="{name}" title="{title}" id="{label}" href=# onclick="return pycmd('{label}')">{name}
|
<a class=hitem tabindex="-1" aria-label="{name}" title="{title}" id="{label}" href=# onclick="return pycmd('{label}')"
|
||||||
<img id=sync-spinner src='/_anki/imgs/refresh.svg'>
|
>{name}<img id=sync-spinner src='/_anki/imgs/refresh.svg'>
|
||||||
</a>"""
|
</a>"""
|
||||||
|
|
||||||
def set_sync_active(self, active: bool) -> None:
|
def set_sync_active(self, active: bool) -> None:
|
||||||
|
|
|
@ -70,7 +70,7 @@ def askAndUpdate(mw: aqt.AnkiQt, ver: str) -> None:
|
||||||
# ignore this update
|
# ignore this update
|
||||||
mw.pm.meta["suppressUpdate"] = ver
|
mw.pm.meta["suppressUpdate"] = ver
|
||||||
elif ret == QMessageBox.StandardButton.Yes:
|
elif ret == QMessageBox.StandardButton.Yes:
|
||||||
openLink(aqt.appWebsite)
|
openLink(aqt.appWebsiteDownloadSection)
|
||||||
|
|
||||||
|
|
||||||
def showMessages(mw: aqt.AnkiQt, data: dict) -> None:
|
def showMessages(mw: aqt.AnkiQt, data: dict) -> None:
|
||||||
|
|
|
@ -848,6 +848,13 @@ def addCloseShortcut(widg: QDialog) -> None:
|
||||||
setattr(widg, "_closeShortcut", shortcut)
|
setattr(widg, "_closeShortcut", shortcut)
|
||||||
|
|
||||||
|
|
||||||
|
def add_close_shortcut(widg: QWidget) -> None:
|
||||||
|
if not is_mac:
|
||||||
|
return
|
||||||
|
shortcut = QShortcut(QKeySequence("Ctrl+W"), widg)
|
||||||
|
qconnect(shortcut.activated, widg.close)
|
||||||
|
|
||||||
|
|
||||||
def downArrow() -> str:
|
def downArrow() -> str:
|
||||||
if is_win:
|
if is_win:
|
||||||
return "▼"
|
return "▼"
|
||||||
|
|
|
@ -115,12 +115,12 @@ def register_repos():
|
||||||
################
|
################
|
||||||
|
|
||||||
core_i18n_repo = "anki-core-i18n"
|
core_i18n_repo = "anki-core-i18n"
|
||||||
core_i18n_commit = "86d2edc647c030827a69790225efde23e720bccf"
|
core_i18n_commit = "d8673a9e101ca90381d5822bd51766239ee52cc9"
|
||||||
core_i18n_zip_csum = "d9f51d4baea7ee79a05e35852eefece29646397f8e89608382794b00eac8aab7"
|
core_i18n_zip_csum = "54b33e668d867bfc26b541cc2ca014441ae82e1e12865028fa6ad19192d52453"
|
||||||
|
|
||||||
qtftl_i18n_repo = "anki-desktop-ftl"
|
qtftl_i18n_repo = "anki-desktop-ftl"
|
||||||
qtftl_i18n_commit = "fbe70aa7b3a0ce8ffb7d34f7392e00f71af541bf"
|
qtftl_i18n_commit = "77881685cf615888c8ce0fe8aed6f3ae665bcfc5"
|
||||||
qtftl_i18n_zip_csum = "a1a01f6822ee048fa7c570a27410c44c8a5cca76cba97d4bbff84ed6cfbf75a6"
|
qtftl_i18n_zip_csum = "c5d48ea05038009d390351c63ecd7b67fe7177b8a66db6087c3b8a8bcc28a85c"
|
||||||
|
|
||||||
i18n_build_content = """
|
i18n_build_content = """
|
||||||
filegroup(
|
filegroup(
|
||||||
|
|
|
@ -26,6 +26,9 @@ impl CardsService for Backend {
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(TryInto::try_into)
|
.map(TryInto::try_into)
|
||||||
.collect::<Result<Vec<Card>, AnkiError>>()?;
|
.collect::<Result<Vec<Card>, AnkiError>>()?;
|
||||||
|
for card in &cards {
|
||||||
|
card.validate_custom_data()?;
|
||||||
|
}
|
||||||
col.update_cards_maybe_undoable(cards, !input.skip_undo_entry)
|
col.update_cards_maybe_undoable(cards, !input.skip_undo_entry)
|
||||||
})
|
})
|
||||||
.map(Into::into)
|
.map(Into::into)
|
||||||
|
@ -87,6 +90,7 @@ impl TryFrom<pb::Card> for Card {
|
||||||
original_deck_id: DeckId(c.original_deck_id),
|
original_deck_id: DeckId(c.original_deck_id),
|
||||||
flags: c.flags as u8,
|
flags: c.flags as u8,
|
||||||
original_position: c.original_position,
|
original_position: c.original_position,
|
||||||
|
custom_data: c.custom_data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -112,6 +116,7 @@ impl From<Card> for pb::Card {
|
||||||
original_deck_id: c.original_deck_id.0,
|
original_deck_id: c.original_deck_id.0,
|
||||||
flags: c.flags as u32,
|
flags: c.flags as u32,
|
||||||
original_position: c.original_position.map(Into::into),
|
original_position: c.original_position.map(Into::into),
|
||||||
|
custom_data: c.custom_data,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@ impl From<pb::CardAnswer> for CardAnswer {
|
||||||
new_state: answer.new_state.unwrap_or_default().into(),
|
new_state: answer.new_state.unwrap_or_default().into(),
|
||||||
answered_at: TimestampMillis(answer.answered_at_millis),
|
answered_at: TimestampMillis(answer.answered_at_millis),
|
||||||
milliseconds_taken: answer.milliseconds_taken,
|
milliseconds_taken: answer.milliseconds_taken,
|
||||||
|
custom_data: answer.custom_data,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -79,6 +79,8 @@ pub struct Card {
|
||||||
pub(crate) flags: u8,
|
pub(crate) flags: u8,
|
||||||
/// The position in the new queue before leaving it.
|
/// The position in the new queue before leaving it.
|
||||||
pub(crate) original_position: Option<u32>,
|
pub(crate) original_position: Option<u32>,
|
||||||
|
/// JSON object or empty; exposed through the reviewer for persisting custom state
|
||||||
|
pub(crate) custom_data: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Card {
|
impl Default for Card {
|
||||||
|
@ -102,6 +104,7 @@ impl Default for Card {
|
||||||
original_deck_id: DeckId(0),
|
original_deck_id: DeckId(0),
|
||||||
flags: 0,
|
flags: 0,
|
||||||
original_position: None,
|
original_position: None,
|
||||||
|
custom_data: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,7 +56,7 @@ impl CsvMetadata {
|
||||||
.ok_or_else(|| AnkiError::invalid_input("notetype oneof not set"))
|
.ok_or_else(|| AnkiError::invalid_input("notetype oneof not set"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn field_source_columns(&self) -> Result<Vec<Option<usize>>> {
|
fn field_source_columns(&self) -> Result<FieldSourceColumns> {
|
||||||
Ok(match self.notetype()? {
|
Ok(match self.notetype()? {
|
||||||
CsvNotetype::GlobalNotetype(global) => global
|
CsvNotetype::GlobalNotetype(global) => global
|
||||||
.field_columns
|
.field_columns
|
||||||
|
@ -115,8 +115,7 @@ struct ColumnContext {
|
||||||
guid_column: Option<usize>,
|
guid_column: Option<usize>,
|
||||||
deck_column: Option<usize>,
|
deck_column: Option<usize>,
|
||||||
notetype_column: Option<usize>,
|
notetype_column: Option<usize>,
|
||||||
/// Source column indices for the fields of a notetype, identified by its
|
/// Source column indices for the fields of a notetype
|
||||||
/// name or id as string. The empty string corresponds to the default notetype.
|
|
||||||
field_source_columns: FieldSourceColumns,
|
field_source_columns: FieldSourceColumns,
|
||||||
/// How fields are converted to strings. Used for escaping HTML if appropriate.
|
/// How fields are converted to strings. Used for escaping HTML if appropriate.
|
||||||
stringify: fn(&str) -> String,
|
stringify: fn(&str) -> String,
|
||||||
|
@ -168,22 +167,20 @@ impl ColumnContext {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn gather_tags(&self, record: &csv::StringRecord) -> Vec<String> {
|
fn gather_tags(&self, record: &csv::StringRecord) -> Option<Vec<String>> {
|
||||||
self.tags_column
|
self.tags_column.and_then(|i| record.get(i - 1)).map(|s| {
|
||||||
.and_then(|i| record.get(i - 1))
|
s.split_whitespace()
|
||||||
.unwrap_or_default()
|
.filter(|s| !s.is_empty())
|
||||||
.split_whitespace()
|
.map(ToString::to_string)
|
||||||
.filter(|s| !s.is_empty())
|
.collect()
|
||||||
.map(ToString::to_string)
|
})
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn gather_note_fields(&self, record: &csv::StringRecord) -> Vec<String> {
|
fn gather_note_fields(&self, record: &csv::StringRecord) -> Vec<Option<String>> {
|
||||||
let stringify = self.stringify;
|
let stringify = self.stringify;
|
||||||
self.field_source_columns
|
self.field_source_columns
|
||||||
.iter()
|
.iter()
|
||||||
.map(|opt| opt.and_then(|idx| record.get(idx - 1)).unwrap_or_default())
|
.map(|opt| opt.and_then(|idx| record.get(idx - 1)).map(stringify))
|
||||||
.map(stringify)
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -253,7 +250,19 @@ mod test {
|
||||||
($metadata:expr, $csv:expr, $expected:expr) => {
|
($metadata:expr, $csv:expr, $expected:expr) => {
|
||||||
let notes = import!(&$metadata, $csv);
|
let notes = import!(&$metadata, $csv);
|
||||||
let fields: Vec<_> = notes.into_iter().map(|note| note.fields).collect();
|
let fields: Vec<_> = notes.into_iter().map(|note| note.fields).collect();
|
||||||
assert_eq!(fields, $expected);
|
assert_eq!(fields.len(), $expected.len());
|
||||||
|
for (note_fields, note_expected) in fields.iter().zip($expected.iter()) {
|
||||||
|
assert_field_eq!(note_fields, note_expected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! assert_field_eq {
|
||||||
|
($fields:expr, $expected:expr) => {
|
||||||
|
assert_eq!($fields.len(), $expected.len());
|
||||||
|
for (field, expected) in $fields.iter().zip($expected.iter()) {
|
||||||
|
assert_eq!(&field.as_ref().map(String::as_str), expected);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -283,20 +292,28 @@ mod test {
|
||||||
#[test]
|
#[test]
|
||||||
fn should_allow_missing_columns() {
|
fn should_allow_missing_columns() {
|
||||||
let metadata = CsvMetadata::defaults_for_testing();
|
let metadata = CsvMetadata::defaults_for_testing();
|
||||||
assert_imported_fields!(metadata, "foo\n", &[&["foo", ""]]);
|
assert_imported_fields!(metadata, "foo\n", [[Some("foo"), None]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_respect_custom_delimiter() {
|
fn should_respect_custom_delimiter() {
|
||||||
let mut metadata = CsvMetadata::defaults_for_testing();
|
let mut metadata = CsvMetadata::defaults_for_testing();
|
||||||
metadata.set_delimiter(Delimiter::Pipe);
|
metadata.set_delimiter(Delimiter::Pipe);
|
||||||
assert_imported_fields!(metadata, "fr,ont|ba,ck\n", &[&["fr,ont", "ba,ck"]]);
|
assert_imported_fields!(
|
||||||
|
metadata,
|
||||||
|
"fr,ont|ba,ck\n",
|
||||||
|
[[Some("fr,ont"), Some("ba,ck")]]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_ignore_first_line_starting_with_tags() {
|
fn should_ignore_first_line_starting_with_tags() {
|
||||||
let metadata = CsvMetadata::defaults_for_testing();
|
let metadata = CsvMetadata::defaults_for_testing();
|
||||||
assert_imported_fields!(metadata, "tags:foo\nfront,back\n", &[&["front", "back"]]);
|
assert_imported_fields!(
|
||||||
|
metadata,
|
||||||
|
"tags:foo\nfront,back\n",
|
||||||
|
[[Some("front"), Some("back")]]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
@ -308,21 +325,29 @@ mod test {
|
||||||
id: 1,
|
id: 1,
|
||||||
field_columns: vec![3, 1],
|
field_columns: vec![3, 1],
|
||||||
}));
|
}));
|
||||||
assert_imported_fields!(metadata, "front,foo,back\n", &[&["back", "front"]]);
|
assert_imported_fields!(
|
||||||
|
metadata,
|
||||||
|
"front,foo,back\n",
|
||||||
|
[[Some("back"), Some("front")]]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_ignore_lines_starting_with_number_sign() {
|
fn should_ignore_lines_starting_with_number_sign() {
|
||||||
let metadata = CsvMetadata::defaults_for_testing();
|
let metadata = CsvMetadata::defaults_for_testing();
|
||||||
assert_imported_fields!(metadata, "#foo\nfront,back\n#bar\n", &[&["front", "back"]]);
|
assert_imported_fields!(
|
||||||
|
metadata,
|
||||||
|
"#foo\nfront,back\n#bar\n",
|
||||||
|
[[Some("front"), Some("back")]]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_escape_html_entities_if_csv_is_html() {
|
fn should_escape_html_entities_if_csv_is_html() {
|
||||||
let mut metadata = CsvMetadata::defaults_for_testing();
|
let mut metadata = CsvMetadata::defaults_for_testing();
|
||||||
assert_imported_fields!(metadata, "<hr>\n", &[&["<hr>", ""]]);
|
assert_imported_fields!(metadata, "<hr>\n", [[Some("<hr>"), None]]);
|
||||||
metadata.is_html = true;
|
metadata.is_html = true;
|
||||||
assert_imported_fields!(metadata, "<hr>\n", &[&["<hr>", ""]]);
|
assert_imported_fields!(metadata, "<hr>\n", [[Some("<hr>"), None]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
@ -330,7 +355,7 @@ mod test {
|
||||||
let mut metadata = CsvMetadata::defaults_for_testing();
|
let mut metadata = CsvMetadata::defaults_for_testing();
|
||||||
metadata.tags_column = 3;
|
metadata.tags_column = 3;
|
||||||
let notes = import!(metadata, "front,back,foo bar\n");
|
let notes = import!(metadata, "front,back,foo bar\n");
|
||||||
assert_eq!(notes[0].tags, &["foo", "bar"]);
|
assert_eq!(notes[0].tags.as_ref().unwrap(), &["foo", "bar"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
@ -347,9 +372,9 @@ mod test {
|
||||||
metadata.notetype.replace(CsvNotetype::NotetypeColumn(1));
|
metadata.notetype.replace(CsvNotetype::NotetypeColumn(1));
|
||||||
metadata.column_labels.push("".to_string());
|
metadata.column_labels.push("".to_string());
|
||||||
let notes = import!(metadata, "Basic,front,back\nCloze,foo,bar\n");
|
let notes = import!(metadata, "Basic,front,back\nCloze,foo,bar\n");
|
||||||
assert_eq!(notes[0].fields, &["front", "back"]);
|
assert_field_eq!(notes[0].fields, [Some("front"), Some("back")]);
|
||||||
assert_eq!(notes[0].notetype, NameOrId::Name(String::from("Basic")));
|
assert_eq!(notes[0].notetype, NameOrId::Name(String::from("Basic")));
|
||||||
assert_eq!(notes[1].fields, &["foo", "bar"]);
|
assert_field_eq!(notes[1].fields, [Some("foo"), Some("bar")]);
|
||||||
assert_eq!(notes[1].notetype, NameOrId::Name(String::from("Cloze")));
|
assert_eq!(notes[1].notetype, NameOrId::Name(String::from("Cloze")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,6 @@
|
||||||
use std::{
|
use std::{
|
||||||
borrow::Cow,
|
borrow::Cow,
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
mem,
|
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -16,8 +15,9 @@ use crate::{
|
||||||
text::{
|
text::{
|
||||||
DupeResolution, ForeignCard, ForeignData, ForeignNote, ForeignNotetype, ForeignTemplate,
|
DupeResolution, ForeignCard, ForeignData, ForeignNote, ForeignNotetype, ForeignTemplate,
|
||||||
},
|
},
|
||||||
ImportProgress, IncrementableProgress, LogNote, NoteLog,
|
ImportProgress, IncrementableProgress, NoteLog,
|
||||||
},
|
},
|
||||||
|
notes::{field_checksum, normalize_field},
|
||||||
notetype::{CardGenContext, CardTemplate, NoteField, NotetypeConfig},
|
notetype::{CardGenContext, CardTemplate, NoteField, NotetypeConfig},
|
||||||
prelude::*,
|
prelude::*,
|
||||||
text::strip_html_preserving_media_filenames,
|
text::strip_html_preserving_media_filenames,
|
||||||
|
@ -78,13 +78,13 @@ struct DeckIdsByNameOrId {
|
||||||
default: Option<DeckId>,
|
default: Option<DeckId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NoteContext {
|
struct NoteContext<'a> {
|
||||||
/// Prepared and with canonified tags.
|
note: ForeignNote,
|
||||||
note: Note,
|
|
||||||
dupes: Vec<Duplicate>,
|
dupes: Vec<Duplicate>,
|
||||||
cards: Vec<Card>,
|
|
||||||
notetype: Arc<Notetype>,
|
notetype: Arc<Notetype>,
|
||||||
deck_id: DeckId,
|
deck_id: DeckId,
|
||||||
|
global_tags: &'a [String],
|
||||||
|
updated_tags: &'a [String],
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Duplicate {
|
struct Duplicate {
|
||||||
|
@ -94,8 +94,8 @@ struct Duplicate {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Duplicate {
|
impl Duplicate {
|
||||||
fn new(dupe: Note, original: &Note, first_field_match: bool) -> Self {
|
fn new(dupe: Note, original: &ForeignNote, first_field_match: bool) -> Self {
|
||||||
let identical = dupe.equal_fields_and_tags(original);
|
let identical = original.equal_fields_and_tags(&dupe);
|
||||||
Self {
|
Self {
|
||||||
note: dupe,
|
note: dupe,
|
||||||
identical,
|
identical,
|
||||||
|
@ -190,14 +190,20 @@ impl<'a> Context<'a> {
|
||||||
let mut log = NoteLog::new(self.dupe_resolution, notes.len() as u32);
|
let mut log = NoteLog::new(self.dupe_resolution, notes.len() as u32);
|
||||||
for foreign in notes {
|
for foreign in notes {
|
||||||
incrementor.increment()?;
|
incrementor.increment()?;
|
||||||
if foreign.first_field_is_empty() {
|
if foreign.first_field_is_the_empty_string() {
|
||||||
log.empty_first_field.push(foreign.into_log_note());
|
log.empty_first_field.push(foreign.into_log_note());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(notetype) = self.notetype_for_note(&foreign)? {
|
if let Some(notetype) = self.notetype_for_note(&foreign)? {
|
||||||
if let Some(deck_id) = self.deck_ids.get(&foreign.deck) {
|
if let Some(deck_id) = self.deck_ids.get(&foreign.deck) {
|
||||||
let ctx = self.build_note_context(foreign, notetype, deck_id, global_tags)?;
|
let ctx = self.build_note_context(
|
||||||
self.import_note(ctx, updated_tags, &mut log)?;
|
foreign,
|
||||||
|
notetype,
|
||||||
|
deck_id,
|
||||||
|
global_tags,
|
||||||
|
updated_tags,
|
||||||
|
)?;
|
||||||
|
self.import_note(ctx, &mut log)?;
|
||||||
} else {
|
} else {
|
||||||
log.missing_deck.push(foreign.into_log_note());
|
log.missing_deck.push(foreign.into_log_note());
|
||||||
}
|
}
|
||||||
|
@ -208,41 +214,45 @@ impl<'a> Context<'a> {
|
||||||
Ok(log)
|
Ok(log)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_note_context(
|
fn build_note_context<'tags>(
|
||||||
&mut self,
|
&mut self,
|
||||||
foreign: ForeignNote,
|
mut note: ForeignNote,
|
||||||
notetype: Arc<Notetype>,
|
notetype: Arc<Notetype>,
|
||||||
deck_id: DeckId,
|
deck_id: DeckId,
|
||||||
global_tags: &[String],
|
global_tags: &'tags [String],
|
||||||
) -> Result<NoteContext> {
|
updated_tags: &'tags [String],
|
||||||
let (mut note, cards) = foreign.into_native(¬etype, deck_id, self.today, global_tags);
|
) -> Result<NoteContext<'tags>> {
|
||||||
note.prepare_for_update(¬etype, self.normalize_notes)?;
|
self.prepare_foreign_note(&mut note)?;
|
||||||
self.col.canonify_note_tags(&mut note, self.usn)?;
|
|
||||||
let dupes = self.find_duplicates(¬etype, ¬e)?;
|
let dupes = self.find_duplicates(¬etype, ¬e)?;
|
||||||
|
|
||||||
Ok(NoteContext {
|
Ok(NoteContext {
|
||||||
note,
|
note,
|
||||||
dupes,
|
dupes,
|
||||||
cards,
|
|
||||||
notetype,
|
notetype,
|
||||||
deck_id,
|
deck_id,
|
||||||
|
global_tags,
|
||||||
|
updated_tags,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_duplicates(&self, notetype: &Notetype, note: &Note) -> Result<Vec<Duplicate>> {
|
fn prepare_foreign_note(&mut self, note: &mut ForeignNote) -> Result<()> {
|
||||||
let checksum = note
|
note.normalize_fields(self.normalize_notes);
|
||||||
.checksum
|
self.col.canonify_foreign_tags(note, self.usn)
|
||||||
.ok_or_else(|| AnkiError::invalid_input("note unprepared"))?;
|
}
|
||||||
|
|
||||||
|
fn find_duplicates(&self, notetype: &Notetype, note: &ForeignNote) -> Result<Vec<Duplicate>> {
|
||||||
if let Some(nid) = self.existing_guids.get(¬e.guid) {
|
if let Some(nid) = self.existing_guids.get(¬e.guid) {
|
||||||
self.get_guid_dupe(*nid, note).map(|dupe| vec![dupe])
|
self.get_guid_dupe(*nid, note).map(|dupe| vec![dupe])
|
||||||
} else if let Some(nids) = self.existing_checksums.get(&(notetype.id, checksum)) {
|
} else if let Some(nids) = note
|
||||||
|
.checksum()
|
||||||
|
.and_then(|csum| self.existing_checksums.get(&(notetype.id, csum)))
|
||||||
|
{
|
||||||
self.get_first_field_dupes(note, nids)
|
self.get_first_field_dupes(note, nids)
|
||||||
} else {
|
} else {
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_guid_dupe(&self, nid: NoteId, original: &Note) -> Result<Duplicate> {
|
fn get_guid_dupe(&self, nid: NoteId, original: &ForeignNote) -> Result<Duplicate> {
|
||||||
self.col
|
self.col
|
||||||
.storage
|
.storage
|
||||||
.get_note(nid)?
|
.get_note(nid)?
|
||||||
|
@ -250,7 +260,7 @@ impl<'a> Context<'a> {
|
||||||
.map(|dupe| Duplicate::new(dupe, original, false))
|
.map(|dupe| Duplicate::new(dupe, original, false))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_first_field_dupes(&self, note: &Note, nids: &[NoteId]) -> Result<Vec<Duplicate>> {
|
fn get_first_field_dupes(&self, note: &ForeignNote, nids: &[NoteId]) -> Result<Vec<Duplicate>> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.col
|
.col
|
||||||
.get_full_duplicates(note, nids)?
|
.get_full_duplicates(note, nids)?
|
||||||
|
@ -259,26 +269,36 @@ impl<'a> Context<'a> {
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn import_note(
|
fn import_note(&mut self, ctx: NoteContext, log: &mut NoteLog) -> Result<()> {
|
||||||
&mut self,
|
|
||||||
ctx: NoteContext,
|
|
||||||
updated_tags: &[String],
|
|
||||||
log: &mut NoteLog,
|
|
||||||
) -> Result<()> {
|
|
||||||
match self.dupe_resolution {
|
match self.dupe_resolution {
|
||||||
_ if ctx.dupes.is_empty() => self.add_note(ctx, &mut log.new)?,
|
_ if ctx.dupes.is_empty() => self.add_note(ctx, log, false)?,
|
||||||
DupeResolution::Add => self.add_note(ctx, &mut log.first_field_match)?,
|
DupeResolution::Add => self.add_note(ctx, log, true)?,
|
||||||
DupeResolution::Update => self.update_with_note(ctx, updated_tags, log)?,
|
DupeResolution::Update => self.update_with_note(ctx, log)?,
|
||||||
DupeResolution::Ignore => log.first_field_match.push(ctx.note.into_log_note()),
|
DupeResolution::Ignore => log.first_field_match.push(ctx.note.into_log_note()),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_note(&mut self, mut ctx: NoteContext, log_queue: &mut Vec<LogNote>) -> Result<()> {
|
fn add_note(&mut self, ctx: NoteContext, log: &mut NoteLog, dupe: bool) -> Result<()> {
|
||||||
ctx.note.usn = self.usn;
|
if !ctx.note.first_field_is_unempty() {
|
||||||
self.col.add_note_only_undoable(&mut ctx.note)?;
|
log.empty_first_field.push(ctx.note.into_log_note());
|
||||||
self.add_cards(&mut ctx.cards, &ctx.note, ctx.deck_id, ctx.notetype)?;
|
return Ok(());
|
||||||
log_queue.push(ctx.note.into_log_note());
|
}
|
||||||
|
|
||||||
|
let mut note = Note::new(&ctx.notetype);
|
||||||
|
let mut cards = ctx
|
||||||
|
.note
|
||||||
|
.into_native(&mut note, ctx.deck_id, self.today, ctx.global_tags);
|
||||||
|
self.prepare_note(&mut note, &ctx.notetype)?;
|
||||||
|
self.col.add_note_only_undoable(&mut note)?;
|
||||||
|
self.add_cards(&mut cards, ¬e, ctx.deck_id, ctx.notetype)?;
|
||||||
|
|
||||||
|
if dupe {
|
||||||
|
log.first_field_match.push(note.into_log_note());
|
||||||
|
} else {
|
||||||
|
log.new.push(note.into_log_note());
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -293,63 +313,46 @@ impl<'a> Context<'a> {
|
||||||
self.generate_missing_cards(notetype, deck_id, note)
|
self.generate_missing_cards(notetype, deck_id, note)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_with_note(
|
fn update_with_note(&mut self, ctx: NoteContext, log: &mut NoteLog) -> Result<()> {
|
||||||
&mut self,
|
for dupe in ctx.dupes {
|
||||||
mut ctx: NoteContext,
|
if dupe.note.notetype_id != ctx.notetype.id {
|
||||||
updated_tags: &[String],
|
log.conflicting.push(dupe.note.into_log_note());
|
||||||
log: &mut NoteLog,
|
continue;
|
||||||
) -> Result<()> {
|
}
|
||||||
self.prepare_note_for_update(&mut ctx.note, updated_tags)?;
|
|
||||||
for dupe in mem::take(&mut ctx.dupes) {
|
let mut note = dupe.note.clone();
|
||||||
self.maybe_update_dupe(dupe, &mut ctx, log)?;
|
let mut cards = ctx.note.clone().into_native(
|
||||||
|
&mut note,
|
||||||
|
ctx.deck_id,
|
||||||
|
self.today,
|
||||||
|
ctx.global_tags.iter().chain(ctx.updated_tags.iter()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if !dupe.identical {
|
||||||
|
self.prepare_note(&mut note, &ctx.notetype)?;
|
||||||
|
self.col.update_note_undoable(¬e, &dupe.note)?;
|
||||||
|
}
|
||||||
|
self.add_cards(&mut cards, ¬e, ctx.deck_id, ctx.notetype.clone())?;
|
||||||
|
|
||||||
|
if dupe.identical {
|
||||||
|
log.duplicate.push(dupe.note.into_log_note());
|
||||||
|
} else if dupe.first_field_match {
|
||||||
|
log.first_field_match.push(note.into_log_note());
|
||||||
|
} else {
|
||||||
|
log.updated.push(note.into_log_note());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepare_note_for_update(&mut self, note: &mut Note, updated_tags: &[String]) -> Result<()> {
|
fn prepare_note(&mut self, note: &mut Note, notetype: &Notetype) -> Result<()> {
|
||||||
if !updated_tags.is_empty() {
|
note.prepare_for_update(notetype, self.normalize_notes)?;
|
||||||
note.tags.extend(updated_tags.iter().cloned());
|
self.col.canonify_note_tags(note, self.usn)?;
|
||||||
self.col.canonify_note_tags(note, self.usn)?;
|
|
||||||
}
|
|
||||||
note.set_modified(self.usn);
|
note.set_modified(self.usn);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn maybe_update_dupe(
|
|
||||||
&mut self,
|
|
||||||
dupe: Duplicate,
|
|
||||||
ctx: &mut NoteContext,
|
|
||||||
log: &mut NoteLog,
|
|
||||||
) -> Result<()> {
|
|
||||||
if dupe.note.notetype_id != ctx.notetype.id {
|
|
||||||
log.conflicting.push(dupe.note.into_log_note());
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if dupe.identical {
|
|
||||||
log.duplicate.push(dupe.note.into_log_note());
|
|
||||||
} else {
|
|
||||||
self.update_dupe(dupe, ctx, log)?;
|
|
||||||
}
|
|
||||||
self.add_cards(&mut ctx.cards, &ctx.note, ctx.deck_id, ctx.notetype.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_dupe(
|
|
||||||
&mut self,
|
|
||||||
dupe: Duplicate,
|
|
||||||
ctx: &mut NoteContext,
|
|
||||||
log: &mut NoteLog,
|
|
||||||
) -> Result<()> {
|
|
||||||
ctx.note.id = dupe.note.id;
|
|
||||||
ctx.note.guid = dupe.note.guid.clone();
|
|
||||||
self.col.update_note_undoable(&ctx.note, &dupe.note)?;
|
|
||||||
if dupe.first_field_match {
|
|
||||||
log.first_field_match.push(dupe.note.into_log_note());
|
|
||||||
} else {
|
|
||||||
log.updated.push(dupe.note.into_log_note());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn import_cards(&mut self, cards: &mut [Card], note_id: NoteId) -> Result<()> {
|
fn import_cards(&mut self, cards: &mut [Card], note_id: NoteId) -> Result<()> {
|
||||||
for card in cards {
|
for card in cards {
|
||||||
card.note_id = note_id;
|
card.note_id = note_id;
|
||||||
|
@ -397,8 +400,18 @@ impl Collection {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_full_duplicates(&self, note: &Note, dupe_ids: &[NoteId]) -> Result<Vec<Note>> {
|
fn canonify_foreign_tags(&mut self, note: &mut ForeignNote, usn: Usn) -> Result<()> {
|
||||||
let first_field = note.first_field_stripped();
|
if let Some(tags) = note.tags.take() {
|
||||||
|
note.tags
|
||||||
|
.replace(self.canonify_tags_without_registering(tags, usn)?);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_full_duplicates(&self, note: &ForeignNote, dupe_ids: &[NoteId]) -> Result<Vec<Note>> {
|
||||||
|
let first_field = note
|
||||||
|
.first_field_stripped()
|
||||||
|
.ok_or_else(|| AnkiError::invalid_input("no first field"))?;
|
||||||
dupe_ids
|
dupe_ids
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|&dupe_id| self.storage.get_note(dupe_id).transpose())
|
.filter_map(|&dupe_id| self.storage.get_note(dupe_id).transpose())
|
||||||
|
@ -411,35 +424,72 @@ impl Collection {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ForeignNote {
|
impl ForeignNote {
|
||||||
fn into_native(
|
/// Updates a native note with the foreign data and returns its new cards.
|
||||||
|
fn into_native<'tags>(
|
||||||
self,
|
self,
|
||||||
notetype: &Notetype,
|
note: &mut Note,
|
||||||
deck_id: DeckId,
|
deck_id: DeckId,
|
||||||
today: u32,
|
today: u32,
|
||||||
extra_tags: &[String],
|
extra_tags: impl IntoIterator<Item = &'tags String>,
|
||||||
) -> (Note, Vec<Card>) {
|
) -> Vec<Card> {
|
||||||
// TODO: Handle new and learning cards
|
// TODO: Handle new and learning cards
|
||||||
let mut note = Note::new(notetype);
|
|
||||||
if !self.guid.is_empty() {
|
if !self.guid.is_empty() {
|
||||||
note.guid = self.guid;
|
note.guid = self.guid;
|
||||||
}
|
}
|
||||||
note.tags = self.tags;
|
if let Some(tags) = self.tags {
|
||||||
note.tags.extend(extra_tags.iter().cloned());
|
note.tags = tags;
|
||||||
|
}
|
||||||
|
note.tags.extend(extra_tags.into_iter().cloned());
|
||||||
note.fields_mut()
|
note.fields_mut()
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.zip(self.fields.into_iter())
|
.zip(self.fields.into_iter())
|
||||||
.for_each(|(field, value)| *field = value);
|
.for_each(|(field, new)| {
|
||||||
let cards = self
|
if let Some(s) = new {
|
||||||
.cards
|
*field = s;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.cards
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(idx, c)| c.into_native(NoteId(0), idx as u16, deck_id, today))
|
.map(|(idx, c)| c.into_native(NoteId(0), idx as u16, deck_id, today))
|
||||||
.collect();
|
.collect()
|
||||||
(note, cards)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn first_field_is_empty(&self) -> bool {
|
fn first_field_is_the_empty_string(&self) -> bool {
|
||||||
self.fields.get(0).map(String::is_empty).unwrap_or(true)
|
matches!(self.fields.get(0), Some(Some(s)) if s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_field_is_unempty(&self) -> bool {
|
||||||
|
matches!(self.fields.get(0), Some(Some(s)) if !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_fields(&mut self, normalize_text: bool) {
|
||||||
|
for field in self.fields.iter_mut().flatten() {
|
||||||
|
normalize_field(field, normalize_text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expects normalized form.
|
||||||
|
fn equal_fields_and_tags(&self, other: &Note) -> bool {
|
||||||
|
self.tags.as_ref().map_or(true, |tags| *tags == other.tags)
|
||||||
|
&& self
|
||||||
|
.fields
|
||||||
|
.iter()
|
||||||
|
.zip(other.fields())
|
||||||
|
.all(|(opt, field)| opt.as_ref().map(|s| s == field).unwrap_or(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_field_stripped(&self) -> Option<Cow<str>> {
|
||||||
|
self.fields
|
||||||
|
.get(0)
|
||||||
|
.and_then(|s| s.as_ref())
|
||||||
|
.map(|field| strip_html_preserving_media_filenames(field.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If the first field is set, returns its checksum. Field is expected to be normalized.
|
||||||
|
fn checksum(&self) -> Option<u32> {
|
||||||
|
self.first_field_stripped()
|
||||||
|
.map(|field| field_checksum(&field))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -493,12 +543,6 @@ impl ForeignTemplate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Note {
|
|
||||||
fn equal_fields_and_tags(&self, other: &Self) -> bool {
|
|
||||||
self.fields() == other.fields() && self.tags == other.tags
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
@ -515,7 +559,7 @@ mod test {
|
||||||
|
|
||||||
fn add_note(&mut self, fields: &[&str]) {
|
fn add_note(&mut self, fields: &[&str]) {
|
||||||
self.notes.push(ForeignNote {
|
self.notes.push(ForeignNote {
|
||||||
fields: fields.iter().map(ToString::to_string).collect(),
|
fields: fields.iter().map(ToString::to_string).map(Some).collect(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -543,7 +587,7 @@ mod test {
|
||||||
data.clone().import(&mut col, |_, _| true).unwrap();
|
data.clone().import(&mut col, |_, _| true).unwrap();
|
||||||
assert_eq!(col.storage.notes_table_len(), 1);
|
assert_eq!(col.storage.notes_table_len(), 1);
|
||||||
|
|
||||||
data.notes[0].fields[1] = "new".to_string();
|
data.notes[0].fields[1].replace("new".to_string());
|
||||||
data.import(&mut col, |_, _| true).unwrap();
|
data.import(&mut col, |_, _| true).unwrap();
|
||||||
let notes = col.storage.get_all_notes();
|
let notes = col.storage.get_all_notes();
|
||||||
assert_eq!(notes.len(), 1);
|
assert_eq!(notes.len(), 1);
|
||||||
|
@ -560,11 +604,30 @@ mod test {
|
||||||
data.clone().import(&mut col, |_, _| true).unwrap();
|
data.clone().import(&mut col, |_, _| true).unwrap();
|
||||||
assert_eq!(col.storage.notes_table_len(), 1);
|
assert_eq!(col.storage.notes_table_len(), 1);
|
||||||
|
|
||||||
data.notes[0].fields[1] = "new".to_string();
|
data.notes[0].fields[1].replace("new".to_string());
|
||||||
data.import(&mut col, |_, _| true).unwrap();
|
data.import(&mut col, |_, _| true).unwrap();
|
||||||
assert_eq!(col.storage.get_all_notes()[0].fields()[1], "new");
|
assert_eq!(col.storage.get_all_notes()[0].fields()[1], "new");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_keep_old_field_content_if_no_new_one_is_supplied() {
|
||||||
|
let mut col = open_test_collection();
|
||||||
|
let mut data = ForeignData::with_defaults();
|
||||||
|
data.add_note(&["same", "unchanged"]);
|
||||||
|
data.add_note(&["same", "unchanged"]);
|
||||||
|
data.dupe_resolution = DupeResolution::Update;
|
||||||
|
|
||||||
|
data.clone().import(&mut col, |_, _| true).unwrap();
|
||||||
|
assert_eq!(col.storage.notes_table_len(), 2);
|
||||||
|
|
||||||
|
data.notes[0].fields[1] = None;
|
||||||
|
data.notes[1].fields.pop();
|
||||||
|
data.import(&mut col, |_, _| true).unwrap();
|
||||||
|
let notes = col.storage.get_all_notes();
|
||||||
|
assert_eq!(notes[0].fields(), &["same", "unchanged"]);
|
||||||
|
assert_eq!(notes[0].fields(), &["same", "unchanged"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_recognize_normalized_duplicate_only_if_normalization_is_enabled() {
|
fn should_recognize_normalized_duplicate_only_if_normalization_is_enabled() {
|
||||||
let mut col = open_test_collection();
|
let mut col = open_test_collection();
|
||||||
|
@ -589,7 +652,7 @@ mod test {
|
||||||
let mut col = open_test_collection();
|
let mut col = open_test_collection();
|
||||||
let mut data = ForeignData::with_defaults();
|
let mut data = ForeignData::with_defaults();
|
||||||
data.add_note(&["foo"]);
|
data.add_note(&["foo"]);
|
||||||
data.notes[0].tags = vec![String::from("bar")];
|
data.notes[0].tags.replace(vec![String::from("bar")]);
|
||||||
data.global_tags = vec![String::from("baz")];
|
data.global_tags = vec![String::from("baz")];
|
||||||
|
|
||||||
data.import(&mut col, |_, _| true).unwrap();
|
data.import(&mut col, |_, _| true).unwrap();
|
||||||
|
@ -601,7 +664,7 @@ mod test {
|
||||||
let mut col = open_test_collection();
|
let mut col = open_test_collection();
|
||||||
let mut data = ForeignData::with_defaults();
|
let mut data = ForeignData::with_defaults();
|
||||||
data.add_note(&["foo"]);
|
data.add_note(&["foo"]);
|
||||||
data.notes[0].tags = vec![String::from("bar")];
|
data.notes[0].tags.replace(vec![String::from("bar")]);
|
||||||
data.global_tags = vec![String::from("baz")];
|
data.global_tags = vec![String::from("baz")];
|
||||||
|
|
||||||
data.import(&mut col, |_, _| true).unwrap();
|
data.import(&mut col, |_, _| true).unwrap();
|
||||||
|
|
|
@ -26,8 +26,8 @@ pub struct ForeignData {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct ForeignNote {
|
pub struct ForeignNote {
|
||||||
guid: String,
|
guid: String,
|
||||||
fields: Vec<String>,
|
fields: Vec<Option<String>>,
|
||||||
tags: Vec<String>,
|
tags: Option<Vec<String>>,
|
||||||
notetype: NameOrId,
|
notetype: NameOrId,
|
||||||
deck: NameOrId,
|
deck: NameOrId,
|
||||||
cards: Vec<ForeignCard>,
|
cards: Vec<ForeignCard>,
|
||||||
|
@ -82,7 +82,11 @@ impl ForeignNote {
|
||||||
pub(crate) fn into_log_note(self) -> LogNote {
|
pub(crate) fn into_log_note(self) -> LogNote {
|
||||||
LogNote {
|
LogNote {
|
||||||
id: None,
|
id: None,
|
||||||
fields: self.fields,
|
fields: self
|
||||||
|
.fields
|
||||||
|
.into_iter()
|
||||||
|
.map(Option::unwrap_or_default)
|
||||||
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -186,16 +186,8 @@ impl Note {
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
for field in &mut self.fields {
|
for field in self.fields_mut() {
|
||||||
if field.contains(invalid_char_for_field) {
|
normalize_field(field, normalize_text);
|
||||||
*field = field.replace(invalid_char_for_field, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if normalize_text {
|
|
||||||
for field in &mut self.fields {
|
|
||||||
ensure_string_in_nfc(field);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let field1_nohtml = strip_html_preserving_media_filenames(&self.fields()[0]);
|
let field1_nohtml = strip_html_preserving_media_filenames(&self.fields()[0]);
|
||||||
|
@ -265,6 +257,16 @@ impl Note {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove invalid characters and optionally ensure nfc normalization.
|
||||||
|
pub(crate) fn normalize_field(field: &mut String, normalize_text: bool) {
|
||||||
|
if field.contains(invalid_char_for_field) {
|
||||||
|
*field = field.replace(invalid_char_for_field, "");
|
||||||
|
}
|
||||||
|
if normalize_text {
|
||||||
|
ensure_string_in_nfc(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<Note> for pb::Note {
|
impl From<Note> for pb::Note {
|
||||||
fn from(n: Note) -> Self {
|
fn from(n: Note) -> Self {
|
||||||
pb::Note {
|
pb::Note {
|
||||||
|
|
|
@ -46,6 +46,7 @@ impl NoteField {
|
||||||
font_name: "Arial".into(),
|
font_name: "Arial".into(),
|
||||||
font_size: 20,
|
font_size: 20,
|
||||||
description: "".into(),
|
description: "".into(),
|
||||||
|
collapsed: false,
|
||||||
other: vec![],
|
other: vec![],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -161,7 +161,7 @@ impl From<Notetype> for NotetypeSchema11 {
|
||||||
|
|
||||||
/// See [crate::deckconfig::schema11::clear_other_duplicates()].
|
/// See [crate::deckconfig::schema11::clear_other_duplicates()].
|
||||||
fn clear_other_field_duplicates(other: &mut HashMap<String, Value>) {
|
fn clear_other_field_duplicates(other: &mut HashMap<String, Value>) {
|
||||||
for key in &["description", "plainText"] {
|
for key in &["description", "plainText", "collapsed"] {
|
||||||
other.remove(*key);
|
other.remove(*key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -215,6 +215,9 @@ pub struct NoteFieldSchema11 {
|
||||||
#[serde(default, deserialize_with = "default_on_invalid")]
|
#[serde(default, deserialize_with = "default_on_invalid")]
|
||||||
pub(crate) plain_text: bool,
|
pub(crate) plain_text: bool,
|
||||||
|
|
||||||
|
#[serde(default, deserialize_with = "default_on_invalid")]
|
||||||
|
pub(crate) collapsed: bool,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub(crate) other: HashMap<String, Value>,
|
pub(crate) other: HashMap<String, Value>,
|
||||||
}
|
}
|
||||||
|
@ -230,6 +233,7 @@ impl Default for NoteFieldSchema11 {
|
||||||
font: "Arial".to_string(),
|
font: "Arial".to_string(),
|
||||||
size: 20,
|
size: 20,
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
|
collapsed: false,
|
||||||
other: Default::default(),
|
other: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -247,6 +251,7 @@ impl From<NoteFieldSchema11> for NoteField {
|
||||||
font_name: f.font,
|
font_name: f.font,
|
||||||
font_size: f.size as u32,
|
font_size: f.size as u32,
|
||||||
description: f.description,
|
description: f.description,
|
||||||
|
collapsed: f.collapsed,
|
||||||
other: other_to_bytes(&f.other),
|
other: other_to_bytes(&f.other),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -269,6 +274,7 @@ impl From<NoteField> for NoteFieldSchema11 {
|
||||||
font: conf.font_name,
|
font: conf.font_name,
|
||||||
size: conf.font_size as u16,
|
size: conf.font_size as u16,
|
||||||
description: conf.description,
|
description: conf.description,
|
||||||
|
collapsed: conf.collapsed,
|
||||||
other,
|
other,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,6 +41,7 @@ pub struct CardAnswer {
|
||||||
pub rating: Rating,
|
pub rating: Rating,
|
||||||
pub answered_at: TimestampMillis,
|
pub answered_at: TimestampMillis,
|
||||||
pub milliseconds_taken: u32,
|
pub milliseconds_taken: u32,
|
||||||
|
pub custom_data: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CardAnswer {
|
impl CardAnswer {
|
||||||
|
@ -273,6 +274,8 @@ impl Collection {
|
||||||
self.maybe_bury_siblings(&original, &updater.config)?;
|
self.maybe_bury_siblings(&original, &updater.config)?;
|
||||||
let timing = updater.timing;
|
let timing = updater.timing;
|
||||||
let mut card = updater.into_card();
|
let mut card = updater.into_card();
|
||||||
|
card.custom_data = answer.custom_data.clone();
|
||||||
|
card.validate_custom_data()?;
|
||||||
self.update_card_inner(&mut card, original, usn)?;
|
self.update_card_inner(&mut card, original, usn)?;
|
||||||
if answer.new_state.leeched() {
|
if answer.new_state.leeched() {
|
||||||
self.add_leech_tag(card.note_id)?;
|
self.add_leech_tag(card.note_id)?;
|
||||||
|
@ -419,6 +422,7 @@ pub mod test_helpers {
|
||||||
rating,
|
rating,
|
||||||
answered_at: TimestampMillis::now(),
|
answered_at: TimestampMillis::now(),
|
||||||
milliseconds_taken: 0,
|
milliseconds_taken: 0,
|
||||||
|
custom_data: String::new(),
|
||||||
})?;
|
})?;
|
||||||
Ok(PostAnswerState {
|
Ok(PostAnswerState {
|
||||||
card_id: queued.card.id,
|
card_id: queued.card.id,
|
||||||
|
|
|
@ -92,6 +92,7 @@ mod test {
|
||||||
rating: Rating::Again,
|
rating: Rating::Again,
|
||||||
answered_at: TimestampMillis::now(),
|
answered_at: TimestampMillis::now(),
|
||||||
milliseconds_taken: 0,
|
milliseconds_taken: 0,
|
||||||
|
custom_data: String::new(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
c = col.storage.get_card(c.id)?.unwrap();
|
c = col.storage.get_card(c.id)?.unwrap();
|
||||||
|
@ -106,6 +107,7 @@ mod test {
|
||||||
rating: Rating::Hard,
|
rating: Rating::Hard,
|
||||||
answered_at: TimestampMillis::now(),
|
answered_at: TimestampMillis::now(),
|
||||||
milliseconds_taken: 0,
|
milliseconds_taken: 0,
|
||||||
|
custom_data: String::new(),
|
||||||
})?;
|
})?;
|
||||||
c = col.storage.get_card(c.id)?.unwrap();
|
c = col.storage.get_card(c.id)?.unwrap();
|
||||||
assert_eq!(c.queue, CardQueue::PreviewRepeat);
|
assert_eq!(c.queue, CardQueue::PreviewRepeat);
|
||||||
|
@ -119,6 +121,7 @@ mod test {
|
||||||
rating: Rating::Good,
|
rating: Rating::Good,
|
||||||
answered_at: TimestampMillis::now(),
|
answered_at: TimestampMillis::now(),
|
||||||
milliseconds_taken: 0,
|
milliseconds_taken: 0,
|
||||||
|
custom_data: String::new(),
|
||||||
})?;
|
})?;
|
||||||
c = col.storage.get_card(c.id)?.unwrap();
|
c = col.storage.get_card(c.id)?.unwrap();
|
||||||
assert_eq!(c.queue, CardQueue::PreviewRepeat);
|
assert_eq!(c.queue, CardQueue::PreviewRepeat);
|
||||||
|
@ -132,6 +135,7 @@ mod test {
|
||||||
rating: Rating::Easy,
|
rating: Rating::Easy,
|
||||||
answered_at: TimestampMillis::now(),
|
answered_at: TimestampMillis::now(),
|
||||||
milliseconds_taken: 0,
|
milliseconds_taken: 0,
|
||||||
|
custom_data: String::new(),
|
||||||
})?;
|
})?;
|
||||||
c = col.storage.get_card(c.id)?.unwrap();
|
c = col.storage.get_card(c.id)?.unwrap();
|
||||||
assert_eq!(c.queue, CardQueue::DayLearn);
|
assert_eq!(c.queue, CardQueue::DayLearn);
|
||||||
|
|
|
@ -1,32 +1,45 @@
|
||||||
// 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
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use rusqlite::{
|
use rusqlite::{
|
||||||
types::{FromSql, FromSqlError, ToSqlOutput, ValueRef},
|
types::{FromSql, FromSqlError, ToSqlOutput, ValueRef},
|
||||||
ToSql,
|
ToSql,
|
||||||
};
|
};
|
||||||
use serde_derive::{Deserialize, Serialize};
|
use serde_derive::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{prelude::*, serde::default_on_invalid};
|
use crate::{prelude::*, serde::default_on_invalid};
|
||||||
|
|
||||||
/// Helper for serdeing the card data column.
|
/// Helper for serdeing the card data column.
|
||||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(super) struct CardData {
|
pub(crate) struct CardData {
|
||||||
#[serde(
|
#[serde(
|
||||||
skip_serializing_if = "Option::is_none",
|
skip_serializing_if = "Option::is_none",
|
||||||
rename = "pos",
|
rename = "pos",
|
||||||
deserialize_with = "default_on_invalid"
|
deserialize_with = "default_on_invalid"
|
||||||
)]
|
)]
|
||||||
pub(crate) original_position: Option<u32>,
|
pub(crate) original_position: Option<u32>,
|
||||||
|
/// A string representation of a JSON object storing optional data
|
||||||
|
/// associated with the card, so v3 custom scheduling code can persist
|
||||||
|
/// state.
|
||||||
|
#[serde(default, rename = "cd", skip_serializing_if = "meta_is_empty")]
|
||||||
|
pub(crate) custom_data: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CardData {
|
impl CardData {
|
||||||
pub(super) fn from_card(card: &Card) -> Self {
|
pub(crate) fn from_card(card: &Card) -> Self {
|
||||||
Self {
|
Self {
|
||||||
original_position: card.original_position,
|
original_position: card.original_position,
|
||||||
|
custom_data: card.custom_data.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn from_str(s: &str) -> Self {
|
||||||
|
serde_json::from_str(s).unwrap_or_default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromSql for CardData {
|
impl FromSql for CardData {
|
||||||
|
@ -53,8 +66,45 @@ pub(crate) fn card_data_string(card: &Card) -> String {
|
||||||
serde_json::to_string(&CardData::from_card(card)).unwrap()
|
serde_json::to_string(&CardData::from_card(card)).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract original position from JSON `data`.
|
fn meta_is_empty(s: &str) -> bool {
|
||||||
pub(crate) fn original_position_from_card_data(card_data: &str) -> Option<u32> {
|
matches!(s, "" | "{}")
|
||||||
let data: CardData = serde_json::from_str(card_data).unwrap_or_default();
|
}
|
||||||
data.original_position
|
|
||||||
|
fn validate_custom_data(json_str: &str) -> Result<()> {
|
||||||
|
if !meta_is_empty(json_str) {
|
||||||
|
let object: HashMap<&str, Value> = serde_json::from_str(json_str)
|
||||||
|
.map_err(|e| AnkiError::invalid_input(format!("custom data not an object: {e}")))?;
|
||||||
|
if object.keys().any(|k| k.as_bytes().len() > 8) {
|
||||||
|
return Err(AnkiError::invalid_input(
|
||||||
|
"custom data keys must be <= 8 bytes",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if json_str.len() > 100 {
|
||||||
|
return Err(AnkiError::invalid_input(
|
||||||
|
"serialized custom data must be under 100 bytes",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Card {
|
||||||
|
pub(crate) fn validate_custom_data(&self) -> Result<()> {
|
||||||
|
validate_custom_data(&self.custom_data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn validation() {
|
||||||
|
assert!(validate_custom_data("").is_ok());
|
||||||
|
assert!(validate_custom_data("{}").is_ok());
|
||||||
|
assert!(validate_custom_data(r#"{"foo": 5}"#).is_ok());
|
||||||
|
assert!(validate_custom_data(r#"["foo"]"#).is_err());
|
||||||
|
assert!(validate_custom_data(r#"{"日": 5}"#).is_ok());
|
||||||
|
assert!(validate_custom_data(r#"{"日本語": 5}"#).is_err());
|
||||||
|
assert!(validate_custom_data(&format!(r#"{{"foo": "{}"}}"#, "x".repeat(100))).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,6 +69,7 @@ fn row_to_card(row: &Row) -> result::Result<Card, rusqlite::Error> {
|
||||||
original_deck_id: row.get(15)?,
|
original_deck_id: row.get(15)?,
|
||||||
flags: row.get(16)?,
|
flags: row.get(16)?,
|
||||||
original_position: data.original_position,
|
original_position: data.original_position,
|
||||||
|
custom_data: data.custom_data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -28,7 +28,7 @@ use crate::{
|
||||||
revlog::RevlogEntry,
|
revlog::RevlogEntry,
|
||||||
serde::{default_on_invalid, deserialize_int_from_number},
|
serde::{default_on_invalid, deserialize_int_from_number},
|
||||||
storage::{
|
storage::{
|
||||||
card::data::{card_data_string, original_position_from_card_data},
|
card::data::{card_data_string, CardData},
|
||||||
open_and_check_sqlite_file, SchemaVersion,
|
open_and_check_sqlite_file, SchemaVersion,
|
||||||
},
|
},
|
||||||
tags::{join_tags, split_tags, Tag},
|
tags::{join_tags, split_tags, Tag},
|
||||||
|
@ -1081,6 +1081,10 @@ impl Collection {
|
||||||
|
|
||||||
impl From<CardEntry> for Card {
|
impl From<CardEntry> for Card {
|
||||||
fn from(e: CardEntry) -> Self {
|
fn from(e: CardEntry) -> Self {
|
||||||
|
let CardData {
|
||||||
|
original_position,
|
||||||
|
custom_data,
|
||||||
|
} = CardData::from_str(&e.data);
|
||||||
Card {
|
Card {
|
||||||
id: e.id,
|
id: e.id,
|
||||||
note_id: e.nid,
|
note_id: e.nid,
|
||||||
|
@ -1099,7 +1103,8 @@ impl From<CardEntry> for Card {
|
||||||
original_due: e.odue,
|
original_due: e.odue,
|
||||||
original_deck_id: e.odid,
|
original_deck_id: e.odid,
|
||||||
flags: e.flags,
|
flags: e.flags,
|
||||||
original_position: original_position_from_card_data(&e.data),
|
original_position,
|
||||||
|
custom_data,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,26 @@ impl Collection {
|
||||||
&mut self,
|
&mut self,
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
usn: Usn,
|
usn: Usn,
|
||||||
|
) -> Result<(Vec<String>, bool)> {
|
||||||
|
self.canonify_tags_inner(tags, usn, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn canonify_tags_without_registering(
|
||||||
|
&mut self,
|
||||||
|
tags: Vec<String>,
|
||||||
|
usn: Usn,
|
||||||
|
) -> Result<Vec<String>> {
|
||||||
|
self.canonify_tags_inner(tags, usn, false)
|
||||||
|
.map(|(tags, _)| tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [canonify_tags()], but doesn't save new tags. As a consequence, new
|
||||||
|
/// parents are not canonified.
|
||||||
|
fn canonify_tags_inner(
|
||||||
|
&mut self,
|
||||||
|
tags: Vec<String>,
|
||||||
|
usn: Usn,
|
||||||
|
register: bool,
|
||||||
) -> Result<(Vec<String>, bool)> {
|
) -> Result<(Vec<String>, bool)> {
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
let mut added = false;
|
let mut added = false;
|
||||||
|
@ -24,7 +44,11 @@ impl Collection {
|
||||||
let tags: Vec<_> = tags.iter().flat_map(|t| split_tags(t)).collect();
|
let tags: Vec<_> = tags.iter().flat_map(|t| split_tags(t)).collect();
|
||||||
for tag in tags {
|
for tag in tags {
|
||||||
let mut tag = Tag::new(tag.to_string(), usn);
|
let mut tag = Tag::new(tag.to_string(), usn);
|
||||||
added |= self.register_tag(&mut tag)?;
|
if register {
|
||||||
|
added |= self.register_tag(&mut tag)?;
|
||||||
|
} else {
|
||||||
|
self.prepare_tag_for_registering(&mut tag)?;
|
||||||
|
}
|
||||||
seen.insert(UniCase::new(tag.name));
|
seen.insert(UniCase::new(tag.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,12 +10,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
export { className as class };
|
export { className as class };
|
||||||
|
|
||||||
export let collapsed = false;
|
export let collapsed = false;
|
||||||
|
let isCollapsed = false;
|
||||||
|
let hidden = collapsed;
|
||||||
|
|
||||||
const [outerPromise, outerResolve] = promiseWithResolver<HTMLElement>();
|
const [outerPromise, outerResolve] = promiseWithResolver<HTMLElement>();
|
||||||
const [innerPromise, innerResolve] = promiseWithResolver<HTMLElement>();
|
const [innerPromise, innerResolve] = promiseWithResolver<HTMLElement>();
|
||||||
|
|
||||||
let isCollapsed = false;
|
|
||||||
|
|
||||||
let style: string;
|
let style: string;
|
||||||
function setStyle(height: number, duration: number) {
|
function setStyle(height: number, duration: number) {
|
||||||
style = `--collapse-height: -${height}px; --duration: ${duration}ms`;
|
style = `--collapse-height: -${height}px; --duration: ${duration}ms`;
|
||||||
|
@ -60,18 +60,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
() => {
|
() => {
|
||||||
inner.toggleAttribute("hidden", collapse);
|
inner.toggleAttribute("hidden", collapse);
|
||||||
outer.style.removeProperty("overflow");
|
outer.style.removeProperty("overflow");
|
||||||
|
hidden = collapse;
|
||||||
},
|
},
|
||||||
{ once: true },
|
{ once: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* prevent transition on mount for performance reasons */
|
/* prevent transition on mount for performance reasons */
|
||||||
let blockTransition = true;
|
let firstTransition = true;
|
||||||
|
|
||||||
$: if (blockTransition) {
|
$: {
|
||||||
blockTransition = false;
|
|
||||||
} else {
|
|
||||||
transition(collapsed);
|
transition(collapsed);
|
||||||
|
firstTransition = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -79,10 +79,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<div
|
<div
|
||||||
class="collapsible-inner"
|
class="collapsible-inner"
|
||||||
class:collapsed={isCollapsed}
|
class:collapsed={isCollapsed}
|
||||||
|
class:no-transition={firstTransition}
|
||||||
use:innerResolve
|
use:innerResolve
|
||||||
{style}
|
{style}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot {hidden} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -96,5 +97,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
&.collapsed {
|
&.collapsed {
|
||||||
margin-top: var(--collapse-height);
|
margin-top: var(--collapse-height);
|
||||||
}
|
}
|
||||||
|
&.no-transition {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -21,3 +21,7 @@ p {
|
||||||
:host(body) {
|
:host(body) {
|
||||||
@include scrollbar.custom;
|
@include scrollbar.custom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
|
@ -88,7 +88,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.code-mirror :global(.CodeMirror) {
|
.code-mirror {
|
||||||
height: auto;
|
:global(.CodeMirror) {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
:global(.CodeMirror-wrap pre) {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -14,6 +14,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
direction: "ltr" | "rtl";
|
direction: "ltr" | "rtl";
|
||||||
plainText: boolean;
|
plainText: boolean;
|
||||||
description: string;
|
description: string;
|
||||||
|
collapsed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditorFieldAPI {
|
export interface EditorFieldAPI {
|
||||||
|
@ -54,6 +55,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
export let content: Writable<string>;
|
export let content: Writable<string>;
|
||||||
export let field: FieldData;
|
export let field: FieldData;
|
||||||
export let collapsed = false;
|
export let collapsed = false;
|
||||||
|
export let flipInputs = false;
|
||||||
|
|
||||||
const directionStore = writable<"ltr" | "rtl">();
|
const directionStore = writable<"ltr" | "rtl">();
|
||||||
setContext(directionKey, directionStore);
|
setContext(directionKey, directionStore);
|
||||||
|
@ -101,7 +103,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
fontSize={field.fontSize}
|
fontSize={field.fontSize}
|
||||||
api={editingArea}
|
api={editingArea}
|
||||||
>
|
>
|
||||||
<slot name="editing-inputs" />
|
{#if flipInputs}
|
||||||
|
<slot name="plain-text-input" />
|
||||||
|
<slot name="rich-text-input" />
|
||||||
|
{:else}
|
||||||
|
<slot name="rich-text-input" />
|
||||||
|
<slot name="plain-text-input" />
|
||||||
|
{/if}
|
||||||
</EditingArea>
|
</EditingArea>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -40,9 +40,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
/* same as in ContentEditable */
|
|
||||||
padding: 6px;
|
|
||||||
|
|
||||||
/* stay a on single line */
|
/* stay a on single line */
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
|
@ -66,6 +66,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import PlainTextInput from "./plain-text-input";
|
import PlainTextInput from "./plain-text-input";
|
||||||
import PlainTextBadge from "./PlainTextBadge.svelte";
|
import PlainTextBadge from "./PlainTextBadge.svelte";
|
||||||
import RichTextInput, { editingInputIsRichText } from "./rich-text-input";
|
import RichTextInput, { editingInputIsRichText } from "./rich-text-input";
|
||||||
|
import RichTextBadge from "./RichTextBadge.svelte";
|
||||||
|
|
||||||
function quoteFontFamily(fontFamily: string): string {
|
function quoteFontFamily(fontFamily: string): string {
|
||||||
// generic families (e.g. sans-serif) must not be quoted
|
// generic families (e.g. sans-serif) must not be quoted
|
||||||
|
@ -113,13 +114,19 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
fieldNames = newFieldNames;
|
fieldNames = newFieldNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
let plainTexts: boolean[] = [];
|
let fieldsCollapsed: boolean[] = [];
|
||||||
|
export function setCollapsed(fs: boolean[]): void {
|
||||||
|
fieldsCollapsed = fs;
|
||||||
|
}
|
||||||
|
|
||||||
let richTextsHidden: boolean[] = [];
|
let richTextsHidden: boolean[] = [];
|
||||||
let plainTextsHidden: boolean[] = [];
|
let plainTextsHidden: boolean[] = [];
|
||||||
|
let plainTextDefaults: boolean[] = [];
|
||||||
|
|
||||||
export function setPlainTexts(fs: boolean[]): void {
|
export function setPlainTexts(fs: boolean[]): void {
|
||||||
richTextsHidden = plainTexts = fs;
|
richTextsHidden = fs;
|
||||||
plainTextsHidden = Array.from(fs, (v) => !v);
|
plainTextsHidden = Array.from(fs, (v) => !v);
|
||||||
|
plainTextDefaults = [...richTextsHidden];
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMathjaxEnabled(enabled: boolean): void {
|
function setMathjaxEnabled(enabled: boolean): void {
|
||||||
|
@ -132,13 +139,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
}
|
}
|
||||||
|
|
||||||
let fonts: [string, number, boolean][] = [];
|
let fonts: [string, number, boolean][] = [];
|
||||||
let fieldsCollapsed: boolean[] = [];
|
|
||||||
|
|
||||||
const fields = clearableArray<EditorFieldAPI>();
|
const fields = clearableArray<EditorFieldAPI>();
|
||||||
|
|
||||||
export function setFonts(fs: [string, number, boolean][]): void {
|
export function setFonts(fs: [string, number, boolean][]): void {
|
||||||
fonts = fs;
|
fonts = fs;
|
||||||
fieldsCollapsed = fonts.map((_, index) => fieldsCollapsed[index] ?? false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function focusField(index: number | null): void {
|
export function focusField(index: number | null): void {
|
||||||
|
@ -186,11 +191,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
$: fieldsData = fieldNames.map((name, index) => ({
|
$: fieldsData = fieldNames.map((name, index) => ({
|
||||||
name,
|
name,
|
||||||
plainText: plainTexts[index],
|
plainText: plainTextDefaults[index],
|
||||||
description: fieldDescriptions[index],
|
description: fieldDescriptions[index],
|
||||||
fontFamily: quoteFontFamily(fonts[index][0]),
|
fontFamily: quoteFontFamily(fonts[index][0]),
|
||||||
fontSize: fonts[index][1],
|
fontSize: fonts[index][1],
|
||||||
direction: fonts[index][2] ? "rtl" : "ltr",
|
direction: fonts[index][2] ? "rtl" : "ltr",
|
||||||
|
collapsed: fieldsCollapsed[index],
|
||||||
})) as FieldData[];
|
})) as FieldData[];
|
||||||
|
|
||||||
function saveTags({ detail }: CustomEvent): void {
|
function saveTags({ detail }: CustomEvent): void {
|
||||||
|
@ -241,6 +247,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
import { mathjaxConfig } from "../editable/mathjax-element";
|
import { mathjaxConfig } from "../editable/mathjax-element";
|
||||||
import { wrapInternal } from "../lib/wrap";
|
import { wrapInternal } from "../lib/wrap";
|
||||||
|
import { refocusInput } from "./helpers";
|
||||||
import * as oldEditorAdapter from "./old-editor-adapter";
|
import * as oldEditorAdapter from "./old-editor-adapter";
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
@ -256,6 +263,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
Object.assign(globalThis, {
|
Object.assign(globalThis, {
|
||||||
setFields,
|
setFields,
|
||||||
|
setCollapsed,
|
||||||
setPlainTexts,
|
setPlainTexts,
|
||||||
setDescriptions,
|
setDescriptions,
|
||||||
setFonts,
|
setFonts,
|
||||||
|
@ -329,6 +337,7 @@ the AddCards dialog) should be implemented in the user of this component.
|
||||||
<EditorField
|
<EditorField
|
||||||
{field}
|
{field}
|
||||||
{content}
|
{content}
|
||||||
|
flipInputs={plainTextDefaults[index]}
|
||||||
api={fields[index]}
|
api={fields[index]}
|
||||||
on:focusin={() => {
|
on:focusin={() => {
|
||||||
$focusedField = fields[index];
|
$focusedField = fields[index];
|
||||||
|
@ -357,11 +366,16 @@ the AddCards dialog) should be implemented in the user of this component.
|
||||||
on:toggle={async () => {
|
on:toggle={async () => {
|
||||||
fieldsCollapsed[index] = !fieldsCollapsed[index];
|
fieldsCollapsed[index] = !fieldsCollapsed[index];
|
||||||
|
|
||||||
|
const defaultInput = !plainTextDefaults[index]
|
||||||
|
? richTextInputs[index]
|
||||||
|
: plainTextInputs[index];
|
||||||
|
|
||||||
if (!fieldsCollapsed[index]) {
|
if (!fieldsCollapsed[index]) {
|
||||||
await tick();
|
refocusInput(defaultInput.api);
|
||||||
richTextInputs[index].api.refocus();
|
} else if (!plainTextDefaults[index]) {
|
||||||
} else {
|
|
||||||
plainTextsHidden[index] = true;
|
plainTextsHidden[index] = true;
|
||||||
|
} else {
|
||||||
|
richTextsHidden[index] = true;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -374,21 +388,41 @@ the AddCards dialog) should be implemented in the user of this component.
|
||||||
{#if cols[index] === "dupe"}
|
{#if cols[index] === "dupe"}
|
||||||
<DuplicateLink />
|
<DuplicateLink />
|
||||||
{/if}
|
{/if}
|
||||||
<PlainTextBadge
|
{#if plainTextDefaults[index]}
|
||||||
visible={!fieldsCollapsed[index] &&
|
<RichTextBadge
|
||||||
(fields[index] === $hoveredField ||
|
visible={!fieldsCollapsed[index] &&
|
||||||
fields[index] === $focusedField)}
|
(fields[index] === $hoveredField ||
|
||||||
bind:off={plainTextsHidden[index]}
|
fields[index] === $focusedField)}
|
||||||
on:toggle={async () => {
|
bind:off={richTextsHidden[index]}
|
||||||
plainTextsHidden[index] =
|
on:toggle={async () => {
|
||||||
!plainTextsHidden[index];
|
richTextsHidden[index] =
|
||||||
|
!richTextsHidden[index];
|
||||||
|
|
||||||
if (!plainTextsHidden[index]) {
|
if (!richTextsHidden[index]) {
|
||||||
await tick();
|
refocusInput(
|
||||||
plainTextInputs[index].api.refocus();
|
richTextInputs[index].api,
|
||||||
}
|
);
|
||||||
}}
|
}
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<PlainTextBadge
|
||||||
|
visible={!fieldsCollapsed[index] &&
|
||||||
|
(fields[index] === $hoveredField ||
|
||||||
|
fields[index] === $focusedField)}
|
||||||
|
bind:off={plainTextsHidden[index]}
|
||||||
|
on:toggle={async () => {
|
||||||
|
plainTextsHidden[index] =
|
||||||
|
!plainTextsHidden[index];
|
||||||
|
|
||||||
|
if (!plainTextsHidden[index]) {
|
||||||
|
refocusInput(
|
||||||
|
plainTextInputs[index].api,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
<slot
|
<slot
|
||||||
name="field-state"
|
name="field-state"
|
||||||
{field}
|
{field}
|
||||||
|
@ -399,10 +433,10 @@ the AddCards dialog) should be implemented in the user of this component.
|
||||||
</FieldState>
|
</FieldState>
|
||||||
</LabelContainer>
|
</LabelContainer>
|
||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
<svelte:fragment slot="editing-inputs">
|
<svelte:fragment slot="rich-text-input">
|
||||||
<Collapsible collapsed={richTextsHidden[index]}>
|
<Collapsible collapsed={richTextsHidden[index]} let:hidden>
|
||||||
<RichTextInput
|
<RichTextInput
|
||||||
bind:hidden={richTextsHidden[index]}
|
{hidden}
|
||||||
on:focusout={() => {
|
on:focusout={() => {
|
||||||
saveFieldNow();
|
saveFieldNow();
|
||||||
$focusedInput = null;
|
$focusedInput = null;
|
||||||
|
@ -416,10 +450,13 @@ the AddCards dialog) should be implemented in the user of this component.
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
</RichTextInput>
|
</RichTextInput>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
</svelte:fragment>
|
||||||
<Collapsible collapsed={plainTextsHidden[index]}>
|
<svelte:fragment slot="plain-text-input">
|
||||||
|
<Collapsible collapsed={plainTextsHidden[index]} let:hidden>
|
||||||
<PlainTextInput
|
<PlainTextInput
|
||||||
bind:hidden={plainTextsHidden[index]}
|
{hidden}
|
||||||
|
isDefault={plainTextDefaults[index]}
|
||||||
|
richTextHidden={richTextsHidden[index]}
|
||||||
on:focusout={() => {
|
on:focusout={() => {
|
||||||
saveFieldNow();
|
saveFieldNow();
|
||||||
$focusedInput = null;
|
$focusedInput = null;
|
||||||
|
|
59
ts/editor/RichTextBadge.svelte
Normal file
59
ts/editor/RichTextBadge.svelte
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher, onMount } from "svelte";
|
||||||
|
|
||||||
|
import Badge from "../components/Badge.svelte";
|
||||||
|
import * as tr from "../lib/ftl";
|
||||||
|
import { getPlatformString, registerShortcut } from "../lib/shortcuts";
|
||||||
|
import { context as editorFieldContext } from "./EditorField.svelte";
|
||||||
|
import { richTextIcon } from "./icons";
|
||||||
|
|
||||||
|
const editorField = editorFieldContext.get();
|
||||||
|
const keyCombination = "Control+Shift+X";
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let visible = false;
|
||||||
|
export let off = false;
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
dispatch("toggle");
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortcut(target: HTMLElement): () => void {
|
||||||
|
return registerShortcut(toggle, keyCombination, { target });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => editorField.element.then(shortcut));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="plain-text-badge"
|
||||||
|
class:visible
|
||||||
|
class:highlighted={!off}
|
||||||
|
on:click|stopPropagation={toggle}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
tooltip="{tr.editingToggleVisualEditor()} ({getPlatformString(keyCombination)})"
|
||||||
|
iconSize={80}>{@html richTextIcon}</Badge
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
span {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
opacity: 0.4;
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.highlighted {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,6 +1,9 @@
|
||||||
// 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 type { PlainTextInputAPI } from "./plain-text-input";
|
||||||
|
import type { RichTextInputAPI } from "./rich-text-input";
|
||||||
|
|
||||||
function isFontElement(element: Element): element is HTMLFontElement {
|
function isFontElement(element: Element): element is HTMLFontElement {
|
||||||
return element.tagName === "FONT";
|
return element.tagName === "FONT";
|
||||||
}
|
}
|
||||||
|
@ -19,3 +22,15 @@ export function withFontColor(
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/***
|
||||||
|
* Required for field inputs wrapped in Collapsible
|
||||||
|
*/
|
||||||
|
export async function refocusInput(
|
||||||
|
api: RichTextInputAPI | PlainTextInputAPI,
|
||||||
|
): Promise<void> {
|
||||||
|
do {
|
||||||
|
await new Promise(window.requestAnimationFrame);
|
||||||
|
} while (!api.focusable);
|
||||||
|
api.refocus();
|
||||||
|
}
|
||||||
|
|
|
@ -39,7 +39,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import removeProhibitedTags from "./remove-prohibited";
|
import removeProhibitedTags from "./remove-prohibited";
|
||||||
import { storedToUndecorated, undecoratedToStored } from "./transform";
|
import { storedToUndecorated, undecoratedToStored } from "./transform";
|
||||||
|
|
||||||
|
export let isDefault: boolean;
|
||||||
export let hidden: boolean;
|
export let hidden: boolean;
|
||||||
|
export let richTextHidden: boolean;
|
||||||
|
|
||||||
const configuration = {
|
const configuration = {
|
||||||
mode: htmlanki,
|
mode: htmlanki,
|
||||||
|
@ -143,6 +145,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<div
|
<div
|
||||||
class="plain-text-input"
|
class="plain-text-input"
|
||||||
class:light-theme={!$pageTheme.isDark}
|
class:light-theme={!$pageTheme.isDark}
|
||||||
|
class:is-default={isDefault}
|
||||||
|
class:alone={richTextHidden}
|
||||||
on:focusin={() => ($focusedInput = api)}
|
on:focusin={() => ($focusedInput = api)}
|
||||||
>
|
>
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
|
@ -156,11 +160,21 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.plain-text-input {
|
.plain-text-input {
|
||||||
overflow-x: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
border-radius: 0 0 5px 5px;
|
||||||
|
|
||||||
|
&.is-default {
|
||||||
|
border-top: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
border-radius: 5px 5px 0 0;
|
||||||
|
}
|
||||||
|
&.alone {
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
:global(.CodeMirror) {
|
:global(.CodeMirror) {
|
||||||
border-radius: 0 0 5px 5px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
background: var(--code-bg);
|
background: var(--code-bg);
|
||||||
}
|
}
|
||||||
:global(.CodeMirror-lines) {
|
:global(.CodeMirror-lines) {
|
||||||
|
|
|
@ -244,7 +244,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.rich-text-input {
|
.rich-text-input {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 6px;
|
margin: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
|
|
|
@ -4,25 +4,38 @@
|
||||||
import { postRequest } from "../lib/postrequest";
|
import { postRequest } from "../lib/postrequest";
|
||||||
import { Scheduler } from "../lib/proto";
|
import { Scheduler } from "../lib/proto";
|
||||||
|
|
||||||
async function getNextStates(): Promise<Scheduler.NextCardStates> {
|
async function getCustomScheduling(): Promise<Scheduler.CustomScheduling> {
|
||||||
return Scheduler.NextCardStates.decode(
|
return Scheduler.CustomScheduling.decode(
|
||||||
await postRequest("/_anki/nextCardStates", ""),
|
await postRequest("/_anki/getCustomScheduling", ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setNextStates(
|
async function setCustomScheduling(
|
||||||
key: string,
|
key: string,
|
||||||
states: Scheduler.NextCardStates,
|
scheduling: Scheduler.CustomScheduling,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const data: Uint8Array = Scheduler.NextCardStates.encode(states).finish();
|
const bytes = Scheduler.CustomScheduling.encode(scheduling).finish();
|
||||||
await postRequest("/_anki/setNextCardStates", data, { key });
|
await postRequest("/_anki/setCustomScheduling", bytes, { key });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mutateNextCardStates(
|
export async function mutateNextCardStates(
|
||||||
key: string,
|
key: string,
|
||||||
mutator: (states: Scheduler.NextCardStates) => void,
|
mutator: (
|
||||||
|
states: Scheduler.NextCardStates,
|
||||||
|
customData: Record<string, unknown>,
|
||||||
|
) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const states = await getNextStates();
|
const scheduling = await getCustomScheduling();
|
||||||
mutator(states);
|
let customData = {};
|
||||||
await setNextStates(key, states);
|
try {
|
||||||
|
customData = JSON.parse(scheduling.customData);
|
||||||
|
} catch {
|
||||||
|
// can't be parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
mutator(scheduling.states!, customData);
|
||||||
|
|
||||||
|
scheduling.customData = JSON.stringify(customData);
|
||||||
|
|
||||||
|
await setCustomScheduling(key, scheduling);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue