diff --git a/.gitignore b/.gitignore index 350f526f2..d9b0a7aef 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,6 @@ target .dmypy.json node_modules /.idea/ +/.vscode/ /.bazel /windows.bazelrc diff --git a/.vscode/extensions.json b/.vscode.dist/extensions.json similarity index 100% rename from .vscode/extensions.json rename to .vscode.dist/extensions.json diff --git a/.vscode/settings.json b/.vscode.dist/settings.json similarity index 100% rename from .vscode/settings.json rename to .vscode.dist/settings.json diff --git a/docs/editing.md b/docs/editing.md index 6000dac72..86656f6f4 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -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. diff --git a/ftl/core/editing.ftl b/ftl/core/editing.ftl index 96e5af0e5..244872282 100644 --- a/ftl/core/editing.ftl +++ b/ftl/core/editing.ftl @@ -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. diff --git a/pylib/anki/errors.py b/pylib/anki/errors.py index 35fb6d013..b1ca0c7c6 100644 --- a/pylib/anki/errors.py +++ b/pylib/anki/errors.py @@ -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 diff --git a/python/pyqt/6/requirements.txt b/python/pyqt/6/requirements.txt index 00e88ef72..8d4021591 100644 --- a/python/pyqt/6/requirements.txt +++ b/python/pyqt/6/requirements.txt @@ -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 \ diff --git a/qt/aqt/addons.py b/qt/aqt/addons.py index 30e75e173..871f3a4ee 100644 --- a/qt/aqt/addons.py +++ b/qt/aqt/addons.py @@ -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]] diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 744f53041..b770902c6 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -1370,6 +1370,7 @@ title="{}" {}>{}""".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="{}" {}>{}""".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 ########################################################################## diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 5001f5f54..722dd70d4 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -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) diff --git a/qt/aqt/sync.py b/qt/aqt/sync.py index 8dfdd298a..94f95b117 100644 --- a/qt/aqt/sync.py +++ b/qt/aqt/sync.py @@ -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,13 +130,26 @@ 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: - full_upload(mw, on_done) - elif choice == FullSyncChoice.DOWNLOAD: - full_download(mw, on_done) - else: - on_done() + 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 == 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: @@ -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]: diff --git a/qt/aqt/toolbar.py b/qt/aqt/toolbar.py index c0d0ba320..34b3e652e 100644 --- a/qt/aqt/toolbar.py +++ b/qt/aqt/toolbar.py @@ -144,8 +144,8 @@ class Toolbar: self.link_handlers[label] = self._syncLinkHandler return f""" -{name} - +{name} """ def set_sync_active(self, active: bool) -> None: diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index 1d6163937..b067c1b3e 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -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, diff --git a/qt/mypy.ini b/qt/mypy.ini index cff9c7490..53c847e43 100644 --- a/qt/mypy.ini +++ b/qt/mypy.ini @@ -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 diff --git a/rslib/src/import_export/text/csv/import.rs b/rslib/src/import_export/text/csv/import.rs index 6da753812..a38d6166e 100644 --- a/rslib/src/import_export/text/csv/import.rs +++ b/rslib/src/import_export/text/csv/import.rs @@ -56,7 +56,7 @@ impl CsvMetadata { .ok_or_else(|| AnkiError::invalid_input("notetype oneof not set")) } - fn field_source_columns(&self) -> Result>> { + fn field_source_columns(&self) -> Result { Ok(match self.notetype()? { CsvNotetype::GlobalNotetype(global) => global .field_columns @@ -115,8 +115,7 @@ struct ColumnContext { guid_column: Option, deck_column: Option, notetype_column: Option, - /// 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 { - self.tags_column - .and_then(|i| record.get(i - 1)) - .unwrap_or_default() - .split_whitespace() - .filter(|s| !s.is_empty()) - .map(ToString::to_string) - .collect() + fn gather_tags(&self, record: &csv::StringRecord) -> Option> { + 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 { + fn gather_note_fields(&self, record: &csv::StringRecord) -> Vec> { 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, "
\n", &[&["<hr>", ""]]); + assert_imported_fields!(metadata, "
\n", [[Some("<hr>"), None]]); metadata.is_html = true; - assert_imported_fields!(metadata, "
\n", &[&["
", ""]]); + assert_imported_fields!(metadata, "
\n", [[Some("
"), 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"))); } } diff --git a/rslib/src/import_export/text/import.rs b/rslib/src/import_export/text/import.rs index f036cc6bc..f51770a29 100644 --- a/rslib/src/import_export/text/import.rs +++ b/rslib/src/import_export/text/import.rs @@ -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, } -struct NoteContext { - /// Prepared and with canonified tags. - note: Note, +struct NoteContext<'a> { + note: ForeignNote, dupes: Vec, - cards: Vec, notetype: Arc, 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, deck_id: DeckId, - global_tags: &[String], - ) -> Result { - let (mut note, cards) = foreign.into_native(¬etype, deck_id, self.today, global_tags); - note.prepare_for_update(¬etype, self.normalize_notes)?; - self.col.canonify_note_tags(&mut note, self.usn)?; + global_tags: &'tags [String], + updated_tags: &'tags [String], + ) -> Result> { + self.prepare_foreign_note(&mut note)?; let dupes = self.find_duplicates(¬etype, ¬e)?; - Ok(NoteContext { note, dupes, - cards, notetype, deck_id, + global_tags, + updated_tags, }) } - fn find_duplicates(&self, notetype: &Notetype, note: &Note) -> Result> { - 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> { if let Some(nid) = self.existing_guids.get(¬e.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 { + fn get_guid_dupe(&self, nid: NoteId, original: &ForeignNote) -> Result { 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> { + fn get_first_field_dupes(&self, note: &ForeignNote, nids: &[NoteId]) -> Result> { 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) -> 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, ¬e, ctx.deck_id, ctx.notetype)?; + + if dupe { + log.first_field_match.push(note.into_log_note()); + } else { + log.new.push(note.into_log_note()); + } + Ok(()) } @@ -293,63 +313,46 @@ 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)?; + 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()); + continue; + } + + 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(¬e, &dupe.note)?; + } + self.add_cards(&mut cards, ¬e, ctx.deck_id, ctx.notetype.clone())?; + + if dupe.identical { + log.duplicate.push(dupe.note.into_log_note()); + } else if dupe.first_field_match { + log.first_field_match.push(note.into_log_note()); + } else { + log.updated.push(note.into_log_note()); + } } + Ok(()) } - 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)?; - } + 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(()) } - 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<()> { for card in cards { card.note_id = note_id; @@ -397,8 +400,18 @@ impl Collection { } } - fn get_full_duplicates(&self, note: &Note, dupe_ids: &[NoteId]) -> Result> { - 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> { + 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) { + extra_tags: impl IntoIterator, + ) -> Vec { // 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> { + 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 { + 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(); diff --git a/rslib/src/import_export/text/mod.rs b/rslib/src/import_export/text/mod.rs index b3cefbdee..e976dca5a 100644 --- a/rslib/src/import_export/text/mod.rs +++ b/rslib/src/import_export/text/mod.rs @@ -26,8 +26,8 @@ pub struct ForeignData { #[serde(default)] pub struct ForeignNote { guid: String, - fields: Vec, - tags: Vec, + fields: Vec>, + tags: Option>, notetype: NameOrId, deck: NameOrId, cards: Vec, @@ -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(), } } } diff --git a/rslib/src/notes/mod.rs b/rslib/src/notes/mod.rs index f6a1b726b..ae198d5ac 100644 --- a/rslib/src/notes/mod.rs +++ b/rslib/src/notes/mod.rs @@ -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 for pb::Note { fn from(n: Note) -> Self { pb::Note { diff --git a/rslib/src/tags/register.rs b/rslib/src/tags/register.rs index b2e902224..bb5cab145 100644 --- a/rslib/src/tags/register.rs +++ b/rslib/src/tags/register.rs @@ -17,6 +17,26 @@ impl Collection { &mut self, tags: Vec, usn: Usn, + ) -> Result<(Vec, bool)> { + self.canonify_tags_inner(tags, usn, true) + } + + pub(crate) fn canonify_tags_without_registering( + &mut self, + tags: Vec, + usn: Usn, + ) -> Result> { + 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, + usn: Usn, + register: bool, ) -> Result<(Vec, 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); - 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)); } diff --git a/sass/_vars.scss b/sass/_vars.scss index 3759e0320..c2937ed26 100644 --- a/sass/_vars.scss +++ b/sass/_vars.scss @@ -161,6 +161,10 @@ $vars: ( light: get($color, indigo, 6), dark: get($color, indigo, 5), ), + code-bg: ( + light: white, + dark: #272822, + ), ), ); diff --git a/ts/components/Collapsible.svelte b/ts/components/Collapsible.svelte new file mode 100644 index 000000000..3a116aa53 --- /dev/null +++ b/ts/components/Collapsible.svelte @@ -0,0 +1,100 @@ + + + +
+
+ +
+
+ + diff --git a/ts/editor/CollapseBadge.svelte b/ts/editor/CollapseBadge.svelte new file mode 100644 index 000000000..42f7e1b17 --- /dev/null +++ b/ts/editor/CollapseBadge.svelte @@ -0,0 +1,35 @@ + + + +
+ {@html chevronDown} +
+ + diff --git a/ts/editor/EditingArea.svelte b/ts/editor/EditingArea.svelte index 8aa156bed..da5dfd0f9 100644 --- a/ts/editor/EditingArea.svelte +++ b/ts/editor/EditingArea.svelte @@ -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); + } } } diff --git a/ts/editor/EditorField.svelte b/ts/editor/EditorField.svelte index 23588ffb6..bd3f1983d 100644 --- a/ts/editor/EditorField.svelte +++ b/ts/editor/EditorField.svelte @@ -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; export let field: FieldData; + export let collapsed = false; const directionStore = writable<"ltr" | "rtl">(); setContext(directionKey, directionStore); $: $directionStore = field.direction; + const collapsedStore = writable(); + setContext(collapsedKey, collapsedStore); + + $: $collapsedStore = collapsed; + const editingArea: Partial = {}; const [element, elementResolve] = promiseWithResolver(); @@ -85,37 +89,26 @@ 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 > - - - - {field.name} - - - - - - - + + + + + + + diff --git a/ts/editor/FieldState.svelte b/ts/editor/FieldState.svelte index f41ed793a..d0cc34444 100644 --- a/ts/editor/FieldState.svelte +++ b/ts/editor/FieldState.svelte @@ -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; } } diff --git a/ts/editor/Fields.svelte b/ts/editor/Fields.svelte index d63350031..b4fcffaad 100644 --- a/ts/editor/Fields.svelte +++ b/ts/editor/Fields.svelte @@ -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%; diff --git a/ts/editor/FieldsEditor.svelte b/ts/editor/FieldsEditor.svelte index b71ee9e79..f38dd6785 100644 --- a/ts/editor/FieldsEditor.svelte +++ b/ts/editor/FieldsEditor.svelte @@ -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; + } } diff --git a/ts/editor/LabelContainer.svelte b/ts/editor/LabelContainer.svelte index 770e0a9fc..ad546461a 100644 --- a/ts/editor/LabelContainer.svelte +++ b/ts/editor/LabelContainer.svelte @@ -3,38 +3,54 @@ Copyright: Ankitects Pty Ltd and contributors License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html --> -
+
+ (hovered = true)} + on:mouseleave={() => (hovered = false)} + > + + +
diff --git a/ts/editor/NoteCreator.svelte b/ts/editor/NoteCreator.svelte index 3ada91086..064c78180 100644 --- a/ts/editor/NoteCreator.svelte +++ b/ts/editor/NoteCreator.svelte @@ -45,7 +45,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - - + + diff --git a/ts/editor/NoteEditor.svelte b/ts/editor/NoteEditor.svelte index 29ea40f07..8a498cb37 100644 --- a/ts/editor/NoteEditor.svelte +++ b/ts/editor/NoteEditor.svelte @@ -5,12 +5,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - - - {@html icon} - - - diff --git a/ts/editor/StickyBadge.svelte b/ts/editor/StickyBadge.svelte index 0b264c6c8..a8f038aea 100644 --- a/ts/editor/StickyBadge.svelte +++ b/ts/editor/StickyBadge.svelte @@ -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)); - + {@html icon}{@html stickyIcon} diff --git a/ts/editor/icons.ts b/ts/editor/icons.ts index 701483237..018bb7071 100644 --- a/ts/editor/icons.ts +++ b/ts/editor/icons.ts @@ -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"; diff --git a/ts/editor/plain-text-input/PlainTextInput.svelte b/ts/editor/plain-text-input/PlainTextInput.svelte index 4a28b7185..67d4f8049 100644 --- a/ts/editor/plain-text-input/PlainTextInput.svelte +++ b/ts/editor/plain-text-input/PlainTextInput.svelte @@ -143,7 +143,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
($focusedInput = api)} > diff --git a/ts/editor/rich-text-input/RichTextInput.svelte b/ts/editor/rich-text-input/RichTextInput.svelte index 35b952b94..c04813c95 100644 --- a/ts/editor/rich-text-input/RichTextInput.svelte +++ b/ts/editor/rich-text-input/RichTextInput.svelte @@ -211,7 +211,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html setupLifecycleHooks(api); -
+
{/await} +