Merge branch 'main' into color-palette

This commit is contained in:
Matthias Metelka 2022-08-29 05:08:27 +02:00 committed by GitHub
commit dfe3aba2d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 967 additions and 446 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.

View file

@ -54,7 +54,8 @@ 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-sticky = Toggle sticky editing-toggle-sticky = Toggle sticky
editing-toggle-visual-editor = Toggle Visual Editor editing-expand-field = Expand field
editing-collapse-field = Collapse field
editing-underline-text = Underline text editing-underline-text = Underline text
editing-unordered-list = Unordered list editing-unordered-list = Unordered list
editing-warning-cloze-deletions-will-not-work = Warning, cloze deletions will not work until you switch the type at the top to Cloze. editing-warning-cloze-deletions-will-not-work = Warning, cloze deletions will not work until you switch the type at the top to Cloze.

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

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

@ -43,6 +43,7 @@ from aqt.utils import (
saveGeom, saveGeom,
saveSplitter, saveSplitter,
send_to_trash, send_to_trash,
show_info,
showInfo, showInfo,
showWarning, showWarning,
tooltip, tooltip,
@ -862,14 +863,14 @@ class AddonsDialog(QDialog):
def onlyOneSelected(self) -> str | None: def onlyOneSelected(self) -> str | None:
dirs = self.selectedAddons() dirs = self.selectedAddons()
if len(dirs) != 1: if len(dirs) != 1:
showInfo(tr.addons_please_select_a_single_addon_first()) show_info(tr.addons_please_select_a_single_addon_first())
return None return None
return dirs[0] return dirs[0]
def selected_addon_meta(self) -> AddonMeta | None: def selected_addon_meta(self) -> AddonMeta | None:
idxs = [x.row() for x in self.form.addonList.selectedIndexes()] idxs = [x.row() for x in self.form.addonList.selectedIndexes()]
if len(idxs) != 1: if len(idxs) != 1:
showInfo(tr.addons_please_select_a_single_addon_first()) show_info(tr.addons_please_select_a_single_addon_first())
return None return None
return self.addons[idxs[0]] return self.addons[idxs[0]]

View file

@ -1370,6 +1370,7 @@ title="{}" {}>{}</button>""".format(
True, True,
parent=self, parent=self,
) )
self.progress.timer(12 * 60 * 1000, self.refresh_certs, False, parent=self)
def onRefreshTimer(self) -> None: def onRefreshTimer(self) -> None:
if self.state == "deckBrowser": if self.state == "deckBrowser":
@ -1385,6 +1386,15 @@ title="{}" {}>{}</button>""".format(
if elap > minutes * 60: if elap > minutes * 60:
self.maybe_auto_sync_media() self.maybe_auto_sync_media()
def refresh_certs(self) -> None:
# The requests library copies the certs into a temporary folder on startup,
# and chokes when the file is later missing due to temp file cleaners.
# Work around the issue by accessing them once every 12 hours.
import certifi
with open(certifi.where(), "rb") as f:
f.read()
# Backups # Backups
########################################################################## ##########################################################################

View file

@ -41,7 +41,14 @@ from aqt.qt import *
from aqt.sound import av_player, play_clicked_audio, record_audio from aqt.sound import av_player, play_clicked_audio, record_audio
from aqt.theme import theme_manager from aqt.theme import theme_manager
from aqt.toolbar import BottomBar from aqt.toolbar import BottomBar
from aqt.utils import askUserDialog, downArrow, qtMenuShortcutWorkaround, tooltip, tr from aqt.utils import (
askUserDialog,
downArrow,
qtMenuShortcutWorkaround,
show_warning,
tooltip,
tr,
)
class RefreshNeeded(Enum): class RefreshNeeded(Enum):
@ -136,6 +143,7 @@ class Reviewer:
def show(self) -> None: def show(self) -> None:
if self.mw.col.sched_ver() == 1: if self.mw.col.sched_ver() == 1:
self.mw.moveToState("deckBrowser") self.mw.moveToState("deckBrowser")
show_warning(tr.scheduling_update_required())
return return
self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore
self.web.set_bridge_command(self._linkHandler, self) self.web.set_bridge_command(self._linkHandler, self)

View file

@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import enum
import os import os
from concurrent.futures import Future from concurrent.futures import Future
from typing import Callable from typing import Callable
@ -27,8 +26,8 @@ from aqt.qt import (
qconnect, qconnect,
) )
from aqt.utils import ( from aqt.utils import (
ask_user_dialog,
askUser, askUser,
askUserDialog,
disable_help_button, disable_help_button,
showText, showText,
showWarning, showWarning,
@ -36,12 +35,6 @@ from aqt.utils import (
) )
class FullSyncChoice(enum.Enum):
CANCEL = 0
UPLOAD = 1
DOWNLOAD = 2
def get_sync_status( def get_sync_status(
mw: aqt.main.AnkiQt, callback: Callable[[SyncStatus], None] mw: aqt.main.AnkiQt, callback: Callable[[SyncStatus], None]
) -> None: ) -> None:
@ -102,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()
@ -135,13 +130,26 @@ def full_sync(
elif out.required == out.FULL_UPLOAD: elif out.required == out.FULL_UPLOAD:
full_upload(mw, on_done) full_upload(mw, on_done)
else: else:
choice = ask_user_to_decide_direction() button_labels: list[str] = [
if choice == FullSyncChoice.UPLOAD: tr.sync_upload_to_ankiweb(),
full_upload(mw, on_done) tr.sync_download_from_ankiweb(),
elif choice == FullSyncChoice.DOWNLOAD: tr.sync_cancel_button(),
full_download(mw, on_done) ]
else:
on_done() def callback(choice: int) -> None:
if choice == 0:
full_upload(mw, on_done)
elif choice == 1:
full_download(mw, on_done)
else:
on_done()
ask_user_dialog(
tr.sync_conflict_explanation(),
callback=callback,
buttons=button_labels,
default_button=2,
)
def confirm_full_download(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None: def confirm_full_download(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
@ -277,23 +285,6 @@ def sync_login(
) )
def ask_user_to_decide_direction() -> FullSyncChoice:
button_labels = [
tr.sync_upload_to_ankiweb(),
tr.sync_download_from_ankiweb(),
tr.sync_cancel_button(),
]
diag = askUserDialog(tr.sync_conflict_explanation(), button_labels)
diag.setDefault(2)
ret = diag.run()
if ret == button_labels[0]:
return FullSyncChoice.UPLOAD
elif ret == button_labels[1]:
return FullSyncChoice.DOWNLOAD
else:
return FullSyncChoice.CANCEL
def get_id_and_pass_from_user( def get_id_and_pass_from_user(
mw: aqt.main.AnkiQt, username: str = "", password: str = "" mw: aqt.main.AnkiQt, username: str = "", password: str = ""
) -> tuple[str, str]: ) -> tuple[str, str]:

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

@ -7,9 +7,9 @@ import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
from functools import wraps from functools import partial, wraps
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Sequence, no_type_check from typing import TYPE_CHECKING, Any, Callable, Literal, Sequence, Union, no_type_check
from send2trash import send2trash from send2trash import send2trash
@ -25,6 +25,56 @@ from anki.utils import (
version_with_build, version_with_build,
) )
from aqt.qt import * from aqt.qt import *
from aqt.qt import (
PYQT_VERSION_STR,
QT_VERSION_STR,
QAction,
QApplication,
QCheckBox,
QColor,
QComboBox,
QDesktopServices,
QDialog,
QDialogButtonBox,
QEvent,
QFileDialog,
QFrame,
QHeaderView,
QIcon,
QKeySequence,
QLabel,
QLineEdit,
QListWidget,
QMainWindow,
QMenu,
QMessageBox,
QMouseEvent,
QNativeGestureEvent,
QOffscreenSurface,
QOpenGLContext,
QPalette,
QPixmap,
QPlainTextEdit,
QPoint,
QPushButton,
QShortcut,
QSize,
QSplitter,
QStandardPaths,
Qt,
QTextBrowser,
QTextOption,
QTimer,
QUrl,
QVBoxLayout,
QWheelEvent,
QWidget,
pyqtSlot,
qconnect,
qtmajor,
qtminor,
traceback,
)
from aqt.theme import theme_manager from aqt.theme import theme_manager
if TYPE_CHECKING: if TYPE_CHECKING:
@ -70,6 +120,111 @@ def openLink(link: str | QUrl) -> None:
QDesktopServices.openUrl(QUrl(link)) QDesktopServices.openUrl(QUrl(link))
class MessageBox(QMessageBox):
def __init__(
self,
text: str,
callback: Callable[[int], None] | None = None,
parent: QWidget | None = None,
icon: QMessageBox.Icon = QMessageBox.Icon.NoIcon,
help: HelpPageArgument | None = None,
title: str = "Anki",
buttons: Sequence[str | QMessageBox.StandardButton] | None = None,
default_button: int = 0,
textFormat: Qt.TextFormat = Qt.TextFormat.PlainText,
) -> None:
parent = parent or aqt.mw.app.activeWindow() or aqt.mw
super().__init__(parent)
self.setText(text)
self.setWindowTitle(title)
self.setWindowModality(Qt.WindowModality.WindowModal)
self.setIcon(icon)
if icon == QMessageBox.Icon.Question and theme_manager.night_mode:
img = self.iconPixmap().toImage()
img.invertPixels()
self.setIconPixmap(QPixmap(img))
self.setTextFormat(textFormat)
if buttons is None:
buttons = [QMessageBox.StandardButton.Ok]
for i, button in enumerate(buttons):
if isinstance(button, str):
b = self.addButton(button, QMessageBox.ButtonRole.ActionRole)
elif isinstance(button, QMessageBox.StandardButton):
b = self.addButton(button)
else:
continue
if callback is not None:
qconnect(b.clicked, partial(callback, i))
if i == default_button:
self.setDefaultButton(b)
if help is not None:
b = self.addButton(QMessageBox.StandardButton.Help)
qconnect(b.clicked, lambda: openHelp(help))
self.open()
def ask_user(
text: str,
callback: Callable[[bool], None],
defaults_yes: bool = True,
**kwargs: Any,
) -> MessageBox:
"Shows a yes/no question, passes the answer to the callback function as a bool."
return MessageBox(
text,
callback=lambda response: callback(not response),
icon=QMessageBox.Icon.Question,
buttons=[QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No],
default_button=not defaults_yes,
**kwargs,
)
def ask_user_dialog(
text: str,
callback: Callable[[int], None],
buttons: Sequence[str | QMessageBox.StandardButton] | None = None,
default_button: int = 1,
**kwargs: Any,
) -> MessageBox:
"Shows a question to the user, passes the index of the button clicked to the callback."
if buttons is None:
buttons = [QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No]
return MessageBox(
text,
callback=callback,
icon=QMessageBox.Icon.Question,
buttons=buttons,
default_button=default_button,
**kwargs,
)
def show_info(text: str, callback: Callable | None = None, **kwargs: Any) -> MessageBox:
"Show a small info window with an OK button."
if "icon" not in kwargs:
kwargs["icon"] = QMessageBox.Icon.Information
return MessageBox(
text,
callback=(lambda _: callback()) if callback is not None else None,
**kwargs,
)
def show_warning(
text: str, callback: Callable | None = None, **kwargs: Any
) -> MessageBox:
"Show a small warning window with an OK button."
return show_info(text, icon=QMessageBox.Icon.Warning, callback=callback, **kwargs)
def show_critical(
text: str, callback: Callable | None = None, **kwargs: Any
) -> MessageBox:
"Show a small critical error window with an OK button."
return show_info(text, icon=QMessageBox.Icon.Critical, callback=callback, **kwargs)
def showWarning( def showWarning(
text: str, text: str,
parent: QWidget | None = None, parent: QWidget | None = None,

View file

@ -63,6 +63,8 @@ ignore_missing_imports = True
ignore_missing_imports = True ignore_missing_imports = True
[mypy-stringcase] [mypy-stringcase]
ignore_missing_imports = True ignore_missing_imports = True
[mypy-certifi]
ignore_missing_imports = True
[mypy-aqt.forms.*] [mypy-aqt.forms.*]
disallow_untyped_defs = false disallow_untyped_defs = false

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() .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", &[&["&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,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(&note, &dupe.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(()) 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();

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

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

View file

@ -161,6 +161,10 @@ $vars: (
light: get($color, indigo, 6), light: get($color, indigo, 6),
dark: get($color, indigo, 5), dark: get($color, indigo, 5),
), ),
code-bg: (
light: white,
dark: #272822,
),
), ),
); );

View file

@ -0,0 +1,100 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { promiseWithResolver } from "../lib/promise";
export let id: string | undefined = undefined;
let className: string = "";
export { className as class };
export let collapsed = false;
const [outerPromise, outerResolve] = promiseWithResolver<HTMLElement>();
const [innerPromise, innerResolve] = promiseWithResolver<HTMLElement>();
let isCollapsed = false;
let style: string;
function setStyle(height: number, duration: number) {
style = `--collapse-height: -${height}px; --duration: ${duration}ms`;
}
/* The following two functions use synchronous DOM-manipulation,
because Editor field inputs would lose focus when using tick() */
function getRequiredHeight(el: HTMLElement): number {
el.style.setProperty("position", "absolute");
el.style.setProperty("visibility", "hidden");
el.removeAttribute("hidden");
const height = el.clientHeight;
el.setAttribute("hidden", "");
el.style.removeProperty("position");
el.style.removeProperty("visibility");
return height;
}
async function transition(collapse: boolean) {
const outer = await outerPromise;
const inner = await innerPromise;
outer.style.setProperty("overflow", "hidden");
isCollapsed = true;
const height = collapse ? inner.clientHeight : getRequiredHeight(inner);
const duration = Math.sqrt(height * 80);
setStyle(height, duration);
if (!collapse) {
inner.removeAttribute("hidden");
isCollapsed = false;
}
inner.addEventListener(
"transitionend",
() => {
inner.toggleAttribute("hidden", collapse);
outer.style.removeProperty("overflow");
},
{ once: true },
);
}
/* prevent transition on mount for performance reasons */
let blockTransition = true;
$: if (blockTransition) {
blockTransition = false;
} else {
transition(collapsed);
}
</script>
<div {id} class="collapsible-container {className}" use:outerResolve>
<div
class="collapsible-inner"
class:collapsed={isCollapsed}
use:innerResolve
{style}
>
<slot />
</div>
</div>
<style lang="scss">
.collapsible-container {
position: relative;
}
.collapsible-inner {
transition: margin-top var(--duration) ease-in;
&.collapsed {
margin-top: var(--collapse-height);
}
}
</style>

View file

@ -0,0 +1,35 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Badge from "../components/Badge.svelte";
import { chevronDown } from "./icons";
export let collapsed = false;
export let highlighted = false;
</script>
<div class="collapse-badge" class:collapsed class:highlighted>
<Badge iconSize={80} --icon-align="text-bottom">{@html chevronDown}</Badge>
</div>
<style lang="scss">
.collapse-badge {
display: inline-block;
opacity: 0.4;
transition: opacity 0.2s ease-in-out, transform 80ms ease-in;
&.highlighted {
opacity: 1;
}
&.collapsed {
transform: rotate(-90deg);
}
}
:global([dir="rtl"]) {
.collapse-badge.collapsed {
transform: rotate(90deg);
}
}
</style>

View file

@ -189,10 +189,28 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
position: relative; position: relative;
background: var(--frame-bg); background: var(--frame-bg);
border-radius: 0 0 5px 5px; border-radius: 5px;
border: 1px solid var(--border);
&:focus { box-shadow: 0px 0px 2px 0px var(--border);
transition: box-shadow 80ms cubic-bezier(0.33, 1, 0.68, 1);
&:focus-within {
outline: none; outline: none;
/* This pseudo-element is required to display
the inset box-shadow above field contents */
&::after {
content: "";
position: absolute;
top: -1px;
right: -1px;
bottom: -1px;
left: -1px;
pointer-events: none;
border-radius: 5px;
box-shadow: inset 0 0 0 2px var(--focus-border);
}
} }
} }
</style> </style>

