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
node_modules
/.idea/
/.vscode/
/.bazel
/windows.bazelrc

View file

@ -1,8 +1,7 @@
# Editing/IDEs
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
for you to install.
Anki uses. To set up the recommended workspace settings for VS Code, please see below.
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.
@ -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
'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
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-toggle-html-editor = Toggle HTML Editor
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-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.

View file

@ -10,7 +10,22 @@ if TYPE_CHECKING:
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."
def __init__(self, localized: str) -> None:
@ -29,7 +44,7 @@ class DocumentedError(LocalizedError):
super().__init__(localized)
class Interrupted(Exception):
class Interrupted(AnkiException):
pass
@ -68,7 +83,7 @@ class TemplateError(LocalizedError):
pass
class NotFoundError(Exception):
class NotFoundError(AnkiException):
pass
@ -76,11 +91,11 @@ class DeletedError(LocalizedError):
pass
class ExistsError(Exception):
class ExistsError(AnkiException):
pass
class UndoEmpty(Exception):
class UndoEmpty(AnkiException):
pass
@ -96,7 +111,7 @@ class SearchError(LocalizedError):
pass
class AbortSchemaModification(Exception):
class AbortSchemaModification(AnkiException):
pass

View file

@ -18,8 +18,8 @@ pyqt6-sip==13.4.0 \
--hash=sha256:2694ae67811cefb6ea3ee0e9995755b45e4952f4dcadec8c04300fd828f91c75 \
--hash=sha256:3486914137f5336cff6e10a5e9d52c1e60ff883473938b45f267f794daeacb2f \
--hash=sha256:3ac7e0800180202dcc0c7035ff88c2a6f4a0f5acb20c4a19f71d807d0f7857b7 \
--hash=sha256:3de18c4a32f717a351d560a39f528af24077f5135aacfa8890a2f2d79f0633da \
--hash=sha256:6d87a3ee5872d7511b76957d68a32109352caf3b7a42a01d9ee20032b350d979 \
--hash=sha256:7b9bbb5fb880440a3a8e7fa3dff70473aa1128aaf7dc9fb6e30512eed4fd38f6 \
--hash=sha256:802b0cfed19900183220c46895c2635f0dd062f2d275a25506423f911ef74db4 \
--hash=sha256:83b446d247a92d119d507dbc94fc1f47389d8118a5b6232a2859951157319a30 \
--hash=sha256:9c5231536e6153071b22175e46e368045fd08d772a90d772a0977d1166c7822c \

View file

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

View file

@ -1370,6 +1370,7 @@ title="{}" {}>{}</button>""".format(
True,
parent=self,
)
self.progress.timer(12 * 60 * 1000, self.refresh_certs, False, parent=self)
def onRefreshTimer(self) -> None:
if self.state == "deckBrowser":
@ -1385,6 +1386,15 @@ title="{}" {}>{}</button>""".format(
if elap > minutes * 60:
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
##########################################################################

View file

@ -41,7 +41,14 @@ from aqt.qt import *
from aqt.sound import av_player, play_clicked_audio, record_audio
from aqt.theme import theme_manager
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):
@ -136,6 +143,7 @@ class Reviewer:
def show(self) -> None:
if self.mw.col.sched_ver() == 1:
self.mw.moveToState("deckBrowser")
show_warning(tr.scheduling_update_required())
return
self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore
self.web.set_bridge_command(self._linkHandler, self)

View file

