Merge branch 'main' into custom-qt-controls

This commit is contained in:
Matthias Metelka 2022-09-04 13:57:24 +02:00
commit 123e2377de
53 changed files with 876 additions and 305 deletions

1
.gitignore vendored
View file

@ -6,5 +6,6 @@ target
.dmypy.json .dmypy.json
node_modules node_modules
/.idea/ /.idea/
/.vscode/
/.bazel /.bazel
/windows.bazelrc /windows.bazelrc

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()
.split_whitespace()
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.map(ToString::to_string) .map(ToString::to_string)
.collect() .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", &[&["&lt;hr&gt;", ""]]); assert_imported_fields!(metadata, "<hr>\n", [[Some("&lt;hr&gt;"), 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")));
} }
} }

View file

@ -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(&notetype, deck_id, self.today, global_tags); ) -> Result<NoteContext<'tags>> {
note.prepare_for_update(&notetype, self.normalize_notes)?; self.prepare_foreign_note(&mut note)?;
self.col.canonify_note_tags(&mut note, self.usn)?;
let dupes = self.find_duplicates(&notetype, &note)?; let dupes = self.find_duplicates(&notetype, &note)?;
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(&note.guid) { if let Some(nid) = self.existing_guids.get(&note.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, &note, 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,60 +313,43 @@ 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,
updated_tags: &[String],
log: &mut NoteLog,
) -> Result<()> {
self.prepare_note_for_update(&mut ctx.note, updated_tags)?;
for dupe in mem::take(&mut ctx.dupes) {
self.maybe_update_dupe(dupe, &mut ctx, log)?;
}
Ok(())
}
fn prepare_note_for_update(&mut self, note: &mut Note, updated_tags: &[String]) -> Result<()> {
if !updated_tags.is_empty() {
note.tags.extend(updated_tags.iter().cloned());
self.col.canonify_note_tags(note, self.usn)?;
}
note.set_modified(self.usn);
Ok(())
}
fn maybe_update_dupe(
&mut self,
dupe: Duplicate,
ctx: &mut NoteContext,
log: &mut NoteLog,
) -> Result<()> {
if dupe.note.notetype_id != ctx.notetype.id { if dupe.note.notetype_id != ctx.notetype.id {
log.conflicting.push(dupe.note.into_log_note()); log.conflicting.push(dupe.note.into_log_note());
return Ok(()); continue;
}
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( let mut note = dupe.note.clone();
&mut self, let mut cards = ctx.note.clone().into_native(
dupe: Duplicate, &mut note,
ctx: &mut NoteContext, ctx.deck_id,
log: &mut NoteLog, self.today,
) -> Result<()> { ctx.global_tags.iter().chain(ctx.updated_tags.iter()),
ctx.note.id = dupe.note.id; );
ctx.note.guid = dupe.note.guid.clone();
self.col.update_note_undoable(&ctx.note, &dupe.note)?; if !dupe.identical {
if dupe.first_field_match { self.prepare_note(&mut note, &ctx.notetype)?;
log.first_field_match.push(dupe.note.into_log_note()); self.col.update_note_undoable(&note, &dupe.note)?;
} else {
log.updated.push(dupe.note.into_log_note());
} }
self.add_cards(&mut cards, &note, 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(())
}
fn prepare_note(&mut self, note: &mut Note, notetype: &Notetype) -> Result<()> {
note.prepare_for_update(notetype, self.normalize_notes)?;
self.col.canonify_note_tags(note, self.usn)?;
note.set_modified(self.usn);
Ok(()) Ok(())
} }
@ -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();

View file

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

View file

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

View file

@ -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![],
}, },
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
}) })
} }

View file

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

View file

@ -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);
if register {
added |= self.register_tag(&mut tag)?; 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));
} }

View file

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

View file

@ -21,3 +21,7 @@ p {
:host(body) { :host(body) {
@include scrollbar.custom; @include scrollbar.custom;
} }
pre {
white-space: pre-wrap;
}

View file

@ -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 {
:global(.CodeMirror) {
height: auto; height: auto;
} }
:global(.CodeMirror-wrap pre) {
word-break: break-word;
}
}
</style> </style>

View file

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

View file

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

View file

@ -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,6 +388,24 @@ the AddCards dialog) should be implemented in the user of this component.
{#if cols[index] === "dupe"} {#if cols[index] === "dupe"}
<DuplicateLink /> <DuplicateLink />
{/if} {/if}
{#if plainTextDefaults[index]}
<RichTextBadge
visible={!fieldsCollapsed[index] &&
(fields[index] === $hoveredField ||
fields[index] === $focusedField)}
bind:off={richTextsHidden[index]}
on:toggle={async () => {
richTextsHidden[index] =
!richTextsHidden[index];
if (!richTextsHidden[index]) {
refocusInput(
richTextInputs[index].api,
);
}
}}
/>
{:else}
<PlainTextBadge <PlainTextBadge
visible={!fieldsCollapsed[index] && visible={!fieldsCollapsed[index] &&
(fields[index] === $hoveredField || (fields[index] === $hoveredField ||
@ -384,11 +416,13 @@ the AddCards dialog) should be implemented in the user of this component.
!plainTextsHidden[index]; !plainTextsHidden[index];
if (!plainTextsHidden[index]) { if (!plainTextsHidden[index]) {
await tick(); refocusInput(
plainTextInputs[index].api.refocus(); 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;

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

View file

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

View file

@ -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;
:global(.CodeMirror) {
border-radius: 0 0 5px 5px;
border-top: 1px solid var(--border); 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) {
background: var(--code-bg); background: var(--code-bg);
} }
:global(.CodeMirror-lines) { :global(.CodeMirror-lines) {

View file

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

View file

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