View file

@ -45,22 +45,26 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { directionKey } from "../lib/context-keys"; import Collapsible from "../components/Collapsible.svelte";
import { collapsedKey, directionKey } from "../lib/context-keys";
import { promiseWithResolver } from "../lib/promise"; import { promiseWithResolver } from "../lib/promise";
import type { Destroyable } from "./destroyable"; import type { Destroyable } from "./destroyable";
import EditingArea from "./EditingArea.svelte"; import EditingArea from "./EditingArea.svelte";
import FieldState from "./FieldState.svelte";
import LabelContainer from "./LabelContainer.svelte";
import LabelName from "./LabelName.svelte";
export let content: Writable<string>; export let content: Writable<string>;
export let field: FieldData; export let field: FieldData;
export let collapsed = false;
const directionStore = writable<"ltr" | "rtl">(); const directionStore = writable<"ltr" | "rtl">();
setContext(directionKey, directionStore); setContext(directionKey, directionStore);
$: $directionStore = field.direction; $: $directionStore = field.direction;
const collapsedStore = writable<boolean>();
setContext(collapsedKey, collapsedStore);
$: $collapsedStore = collapsed;
const editingArea: Partial<EditingAreaAPI> = {}; const editingArea: Partial<EditingAreaAPI> = {};
const [element, elementResolve] = promiseWithResolver<HTMLElement>(); const [element, elementResolve] = promiseWithResolver<HTMLElement>();
@ -85,37 +89,26 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
on:focusin on:focusin
on:focusout on:focusout
on:click={() => editingArea.focus?.()} on:click={() => editingArea.focus?.()}
on:mouseenter
on:mouseleave
> >
<LabelContainer> <slot name="field-label" />
<span>
<LabelName> <Collapsible {collapsed}>
{field.name} <EditingArea
</LabelName> {content}
</span> fontFamily={field.fontFamily}
<FieldState><slot name="field-state" /></FieldState> fontSize={field.fontSize}
</LabelContainer> api={editingArea}
<EditingArea >
{content} <slot name="editing-inputs" />
fontFamily={field.fontFamily} </EditingArea>
fontSize={field.fontSize} </Collapsible>
api={editingArea}
>
<slot name="editing-inputs" />
</EditingArea>
</div> </div>
<style lang="scss"> <style lang="scss">
.editor-field { .editor-field {
position: relative;
--border-color: var(--border); --border-color: var(--border);
border-radius: 5px;
border: 1px solid var(--border-color);
&:focus-within {
--border-color: var(--focus-border);
outline: none;
box-shadow: 0 0 0 3px var(--focus-shadow);
}
} }
</style> </style>