@ -3,7 +3,6 @@
from __future__ import annotations
import enum
import os
from concurrent.futures import Future
from typing import Callable
@ -27,8 +26,8 @@ from aqt.qt import (
qconnect,
)
from aqt.utils import (
ask_user_dialog,
askUser,
askUserDialog,
disable_help_button,
showText,
showWarning,
@ -36,12 +35,6 @@ from aqt.utils import (
)
class FullSyncChoice(enum.Enum):
CANCEL = 0
UPLOAD = 1
DOWNLOAD = 2
def get_sync_status(
mw: aqt.main.AnkiQt, callback: Callable[[SyncStatus], 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:
mw.col.db.begin()
# scheduler version may have changed
mw.col._load_scheduler()
timer.stop()
try:
out: SyncOutput = fut.result()
@ -135,14 +130,27 @@ def full_sync(
elif out.required == out.FULL_UPLOAD:
full_upload(mw, on_done)
else:
choice = ask_user_to_decide_direction()
if choice == FullSyncChoice.UPLOAD:
button_labels: list[str] = [
tr.sync_upload_to_ankiweb(),
tr.sync_download_from_ankiweb(),
tr.sync_cancel_button(),
]
def callback(choice: int) -> None:
if choice == 0:
full_upload(mw, on_done)
elif choice == FullSyncChoice.DOWNLOAD:
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:
# confirmation step required, as some users customize their notetypes
@ -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(
mw: aqt.main.AnkiQt, username: str = "", password: str = ""
) -> tuple[str, str]:

View file

@ -144,8 +144,8 @@ class Toolbar:
self.link_handlers[label] = self._syncLinkHandler
return f"""
<a class=hitem tabindex="-1" aria-label="{name}" title="{title}" id="{label}" href=# onclick="return pycmd('{label}')">{name}
<img id=sync-spinner src='/_anki/imgs/refresh.svg'>
<a class=hitem tabindex="-1" aria-label="{name}" title="{title}" id="{label}" href=# onclick="return pycmd('{label}')"
>{name}<img id=sync-spinner src='/_anki/imgs/refresh.svg'>
</a>"""
def set_sync_active(self, active: bool) -> None:

View file

@ -7,9 +7,9 @@ import re
import shutil
import subprocess
import sys
from functools import wraps
from functools import partial, wraps
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
@ -25,6 +25,56 @@ from anki.utils import (
version_with_build,
)
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
if TYPE_CHECKING:
@ -70,6 +120,111 @@ def openLink(link: str | QUrl) -> None:
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(
text: str,
parent: QWidget | None = None,

View file

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

View file

@ -56,7 +56,7 @@ impl CsvMetadata {
.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()? {
CsvNotetype::GlobalNotetype(global) => global
.field_columns
@ -115,8 +115,7 @@ struct ColumnContext {
guid_column: Option<usize>,
deck_column: Option<usize>,
notetype_column: Option<usize>,
/// Source column indices for the fields of a notetype, identified by its
/// name or id as string. The empty string corresponds to the default notetype.
/// Source column indices for the fields of a notetype
field_source_columns: FieldSourceColumns,
/// How fields are converted to strings. Used for escaping HTML if appropriate.
stringify: fn(&str) -> String,
@ -168,22 +167,20 @@ impl ColumnContext {
}
}
fn gather_tags(&self, record: &csv::StringRecord) -> Vec<String> {
self.tags_column
.and_then(|i| record.get(i - 1))
.unwrap_or_default()
.split_whitespace()
fn gather_tags(&self, record: &csv::StringRecord) -> Option<Vec<String>> {
self.tags_column.and_then(|i| record.get(i - 1)).map(|s| {
s.split_whitespace()
.filter(|s| !s.is_empty())
.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;
self.field_source_columns
.iter()
.map(|opt| opt.and_then(|idx| record.get(idx - 1)).unwrap_or_default())
.map(stringify)
.map(|opt| opt.and_then(|idx| record.get(idx - 1)).map(stringify))
.collect()
}
}
@ -253,7 +250,19 @@ mod test {
($metadata:expr, $csv:expr, $expected:expr) => {
let notes = import!(&$metadata, $csv);
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]
fn should_allow_missing_columns() {
let metadata = CsvMetadata::defaults_for_testing();
assert_imported_fields!(metadata, "foo\n", &[&["foo", ""]]);
assert_imported_fields!(metadata, "foo\n", [[Some("foo"), None]]);
}
#[test]
fn should_respect_custom_delimiter() {
let mut metadata = CsvMetadata::defaults_for_testing();
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]
fn should_ignore_first_line_starting_with_tags() {
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]
@ -308,21 +325,29 @@ mod test {
id: 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]
fn should_ignore_lines_starting_with_number_sign() {
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]
fn should_escape_html_entities_if_csv_is_html() {
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;
assert_imported_fields!(metadata, "<hr>\n", &[&["<hr>", ""]]);
assert_imported_fields!(metadata, "<hr>\n", [[Some("<hr>"), None]]);
}
#[test]
@ -330,7 +355,7 @@ mod test {
let mut metadata = CsvMetadata::defaults_for_testing();
metadata.tags_column = 3;
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]
@ -347,9 +372,9 @@ mod test {
metadata.notetype.replace(CsvNotetype::NotetypeColumn(1));
metadata.column_labels.push("".to_string());
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[1].fields, &["foo", "bar"]);
assert_field_eq!(notes[1].fields, [Some("foo"), Some("bar")]);
assert_eq!(notes[1].notetype, NameOrId::Name(String::from("Cloze")));
}
}

View file

@ -4,7 +4,6 @@
use std::{
borrow::Cow,
collections::{HashMap, HashSet},
mem,
sync::Arc,
};
@ -16,8 +15,9 @@ use crate::{
text::{
DupeResolution, ForeignCard, ForeignData, ForeignNote, ForeignNotetype, ForeignTemplate,
},
ImportProgress, IncrementableProgress, LogNote, NoteLog,
ImportProgress, IncrementableProgress, NoteLog,
},
notes::{field_checksum, normalize_field},
notetype::{CardGenContext, CardTemplate, NoteField, NotetypeConfig},
prelude::*,
text::strip_html_preserving_media_filenames,
@ -78,13 +78,13 @@ struct DeckIdsByNameOrId {
default: Option<DeckId>,
}
struct NoteContext {
/// Prepared and with canonified tags.
note: Note,
struct NoteContext<'a> {
note: ForeignNote,
dupes: Vec<Duplicate>,
cards: Vec<Card>,
notetype: Arc<Notetype>,
deck_id: DeckId,
global_tags: &'a [String],
updated_tags: &'a [String],
}
struct Duplicate {
@ -94,8 +94,8 @@ struct Duplicate {
}
impl Duplicate {
fn new(dupe: Note, original: &Note, first_field_match: bool) -> Self {
let identical = dupe.equal_fields_and_tags(original);
fn new(dupe: Note, original: &ForeignNote, first_field_match: bool) -> Self {
let identical = original.equal_fields_and_tags(&dupe);
Self {
note: dupe,
identical,
@ -190,14 +190,20 @@ impl<'a> Context<'a> {
let mut log = NoteLog::new(self.dupe_resolution, notes.len() as u32);
for foreign in notes {
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());
continue;
}
if let Some(notetype) = self.notetype_for_note(&foreign)? {
if let Some(deck_id) = self.deck_ids.get(&foreign.deck) {
let ctx = self.build_note_context(foreign, notetype, deck_id, global_tags)?;
self.import_note(ctx, updated_tags, &mut log)?;
let ctx = self.build_note_context(
foreign,
notetype,
deck_id,
global_tags,
updated_tags,
)?;
self.import_note(ctx, &mut log)?;
} else {
log.missing_deck.push(foreign.into_log_note());
}
@ -208,41 +214,45 @@ impl<'a> Context<'a> {
Ok(log)
}
fn build_note_context(
fn build_note_context<'tags>(
&mut self,
foreign: ForeignNote,
mut note: ForeignNote,
notetype: Arc<Notetype>,
deck_id: DeckId,
global_tags: &[String],
) -> Result<NoteContext> {
let (mut note, cards) = foreign.into_native(&notetype, deck_id, self.today, global_tags);
note.prepare_for_update(&notetype, self.normalize_notes)?;
self.col.canonify_note_tags(&mut note, self.usn)?;
global_tags: &'tags [String],
updated_tags: &'tags [String],
) -> Result<NoteContext<'tags>> {
self.prepare_foreign_note(&mut note)?;
let dupes = self.find_duplicates(&notetype, &note)?;
Ok(NoteContext {
note,
dupes,
cards,
notetype,
deck_id,
global_tags,
updated_tags,
})
}
fn find_duplicates(&self, notetype: &Notetype, note: &Note) -> Result<Vec<Duplicate>> {
let checksum = note
.checksum
.ok_or_else(|| AnkiError::invalid_input("note unprepared"))?;
fn prepare_foreign_note(&mut self, note: &mut ForeignNote) -> Result<()> {
note.normalize_fields(self.normalize_notes);
self.col.canonify_foreign_tags(note, self.usn)
}
fn find_duplicates(&self, notetype: &Notetype, note: &ForeignNote) -> Result<Vec<Duplicate>> {
if let Some(nid) = self.existing_guids.get(&note.guid) {
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)
} else {
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
.storage
.get_note(nid)?
@ -250,7 +260,7 @@ impl<'a> Context<'a> {
.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
.col
.get_full_duplicates(note, nids)?
@ -259,26 +269,36 @@ impl<'a> Context<'a> {
.collect())
}
fn import_note(
&mut self,
ctx: NoteContext,
updated_tags: &[String],
log: &mut NoteLog,
) -> Result<()> {
fn import_note(&mut self, ctx: NoteContext, log: &mut NoteLog) -> Result<()> {
match self.dupe_resolution {
_ if ctx.dupes.is_empty() => self.add_note(ctx, &mut log.new)?,
DupeResolution::Add => self.add_note(ctx, &mut log.first_field_match)?,
DupeResolution::Update => self.update_with_note(ctx, updated_tags, log)?,
_ if ctx.dupes.is_empty() => self.add_note(ctx, log, false)?,
DupeResolution::Add => self.add_note(ctx, log, true)?,
DupeResolution::Update => self.update_with_note(ctx, log)?,
DupeResolution::Ignore => log.first_field_match.push(ctx.note.into_log_note()),
}
Ok(())
}
fn add_note(&mut self, mut ctx: NoteContext, log_queue: &mut Vec<LogNote>) -> Result<()> {
ctx.note.usn = self.usn;
self.col.add_note_only_undoable(&mut ctx.note)?;
self.add_cards(&mut ctx.cards, &ctx.note, ctx.deck_id, ctx.notetype)?;
log_queue.push(ctx.note.into_log_note());
fn add_note(&mut self, ctx: NoteContext, log: &mut NoteLog, dupe: bool) -> Result<()> {
if !ctx.note.first_field_is_unempty() {
log.empty_first_field.push(ctx.note.into_log_note());
return Ok(());
}
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(())
}
@ -293,60 +313,43 @@ impl<'a> Context<'a> {
self.generate_missing_cards(notetype, deck_id, note)
}
fn update_with_note(
&mut self,
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<()> {
fn update_with_note(&mut self, ctx: NoteContext, log: &mut NoteLog) -> Result<()> {
for dupe in ctx.dupes {
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())
continue;
}
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());
let mut note = dupe.note.clone();
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(())
}
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(())
}
@ -397,8 +400,18 @@ impl Collection {
}
}
fn get_full_duplicates(&self, note: &Note, dupe_ids: &[NoteId]) -> Result<Vec<Note>> {
let first_field = note.first_field_stripped();
fn canonify_foreign_tags(&mut self, note: &mut ForeignNote, usn: Usn) -> Result<()> {
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
.iter()
.filter_map(|&dupe_id| self.storage.get_note(dupe_id).transpose())
@ -411,35 +424,72 @@ impl Collection {
}
impl ForeignNote {
fn into_native(
/// Updates a native note with the foreign data and returns its new cards.
fn into_native<'tags>(
self,
notetype: &Notetype,
note: &mut Note,
deck_id: DeckId,
today: u32,
extra_tags: &[String],
) -> (Note, Vec<Card>) {
extra_tags: impl IntoIterator<Item = &'tags String>,
) -> Vec<Card> {
// TODO: Handle new and learning cards
let mut note = Note::new(notetype);
if !self.guid.is_empty() {
note.guid = self.guid;
}
note.tags = self.tags;
note.tags.extend(extra_tags.iter().cloned());
if let Some(tags) = self.tags {
note.tags = tags;
}
note.tags.extend(extra_tags.into_iter().cloned());
note.fields_mut()
.iter_mut()
.zip(self.fields.into_iter())
.for_each(|(field, value)| *field = value);
let cards = self
.cards
.for_each(|(field, new)| {
if let Some(s) = new {
*field = s;
}
});
self.cards
.into_iter()
.enumerate()
.map(|(idx, c)| c.into_native(NoteId(0), idx as u16, deck_id, today))
.collect();
(note, cards)
.collect()
}
fn first_field_is_empty(&self) -> bool {
self.fields.get(0).map(String::is_empty).unwrap_or(true)
fn first_field_is_the_empty_string(&self) -> bool {
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)]
mod test {
use super::*;
@ -515,7 +559,7 @@ mod test {
fn add_note(&mut self, fields: &[&str]) {
self.notes.push(ForeignNote {
fields: fields.iter().map(ToString::to_string).collect(),
fields: fields.iter().map(ToString::to_string).map(Some).collect(),
..Default::default()
});
}
@ -543,7 +587,7 @@ mod test {
data.clone().import(&mut col, |_, _| true).unwrap();
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();
let notes = col.storage.get_all_notes();
assert_eq!(notes.len(), 1);
@ -560,11 +604,30 @@ mod test {
data.clone().import(&mut col, |_, _| true).unwrap();
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();
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]
fn should_recognize_normalized_duplicate_only_if_normalization_is_enabled() {
let mut col = open_test_collection();
@ -589,7 +652,7 @@ mod test {
let mut col = open_test_collection();
let mut data = ForeignData::with_defaults();
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.import(&mut col, |_, _| true).unwrap();
@ -601,7 +664,7 @@ mod test {
let mut col = open_test_collection();
let mut data = ForeignData::with_defaults();
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.import(&mut col, |_, _| true).unwrap();

View file

@ -26,8 +26,8 @@ pub struct ForeignData {
#[serde(default)]
pub struct ForeignNote {
guid: String,
fields: Vec<String>,
tags: Vec<String>,
fields: Vec<Option<String>>,
tags: Option<Vec<String>>,
notetype: NameOrId,
deck: NameOrId,
cards: Vec<ForeignCard>,
@ -82,7 +82,11 @@ impl ForeignNote {
pub(crate) fn into_log_note(self) -> LogNote {
LogNote {
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 {
if field.contains(invalid_char_for_field) {
*field = field.replace(invalid_char_for_field, "");
}
}
if normalize_text {
for field in &mut self.fields {
ensure_string_in_nfc(field);
}
for field in self.fields_mut() {
normalize_field(field, normalize_text);
}
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 {
fn from(n: Note) -> Self {
pb::Note {

View file

@ -17,6 +17,26 @@ impl Collection {
&mut self,
tags: Vec<String>,
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)> {
let mut seen = HashSet::new();
let mut added = false;
@ -24,7 +44,11 @@ impl Collection {
let tags: Vec<_> = tags.iter().flat_map(|t| split_tags(t)).collect();
for tag in tags {
let mut tag = Tag::new(tag.to_string(), usn);
if register {
added |= self.register_tag(&mut tag)?;
} else {
self.prepare_tag_for_registering(&mut tag)?;
}
seen.insert(UniCase::new(tag.name));
}

View file

@ -161,6 +161,10 @@ $vars: (
light: get($color, indigo, 6),
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;
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;
/* 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>

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 { 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 type { Destroyable } from "./destroyable";
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 field: FieldData;
export let collapsed = false;
const directionStore = writable<"ltr" | "rtl">();
setContext(directionKey, directionStore);
$: $directionStore = field.direction;
const collapsedStore = writable<boolean>();
setContext(collapsedKey, collapsedStore);
$: $collapsedStore = collapsed;
const editingArea: Partial<EditingAreaAPI> = {};
const [element, elementResolve] = promiseWithResolver<HTMLElement>();
@ -85,15 +89,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
on:focusin
on:focusout
on:click={() => editingArea.focus?.()}
on:mouseenter
on:mouseleave
>
<LabelContainer>
<span>
<LabelName>
{field.name}
</LabelName>
</span>
<FieldState><slot name="field-state" /></FieldState>
</LabelContainer>
<slot name="field-label" />
<Collapsible {collapsed}>
<EditingArea
{content}
fontFamily={field.fontFamily}
@ -102,20 +103,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
>
<slot name="editing-inputs" />
</EditingArea>
</Collapsible>
</div>
<style lang="scss">
.editor-field {
position: relative;
--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>

View file

@ -12,8 +12,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
justify-content: flex;
flex-grow: 1;
& > :global(*) {
margin-left: 2px;
/* replace with "gap: 5px" once it's available
- 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>

View file

@ -10,9 +10,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
.fields {
display: grid;
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 */
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-grow: 1;
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>

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
-->
<script lang="ts">
import { getContext } from "svelte";
import type { Readable } from "svelte/store";
import { createEventDispatcher } from "svelte";
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>
<div
class="label-container"
class:rtl={$direction === "rtl"}
on:mousedown|preventDefault
>
<div class="label-container" on:mousedown|preventDefault>
<span
class="clickable"
title={tooltip}
on:click|stopPropagation={toggle}
on:mouseenter={() => (hovered = true)}
on:mouseleave={() => (hovered = false)}
>
<CollapseBadge {collapsed} highlighted={hovered} />
<slot name="field-name" />
</span>
<slot />
</div>
<style lang="scss">
.label-container {
display: flex;
position: sticky;
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;
border-style: dashed;
border-color: var(--border-color);
border-radius: 5px 5px 0 0;
padding: 0px 6px;
.clickable {
cursor: pointer;
}
.rtl {
direction: rtl;
}
</style>

View file

@ -45,7 +45,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script>
<NoteEditor bind:this={noteEditor} {api}>
<svelte:fragment slot="field-state" let:index>
<StickyBadge active={stickies[index]} {index} />
<svelte:fragment slot="field-state" let:index let:visible>
<StickyBadge bind:active={stickies[index]} {index} {visible} />
</svelte:fragment>
</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">
import type { Writable } from "svelte/store";
import Collapsible from "../components/Collapsible.svelte";
import type { EditingInputAPI } from "./EditingArea.svelte";
import type { EditorToolbarAPI } from "./editor-toolbar";
import type { EditorFieldAPI } from "./EditorField.svelte";
import FieldState from "./FieldState.svelte";
import LabelContainer from "./LabelContainer.svelte";
import LabelName from "./LabelName.svelte";
export interface NoteEditorAPI {
fields: EditorFieldAPI[];
hoveredField: Writable<EditorFieldAPI | null>;
focusedField: Writable<EditorFieldAPI | null>;
focusedInput: Writable<EditingInputAPI | null>;
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 PlainTextBadge from "./PlainTextBadge.svelte";
import RichTextInput, { editingInputIsRichText } from "./rich-text-input";
import RichTextBadge from "./RichTextBadge.svelte";
function quoteFontFamily(fontFamily: string): string {
// 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 fieldsCollapsed: boolean[] = [];
const fields = clearableArray<EditorFieldAPI>();
export function setFonts(fs: [string, number, boolean][]): void {
fonts = fs;
fieldsCollapsed = fonts.map((_, index) => fieldsCollapsed[index] ?? false);
}
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> = {};
export { apiPartial as api };
const hoveredField: NoteEditorAPI["hoveredField"] = writable(null);
const focusedField: NoteEditorAPI["focusedField"] = writable(null);
const focusedInput: NoteEditorAPI["focusedInput"] = writable(null);
const api: NoteEditorAPI = {
...apiPartial,
hoveredField,
focusedField,
focusedInput,
toolbar: toolbar as EditorToolbarAPI,
@ -331,41 +341,69 @@ the AddCards dialog) should be implemented in the user of this component.
`blur:${index}:${getNoteId()}:${get(content)}`,
);
}}
on:mouseenter={() => {
$hoveredField = fields[index];
}}
on:mouseleave={() => {
$hoveredField = null;
}}
collapsed={fieldsCollapsed[index]}
--label-color={cols[index] === "dupe"
? "var(--flag1-bg)"
: "transparent"}
: "var(--window-bg)"}
>
<svelte:fragment slot="field-state">
<svelte:fragment slot="field-label">
<LabelContainer
collapsed={fieldsCollapsed[index]}
on:toggle={async () => {
fieldsCollapsed[index] = !fieldsCollapsed[index];
if (!fieldsCollapsed[index]) {
await tick();
richTextInputs[index].api.refocus();
} else {
plainTextsHidden[index] = true;
}
}}
>
<svelte:fragment slot="field-name">
<LabelName>
{field.name}
</LabelName>
</svelte:fragment>
<FieldState>
{#if cols[index] === "dupe"}
<DuplicateLink />
{/if}
<RichTextBadge
bind:off={richTextsHidden[index]}
on:toggle={() => {
richTextsHidden[index] = !richTextsHidden[index];
if (!richTextsHidden[index]) {
richTextInputs[index].api.refocus();
}
}}
/>
<PlainTextBadge
visible={!fieldsCollapsed[index] &&
(fields[index] === $hoveredField ||
fields[index] === $focusedField)}
bind:off={plainTextsHidden[index]}
on:toggle={() => {
plainTextsHidden[index] = !plainTextsHidden[index];
on:toggle={async () => {
plainTextsHidden[index] =
!plainTextsHidden[index];
if (!plainTextsHidden[index]) {
await tick();
plainTextInputs[index].api.refocus();
}
}}
/>
<slot name="field-state" {field} {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
hidden={richTextsHidden[index]}
bind:hidden={richTextsHidden[index]}
on:focusout={() => {
saveFieldNow();
$focusedInput = null;
@ -378,15 +416,18 @@ the AddCards dialog) should be implemented in the user of this component.
{field.description}
</FieldDescription>
</RichTextInput>
</Collapsible>
<Collapsible collapsed={plainTextsHidden[index]}>
<PlainTextInput
hidden={plainTextsHidden[index]}
bind:hidden={plainTextsHidden[index]}
on:focusout={() => {
saveFieldNow();
$focusedInput = null;
}}
bind:this={plainTextInputs[index]}
/>
</Collapsible>
</svelte:fragment>
</EditorField>
{/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 { getPlatformString, registerShortcut } from "../lib/shortcuts";
import { context as editorFieldContext } from "./EditorField.svelte";
import { htmlOff, htmlOn } from "./icons";
import { plainTextIcon } from "./icons";
const editorField = editorFieldContext.get();
const keyCombination = "Control+Shift+X";
const dispatch = createEventDispatcher();
export let visible = false;
export let off = false;
$: icon = off ? htmlOff : htmlOn;
function toggle() {
dispatch("toggle");
}
@ -32,25 +31,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<span
class="plain-text-badge"
class:visible
class:highlighted={!off}
on:click|stopPropagation={toggle}
>
<Badge
tooltip="{tr.editingToggleHtmlEditor()} ({getPlatformString(keyCombination)})"
iconSize={80}
--icon-align="text-top">{@html icon}</Badge
iconSize={80}>{@html plainTextIcon}</Badge
>
</span>
<style lang="scss">
span {
opacity: 0.4;
cursor: pointer;
opacity: 0;
&.highlighted {
opacity: 1;
}
&.visible {
opacity: 0.4;
&:hover {
opacity: 0.8;
}
}
&.highlighted {
opacity: 1;
}
}
</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 { getPlatformString, registerShortcut } from "../lib/shortcuts";
import { context as editorFieldContext } from "./EditorField.svelte";
import { stickyOff, stickyOn } from "./icons";
import { stickyIcon } from "./icons";
export let active: boolean;
$: icon = active ? stickyOn : stickyOff;
export let visible: boolean;
const editorField = editorFieldContext.get();
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));
</script>
<span class:highlighted={active} on:click|stopPropagation={toggle}>
<span class:highlighted={active} class:visible on:click|stopPropagation={toggle}>
<Badge
tooltip="{tr.editingToggleSticky()} ({getPlatformString(keyCombination)})"
widthMultiplier={0.7}
--icon-align="text-top">{@html icon}</Badge
widthMultiplier={0.7}>{@html stickyIcon}</Badge
>
</span>
<style lang="scss">
span {
cursor: pointer;
opacity: 0;
&.visible {
transition: none;
opacity: 0.4;
&.highlighted {
opacity: 1;
}
&:hover {
opacity: 0.8;
}
}
&.highlighted {
opacity: 1;
}
}
</style>

View file

@ -5,10 +5,9 @@
export { default as incrementClozeIcon } from "../icons/contain-plus.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 richTextOff } from "@mdi/svg/svg/eye-off-outline.svg";
export { default as richTextOn } from "@mdi/svg/svg/eye-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";
export { default as richTextIcon } from "@mdi/svg/svg/format-font.svg";
export { default as stickyIcon } from "@mdi/svg/svg/pin-outline.svg";

View file

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

View file

@ -211,7 +211,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
setupLifecycleHooks(api);
</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
color={$pageTheme.isDark ? "white" : "black"}
fontFamily={$fontFamily}
@ -238,6 +238,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</div>
{/await}
</RichTextStyles>
<slot name="plain-text-badge" />
</div>
<style lang="scss">

View file

@ -9,4 +9,35 @@ describe("filterHTML", () => {
expect(filterHTML("", true, 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
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
interface AllowPropertiesBlockValues {
[property: string]: string[];
}
type BlockProperties = string[];
/// Keep property if true.
type StylingPredicate = (property: string, value: string) => boolean;
const stylingNightMode: AllowPropertiesBlockValues = {
"font-weight": [],
"font-style": [],
"text-decoration-line": [],
};
const keep = (_key: string, _value: string) => true;
const discard = (_key: string, _value: string) => false;
const stylingLightMode: AllowPropertiesBlockValues = {
color: [],
"background-color": ["transparent"],
...stylingNightMode,
};
const stylingInternal: BlockProperties = [
"background-color",
"font-size",
"font-family",
"width",
"height",
"max-width",
"max-height",
];
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);
/// Return a function that filters out certain styles.
/// - If the style is listed in `exceptions`, the provided predicate is used.
/// - If the style is not listed, the default predicate is used instead.
function filterStyling(
defaultPredicate: StylingPredicate,
exceptions: Record<string, StylingPredicate>,
): (element: HTMLElement) => void {
return (element: HTMLElement): void => {
// jsdom does not support @@iterator, so manually iterate
for (let i = 0; i < element.style.length; i++) {
const key = element.style.item(i);
const value = element.style.getPropertyValue(key);
const predicate = exceptions[key] ?? defaultPredicate;
if (!predicate(key, value)) {
element.style.removeProperty(key);
}
}
};
}
export const filterStylingNightMode = filterStyling(
allowPropertiesBlockValues(stylingNightMode),
);
export const filterStylingLightMode = filterStyling(
allowPropertiesBlockValues(stylingLightMode),
);
export const filterStylingInternal = filterStyling(blockProperties(stylingInternal));
const nightModeExceptions = {
"font-weight": keep,
"font-style": keep,
"text-decoration-line": keep,
};
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 directionKey = Symbol("direction");
export const descriptionKey = Symbol("description");
export const collapsedKey = Symbol("collapsed");

View file

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