View file

@ -12,8 +12,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
justify-content: flex; justify-content: flex;
flex-grow: 1; flex-grow: 1;
& > :global(*) { /* replace with "gap: 5px" once it's available
margin-left: 2px; - required: Chromium 84 (Qt6 only) and iOS 14.1 */
> :global(*) {
margin: 0 3px;
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
}
}
:global([dir="rtl"]) .field-state > :global(*) {
margin: 0 3px;
&:last-child {
margin-left: 0;
}
&:first-child {
margin-right: 0;
} }
} }
</style> </style>

View file

@ -10,9 +10,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
.fields { .fields {
display: grid; display: grid;
grid-auto-rows: min-content; grid-auto-rows: min-content;
grid-gap: 4px; grid-gap: 6px;
padding: 5px 3px 0; padding: 0 3px;
/* set height to 100% for rich text widgets */ /* set height to 100% for rich text widgets */
height: 100%; height: 100%;

View file

@ -13,5 +13,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
overflow-x: hidden; overflow-x: hidden;
/* replace with "gap: 5px" once it's available
- required: Chromium 84 (Qt6 only) and iOS 14.1 */
> :global(*) {
margin: 5px 0;
}
} }
</style> </style>

View file

@ -3,38 +3,54 @@ 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
--> -->
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { createEventDispatcher } from "svelte";
import type { Readable } from "svelte/store";
import { directionKey } from "../lib/context-keys"; import * as tr from "../lib/ftl";
import CollapseBadge from "./CollapseBadge.svelte";
const direction = getContext<Readable<"ltr" | "rtl">>(directionKey); export let collapsed: boolean;
let hovered = false;
$: tooltip = collapsed ? tr.editingExpandField() : tr.editingCollapseField();
const dispatch = createEventDispatcher();
function toggle() {
dispatch("toggle");
}
</script> </script>
<div <div class="label-container" on:mousedown|preventDefault>
class="label-container" <span
class:rtl={$direction === "rtl"} class="clickable"
on:mousedown|preventDefault title={tooltip}
> on:click|stopPropagation={toggle}
on:mouseenter={() => (hovered = true)}
on:mouseleave={() => (hovered = false)}
>
<CollapseBadge {collapsed} highlighted={hovered} />
<slot name="field-name" />
</span>
<slot /> <slot />
</div> </div>
<style lang="scss"> <style lang="scss">
.label-container { .label-container {
display: flex; display: flex;
position: sticky;
justify-content: space-between; justify-content: space-between;
top: 0;
padding-bottom: 1px;
background-color: var(--label-color, transparent); /* slightly wider than EditingArea
to cover field borders on scroll */
left: -1px;
right: -1px;
z-index: 3;
background: var(--label-color);
border-width: 0 0 1px; .clickable {
border-style: dashed; cursor: pointer;
border-color: var(--border-color); }
border-radius: 5px 5px 0 0;
padding: 0px 6px;
}
.rtl {
direction: rtl;
} }
</style> </style>

View file

@ -45,7 +45,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script> </script>
<NoteEditor bind:this={noteEditor} {api}> <NoteEditor bind:this={noteEditor} {api}>
<svelte:fragment slot="field-state" let:index> <svelte:fragment slot="field-state" let:index let:visible>
<StickyBadge active={stickies[index]} {index} /> <StickyBadge bind:active={stickies[index]} {index} {visible} />
</svelte:fragment> </svelte:fragment>
</NoteEditor> </NoteEditor>

View file

@ -5,12 +5,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script context="module" lang="ts"> <script context="module" lang="ts">
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import Collapsible from "../components/Collapsible.svelte";
import type { EditingInputAPI } from "./EditingArea.svelte"; import type { EditingInputAPI } from "./EditingArea.svelte";
import type { EditorToolbarAPI } from "./editor-toolbar"; import type { EditorToolbarAPI } from "./editor-toolbar";
import type { EditorFieldAPI } from "./EditorField.svelte"; import type { EditorFieldAPI } from "./EditorField.svelte";
import FieldState from "./FieldState.svelte";
import LabelContainer from "./LabelContainer.svelte";
import LabelName from "./LabelName.svelte";
export interface NoteEditorAPI { export interface NoteEditorAPI {
fields: EditorFieldAPI[]; fields: EditorFieldAPI[];
hoveredField: Writable<EditorFieldAPI | null>;
focusedField: Writable<EditorFieldAPI | null>; focusedField: Writable<EditorFieldAPI | null>;
focusedInput: Writable<EditingInputAPI | null>; focusedInput: Writable<EditingInputAPI | null>;
toolbar: EditorToolbarAPI; toolbar: EditorToolbarAPI;
@ -61,7 +66,6 @@ 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
@ -128,10 +132,14 @@ 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 {
@ -272,11 +280,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let apiPartial: Partial<NoteEditorAPI> = {}; let apiPartial: Partial<NoteEditorAPI> = {};
export { apiPartial as api }; export { apiPartial as api };
const hoveredField: NoteEditorAPI["hoveredField"] = writable(null);
const focusedField: NoteEditorAPI["focusedField"] = writable(null); const focusedField: NoteEditorAPI["focusedField"] = writable(null);
const focusedInput: NoteEditorAPI["focusedInput"] = writable(null); const focusedInput: NoteEditorAPI["focusedInput"] = writable(null);
const api: NoteEditorAPI = { const api: NoteEditorAPI = {
...apiPartial, ...apiPartial,
hoveredField,
focusedField, focusedField,
focusedInput, focusedInput,
toolbar: toolbar as EditorToolbarAPI, toolbar: toolbar as EditorToolbarAPI,
@ -331,62 +341,93 @@ the AddCards dialog) should be implemented in the user of this component.
`blur:${index}:${getNoteId()}:${get(content)}`, `blur:${index}:${getNoteId()}:${get(content)}`,
); );
}} }}
on:mouseenter={() => {
$hoveredField = fields[index];
}}
on:mouseleave={() => {
$hoveredField = null;
}}
collapsed={fieldsCollapsed[index]}
--label-color={cols[index] === "dupe" --label-color={cols[index] === "dupe"
? "var(--flag1-bg)" ? "var(--flag1-bg)"
: "transparent"} : "var(--window-bg)"}
> >
<svelte:fragment slot="field-state"> <svelte:fragment slot="field-label">
{#if cols[index] === "dupe"} <LabelContainer
<DuplicateLink /> collapsed={fieldsCollapsed[index]}
{/if} on:toggle={async () => {
<RichTextBadge fieldsCollapsed[index] = !fieldsCollapsed[index];
bind:off={richTextsHidden[index]}
on:toggle={() => {
richTextsHidden[index] = !richTextsHidden[index];
if (!richTextsHidden[index]) { if (!fieldsCollapsed[index]) {
await tick();
richTextInputs[index].api.refocus(); richTextInputs[index].api.refocus();
} else {
plainTextsHidden[index] = true;
} }
}} }}
/>
<PlainTextBadge
bind:off={plainTextsHidden[index]}
on:toggle={() => {
plainTextsHidden[index] = !plainTextsHidden[index];
if (!plainTextsHidden[index]) {
plainTextInputs[index].api.refocus();
}
}}
/>
<slot name="field-state" {field} {index} />
</svelte:fragment>
<svelte:fragment slot="editing-inputs">
<RichTextInput
hidden={richTextsHidden[index]}
on:focusout={() => {
saveFieldNow();
$focusedInput = null;
}}
bind:this={richTextInputs[index]}
> >
<ImageHandle maxWidth={250} maxHeight={125} /> <svelte:fragment slot="field-name">
<MathjaxHandle /> <LabelName>
<FieldDescription> {field.name}
{field.description} </LabelName>
</FieldDescription> </svelte:fragment>
</RichTextInput> <FieldState>
{#if cols[index] === "dupe"}
<DuplicateLink />
{/if}
<PlainTextBadge
visible={!fieldsCollapsed[index] &&
(fields[index] === $hoveredField ||
fields[index] === $focusedField)}
bind:off={plainTextsHidden[index]}
on:toggle={async () => {
plainTextsHidden[index] =
!plainTextsHidden[index];
<PlainTextInput if (!plainTextsHidden[index]) {
hidden={plainTextsHidden[index]} await tick();
on:focusout={() => { plainTextInputs[index].api.refocus();
saveFieldNow(); }
$focusedInput = null; }}
}} />
bind:this={plainTextInputs[index]} <slot
/> name="field-state"
{field}
{index}
visible={fields[index] === $hoveredField ||
fields[index] === $focusedField}
/>
</FieldState>
</LabelContainer>
</svelte:fragment>
<svelte:fragment slot="editing-inputs">
<Collapsible collapsed={richTextsHidden[index]}>
<RichTextInput
bind:hidden={richTextsHidden[index]}
on:focusout={() => {
saveFieldNow();
$focusedInput = null;
}}
bind:this={richTextInputs[index]}
>
<ImageHandle maxWidth={250} maxHeight={125} />
<MathjaxHandle />
<FieldDescription>
{field.description}
</FieldDescription>
</RichTextInput>
</Collapsible>
<Collapsible collapsed={plainTextsHidden[index]}>
<PlainTextInput
bind:hidden={plainTextsHidden[index]}
on:focusout={() => {
saveFieldNow();
$focusedInput = null;
}}
bind:this={plainTextInputs[index]}
/>
</Collapsible>
</svelte:fragment> </svelte:fragment>
</EditorField> </EditorField>
{/each} {/each}

View file

@ -9,16 +9,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
import { getPlatformString, registerShortcut } from "../lib/shortcuts"; import { getPlatformString, registerShortcut } from "../lib/shortcuts";
import { context as editorFieldContext } from "./EditorField.svelte"; import { context as editorFieldContext } from "./EditorField.svelte";
import { htmlOff, htmlOn } from "./icons"; import { plainTextIcon } from "./icons";
const editorField = editorFieldContext.get(); const editorField = editorFieldContext.get();
const keyCombination = "Control+Shift+X"; const keyCombination = "Control+Shift+X";
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
export let visible = false;
export let off = false; export let off = false;
$: icon = off ? htmlOff : htmlOn;
function toggle() { function toggle() {
dispatch("toggle"); dispatch("toggle");
} }
@ -32,25 +31,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<span <span
class="plain-text-badge" class="plain-text-badge"
class:visible
class:highlighted={!off} class:highlighted={!off}
on:click|stopPropagation={toggle} on:click|stopPropagation={toggle}
> >
<Badge <Badge
tooltip="{tr.editingToggleHtmlEditor()} ({getPlatformString(keyCombination)})" tooltip="{tr.editingToggleHtmlEditor()} ({getPlatformString(keyCombination)})"
iconSize={80} iconSize={80}>{@html plainTextIcon}</Badge
--icon-align="text-top">{@html icon}</Badge
> >
</span> </span>
<style lang="scss"> <style lang="scss">
span { span {
opacity: 0.4; cursor: pointer;
opacity: 0;
&.visible {
opacity: 0.4;
&:hover {
opacity: 0.8;
}
}
&.highlighted { &.highlighted {
opacity: 1; opacity: 1;
} }
&:hover {
opacity: 0.8;
}
} }
</style> </style>

View file

@ -1,42 +0,0 @@
<!--
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 } from "svelte";
import Badge from "../components/Badge.svelte";
import * as tr from "../lib/ftl";
import { richTextOff, richTextOn } from "./icons";
export let off: boolean;
const dispatch = createEventDispatcher();
function toggle() {
dispatch("toggle");
}
$: icon = off ? richTextOff : richTextOn;
</script>
<span class="rich-text-badge" class:highlighted={off} on:click|stopPropagation={toggle}>
<Badge
tooltip={tr.editingToggleVisualEditor()}
iconSize={80}
--icon-align="text-top">{@html icon}</Badge
>
</span>
<style lang="scss">
span {
opacity: 0.4;
&.highlighted {
opacity: 1;
}
&:hover {
opacity: 0.8;
}
}
</style>

View file

@ -10,11 +10,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
import { getPlatformString, registerShortcut } from "../lib/shortcuts"; import { getPlatformString, registerShortcut } from "../lib/shortcuts";
import { context as editorFieldContext } from "./EditorField.svelte"; import { context as editorFieldContext } from "./EditorField.svelte";
import { stickyOff, stickyOn } from "./icons"; import { stickyIcon } from "./icons";
export let active: boolean; export let active: boolean;
export let visible: boolean;
$: icon = active ? stickyOn : stickyOff;
const editorField = editorFieldContext.get(); const editorField = editorFieldContext.get();
const keyCombination = "F9"; const keyCombination = "F9";
@ -34,23 +33,26 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
onMount(() => editorField.element.then(shortcut)); onMount(() => editorField.element.then(shortcut));
</script> </script>
<span class:highlighted={active} on:click|stopPropagation={toggle}> <span class:highlighted={active} class:visible on:click|stopPropagation={toggle}>
<Badge <Badge
tooltip="{tr.editingToggleSticky()} ({getPlatformString(keyCombination)})" tooltip="{tr.editingToggleSticky()} ({getPlatformString(keyCombination)})"
widthMultiplier={0.7} widthMultiplier={0.7}>{@html stickyIcon}</Badge
--icon-align="text-top">{@html icon}</Badge
> >
</span> </span>
<style lang="scss"> <style lang="scss">
span { span {
opacity: 0.4; cursor: pointer;
opacity: 0;
&.visible {
transition: none;
opacity: 0.4;
&:hover {
opacity: 0.8;
}
}
&.highlighted { &.highlighted {
opacity: 1; opacity: 1;
} }
&:hover {
opacity: 0.8;
}
} }
</style> </style>

View file

@ -5,10 +5,9 @@
export { default as incrementClozeIcon } from "../icons/contain-plus.svg"; export { default as incrementClozeIcon } from "../icons/contain-plus.svg";
export { default as alertIcon } from "@mdi/svg/svg/alert.svg"; export { default as alertIcon } from "@mdi/svg/svg/alert.svg";
export { default as htmlOn } from "@mdi/svg/svg/code-tags.svg"; export { default as chevronDown } from "@mdi/svg/svg/chevron-down.svg";
export { default as chevronUp } from "@mdi/svg/svg/chevron-up.svg";
export { default as plainTextIcon } from "@mdi/svg/svg/code-tags.svg";
export { default as clozeIcon } from "@mdi/svg/svg/contain.svg"; export { default as clozeIcon } from "@mdi/svg/svg/contain.svg";
export { default as richTextOff } from "@mdi/svg/svg/eye-off-outline.svg"; export { default as richTextIcon } from "@mdi/svg/svg/format-font.svg";
export { default as richTextOn } from "@mdi/svg/svg/eye-outline.svg"; export { default as stickyIcon } from "@mdi/svg/svg/pin-outline.svg";
export { default as stickyOff } from "@mdi/svg/svg/pin-off-outline.svg";
export { default as stickyOn } from "@mdi/svg/svg/pin-outline.svg";
export { default as htmlOff } from "@mdi/svg/svg/xml.svg";

View file

@ -143,7 +143,6 @@ 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:hidden
on:focusin={() => ($focusedInput = api)} on:focusin={() => ($focusedInput = api)}
> >
<CodeMirror <CodeMirror
@ -161,18 +160,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
:global(.CodeMirror) { :global(.CodeMirror) {
border-radius: 0 0 5px 5px; border-radius: 0 0 5px 5px;
border-top: 1px solid var(--border);
background: var(--code-bg);
} }
:global(.CodeMirror-lines) { :global(.CodeMirror-lines) {
padding: 6px 0; padding: 8px 0;
} }
&.hidden {
display: none;
}
}
.light-theme :global(.CodeMirror) {
border-top: 1px solid #ddd;
} }
</style> </style>

View file

@ -211,7 +211,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
setupLifecycleHooks(api); setupLifecycleHooks(api);
</script> </script>
<div class="rich-text-input" {hidden} on:focusin={setFocus} on:focusout={removeFocus}> <div class="rich-text-input" on:focusin={setFocus} on:focusout={removeFocus}>
<RichTextStyles <RichTextStyles
color={$pageTheme.isDark ? "white" : "black"} color={$pageTheme.isDark ? "white" : "black"}
fontFamily={$fontFamily} fontFamily={$fontFamily}
@ -238,6 +238,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</div> </div>
{/await} {/await}
</RichTextStyles> </RichTextStyles>
<slot name="plain-text-badge" />
</div> </div>
<style lang="scss"> <style lang="scss">

View file

@ -9,4 +9,35 @@ describe("filterHTML", () => {
expect(filterHTML("", true, false)).toBe(""); expect(filterHTML("", true, false)).toBe("");
expect(filterHTML("", false, false)).toBe(""); expect(filterHTML("", false, false)).toBe("");
}); });
test("internal filtering", () => {
// font-size is filtered, weight is not
expect(
filterHTML(
'<div style="font-weight: bold; font-size: 10px;"></div>',
true,
true,
),
).toBe('<div style="font-weight: bold;"></div>');
});
test("background color", () => {
// transparent is stripped, other colors are not
expect(
filterHTML(
'<span style="background-color: transparent;"></span>',
false,
true,
),
).toBe('<span style=""></span>');
expect(
filterHTML('<span style="background-color: blue;"></span>', false, true),
).toBe('<span style="background-color: blue;"></span>');
// except if extended mode is off
expect(
filterHTML('<span style="background-color: blue;">x</span>', false, false),
).toBe("x");
// or if it's an internal paste
expect(
filterHTML('<span style="background-color: blue;"></span>', true, true),
).toBe('<span style=""></span>');
});
}); });

View file

@ -1,63 +1,50 @@
// 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
interface AllowPropertiesBlockValues { /// Keep property if true.
[property: string]: string[];
}
type BlockProperties = string[];
type StylingPredicate = (property: string, value: string) => boolean; type StylingPredicate = (property: string, value: string) => boolean;
const stylingNightMode: AllowPropertiesBlockValues = { const keep = (_key: string, _value: string) => true;
"font-weight": [], const discard = (_key: string, _value: string) => false;
"font-style": [],
"text-decoration-line": [],
};
const stylingLightMode: AllowPropertiesBlockValues = { /// Return a function that filters out certain styles.
color: [], /// - If the style is listed in `exceptions`, the provided predicate is used.
"background-color": ["transparent"], /// - If the style is not listed, the default predicate is used instead.
...stylingNightMode, function filterStyling(
}; defaultPredicate: StylingPredicate,
exceptions: Record<string, StylingPredicate>,
const stylingInternal: BlockProperties = [ ): (element: HTMLElement) => void {
"background-color", return (element: HTMLElement): void => {
"font-size", // jsdom does not support @@iterator, so manually iterate
"font-family", for (let i = 0; i < element.style.length; i++) {
"width", const key = element.style.item(i);
"height", const value = element.style.getPropertyValue(key);
"max-width", const predicate = exceptions[key] ?? defaultPredicate;
"max-height", if (!predicate(key, value)) {
]; element.style.removeProperty(key);
const allowPropertiesBlockValues =
(allowBlock: AllowPropertiesBlockValues): StylingPredicate =>
(property: string, value: string): boolean =>
Object.prototype.hasOwnProperty.call(allowBlock, property) &&
!allowBlock[property].includes(value);
const blockProperties =
(block: BlockProperties): StylingPredicate =>
(property: string): boolean =>
!block.includes(property);
const filterStyling =
(predicate: (property: string, value: string) => boolean) =>
(element: HTMLElement): void => {
for (const property of [...element.style]) {
const value = element.style.getPropertyValue(property);
if (!predicate(property, value)) {
element.style.removeProperty(property);
} }
} }
}; };
}
export const filterStylingNightMode = filterStyling( const nightModeExceptions = {
allowPropertiesBlockValues(stylingNightMode), "font-weight": keep,
); "font-style": keep,
export const filterStylingLightMode = filterStyling( "text-decoration-line": keep,
allowPropertiesBlockValues(stylingLightMode), };
);
export const filterStylingInternal = filterStyling(blockProperties(stylingInternal)); export const filterStylingNightMode = filterStyling(discard, nightModeExceptions);
export const filterStylingLightMode = filterStyling(discard, {
color: keep,
"background-color": (_key: string, value: string) => value != "transparent",
...nightModeExceptions,
});
export const filterStylingInternal = filterStyling(keep, {
"background-color": discard,
"font-size": discard,
"font-family": discard,
width: discard,
height: discard,
"max-width": discard,
"max-height": discard,
});

View file

@ -5,3 +5,4 @@ export const fontFamilyKey = Symbol("fontFamily");
export const fontSizeKey = Symbol("fontSize"); export const fontSizeKey = Symbol("fontSize");
export const directionKey = Symbol("direction"); export const directionKey = Symbol("direction");
export const descriptionKey = Symbol("description"); export const descriptionKey = Symbol("description");
export const collapsedKey = Symbol("collapsed");

View file

@ -4,5 +4,5 @@
/// <reference types="../../lib/image-import" /> /// <reference types="../../lib/image-import" />
export { default as dotsIcon } from "@mdi/svg/svg/dots-vertical.svg"; export { default as dotsIcon } from "@mdi/svg/svg/dots-vertical.svg";
export { default as tagIcon } from "@mdi/svg/svg/tag.svg"; export { default as tagIcon } from "@mdi/svg/svg/tag-outline.svg";
export { default as addTagIcon } from "@mdi/svg/svg/tag-plus.svg"; export { default as addTagIcon } from "@mdi/svg/svg/tag-plus-outline.svg";