mirror of
https://github.com/ankitects/anki.git
synced 2025-09-21 07:22:23 -04:00
Merge branch 'main' into color-palette
This commit is contained in:
commit
dfe3aba2d8
40 changed files with 967 additions and 446 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -6,5 +6,6 @@ target
|
||||||
.dmypy.json
|
.dmypy.json
|
||||||
node_modules
|
node_modules
|
||||||
/.idea/
|
/.idea/
|
||||||
|
/.vscode/
|
||||||
/.bazel
|
/.bazel
|
||||||
/windows.bazelrc
|
/windows.bazelrc
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
# Editing/IDEs
|
# Editing/IDEs
|
||||||
|
|
||||||
Visual Studio Code is recommended, since it provides decent support for all the languages
|
Visual Studio Code is recommended, since it provides decent support for all the languages
|
||||||
Anki uses. If you open the root of this repo in VS Code, it will suggest some extensions
|
Anki uses. To set up the recommended workspace settings for VS Code, please see below.
|
||||||
for you to install.
|
|
||||||
|
|
||||||
For editing Python, PyCharm/IntelliJ's type checking/completion is a bit nicer than
|
For editing Python, PyCharm/IntelliJ's type checking/completion is a bit nicer than
|
||||||
VS Code, but VS Code has improved considerably in a short span of time.
|
VS Code, but VS Code has improved considerably in a short span of time.
|
||||||
|
@ -36,6 +35,20 @@ Code completion partly depends on files that are generated as part of the
|
||||||
regular build process, so for things to work correctly, use './run' or
|
regular build process, so for things to work correctly, use './run' or
|
||||||
'tools/build' prior to using code completion.
|
'tools/build' prior to using code completion.
|
||||||
|
|
||||||
|
## Visual Studio Code
|
||||||
|
|
||||||
|
### Setting up Recommended Workspace Settings
|
||||||
|
|
||||||
|
To start off with some default workspace settings that are optimized for Anki development, please head to the project root and then run:
|
||||||
|
|
||||||
|
```
|
||||||
|
cp -r .vscode.dist .vscode
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installing Recommended Extensions
|
||||||
|
|
||||||
|
Once the workspace settings are set up, open the root of the repo in VS Code to see and install a number of recommended extensions.
|
||||||
|
|
||||||
## PyCharm/IntelliJ
|
## PyCharm/IntelliJ
|
||||||
|
|
||||||
If you decide to use PyCharm instead of VS Code, there are somethings to be aware of.
|
If you decide to use PyCharm instead of VS Code, there are somethings to be aware of.
|
||||||
|
|
|
@ -54,7 +54,8 @@ editing-text-highlight-color = Text highlight color
|
||||||
editing-to-make-a-cloze-deletion-on = To make a cloze deletion on an existing note, you need to change it to a cloze type first, via 'Notes>Change Note Type'
|
editing-to-make-a-cloze-deletion-on = To make a cloze deletion on an existing note, you need to change it to a cloze type first, via 'Notes>Change Note Type'
|
||||||
editing-toggle-html-editor = Toggle HTML Editor
|
editing-toggle-html-editor = Toggle HTML Editor
|
||||||
editing-toggle-sticky = Toggle sticky
|
editing-toggle-sticky = Toggle sticky
|
||||||
editing-toggle-visual-editor = Toggle Visual Editor
|
editing-expand-field = Expand field
|
||||||
|
editing-collapse-field = Collapse field
|
||||||
editing-underline-text = Underline text
|
editing-underline-text = Underline text
|
||||||
editing-unordered-list = Unordered list
|
editing-unordered-list = Unordered list
|
||||||
editing-warning-cloze-deletions-will-not-work = Warning, cloze deletions will not work until you switch the type at the top to Cloze.
|
editing-warning-cloze-deletions-will-not-work = Warning, cloze deletions will not work until you switch the type at the top to Cloze.
|
||||||
|
|
|
@ -10,7 +10,22 @@ if TYPE_CHECKING:
|
||||||
import anki.collection
|
import anki.collection
|
||||||
|
|
||||||
|
|
||||||
class LocalizedError(Exception):
|
class AnkiException(Exception):
|
||||||
|
"""
|
||||||
|
General Anki exception that all custom exceptions raised by Anki should
|
||||||
|
inherit from. Allows add-ons to easily identify Anki-native exceptions.
|
||||||
|
|
||||||
|
When inheriting from a Python built-in exception other than `Exception`,
|
||||||
|
please supply `AnkiException` as an additional inheritance:
|
||||||
|
|
||||||
|
```
|
||||||
|
class MyNewAnkiException(ValueError, AnkiException):
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class LocalizedError(AnkiException):
|
||||||
"An error with a localized description."
|
"An error with a localized description."
|
||||||
|
|
||||||
def __init__(self, localized: str) -> None:
|
def __init__(self, localized: str) -> None:
|
||||||
|
@ -29,7 +44,7 @@ class DocumentedError(LocalizedError):
|
||||||
super().__init__(localized)
|
super().__init__(localized)
|
||||||
|
|
||||||
|
|
||||||
class Interrupted(Exception):
|
class Interrupted(AnkiException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@ -68,7 +83,7 @@ class TemplateError(LocalizedError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class NotFoundError(Exception):
|
class NotFoundError(AnkiException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@ -76,11 +91,11 @@ class DeletedError(LocalizedError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ExistsError(Exception):
|
class ExistsError(AnkiException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class UndoEmpty(Exception):
|
class UndoEmpty(AnkiException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@ -96,7 +111,7 @@ class SearchError(LocalizedError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AbortSchemaModification(Exception):
|
class AbortSchemaModification(AnkiException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -18,8 +18,8 @@ pyqt6-sip==13.4.0 \
|
||||||
--hash=sha256:2694ae67811cefb6ea3ee0e9995755b45e4952f4dcadec8c04300fd828f91c75 \
|
--hash=sha256:2694ae67811cefb6ea3ee0e9995755b45e4952f4dcadec8c04300fd828f91c75 \
|
||||||
--hash=sha256:3486914137f5336cff6e10a5e9d52c1e60ff883473938b45f267f794daeacb2f \
|
--hash=sha256:3486914137f5336cff6e10a5e9d52c1e60ff883473938b45f267f794daeacb2f \
|
||||||
--hash=sha256:3ac7e0800180202dcc0c7035ff88c2a6f4a0f5acb20c4a19f71d807d0f7857b7 \
|
--hash=sha256:3ac7e0800180202dcc0c7035ff88c2a6f4a0f5acb20c4a19f71d807d0f7857b7 \
|
||||||
|
--hash=sha256:3de18c4a32f717a351d560a39f528af24077f5135aacfa8890a2f2d79f0633da \
|
||||||
--hash=sha256:6d87a3ee5872d7511b76957d68a32109352caf3b7a42a01d9ee20032b350d979 \
|
--hash=sha256:6d87a3ee5872d7511b76957d68a32109352caf3b7a42a01d9ee20032b350d979 \
|
||||||
--hash=sha256:7b9bbb5fb880440a3a8e7fa3dff70473aa1128aaf7dc9fb6e30512eed4fd38f6 \
|
|
||||||
--hash=sha256:802b0cfed19900183220c46895c2635f0dd062f2d275a25506423f911ef74db4 \
|
--hash=sha256:802b0cfed19900183220c46895c2635f0dd062f2d275a25506423f911ef74db4 \
|
||||||
--hash=sha256:83b446d247a92d119d507dbc94fc1f47389d8118a5b6232a2859951157319a30 \
|
--hash=sha256:83b446d247a92d119d507dbc94fc1f47389d8118a5b6232a2859951157319a30 \
|
||||||
--hash=sha256:9c5231536e6153071b22175e46e368045fd08d772a90d772a0977d1166c7822c \
|
--hash=sha256:9c5231536e6153071b22175e46e368045fd08d772a90d772a0977d1166c7822c \
|
||||||
|
|
|
@ -43,6 +43,7 @@ from aqt.utils import (
|
||||||
saveGeom,
|
saveGeom,
|
||||||
saveSplitter,
|
saveSplitter,
|
||||||
send_to_trash,
|
send_to_trash,
|
||||||
|
show_info,
|
||||||
showInfo,
|
showInfo,
|
||||||
showWarning,
|
showWarning,
|
||||||
tooltip,
|
tooltip,
|
||||||
|
@ -862,14 +863,14 @@ class AddonsDialog(QDialog):
|
||||||
def onlyOneSelected(self) -> str | None:
|
def onlyOneSelected(self) -> str | None:
|
||||||
dirs = self.selectedAddons()
|
dirs = self.selectedAddons()
|
||||||
if len(dirs) != 1:
|
if len(dirs) != 1:
|
||||||
showInfo(tr.addons_please_select_a_single_addon_first())
|
show_info(tr.addons_please_select_a_single_addon_first())
|
||||||
return None
|
return None
|
||||||
return dirs[0]
|
return dirs[0]
|
||||||
|
|
||||||
def selected_addon_meta(self) -> AddonMeta | None:
|
def selected_addon_meta(self) -> AddonMeta | None:
|
||||||
idxs = [x.row() for x in self.form.addonList.selectedIndexes()]
|
idxs = [x.row() for x in self.form.addonList.selectedIndexes()]
|
||||||
if len(idxs) != 1:
|
if len(idxs) != 1:
|
||||||
showInfo(tr.addons_please_select_a_single_addon_first())
|
show_info(tr.addons_please_select_a_single_addon_first())
|
||||||
return None
|
return None
|
||||||
return self.addons[idxs[0]]
|
return self.addons[idxs[0]]
|
||||||
|
|
||||||
|
|
|
@ -1370,6 +1370,7 @@ title="{}" {}>{}</button>""".format(
|
||||||
True,
|
True,
|
||||||
parent=self,
|
parent=self,
|
||||||
)
|
)
|
||||||
|
self.progress.timer(12 * 60 * 1000, self.refresh_certs, False, parent=self)
|
||||||
|
|
||||||
def onRefreshTimer(self) -> None:
|
def onRefreshTimer(self) -> None:
|
||||||
if self.state == "deckBrowser":
|
if self.state == "deckBrowser":
|
||||||
|
@ -1385,6 +1386,15 @@ title="{}" {}>{}</button>""".format(
|
||||||
if elap > minutes * 60:
|
if elap > minutes * 60:
|
||||||
self.maybe_auto_sync_media()
|
self.maybe_auto_sync_media()
|
||||||
|
|
||||||
|
def refresh_certs(self) -> None:
|
||||||
|
# The requests library copies the certs into a temporary folder on startup,
|
||||||
|
# and chokes when the file is later missing due to temp file cleaners.
|
||||||
|
# Work around the issue by accessing them once every 12 hours.
|
||||||
|
import certifi
|
||||||
|
|
||||||
|
with open(certifi.where(), "rb") as f:
|
||||||
|
f.read()
|
||||||
|
|
||||||
# Backups
|
# Backups
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
|
||||||
|
|
|
@ -41,7 +41,14 @@ from aqt.qt import *
|
||||||
from aqt.sound import av_player, play_clicked_audio, record_audio
|
from aqt.sound import av_player, play_clicked_audio, record_audio
|
||||||
from aqt.theme import theme_manager
|
from aqt.theme import theme_manager
|
||||||
from aqt.toolbar import BottomBar
|
from aqt.toolbar import BottomBar
|
||||||
from aqt.utils import askUserDialog, downArrow, qtMenuShortcutWorkaround, tooltip, tr
|
from aqt.utils import (
|
||||||
|
askUserDialog,
|
||||||
|
downArrow,
|
||||||
|
qtMenuShortcutWorkaround,
|
||||||
|
show_warning,
|
||||||
|
tooltip,
|
||||||
|
tr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RefreshNeeded(Enum):
|
class RefreshNeeded(Enum):
|
||||||
|
@ -136,6 +143,7 @@ class Reviewer:
|
||||||
def show(self) -> None:
|
def show(self) -> None:
|
||||||
if self.mw.col.sched_ver() == 1:
|
if self.mw.col.sched_ver() == 1:
|
||||||
self.mw.moveToState("deckBrowser")
|
self.mw.moveToState("deckBrowser")
|
||||||
|
show_warning(tr.scheduling_update_required())
|
||||||
return
|
return
|
||||||
self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore
|
self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore
|
||||||
self.web.set_bridge_command(self._linkHandler, self)
|
self.web.set_bridge_command(self._linkHandler, self)
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import enum
|
|
||||||
import os
|
import os
|
||||||
from concurrent.futures import Future
|
from concurrent.futures import Future
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
@ -27,8 +26,8 @@ from aqt.qt import (
|
||||||
qconnect,
|
qconnect,
|
||||||
)
|
)
|
||||||
from aqt.utils import (
|
from aqt.utils import (
|
||||||
|
ask_user_dialog,
|
||||||
askUser,
|
askUser,
|
||||||
askUserDialog,
|
|
||||||
disable_help_button,
|
disable_help_button,
|
||||||
showText,
|
showText,
|
||||||
showWarning,
|
showWarning,
|
||||||
|
@ -36,12 +35,6 @@ from aqt.utils import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class FullSyncChoice(enum.Enum):
|
|
||||||
CANCEL = 0
|
|
||||||
UPLOAD = 1
|
|
||||||
DOWNLOAD = 2
|
|
||||||
|
|
||||||
|
|
||||||
def get_sync_status(
|
def get_sync_status(
|
||||||
mw: aqt.main.AnkiQt, callback: Callable[[SyncStatus], None]
|
mw: aqt.main.AnkiQt, callback: Callable[[SyncStatus], None]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -102,6 +95,8 @@ def sync_collection(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
|
||||||
|
|
||||||
def on_future_done(fut: Future) -> None:
|
def on_future_done(fut: Future) -> None:
|
||||||
mw.col.db.begin()
|
mw.col.db.begin()
|
||||||
|
# scheduler version may have changed
|
||||||
|
mw.col._load_scheduler()
|
||||||
timer.stop()
|
timer.stop()
|
||||||
try:
|
try:
|
||||||
out: SyncOutput = fut.result()
|
out: SyncOutput = fut.result()
|
||||||
|
@ -135,13 +130,26 @@ def full_sync(
|
||||||
elif out.required == out.FULL_UPLOAD:
|
elif out.required == out.FULL_UPLOAD:
|
||||||
full_upload(mw, on_done)
|
full_upload(mw, on_done)
|
||||||
else:
|
else:
|
||||||
choice = ask_user_to_decide_direction()
|
button_labels: list[str] = [
|
||||||
if choice == FullSyncChoice.UPLOAD:
|
tr.sync_upload_to_ankiweb(),
|
||||||
full_upload(mw, on_done)
|
tr.sync_download_from_ankiweb(),
|
||||||
elif choice == FullSyncChoice.DOWNLOAD:
|
tr.sync_cancel_button(),
|
||||||
full_download(mw, on_done)
|
]
|
||||||
else:
|
|
||||||
on_done()
|
def callback(choice: int) -> None:
|
||||||
|
if choice == 0:
|
||||||
|
full_upload(mw, on_done)
|
||||||
|
elif choice == 1:
|
||||||
|
full_download(mw, on_done)
|
||||||
|
else:
|
||||||
|
on_done()
|
||||||
|
|
||||||
|
ask_user_dialog(
|
||||||
|
tr.sync_conflict_explanation(),
|
||||||
|
callback=callback,
|
||||||
|
buttons=button_labels,
|
||||||
|
default_button=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def confirm_full_download(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
|
def confirm_full_download(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
|
||||||
|
@ -277,23 +285,6 @@ def sync_login(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def ask_user_to_decide_direction() -> FullSyncChoice:
|
|
||||||
button_labels = [
|
|
||||||
tr.sync_upload_to_ankiweb(),
|
|
||||||
tr.sync_download_from_ankiweb(),
|
|
||||||
tr.sync_cancel_button(),
|
|
||||||
]
|
|
||||||
diag = askUserDialog(tr.sync_conflict_explanation(), button_labels)
|
|
||||||
diag.setDefault(2)
|
|
||||||
ret = diag.run()
|
|
||||||
if ret == button_labels[0]:
|
|
||||||
return FullSyncChoice.UPLOAD
|
|
||||||
elif ret == button_labels[1]:
|
|
||||||
return FullSyncChoice.DOWNLOAD
|
|
||||||
else:
|
|
||||||
return FullSyncChoice.CANCEL
|
|
||||||
|
|
||||||
|
|
||||||
def get_id_and_pass_from_user(
|
def get_id_and_pass_from_user(
|
||||||
mw: aqt.main.AnkiQt, username: str = "", password: str = ""
|
mw: aqt.main.AnkiQt, username: str = "", password: str = ""
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
|
|
|
@ -144,8 +144,8 @@ class Toolbar:
|
||||||
self.link_handlers[label] = self._syncLinkHandler
|
self.link_handlers[label] = self._syncLinkHandler
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
<a class=hitem tabindex="-1" aria-label="{name}" title="{title}" id="{label}" href=# onclick="return pycmd('{label}')">{name}
|
<a class=hitem tabindex="-1" aria-label="{name}" title="{title}" id="{label}" href=# onclick="return pycmd('{label}')"
|
||||||
<img id=sync-spinner src='/_anki/imgs/refresh.svg'>
|
>{name}<img id=sync-spinner src='/_anki/imgs/refresh.svg'>
|
||||||
</a>"""
|
</a>"""
|
||||||
|
|
||||||
def set_sync_active(self, active: bool) -> None:
|
def set_sync_active(self, active: bool) -> None:
|
||||||
|
|
159
qt/aqt/utils.py
159
qt/aqt/utils.py
|
@ -7,9 +7,9 @@ import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from functools import wraps
|
from functools import partial, wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Literal, Sequence, no_type_check
|
from typing import TYPE_CHECKING, Any, Callable, Literal, Sequence, Union, no_type_check
|
||||||
|
|
||||||
from send2trash import send2trash
|
from send2trash import send2trash
|
||||||
|
|
||||||
|
@ -25,6 +25,56 @@ from anki.utils import (
|
||||||
version_with_build,
|
version_with_build,
|
||||||
)
|
)
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
|
from aqt.qt import (
|
||||||
|
PYQT_VERSION_STR,
|
||||||
|
QT_VERSION_STR,
|
||||||
|
QAction,
|
||||||
|
QApplication,
|
||||||
|
QCheckBox,
|
||||||
|
QColor,
|
||||||
|
QComboBox,
|
||||||
|
QDesktopServices,
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QEvent,
|
||||||
|
QFileDialog,
|
||||||
|
QFrame,
|
||||||
|
QHeaderView,
|
||||||
|
QIcon,
|
||||||
|
QKeySequence,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QListWidget,
|
||||||
|
QMainWindow,
|
||||||
|
QMenu,
|
||||||
|
QMessageBox,
|
||||||
|
QMouseEvent,
|
||||||
|
QNativeGestureEvent,
|
||||||
|
QOffscreenSurface,
|
||||||
|
QOpenGLContext,
|
||||||
|
QPalette,
|
||||||
|
QPixmap,
|
||||||
|
QPlainTextEdit,
|
||||||
|
QPoint,
|
||||||
|
QPushButton,
|
||||||
|
QShortcut,
|
||||||
|
QSize,
|
||||||
|
QSplitter,
|
||||||
|
QStandardPaths,
|
||||||
|
Qt,
|
||||||
|
QTextBrowser,
|
||||||
|
QTextOption,
|
||||||
|
QTimer,
|
||||||
|
QUrl,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWheelEvent,
|
||||||
|
QWidget,
|
||||||
|
pyqtSlot,
|
||||||
|
qconnect,
|
||||||
|
qtmajor,
|
||||||
|
qtminor,
|
||||||
|
traceback,
|
||||||
|
)
|
||||||
from aqt.theme import theme_manager
|
from aqt.theme import theme_manager
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
@ -70,6 +120,111 @@ def openLink(link: str | QUrl) -> None:
|
||||||
QDesktopServices.openUrl(QUrl(link))
|
QDesktopServices.openUrl(QUrl(link))
|
||||||
|
|
||||||
|
|
||||||
|
class MessageBox(QMessageBox):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
callback: Callable[[int], None] | None = None,
|
||||||
|
parent: QWidget | None = None,
|
||||||
|
icon: QMessageBox.Icon = QMessageBox.Icon.NoIcon,
|
||||||
|
help: HelpPageArgument | None = None,
|
||||||
|
title: str = "Anki",
|
||||||
|
buttons: Sequence[str | QMessageBox.StandardButton] | None = None,
|
||||||
|
default_button: int = 0,
|
||||||
|
textFormat: Qt.TextFormat = Qt.TextFormat.PlainText,
|
||||||
|
) -> None:
|
||||||
|
parent = parent or aqt.mw.app.activeWindow() or aqt.mw
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setText(text)
|
||||||
|
self.setWindowTitle(title)
|
||||||
|
self.setWindowModality(Qt.WindowModality.WindowModal)
|
||||||
|
self.setIcon(icon)
|
||||||
|
if icon == QMessageBox.Icon.Question and theme_manager.night_mode:
|
||||||
|
img = self.iconPixmap().toImage()
|
||||||
|
img.invertPixels()
|
||||||
|
self.setIconPixmap(QPixmap(img))
|
||||||
|
self.setTextFormat(textFormat)
|
||||||
|
if buttons is None:
|
||||||
|
buttons = [QMessageBox.StandardButton.Ok]
|
||||||
|
for i, button in enumerate(buttons):
|
||||||
|
if isinstance(button, str):
|
||||||
|
b = self.addButton(button, QMessageBox.ButtonRole.ActionRole)
|
||||||
|
elif isinstance(button, QMessageBox.StandardButton):
|
||||||
|
b = self.addButton(button)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if callback is not None:
|
||||||
|
qconnect(b.clicked, partial(callback, i))
|
||||||
|
if i == default_button:
|
||||||
|
self.setDefaultButton(b)
|
||||||
|
if help is not None:
|
||||||
|
b = self.addButton(QMessageBox.StandardButton.Help)
|
||||||
|
qconnect(b.clicked, lambda: openHelp(help))
|
||||||
|
self.open()
|
||||||
|
|
||||||
|
|
||||||
|
def ask_user(
|
||||||
|
text: str,
|
||||||
|
callback: Callable[[bool], None],
|
||||||
|
defaults_yes: bool = True,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> MessageBox:
|
||||||
|
"Shows a yes/no question, passes the answer to the callback function as a bool."
|
||||||
|
return MessageBox(
|
||||||
|
text,
|
||||||
|
callback=lambda response: callback(not response),
|
||||||
|
icon=QMessageBox.Icon.Question,
|
||||||
|
buttons=[QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No],
|
||||||
|
default_button=not defaults_yes,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ask_user_dialog(
|
||||||
|
text: str,
|
||||||
|
callback: Callable[[int], None],
|
||||||
|
buttons: Sequence[str | QMessageBox.StandardButton] | None = None,
|
||||||
|
default_button: int = 1,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> MessageBox:
|
||||||
|
"Shows a question to the user, passes the index of the button clicked to the callback."
|
||||||
|
if buttons is None:
|
||||||
|
buttons = [QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No]
|
||||||
|
return MessageBox(
|
||||||
|
text,
|
||||||
|
callback=callback,
|
||||||
|
icon=QMessageBox.Icon.Question,
|
||||||
|
buttons=buttons,
|
||||||
|
default_button=default_button,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def show_info(text: str, callback: Callable | None = None, **kwargs: Any) -> MessageBox:
|
||||||
|
"Show a small info window with an OK button."
|
||||||
|
if "icon" not in kwargs:
|
||||||
|
kwargs["icon"] = QMessageBox.Icon.Information
|
||||||
|
return MessageBox(
|
||||||
|
text,
|
||||||
|
callback=(lambda _: callback()) if callback is not None else None,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def show_warning(
|
||||||
|
text: str, callback: Callable | None = None, **kwargs: Any
|
||||||
|
) -> MessageBox:
|
||||||
|
"Show a small warning window with an OK button."
|
||||||
|
return show_info(text, icon=QMessageBox.Icon.Warning, callback=callback, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def show_critical(
|
||||||
|
text: str, callback: Callable | None = None, **kwargs: Any
|
||||||
|
) -> MessageBox:
|
||||||
|
"Show a small critical error window with an OK button."
|
||||||
|
return show_info(text, icon=QMessageBox.Icon.Critical, callback=callback, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def showWarning(
|
def showWarning(
|
||||||
text: str,
|
text: str,
|
||||||
parent: QWidget | None = None,
|
parent: QWidget | None = None,
|
||||||
|
|
|
@ -63,6 +63,8 @@ ignore_missing_imports = True
|
||||||
ignore_missing_imports = True
|
ignore_missing_imports = True
|
||||||
[mypy-stringcase]
|
[mypy-stringcase]
|
||||||
ignore_missing_imports = True
|
ignore_missing_imports = True
|
||||||
|
[mypy-certifi]
|
||||||
|
ignore_missing_imports = True
|
||||||
|
|
||||||
[mypy-aqt.forms.*]
|
[mypy-aqt.forms.*]
|
||||||
disallow_untyped_defs = false
|
disallow_untyped_defs = false
|
||||||
|
|
|
@ -56,7 +56,7 @@ impl CsvMetadata {
|
||||||
.ok_or_else(|| AnkiError::invalid_input("notetype oneof not set"))
|
.ok_or_else(|| AnkiError::invalid_input("notetype oneof not set"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn field_source_columns(&self) -> Result<Vec<Option<usize>>> {
|
fn field_source_columns(&self) -> Result<FieldSourceColumns> {
|
||||||
Ok(match self.notetype()? {
|
Ok(match self.notetype()? {
|
||||||
CsvNotetype::GlobalNotetype(global) => global
|
CsvNotetype::GlobalNotetype(global) => global
|
||||||
.field_columns
|
.field_columns
|
||||||
|
@ -115,8 +115,7 @@ struct ColumnContext {
|
||||||
guid_column: Option<usize>,
|
guid_column: Option<usize>,
|
||||||
deck_column: Option<usize>,
|
deck_column: Option<usize>,
|
||||||
notetype_column: Option<usize>,
|
notetype_column: Option<usize>,
|
||||||
/// Source column indices for the fields of a notetype, identified by its
|
/// Source column indices for the fields of a notetype
|
||||||
/// name or id as string. The empty string corresponds to the default notetype.
|
|
||||||
field_source_columns: FieldSourceColumns,
|
field_source_columns: FieldSourceColumns,
|
||||||
/// How fields are converted to strings. Used for escaping HTML if appropriate.
|
/// How fields are converted to strings. Used for escaping HTML if appropriate.
|
||||||
stringify: fn(&str) -> String,
|
stringify: fn(&str) -> String,
|
||||||
|
@ -168,22 +167,20 @@ impl ColumnContext {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn gather_tags(&self, record: &csv::StringRecord) -> Vec<String> {
|
fn gather_tags(&self, record: &csv::StringRecord) -> Option<Vec<String>> {
|
||||||
self.tags_column
|
self.tags_column.and_then(|i| record.get(i - 1)).map(|s| {
|
||||||
.and_then(|i| record.get(i - 1))
|
s.split_whitespace()
|
||||||
.unwrap_or_default()
|
.filter(|s| !s.is_empty())
|
||||||
.split_whitespace()
|
.map(ToString::to_string)
|
||||||
.filter(|s| !s.is_empty())
|
.collect()
|
||||||
.map(ToString::to_string)
|
})
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn gather_note_fields(&self, record: &csv::StringRecord) -> Vec<String> {
|
fn gather_note_fields(&self, record: &csv::StringRecord) -> Vec<Option<String>> {
|
||||||
let stringify = self.stringify;
|
let stringify = self.stringify;
|
||||||
self.field_source_columns
|
self.field_source_columns
|
||||||
.iter()
|
.iter()
|
||||||
.map(|opt| opt.and_then(|idx| record.get(idx - 1)).unwrap_or_default())
|
.map(|opt| opt.and_then(|idx| record.get(idx - 1)).map(stringify))
|
||||||
.map(stringify)
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -253,7 +250,19 @@ mod test {
|
||||||
($metadata:expr, $csv:expr, $expected:expr) => {
|
($metadata:expr, $csv:expr, $expected:expr) => {
|
||||||
let notes = import!(&$metadata, $csv);
|
let notes = import!(&$metadata, $csv);
|
||||||
let fields: Vec<_> = notes.into_iter().map(|note| note.fields).collect();
|
let fields: Vec<_> = notes.into_iter().map(|note| note.fields).collect();
|
||||||
assert_eq!(fields, $expected);
|
assert_eq!(fields.len(), $expected.len());
|
||||||
|
for (note_fields, note_expected) in fields.iter().zip($expected.iter()) {
|
||||||
|
assert_field_eq!(note_fields, note_expected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! assert_field_eq {
|
||||||
|
($fields:expr, $expected:expr) => {
|
||||||
|
assert_eq!($fields.len(), $expected.len());
|
||||||
|
for (field, expected) in $fields.iter().zip($expected.iter()) {
|
||||||
|
assert_eq!(&field.as_ref().map(String::as_str), expected);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -283,20 +292,28 @@ mod test {
|
||||||
#[test]
|
#[test]
|
||||||
fn should_allow_missing_columns() {
|
fn should_allow_missing_columns() {
|
||||||
let metadata = CsvMetadata::defaults_for_testing();
|
let metadata = CsvMetadata::defaults_for_testing();
|
||||||
assert_imported_fields!(metadata, "foo\n", &[&["foo", ""]]);
|
assert_imported_fields!(metadata, "foo\n", [[Some("foo"), None]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_respect_custom_delimiter() {
|
fn should_respect_custom_delimiter() {
|
||||||
let mut metadata = CsvMetadata::defaults_for_testing();
|
let mut metadata = CsvMetadata::defaults_for_testing();
|
||||||
metadata.set_delimiter(Delimiter::Pipe);
|
metadata.set_delimiter(Delimiter::Pipe);
|
||||||
assert_imported_fields!(metadata, "fr,ont|ba,ck\n", &[&["fr,ont", "ba,ck"]]);
|
assert_imported_fields!(
|
||||||
|
metadata,
|
||||||
|
"fr,ont|ba,ck\n",
|
||||||
|
[[Some("fr,ont"), Some("ba,ck")]]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_ignore_first_line_starting_with_tags() {
|
fn should_ignore_first_line_starting_with_tags() {
|
||||||
let metadata = CsvMetadata::defaults_for_testing();
|
let metadata = CsvMetadata::defaults_for_testing();
|
||||||
assert_imported_fields!(metadata, "tags:foo\nfront,back\n", &[&["front", "back"]]);
|
assert_imported_fields!(
|
||||||
|
metadata,
|
||||||
|
"tags:foo\nfront,back\n",
|
||||||
|
[[Some("front"), Some("back")]]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
@ -308,21 +325,29 @@ mod test {
|
||||||
id: 1,
|
id: 1,
|
||||||
field_columns: vec![3, 1],
|
field_columns: vec![3, 1],
|
||||||
}));
|
}));
|
||||||
assert_imported_fields!(metadata, "front,foo,back\n", &[&["back", "front"]]);
|
assert_imported_fields!(
|
||||||
|
metadata,
|
||||||
|
"front,foo,back\n",
|
||||||
|
[[Some("back"), Some("front")]]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_ignore_lines_starting_with_number_sign() {
|
fn should_ignore_lines_starting_with_number_sign() {
|
||||||
let metadata = CsvMetadata::defaults_for_testing();
|
let metadata = CsvMetadata::defaults_for_testing();
|
||||||
assert_imported_fields!(metadata, "#foo\nfront,back\n#bar\n", &[&["front", "back"]]);
|
assert_imported_fields!(
|
||||||
|
metadata,
|
||||||
|
"#foo\nfront,back\n#bar\n",
|
||||||
|
[[Some("front"), Some("back")]]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_escape_html_entities_if_csv_is_html() {
|
fn should_escape_html_entities_if_csv_is_html() {
|
||||||
let mut metadata = CsvMetadata::defaults_for_testing();
|
let mut metadata = CsvMetadata::defaults_for_testing();
|
||||||
assert_imported_fields!(metadata, "<hr>\n", &[&["<hr>", ""]]);
|
assert_imported_fields!(metadata, "<hr>\n", [[Some("<hr>"), None]]);
|
||||||
metadata.is_html = true;
|
metadata.is_html = true;
|
||||||
assert_imported_fields!(metadata, "<hr>\n", &[&["<hr>", ""]]);
|
assert_imported_fields!(metadata, "<hr>\n", [[Some("<hr>"), None]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
@ -330,7 +355,7 @@ mod test {
|
||||||
let mut metadata = CsvMetadata::defaults_for_testing();
|
let mut metadata = CsvMetadata::defaults_for_testing();
|
||||||
metadata.tags_column = 3;
|
metadata.tags_column = 3;
|
||||||
let notes = import!(metadata, "front,back,foo bar\n");
|
let notes = import!(metadata, "front,back,foo bar\n");
|
||||||
assert_eq!(notes[0].tags, &["foo", "bar"]);
|
assert_eq!(notes[0].tags.as_ref().unwrap(), &["foo", "bar"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
@ -347,9 +372,9 @@ mod test {
|
||||||
metadata.notetype.replace(CsvNotetype::NotetypeColumn(1));
|
metadata.notetype.replace(CsvNotetype::NotetypeColumn(1));
|
||||||
metadata.column_labels.push("".to_string());
|
metadata.column_labels.push("".to_string());
|
||||||
let notes = import!(metadata, "Basic,front,back\nCloze,foo,bar\n");
|
let notes = import!(metadata, "Basic,front,back\nCloze,foo,bar\n");
|
||||||
assert_eq!(notes[0].fields, &["front", "back"]);
|
assert_field_eq!(notes[0].fields, [Some("front"), Some("back")]);
|
||||||
assert_eq!(notes[0].notetype, NameOrId::Name(String::from("Basic")));
|
assert_eq!(notes[0].notetype, NameOrId::Name(String::from("Basic")));
|
||||||
assert_eq!(notes[1].fields, &["foo", "bar"]);
|
assert_field_eq!(notes[1].fields, [Some("foo"), Some("bar")]);
|
||||||
assert_eq!(notes[1].notetype, NameOrId::Name(String::from("Cloze")));
|
assert_eq!(notes[1].notetype, NameOrId::Name(String::from("Cloze")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,6 @@
|
||||||
use std::{
|
use std::{
|
||||||
borrow::Cow,
|
borrow::Cow,
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
mem,
|
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -16,8 +15,9 @@ use crate::{
|
||||||
text::{
|
text::{
|
||||||
DupeResolution, ForeignCard, ForeignData, ForeignNote, ForeignNotetype, ForeignTemplate,
|
DupeResolution, ForeignCard, ForeignData, ForeignNote, ForeignNotetype, ForeignTemplate,
|
||||||
},
|
},
|
||||||
ImportProgress, IncrementableProgress, LogNote, NoteLog,
|
ImportProgress, IncrementableProgress, NoteLog,
|
||||||
},
|
},
|
||||||
|
notes::{field_checksum, normalize_field},
|
||||||
notetype::{CardGenContext, CardTemplate, NoteField, NotetypeConfig},
|
notetype::{CardGenContext, CardTemplate, NoteField, NotetypeConfig},
|
||||||
prelude::*,
|
prelude::*,
|
||||||
text::strip_html_preserving_media_filenames,
|
text::strip_html_preserving_media_filenames,
|
||||||
|
@ -78,13 +78,13 @@ struct DeckIdsByNameOrId {
|
||||||
default: Option<DeckId>,
|
default: Option<DeckId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NoteContext {
|
struct NoteContext<'a> {
|
||||||
/// Prepared and with canonified tags.
|
note: ForeignNote,
|
||||||
note: Note,
|
|
||||||
dupes: Vec<Duplicate>,
|
dupes: Vec<Duplicate>,
|
||||||
cards: Vec<Card>,
|
|
||||||
notetype: Arc<Notetype>,
|
notetype: Arc<Notetype>,
|
||||||
deck_id: DeckId,
|
deck_id: DeckId,
|
||||||
|
global_tags: &'a [String],
|
||||||
|
updated_tags: &'a [String],
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Duplicate {
|
struct Duplicate {
|
||||||
|
@ -94,8 +94,8 @@ struct Duplicate {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Duplicate {
|
impl Duplicate {
|
||||||
fn new(dupe: Note, original: &Note, first_field_match: bool) -> Self {
|
fn new(dupe: Note, original: &ForeignNote, first_field_match: bool) -> Self {
|
||||||
let identical = dupe.equal_fields_and_tags(original);
|
let identical = original.equal_fields_and_tags(&dupe);
|
||||||
Self {
|
Self {
|
||||||
note: dupe,
|
note: dupe,
|
||||||
identical,
|
identical,
|
||||||
|
@ -190,14 +190,20 @@ impl<'a> Context<'a> {
|
||||||
let mut log = NoteLog::new(self.dupe_resolution, notes.len() as u32);
|
let mut log = NoteLog::new(self.dupe_resolution, notes.len() as u32);
|
||||||
for foreign in notes {
|
for foreign in notes {
|
||||||
incrementor.increment()?;
|
incrementor.increment()?;
|
||||||
if foreign.first_field_is_empty() {
|
if foreign.first_field_is_the_empty_string() {
|
||||||
log.empty_first_field.push(foreign.into_log_note());
|
log.empty_first_field.push(foreign.into_log_note());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(notetype) = self.notetype_for_note(&foreign)? {
|
if let Some(notetype) = self.notetype_for_note(&foreign)? {
|
||||||
if let Some(deck_id) = self.deck_ids.get(&foreign.deck) {
|
if let Some(deck_id) = self.deck_ids.get(&foreign.deck) {
|
||||||
let ctx = self.build_note_context(foreign, notetype, deck_id, global_tags)?;
|
let ctx = self.build_note_context(
|
||||||
self.import_note(ctx, updated_tags, &mut log)?;
|
foreign,
|
||||||
|
notetype,
|
||||||
|
deck_id,
|
||||||
|
global_tags,
|
||||||
|
updated_tags,
|
||||||
|
)?;
|
||||||
|
self.import_note(ctx, &mut log)?;
|
||||||
} else {
|
} else {
|
||||||
log.missing_deck.push(foreign.into_log_note());
|
log.missing_deck.push(foreign.into_log_note());
|
||||||
}
|
}
|
||||||
|
@ -208,41 +214,45 @@ impl<'a> Context<'a> {
|
||||||
Ok(log)
|
Ok(log)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_note_context(
|
fn build_note_context<'tags>(
|
||||||
&mut self,
|
&mut self,
|
||||||
foreign: ForeignNote,
|
mut note: ForeignNote,
|
||||||
notetype: Arc<Notetype>,
|
notetype: Arc<Notetype>,
|
||||||
deck_id: DeckId,
|
deck_id: DeckId,
|
||||||
global_tags: &[String],
|
global_tags: &'tags [String],
|
||||||
) -> Result<NoteContext> {
|
updated_tags: &'tags [String],
|
||||||
let (mut note, cards) = foreign.into_native(¬etype, deck_id, self.today, global_tags);
|
) -> Result<NoteContext<'tags>> {
|
||||||
note.prepare_for_update(¬etype, self.normalize_notes)?;
|
self.prepare_foreign_note(&mut note)?;
|
||||||
self.col.canonify_note_tags(&mut note, self.usn)?;
|
|
||||||
let dupes = self.find_duplicates(¬etype, ¬e)?;
|
let dupes = self.find_duplicates(¬etype, ¬e)?;
|
||||||
|
|
||||||
Ok(NoteContext {
|
Ok(NoteContext {
|
||||||
note,
|
note,
|
||||||
dupes,
|
dupes,
|
||||||
cards,
|
|
||||||
notetype,
|
notetype,
|
||||||
deck_id,
|
deck_id,
|
||||||
|
global_tags,
|
||||||
|
updated_tags,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_duplicates(&self, notetype: &Notetype, note: &Note) -> Result<Vec<Duplicate>> {
|
fn prepare_foreign_note(&mut self, note: &mut ForeignNote) -> Result<()> {
|
||||||
let checksum = note
|
note.normalize_fields(self.normalize_notes);
|
||||||
.checksum
|
self.col.canonify_foreign_tags(note, self.usn)
|
||||||
.ok_or_else(|| AnkiError::invalid_input("note unprepared"))?;
|
}
|
||||||
|
|
||||||
|
fn find_duplicates(&self, notetype: &Notetype, note: &ForeignNote) -> Result<Vec<Duplicate>> {
|
||||||
if let Some(nid) = self.existing_guids.get(¬e.guid) {
|
if let Some(nid) = self.existing_guids.get(¬e.guid) {
|
||||||
self.get_guid_dupe(*nid, note).map(|dupe| vec![dupe])
|
self.get_guid_dupe(*nid, note).map(|dupe| vec![dupe])
|
||||||
} else if let Some(nids) = self.existing_checksums.get(&(notetype.id, checksum)) {
|
} else if let Some(nids) = note
|
||||||
|
.checksum()
|
||||||
|
.and_then(|csum| self.existing_checksums.get(&(notetype.id, csum)))
|
||||||
|
{
|
||||||
self.get_first_field_dupes(note, nids)
|
self.get_first_field_dupes(note, nids)
|
||||||
} else {
|
} else {
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_guid_dupe(&self, nid: NoteId, original: &Note) -> Result<Duplicate> {
|
fn get_guid_dupe(&self, nid: NoteId, original: &ForeignNote) -> Result<Duplicate> {
|
||||||
self.col
|
self.col
|
||||||
.storage
|
.storage
|
||||||
.get_note(nid)?
|
.get_note(nid)?
|
||||||
|
@ -250,7 +260,7 @@ impl<'a> Context<'a> {
|
||||||
.map(|dupe| Duplicate::new(dupe, original, false))
|
.map(|dupe| Duplicate::new(dupe, original, false))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_first_field_dupes(&self, note: &Note, nids: &[NoteId]) -> Result<Vec<Duplicate>> {
|
fn get_first_field_dupes(&self, note: &ForeignNote, nids: &[NoteId]) -> Result<Vec<Duplicate>> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.col
|
.col
|
||||||
.get_full_duplicates(note, nids)?
|
.get_full_duplicates(note, nids)?
|
||||||
|
@ -259,26 +269,36 @@ impl<'a> Context<'a> {
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn import_note(
|
fn import_note(&mut self, ctx: NoteContext, log: &mut NoteLog) -> Result<()> {
|
||||||
&mut self,
|
|
||||||
ctx: NoteContext,
|
|
||||||
updated_tags: &[String],
|
|
||||||
log: &mut NoteLog,
|
|
||||||
) -> Result<()> {
|
|
||||||
match self.dupe_resolution {
|
match self.dupe_resolution {
|
||||||
_ if ctx.dupes.is_empty() => self.add_note(ctx, &mut log.new)?,
|
_ if ctx.dupes.is_empty() => self.add_note(ctx, log, false)?,
|
||||||
DupeResolution::Add => self.add_note(ctx, &mut log.first_field_match)?,
|
DupeResolution::Add => self.add_note(ctx, log, true)?,
|
||||||
DupeResolution::Update => self.update_with_note(ctx, updated_tags, log)?,
|
DupeResolution::Update => self.update_with_note(ctx, log)?,
|
||||||
DupeResolution::Ignore => log.first_field_match.push(ctx.note.into_log_note()),
|
DupeResolution::Ignore => log.first_field_match.push(ctx.note.into_log_note()),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_note(&mut self, mut ctx: NoteContext, log_queue: &mut Vec<LogNote>) -> Result<()> {
|
fn add_note(&mut self, ctx: NoteContext, log: &mut NoteLog, dupe: bool) -> Result<()> {
|
||||||
ctx.note.usn = self.usn;
|
if !ctx.note.first_field_is_unempty() {
|
||||||
self.col.add_note_only_undoable(&mut ctx.note)?;
|
log.empty_first_field.push(ctx.note.into_log_note());
|
||||||
self.add_cards(&mut ctx.cards, &ctx.note, ctx.deck_id, ctx.notetype)?;
|
return Ok(());
|
||||||
log_queue.push(ctx.note.into_log_note());
|
}
|
||||||
|
|
||||||
|
let mut note = Note::new(&ctx.notetype);
|
||||||
|
let mut cards = ctx
|
||||||
|
.note
|
||||||
|
.into_native(&mut note, ctx.deck_id, self.today, ctx.global_tags);
|
||||||
|
self.prepare_note(&mut note, &ctx.notetype)?;
|
||||||
|
self.col.add_note_only_undoable(&mut note)?;
|
||||||
|
self.add_cards(&mut cards, ¬e, ctx.deck_id, ctx.notetype)?;
|
||||||
|
|
||||||
|
if dupe {
|
||||||
|
log.first_field_match.push(note.into_log_note());
|
||||||
|
} else {
|
||||||
|
log.new.push(note.into_log_note());
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -293,63 +313,46 @@ impl<'a> Context<'a> {
|
||||||
self.generate_missing_cards(notetype, deck_id, note)
|
self.generate_missing_cards(notetype, deck_id, note)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_with_note(
|
fn update_with_note(&mut self, ctx: NoteContext, log: &mut NoteLog) -> Result<()> {
|
||||||
&mut self,
|
for dupe in ctx.dupes {
|
||||||
mut ctx: NoteContext,
|
if dupe.note.notetype_id != ctx.notetype.id {
|
||||||
updated_tags: &[String],
|
log.conflicting.push(dupe.note.into_log_note());
|
||||||
log: &mut NoteLog,
|
continue;
|
||||||
) -> Result<()> {
|
}
|
||||||
self.prepare_note_for_update(&mut ctx.note, updated_tags)?;
|
|
||||||
for dupe in mem::take(&mut ctx.dupes) {
|
let mut note = dupe.note.clone();
|
||||||
self.maybe_update_dupe(dupe, &mut ctx, log)?;
|
let mut cards = ctx.note.clone().into_native(
|
||||||
|
&mut note,
|
||||||
|
ctx.deck_id,
|
||||||
|
self.today,
|
||||||
|
ctx.global_tags.iter().chain(ctx.updated_tags.iter()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if !dupe.identical {
|
||||||
|
self.prepare_note(&mut note, &ctx.notetype)?;
|
||||||
|
self.col.update_note_undoable(¬e, &dupe.note)?;
|
||||||
|
}
|
||||||
|
self.add_cards(&mut cards, ¬e, ctx.deck_id, ctx.notetype.clone())?;
|
||||||
|
|
||||||
|
if dupe.identical {
|
||||||
|
log.duplicate.push(dupe.note.into_log_note());
|
||||||
|
} else if dupe.first_field_match {
|
||||||
|
log.first_field_match.push(note.into_log_note());
|
||||||
|
} else {
|
||||||
|
log.updated.push(note.into_log_note());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepare_note_for_update(&mut self, note: &mut Note, updated_tags: &[String]) -> Result<()> {
|
fn prepare_note(&mut self, note: &mut Note, notetype: &Notetype) -> Result<()> {
|
||||||
if !updated_tags.is_empty() {
|
note.prepare_for_update(notetype, self.normalize_notes)?;
|
||||||
note.tags.extend(updated_tags.iter().cloned());
|
self.col.canonify_note_tags(note, self.usn)?;
|
||||||
self.col.canonify_note_tags(note, self.usn)?;
|
|
||||||
}
|
|
||||||
note.set_modified(self.usn);
|
note.set_modified(self.usn);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn maybe_update_dupe(
|
|
||||||
&mut self,
|
|
||||||
dupe: Duplicate,
|
|
||||||
ctx: &mut NoteContext,
|
|
||||||
log: &mut NoteLog,
|
|
||||||
) -> Result<()> {
|
|
||||||
if dupe.note.notetype_id != ctx.notetype.id {
|
|
||||||
log.conflicting.push(dupe.note.into_log_note());
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if dupe.identical {
|
|
||||||
log.duplicate.push(dupe.note.into_log_note());
|
|
||||||
} else {
|
|
||||||
self.update_dupe(dupe, ctx, log)?;
|
|
||||||
}
|
|
||||||
self.add_cards(&mut ctx.cards, &ctx.note, ctx.deck_id, ctx.notetype.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_dupe(
|
|
||||||
&mut self,
|
|
||||||
dupe: Duplicate,
|
|
||||||
ctx: &mut NoteContext,
|
|
||||||
log: &mut NoteLog,
|
|
||||||
) -> Result<()> {
|
|
||||||
ctx.note.id = dupe.note.id;
|
|
||||||
ctx.note.guid = dupe.note.guid.clone();
|
|
||||||
self.col.update_note_undoable(&ctx.note, &dupe.note)?;
|
|
||||||
if dupe.first_field_match {
|
|
||||||
log.first_field_match.push(dupe.note.into_log_note());
|
|
||||||
} else {
|
|
||||||
log.updated.push(dupe.note.into_log_note());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn import_cards(&mut self, cards: &mut [Card], note_id: NoteId) -> Result<()> {
|
fn import_cards(&mut self, cards: &mut [Card], note_id: NoteId) -> Result<()> {
|
||||||
for card in cards {
|
for card in cards {
|
||||||
card.note_id = note_id;
|
card.note_id = note_id;
|
||||||
|
@ -397,8 +400,18 @@ impl Collection {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_full_duplicates(&self, note: &Note, dupe_ids: &[NoteId]) -> Result<Vec<Note>> {
|
fn canonify_foreign_tags(&mut self, note: &mut ForeignNote, usn: Usn) -> Result<()> {
|
||||||
let first_field = note.first_field_stripped();
|
if let Some(tags) = note.tags.take() {
|
||||||
|
note.tags
|
||||||
|
.replace(self.canonify_tags_without_registering(tags, usn)?);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_full_duplicates(&self, note: &ForeignNote, dupe_ids: &[NoteId]) -> Result<Vec<Note>> {
|
||||||
|
let first_field = note
|
||||||
|
.first_field_stripped()
|
||||||
|
.ok_or_else(|| AnkiError::invalid_input("no first field"))?;
|
||||||
dupe_ids
|
dupe_ids
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|&dupe_id| self.storage.get_note(dupe_id).transpose())
|
.filter_map(|&dupe_id| self.storage.get_note(dupe_id).transpose())
|
||||||
|
@ -411,35 +424,72 @@ impl Collection {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ForeignNote {
|
impl ForeignNote {
|
||||||
fn into_native(
|
/// Updates a native note with the foreign data and returns its new cards.
|
||||||
|
fn into_native<'tags>(
|
||||||
self,
|
self,
|
||||||
notetype: &Notetype,
|
note: &mut Note,
|
||||||
deck_id: DeckId,
|
deck_id: DeckId,
|
||||||
today: u32,
|
today: u32,
|
||||||
extra_tags: &[String],
|
extra_tags: impl IntoIterator<Item = &'tags String>,
|
||||||
) -> (Note, Vec<Card>) {
|
) -> Vec<Card> {
|
||||||
// TODO: Handle new and learning cards
|
// TODO: Handle new and learning cards
|
||||||
let mut note = Note::new(notetype);
|
|
||||||
if !self.guid.is_empty() {
|
if !self.guid.is_empty() {
|
||||||
note.guid = self.guid;
|
note.guid = self.guid;
|
||||||
}
|
}
|
||||||
note.tags = self.tags;
|
if let Some(tags) = self.tags {
|
||||||
note.tags.extend(extra_tags.iter().cloned());
|
note.tags = tags;
|
||||||
|
}
|
||||||
|
note.tags.extend(extra_tags.into_iter().cloned());
|
||||||
note.fields_mut()
|
note.fields_mut()
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.zip(self.fields.into_iter())
|
.zip(self.fields.into_iter())
|
||||||
.for_each(|(field, value)| *field = value);
|
.for_each(|(field, new)| {
|
||||||
let cards = self
|
if let Some(s) = new {
|
||||||
.cards
|
*field = s;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.cards
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(idx, c)| c.into_native(NoteId(0), idx as u16, deck_id, today))
|
.map(|(idx, c)| c.into_native(NoteId(0), idx as u16, deck_id, today))
|
||||||
.collect();
|
.collect()
|
||||||
(note, cards)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn first_field_is_empty(&self) -> bool {
|
fn first_field_is_the_empty_string(&self) -> bool {
|
||||||
self.fields.get(0).map(String::is_empty).unwrap_or(true)
|
matches!(self.fields.get(0), Some(Some(s)) if s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_field_is_unempty(&self) -> bool {
|
||||||
|
matches!(self.fields.get(0), Some(Some(s)) if !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_fields(&mut self, normalize_text: bool) {
|
||||||
|
for field in self.fields.iter_mut().flatten() {
|
||||||
|
normalize_field(field, normalize_text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expects normalized form.
|
||||||
|
fn equal_fields_and_tags(&self, other: &Note) -> bool {
|
||||||
|
self.tags.as_ref().map_or(true, |tags| *tags == other.tags)
|
||||||
|
&& self
|
||||||
|
.fields
|
||||||
|
.iter()
|
||||||
|
.zip(other.fields())
|
||||||
|
.all(|(opt, field)| opt.as_ref().map(|s| s == field).unwrap_or(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_field_stripped(&self) -> Option<Cow<str>> {
|
||||||
|
self.fields
|
||||||
|
.get(0)
|
||||||
|
.and_then(|s| s.as_ref())
|
||||||
|
.map(|field| strip_html_preserving_media_filenames(field.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If the first field is set, returns its checksum. Field is expected to be normalized.
|
||||||
|
fn checksum(&self) -> Option<u32> {
|
||||||
|
self.first_field_stripped()
|
||||||
|
.map(|field| field_checksum(&field))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -493,12 +543,6 @@ impl ForeignTemplate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Note {
|
|
||||||
fn equal_fields_and_tags(&self, other: &Self) -> bool {
|
|
||||||
self.fields() == other.fields() && self.tags == other.tags
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
@ -515,7 +559,7 @@ mod test {
|
||||||
|
|
||||||
fn add_note(&mut self, fields: &[&str]) {
|
fn add_note(&mut self, fields: &[&str]) {
|
||||||
self.notes.push(ForeignNote {
|
self.notes.push(ForeignNote {
|
||||||
fields: fields.iter().map(ToString::to_string).collect(),
|
fields: fields.iter().map(ToString::to_string).map(Some).collect(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -543,7 +587,7 @@ mod test {
|
||||||
data.clone().import(&mut col, |_, _| true).unwrap();
|
data.clone().import(&mut col, |_, _| true).unwrap();
|
||||||
assert_eq!(col.storage.notes_table_len(), 1);
|
assert_eq!(col.storage.notes_table_len(), 1);
|
||||||
|
|
||||||
data.notes[0].fields[1] = "new".to_string();
|
data.notes[0].fields[1].replace("new".to_string());
|
||||||
data.import(&mut col, |_, _| true).unwrap();
|
data.import(&mut col, |_, _| true).unwrap();
|
||||||
let notes = col.storage.get_all_notes();
|
let notes = col.storage.get_all_notes();
|
||||||
assert_eq!(notes.len(), 1);
|
assert_eq!(notes.len(), 1);
|
||||||
|
@ -560,11 +604,30 @@ mod test {
|
||||||
data.clone().import(&mut col, |_, _| true).unwrap();
|
data.clone().import(&mut col, |_, _| true).unwrap();
|
||||||
assert_eq!(col.storage.notes_table_len(), 1);
|
assert_eq!(col.storage.notes_table_len(), 1);
|
||||||
|
|
||||||
data.notes[0].fields[1] = "new".to_string();
|
data.notes[0].fields[1].replace("new".to_string());
|
||||||
data.import(&mut col, |_, _| true).unwrap();
|
data.import(&mut col, |_, _| true).unwrap();
|
||||||
assert_eq!(col.storage.get_all_notes()[0].fields()[1], "new");
|
assert_eq!(col.storage.get_all_notes()[0].fields()[1], "new");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_keep_old_field_content_if_no_new_one_is_supplied() {
|
||||||
|
let mut col = open_test_collection();
|
||||||
|
let mut data = ForeignData::with_defaults();
|
||||||
|
data.add_note(&["same", "unchanged"]);
|
||||||
|
data.add_note(&["same", "unchanged"]);
|
||||||
|
data.dupe_resolution = DupeResolution::Update;
|
||||||
|
|
||||||
|
data.clone().import(&mut col, |_, _| true).unwrap();
|
||||||
|
assert_eq!(col.storage.notes_table_len(), 2);
|
||||||
|
|
||||||
|
data.notes[0].fields[1] = None;
|
||||||
|
data.notes[1].fields.pop();
|
||||||
|
data.import(&mut col, |_, _| true).unwrap();
|
||||||
|
let notes = col.storage.get_all_notes();
|
||||||
|
assert_eq!(notes[0].fields(), &["same", "unchanged"]);
|
||||||
|
assert_eq!(notes[0].fields(), &["same", "unchanged"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_recognize_normalized_duplicate_only_if_normalization_is_enabled() {
|
fn should_recognize_normalized_duplicate_only_if_normalization_is_enabled() {
|
||||||
let mut col = open_test_collection();
|
let mut col = open_test_collection();
|
||||||
|
@ -589,7 +652,7 @@ mod test {
|
||||||
let mut col = open_test_collection();
|
let mut col = open_test_collection();
|
||||||
let mut data = ForeignData::with_defaults();
|
let mut data = ForeignData::with_defaults();
|
||||||
data.add_note(&["foo"]);
|
data.add_note(&["foo"]);
|
||||||
data.notes[0].tags = vec![String::from("bar")];
|
data.notes[0].tags.replace(vec![String::from("bar")]);
|
||||||
data.global_tags = vec![String::from("baz")];
|
data.global_tags = vec![String::from("baz")];
|
||||||
|
|
||||||
data.import(&mut col, |_, _| true).unwrap();
|
data.import(&mut col, |_, _| true).unwrap();
|
||||||
|
@ -601,7 +664,7 @@ mod test {
|
||||||
let mut col = open_test_collection();
|
let mut col = open_test_collection();
|
||||||
let mut data = ForeignData::with_defaults();
|
let mut data = ForeignData::with_defaults();
|
||||||
data.add_note(&["foo"]);
|
data.add_note(&["foo"]);
|
||||||
data.notes[0].tags = vec![String::from("bar")];
|
data.notes[0].tags.replace(vec![String::from("bar")]);
|
||||||
data.global_tags = vec![String::from("baz")];
|
data.global_tags = vec![String::from("baz")];
|
||||||
|
|
||||||
data.import(&mut col, |_, _| true).unwrap();
|
data.import(&mut col, |_, _| true).unwrap();
|
||||||
|
|
|
@ -26,8 +26,8 @@ pub struct ForeignData {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct ForeignNote {
|
pub struct ForeignNote {
|
||||||
guid: String,
|
guid: String,
|
||||||
fields: Vec<String>,
|
fields: Vec<Option<String>>,
|
||||||
tags: Vec<String>,
|
tags: Option<Vec<String>>,
|
||||||
notetype: NameOrId,
|
notetype: NameOrId,
|
||||||
deck: NameOrId,
|
deck: NameOrId,
|
||||||
cards: Vec<ForeignCard>,
|
cards: Vec<ForeignCard>,
|
||||||
|
@ -82,7 +82,11 @@ impl ForeignNote {
|
||||||
pub(crate) fn into_log_note(self) -> LogNote {
|
pub(crate) fn into_log_note(self) -> LogNote {
|
||||||
LogNote {
|
LogNote {
|
||||||
id: None,
|
id: None,
|
||||||
fields: self.fields,
|
fields: self
|
||||||
|
.fields
|
||||||
|
.into_iter()
|
||||||
|
.map(Option::unwrap_or_default)
|
||||||
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -186,16 +186,8 @@ impl Note {
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
for field in &mut self.fields {
|
for field in self.fields_mut() {
|
||||||
if field.contains(invalid_char_for_field) {
|
normalize_field(field, normalize_text);
|
||||||
*field = field.replace(invalid_char_for_field, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if normalize_text {
|
|
||||||
for field in &mut self.fields {
|
|
||||||
ensure_string_in_nfc(field);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let field1_nohtml = strip_html_preserving_media_filenames(&self.fields()[0]);
|
let field1_nohtml = strip_html_preserving_media_filenames(&self.fields()[0]);
|
||||||
|
@ -265,6 +257,16 @@ impl Note {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove invalid characters and optionally ensure nfc normalization.
|
||||||
|
pub(crate) fn normalize_field(field: &mut String, normalize_text: bool) {
|
||||||
|
if field.contains(invalid_char_for_field) {
|
||||||
|
*field = field.replace(invalid_char_for_field, "");
|
||||||
|
}
|
||||||
|
if normalize_text {
|
||||||
|
ensure_string_in_nfc(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<Note> for pb::Note {
|
impl From<Note> for pb::Note {
|
||||||
fn from(n: Note) -> Self {
|
fn from(n: Note) -> Self {
|
||||||
pb::Note {
|
pb::Note {
|
||||||
|
|
|
@ -17,6 +17,26 @@ impl Collection {
|
||||||
&mut self,
|
&mut self,
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
usn: Usn,
|
usn: Usn,
|
||||||
|
) -> Result<(Vec<String>, bool)> {
|
||||||
|
self.canonify_tags_inner(tags, usn, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn canonify_tags_without_registering(
|
||||||
|
&mut self,
|
||||||
|
tags: Vec<String>,
|
||||||
|
usn: Usn,
|
||||||
|
) -> Result<Vec<String>> {
|
||||||
|
self.canonify_tags_inner(tags, usn, false)
|
||||||
|
.map(|(tags, _)| tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [canonify_tags()], but doesn't save new tags. As a consequence, new
|
||||||
|
/// parents are not canonified.
|
||||||
|
fn canonify_tags_inner(
|
||||||
|
&mut self,
|
||||||
|
tags: Vec<String>,
|
||||||
|
usn: Usn,
|
||||||
|
register: bool,
|
||||||
) -> Result<(Vec<String>, bool)> {
|
) -> Result<(Vec<String>, bool)> {
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
let mut added = false;
|
let mut added = false;
|
||||||
|
@ -24,7 +44,11 @@ impl Collection {
|
||||||
let tags: Vec<_> = tags.iter().flat_map(|t| split_tags(t)).collect();
|
let tags: Vec<_> = tags.iter().flat_map(|t| split_tags(t)).collect();
|
||||||
for tag in tags {
|
for tag in tags {
|
||||||
let mut tag = Tag::new(tag.to_string(), usn);
|
let mut tag = Tag::new(tag.to_string(), usn);
|
||||||
added |= self.register_tag(&mut tag)?;
|
if register {
|
||||||
|
added |= self.register_tag(&mut tag)?;
|
||||||
|
} else {
|
||||||
|
self.prepare_tag_for_registering(&mut tag)?;
|
||||||
|
}
|
||||||
seen.insert(UniCase::new(tag.name));
|
seen.insert(UniCase::new(tag.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -161,6 +161,10 @@ $vars: (
|
||||||
light: get($color, indigo, 6),
|
light: get($color, indigo, 6),
|
||||||
dark: get($color, indigo, 5),
|
dark: get($color, indigo, 5),
|
||||||
),
|
),
|
||||||
|
code-bg: (
|
||||||
|
light: white,
|
||||||
|
dark: #272822,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
100
ts/components/Collapsible.svelte
Normal file
100
ts/components/Collapsible.svelte
Normal 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>
|
35
ts/editor/CollapseBadge.svelte
Normal file
35
ts/editor/CollapseBadge.svelte
Normal 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>
|
|
@ -189,10 +189,28 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--frame-bg);
|
background: var(--frame-bg);
|
||||||
border-radius: 0 0 5px 5px;
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
|
||||||
&:focus {
|
box-shadow: 0px 0px 2px 0px var(--border);
|
||||||
|
transition: box-shadow 80ms cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
|
/* This pseudo-element is required to display
|
||||||
|
the inset box-shadow above field contents */
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
right: -1px;
|
||||||
|
bottom: -1px;
|
||||||
|
left: -1px;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: inset 0 0 0 2px var(--focus-border);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -45,22 +45,26 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
import { directionKey } from "../lib/context-keys";
|
import Collapsible from "../components/Collapsible.svelte";
|
||||||
|
import { collapsedKey, directionKey } from "../lib/context-keys";
|
||||||
import { promiseWithResolver } from "../lib/promise";
|
import { promiseWithResolver } from "../lib/promise";
|
||||||
import type { Destroyable } from "./destroyable";
|
import type { Destroyable } from "./destroyable";
|
||||||
import EditingArea from "./EditingArea.svelte";
|
import EditingArea from "./EditingArea.svelte";
|
||||||
import FieldState from "./FieldState.svelte";
|
|
||||||
import LabelContainer from "./LabelContainer.svelte";
|
|
||||||
import LabelName from "./LabelName.svelte";
|
|
||||||
|
|
||||||
export let content: Writable<string>;
|
export let content: Writable<string>;
|
||||||
export let field: FieldData;
|
export let field: FieldData;
|
||||||
|
export let collapsed = false;
|
||||||
|
|
||||||
const directionStore = writable<"ltr" | "rtl">();
|
const directionStore = writable<"ltr" | "rtl">();
|
||||||
setContext(directionKey, directionStore);
|
setContext(directionKey, directionStore);
|
||||||
|
|
||||||
$: $directionStore = field.direction;
|
$: $directionStore = field.direction;
|
||||||
|
|
||||||
|
const collapsedStore = writable<boolean>();
|
||||||
|
setContext(collapsedKey, collapsedStore);
|
||||||
|
|
||||||
|
$: $collapsedStore = collapsed;
|
||||||
|
|
||||||
const editingArea: Partial<EditingAreaAPI> = {};
|
const editingArea: Partial<EditingAreaAPI> = {};
|
||||||
const [element, elementResolve] = promiseWithResolver<HTMLElement>();
|
const [element, elementResolve] = promiseWithResolver<HTMLElement>();
|
||||||
|
|
||||||
|
@ -85,37 +89,26 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
on:focusin
|
on:focusin
|
||||||
on:focusout
|
on:focusout
|
||||||
on:click={() => editingArea.focus?.()}
|
on:click={() => editingArea.focus?.()}
|
||||||
|
on:mouseenter
|
||||||
|
on:mouseleave
|
||||||
>
|
>
|
||||||
<LabelContainer>
|
<slot name="field-label" />
|
||||||
<span>
|
|
||||||
<LabelName>
|
<Collapsible {collapsed}>
|
||||||
{field.name}
|
<EditingArea
|
||||||
</LabelName>
|
{content}
|
||||||
</span>
|
fontFamily={field.fontFamily}
|
||||||
<FieldState><slot name="field-state" /></FieldState>
|
fontSize={field.fontSize}
|
||||||
</LabelContainer>
|
api={editingArea}
|
||||||
<EditingArea
|
>
|
||||||
{content}
|
<slot name="editing-inputs" />
|
||||||
fontFamily={field.fontFamily}
|
</EditingArea>
|
||||||
fontSize={field.fontSize}
|
</Collapsible>
|
||||||
api={editingArea}
|
|
||||||
>
|
|
||||||
<slot name="editing-inputs" />
|
|
||||||
</EditingArea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.editor-field {
|
.editor-field {
|
||||||
|
position: relative;
|
||||||
--border-color: var(--border);
|
--border-color: var(--border);
|
||||||
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
|
|
||||||
&:focus-within {
|
|
||||||
--border-color: var(--focus-border);
|
|
||||||
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 0 3px var(--focus-shadow);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -12,8 +12,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
justify-content: flex;
|
justify-content: flex;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|
||||||
& > :global(*) {
|
/* replace with "gap: 5px" once it's available
|
||||||
margin-left: 2px;
|
- required: Chromium 84 (Qt6 only) and iOS 14.1 */
|
||||||
|
> :global(*) {
|
||||||
|
margin: 0 3px;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:global([dir="rtl"]) .field-state > :global(*) {
|
||||||
|
margin: 0 3px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
&:first-child {
|
||||||
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -10,9 +10,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
.fields {
|
.fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-rows: min-content;
|
grid-auto-rows: min-content;
|
||||||
grid-gap: 4px;
|
grid-gap: 6px;
|
||||||
|
|
||||||
padding: 5px 3px 0;
|
padding: 0 3px;
|
||||||
/* set height to 100% for rich text widgets */
|
/* set height to 100% for rich text widgets */
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
|
|
|
@ -13,5 +13,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
/* replace with "gap: 5px" once it's available
|
||||||
|
- required: Chromium 84 (Qt6 only) and iOS 14.1 */
|
||||||
|
> :global(*) {
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -3,38 +3,54 @@ Copyright: Ankitects Pty Ltd and contributors
|
||||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from "svelte";
|
import { createEventDispatcher } from "svelte";
|
||||||
import type { Readable } from "svelte/store";
|
|
||||||
|
|
||||||
import { directionKey } from "../lib/context-keys";
|
import * as tr from "../lib/ftl";
|
||||||
|
import CollapseBadge from "./CollapseBadge.svelte";
|
||||||
|
|
||||||
const direction = getContext<Readable<"ltr" | "rtl">>(directionKey);
|
export let collapsed: boolean;
|
||||||
|
let hovered = false;
|
||||||
|
|
||||||
|
$: tooltip = collapsed ? tr.editingExpandField() : tr.editingCollapseField();
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
dispatch("toggle");
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="label-container" on:mousedown|preventDefault>
|
||||||
class="label-container"
|
<span
|
||||||
class:rtl={$direction === "rtl"}
|
class="clickable"
|
||||||
on:mousedown|preventDefault
|
title={tooltip}
|
||||||
>
|
on:click|stopPropagation={toggle}
|
||||||
|
on:mouseenter={() => (hovered = true)}
|
||||||
|
on:mouseleave={() => (hovered = false)}
|
||||||
|
>
|
||||||
|
<CollapseBadge {collapsed} highlighted={hovered} />
|
||||||
|
<slot name="field-name" />
|
||||||
|
</span>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.label-container {
|
.label-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
position: sticky;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
top: 0;
|
||||||
|
padding-bottom: 1px;
|
||||||
|
|
||||||
background-color: var(--label-color, transparent);
|
/* slightly wider than EditingArea
|
||||||
|
to cover field borders on scroll */
|
||||||
|
left: -1px;
|
||||||
|
right: -1px;
|
||||||
|
z-index: 3;
|
||||||
|
background: var(--label-color);
|
||||||
|
|
||||||
border-width: 0 0 1px;
|
.clickable {
|
||||||
border-style: dashed;
|
cursor: pointer;
|
||||||
border-color: var(--border-color);
|
}
|
||||||
border-radius: 5px 5px 0 0;
|
|
||||||
|
|
||||||
padding: 0px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rtl {
|
|
||||||
direction: rtl;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -45,7 +45,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<NoteEditor bind:this={noteEditor} {api}>
|
<NoteEditor bind:this={noteEditor} {api}>
|
||||||
<svelte:fragment slot="field-state" let:index>
|
<svelte:fragment slot="field-state" let:index let:visible>
|
||||||
<StickyBadge active={stickies[index]} {index} />
|
<StickyBadge bind:active={stickies[index]} {index} {visible} />
|
||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
</NoteEditor>
|
</NoteEditor>
|
||||||
|
|
|
@ -5,12 +5,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<script context="module" lang="ts">
|
<script context="module" lang="ts">
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
|
|
||||||
|
import Collapsible from "../components/Collapsible.svelte";
|
||||||
import type { EditingInputAPI } from "./EditingArea.svelte";
|
import type { EditingInputAPI } from "./EditingArea.svelte";
|
||||||
import type { EditorToolbarAPI } from "./editor-toolbar";
|
import type { EditorToolbarAPI } from "./editor-toolbar";
|
||||||
import type { EditorFieldAPI } from "./EditorField.svelte";
|
import type { EditorFieldAPI } from "./EditorField.svelte";
|
||||||
|
import FieldState from "./FieldState.svelte";
|
||||||
|
import LabelContainer from "./LabelContainer.svelte";
|
||||||
|
import LabelName from "./LabelName.svelte";
|
||||||
|
|
||||||
export interface NoteEditorAPI {
|
export interface NoteEditorAPI {
|
||||||
fields: EditorFieldAPI[];
|
fields: EditorFieldAPI[];
|
||||||
|
hoveredField: Writable<EditorFieldAPI | null>;
|
||||||
focusedField: Writable<EditorFieldAPI | null>;
|
focusedField: Writable<EditorFieldAPI | null>;
|
||||||
focusedInput: Writable<EditingInputAPI | null>;
|
focusedInput: Writable<EditingInputAPI | null>;
|
||||||
toolbar: EditorToolbarAPI;
|
toolbar: EditorToolbarAPI;
|
||||||
|
@ -61,7 +66,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import PlainTextInput from "./plain-text-input";
|
import PlainTextInput from "./plain-text-input";
|
||||||
import PlainTextBadge from "./PlainTextBadge.svelte";
|
import PlainTextBadge from "./PlainTextBadge.svelte";
|
||||||
import RichTextInput, { editingInputIsRichText } from "./rich-text-input";
|
import RichTextInput, { editingInputIsRichText } from "./rich-text-input";
|
||||||
import RichTextBadge from "./RichTextBadge.svelte";
|
|
||||||
|
|
||||||
function quoteFontFamily(fontFamily: string): string {
|
function quoteFontFamily(fontFamily: string): string {
|
||||||
// generic families (e.g. sans-serif) must not be quoted
|
// generic families (e.g. sans-serif) must not be quoted
|
||||||
|
@ -128,10 +132,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
}
|
}
|
||||||
|
|
||||||
let fonts: [string, number, boolean][] = [];
|
let fonts: [string, number, boolean][] = [];
|
||||||
|
|
||||||
|
let fieldsCollapsed: boolean[] = [];
|
||||||
|
|
||||||
const fields = clearableArray<EditorFieldAPI>();
|
const fields = clearableArray<EditorFieldAPI>();
|
||||||
|
|
||||||
export function setFonts(fs: [string, number, boolean][]): void {
|
export function setFonts(fs: [string, number, boolean][]): void {
|
||||||
fonts = fs;
|
fonts = fs;
|
||||||
|
fieldsCollapsed = fonts.map((_, index) => fieldsCollapsed[index] ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function focusField(index: number | null): void {
|
export function focusField(index: number | null): void {
|
||||||
|
@ -272,11 +280,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
let apiPartial: Partial<NoteEditorAPI> = {};
|
let apiPartial: Partial<NoteEditorAPI> = {};
|
||||||
export { apiPartial as api };
|
export { apiPartial as api };
|
||||||
|
|
||||||
|
const hoveredField: NoteEditorAPI["hoveredField"] = writable(null);
|
||||||
const focusedField: NoteEditorAPI["focusedField"] = writable(null);
|
const focusedField: NoteEditorAPI["focusedField"] = writable(null);
|
||||||
const focusedInput: NoteEditorAPI["focusedInput"] = writable(null);
|
const focusedInput: NoteEditorAPI["focusedInput"] = writable(null);
|
||||||
|
|
||||||
const api: NoteEditorAPI = {
|
const api: NoteEditorAPI = {
|
||||||
...apiPartial,
|
...apiPartial,
|
||||||
|
hoveredField,
|
||||||
focusedField,
|
focusedField,
|
||||||
focusedInput,
|
focusedInput,
|
||||||
toolbar: toolbar as EditorToolbarAPI,
|
toolbar: toolbar as EditorToolbarAPI,
|
||||||
|
@ -331,62 +341,93 @@ the AddCards dialog) should be implemented in the user of this component.
|
||||||
`blur:${index}:${getNoteId()}:${get(content)}`,
|
`blur:${index}:${getNoteId()}:${get(content)}`,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
on:mouseenter={() => {
|
||||||
|
$hoveredField = fields[index];
|
||||||
|
}}
|
||||||
|
on:mouseleave={() => {
|
||||||
|
$hoveredField = null;
|
||||||
|
}}
|
||||||
|
collapsed={fieldsCollapsed[index]}
|
||||||
--label-color={cols[index] === "dupe"
|
--label-color={cols[index] === "dupe"
|
||||||
? "var(--flag1-bg)"
|
? "var(--flag1-bg)"
|
||||||
: "transparent"}
|
: "var(--window-bg)"}
|
||||||
>
|
>
|
||||||
<svelte:fragment slot="field-state">
|
<svelte:fragment slot="field-label">
|
||||||
{#if cols[index] === "dupe"}
|
<LabelContainer
|
||||||
<DuplicateLink />
|
collapsed={fieldsCollapsed[index]}
|
||||||
{/if}
|
on:toggle={async () => {
|
||||||
<RichTextBadge
|
fieldsCollapsed[index] = !fieldsCollapsed[index];
|
||||||
bind:off={richTextsHidden[index]}
|
|
||||||
on:toggle={() => {
|
|
||||||
richTextsHidden[index] = !richTextsHidden[index];
|
|
||||||
|
|
||||||
if (!richTextsHidden[index]) {
|
if (!fieldsCollapsed[index]) {
|
||||||
|
await tick();
|
||||||
richTextInputs[index].api.refocus();
|
richTextInputs[index].api.refocus();
|
||||||
|
} else {
|
||||||
|
plainTextsHidden[index] = true;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
|
||||||
<PlainTextBadge
|
|
||||||
bind:off={plainTextsHidden[index]}
|
|
||||||
on:toggle={() => {
|
|
||||||
plainTextsHidden[index] = !plainTextsHidden[index];
|
|
||||||
|
|
||||||
if (!plainTextsHidden[index]) {
|
|
||||||
plainTextInputs[index].api.refocus();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<slot name="field-state" {field} {index} />
|
|
||||||
</svelte:fragment>
|
|
||||||
|
|
||||||
<svelte:fragment slot="editing-inputs">
|
|
||||||
<RichTextInput
|
|
||||||
hidden={richTextsHidden[index]}
|
|
||||||
on:focusout={() => {
|
|
||||||
saveFieldNow();
|
|
||||||
$focusedInput = null;
|
|
||||||
}}
|
|
||||||
bind:this={richTextInputs[index]}
|
|
||||||
>
|
>
|
||||||
<ImageHandle maxWidth={250} maxHeight={125} />
|
<svelte:fragment slot="field-name">
|
||||||
<MathjaxHandle />
|
<LabelName>
|
||||||
<FieldDescription>
|
{field.name}
|
||||||
{field.description}
|
</LabelName>
|
||||||
</FieldDescription>
|
</svelte:fragment>
|
||||||
</RichTextInput>
|
<FieldState>
|
||||||
|
{#if cols[index] === "dupe"}
|
||||||
|
<DuplicateLink />
|
||||||
|
{/if}
|
||||||
|
<PlainTextBadge
|
||||||
|
visible={!fieldsCollapsed[index] &&
|
||||||
|
(fields[index] === $hoveredField ||
|
||||||
|
fields[index] === $focusedField)}
|
||||||
|
bind:off={plainTextsHidden[index]}
|
||||||
|
on:toggle={async () => {
|
||||||
|
plainTextsHidden[index] =
|
||||||
|
!plainTextsHidden[index];
|
||||||
|
|
||||||
<PlainTextInput
|
if (!plainTextsHidden[index]) {
|
||||||
hidden={plainTextsHidden[index]}
|
await tick();
|
||||||
on:focusout={() => {
|
plainTextInputs[index].api.refocus();
|
||||||
saveFieldNow();
|
}
|
||||||
$focusedInput = null;
|
}}
|
||||||
}}
|
/>
|
||||||
bind:this={plainTextInputs[index]}
|
<slot
|
||||||
/>
|
name="field-state"
|
||||||
|
{field}
|
||||||
|
{index}
|
||||||
|
visible={fields[index] === $hoveredField ||
|
||||||
|
fields[index] === $focusedField}
|
||||||
|
/>
|
||||||
|
</FieldState>
|
||||||
|
</LabelContainer>
|
||||||
|
</svelte:fragment>
|
||||||
|
<svelte:fragment slot="editing-inputs">
|
||||||
|
<Collapsible collapsed={richTextsHidden[index]}>
|
||||||
|
<RichTextInput
|
||||||
|
bind:hidden={richTextsHidden[index]}
|
||||||
|
on:focusout={() => {
|
||||||
|
saveFieldNow();
|
||||||
|
$focusedInput = null;
|
||||||
|
}}
|
||||||
|
bind:this={richTextInputs[index]}
|
||||||
|
>
|
||||||
|
<ImageHandle maxWidth={250} maxHeight={125} />
|
||||||
|
<MathjaxHandle />
|
||||||
|
<FieldDescription>
|
||||||
|
{field.description}
|
||||||
|
</FieldDescription>
|
||||||
|
</RichTextInput>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
<Collapsible collapsed={plainTextsHidden[index]}>
|
||||||
|
<PlainTextInput
|
||||||
|
bind:hidden={plainTextsHidden[index]}
|
||||||
|
on:focusout={() => {
|
||||||
|
saveFieldNow();
|
||||||
|
$focusedInput = null;
|
||||||
|
}}
|
||||||
|
bind:this={plainTextInputs[index]}
|
||||||
|
/>
|
||||||
|
</Collapsible>
|
||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
</EditorField>
|
</EditorField>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
@ -9,16 +9,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import * as tr from "../lib/ftl";
|
import * as tr from "../lib/ftl";
|
||||||
import { getPlatformString, registerShortcut } from "../lib/shortcuts";
|
import { getPlatformString, registerShortcut } from "../lib/shortcuts";
|
||||||
import { context as editorFieldContext } from "./EditorField.svelte";
|
import { context as editorFieldContext } from "./EditorField.svelte";
|
||||||
import { htmlOff, htmlOn } from "./icons";
|
import { plainTextIcon } from "./icons";
|
||||||
|
|
||||||
const editorField = editorFieldContext.get();
|
const editorField = editorFieldContext.get();
|
||||||
const keyCombination = "Control+Shift+X";
|
const keyCombination = "Control+Shift+X";
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let visible = false;
|
||||||
export let off = false;
|
export let off = false;
|
||||||
|
|
||||||
$: icon = off ? htmlOff : htmlOn;
|
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
dispatch("toggle");
|
dispatch("toggle");
|
||||||
}
|
}
|
||||||
|
@ -32,25 +31,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class="plain-text-badge"
|
class="plain-text-badge"
|
||||||
|
class:visible
|
||||||
class:highlighted={!off}
|
class:highlighted={!off}
|
||||||
on:click|stopPropagation={toggle}
|
on:click|stopPropagation={toggle}
|
||||||
>
|
>
|
||||||
<Badge
|
<Badge
|
||||||
tooltip="{tr.editingToggleHtmlEditor()} ({getPlatformString(keyCombination)})"
|
tooltip="{tr.editingToggleHtmlEditor()} ({getPlatformString(keyCombination)})"
|
||||||
iconSize={80}
|
iconSize={80}>{@html plainTextIcon}</Badge
|
||||||
--icon-align="text-top">{@html icon}</Badge
|
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
span {
|
span {
|
||||||
opacity: 0.4;
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
opacity: 0.4;
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
&.highlighted {
|
&.highlighted {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
&:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -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>
|
|
|
@ -10,11 +10,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import * as tr from "../lib/ftl";
|
import * as tr from "../lib/ftl";
|
||||||
import { getPlatformString, registerShortcut } from "../lib/shortcuts";
|
import { getPlatformString, registerShortcut } from "../lib/shortcuts";
|
||||||
import { context as editorFieldContext } from "./EditorField.svelte";
|
import { context as editorFieldContext } from "./EditorField.svelte";
|
||||||
import { stickyOff, stickyOn } from "./icons";
|
import { stickyIcon } from "./icons";
|
||||||
|
|
||||||
export let active: boolean;
|
export let active: boolean;
|
||||||
|
export let visible: boolean;
|
||||||
$: icon = active ? stickyOn : stickyOff;
|
|
||||||
|
|
||||||
const editorField = editorFieldContext.get();
|
const editorField = editorFieldContext.get();
|
||||||
const keyCombination = "F9";
|
const keyCombination = "F9";
|
||||||
|
@ -34,23 +33,26 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
onMount(() => editorField.element.then(shortcut));
|
onMount(() => editorField.element.then(shortcut));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span class:highlighted={active} on:click|stopPropagation={toggle}>
|
<span class:highlighted={active} class:visible on:click|stopPropagation={toggle}>
|
||||||
<Badge
|
<Badge
|
||||||
tooltip="{tr.editingToggleSticky()} ({getPlatformString(keyCombination)})"
|
tooltip="{tr.editingToggleSticky()} ({getPlatformString(keyCombination)})"
|
||||||
widthMultiplier={0.7}
|
widthMultiplier={0.7}>{@html stickyIcon}</Badge
|
||||||
--icon-align="text-top">{@html icon}</Badge
|
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
span {
|
span {
|
||||||
opacity: 0.4;
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
&.visible {
|
||||||
|
transition: none;
|
||||||
|
opacity: 0.4;
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
&.highlighted {
|
&.highlighted {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
&:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -5,10 +5,9 @@
|
||||||
|
|
||||||
export { default as incrementClozeIcon } from "../icons/contain-plus.svg";
|
export { default as incrementClozeIcon } from "../icons/contain-plus.svg";
|
||||||
export { default as alertIcon } from "@mdi/svg/svg/alert.svg";
|
export { default as alertIcon } from "@mdi/svg/svg/alert.svg";
|
||||||
export { default as htmlOn } from "@mdi/svg/svg/code-tags.svg";
|
export { default as chevronDown } from "@mdi/svg/svg/chevron-down.svg";
|
||||||
|
export { default as chevronUp } from "@mdi/svg/svg/chevron-up.svg";
|
||||||
|
export { default as plainTextIcon } from "@mdi/svg/svg/code-tags.svg";
|
||||||
export { default as clozeIcon } from "@mdi/svg/svg/contain.svg";
|
export { default as clozeIcon } from "@mdi/svg/svg/contain.svg";
|
||||||
export { default as richTextOff } from "@mdi/svg/svg/eye-off-outline.svg";
|
export { default as richTextIcon } from "@mdi/svg/svg/format-font.svg";
|
||||||
export { default as richTextOn } from "@mdi/svg/svg/eye-outline.svg";
|
export { default as stickyIcon } from "@mdi/svg/svg/pin-outline.svg";
|
||||||
export { default as stickyOff } from "@mdi/svg/svg/pin-off-outline.svg";
|
|
||||||
export { default as stickyOn } from "@mdi/svg/svg/pin-outline.svg";
|
|
||||||
export { default as htmlOff } from "@mdi/svg/svg/xml.svg";
|
|
||||||
|
|
|
@ -143,7 +143,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<div
|
<div
|
||||||
class="plain-text-input"
|
class="plain-text-input"
|
||||||
class:light-theme={!$pageTheme.isDark}
|
class:light-theme={!$pageTheme.isDark}
|
||||||
class:hidden
|
|
||||||
on:focusin={() => ($focusedInput = api)}
|
on:focusin={() => ($focusedInput = api)}
|
||||||
>
|
>
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
|
@ -161,18 +160,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
:global(.CodeMirror) {
|
:global(.CodeMirror) {
|
||||||
border-radius: 0 0 5px 5px;
|
border-radius: 0 0 5px 5px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--code-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.CodeMirror-lines) {
|
:global(.CodeMirror-lines) {
|
||||||
padding: 6px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.light-theme :global(.CodeMirror) {
|
|
||||||
border-top: 1px solid #ddd;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -211,7 +211,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
setupLifecycleHooks(api);
|
setupLifecycleHooks(api);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rich-text-input" {hidden} on:focusin={setFocus} on:focusout={removeFocus}>
|
<div class="rich-text-input" on:focusin={setFocus} on:focusout={removeFocus}>
|
||||||
<RichTextStyles
|
<RichTextStyles
|
||||||
color={$pageTheme.isDark ? "white" : "black"}
|
color={$pageTheme.isDark ? "white" : "black"}
|
||||||
fontFamily={$fontFamily}
|
fontFamily={$fontFamily}
|
||||||
|
@ -238,6 +238,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
</div>
|
</div>
|
||||||
{/await}
|
{/await}
|
||||||
</RichTextStyles>
|
</RichTextStyles>
|
||||||
|
<slot name="plain-text-badge" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
|
@ -9,4 +9,35 @@ describe("filterHTML", () => {
|
||||||
expect(filterHTML("", true, false)).toBe("");
|
expect(filterHTML("", true, false)).toBe("");
|
||||||
expect(filterHTML("", false, false)).toBe("");
|
expect(filterHTML("", false, false)).toBe("");
|
||||||
});
|
});
|
||||||
|
test("internal filtering", () => {
|
||||||
|
// font-size is filtered, weight is not
|
||||||
|
expect(
|
||||||
|
filterHTML(
|
||||||
|
'<div style="font-weight: bold; font-size: 10px;"></div>',
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
).toBe('<div style="font-weight: bold;"></div>');
|
||||||
|
});
|
||||||
|
test("background color", () => {
|
||||||
|
// transparent is stripped, other colors are not
|
||||||
|
expect(
|
||||||
|
filterHTML(
|
||||||
|
'<span style="background-color: transparent;"></span>',
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
).toBe('<span style=""></span>');
|
||||||
|
expect(
|
||||||
|
filterHTML('<span style="background-color: blue;"></span>', false, true),
|
||||||
|
).toBe('<span style="background-color: blue;"></span>');
|
||||||
|
// except if extended mode is off
|
||||||
|
expect(
|
||||||
|
filterHTML('<span style="background-color: blue;">x</span>', false, false),
|
||||||
|
).toBe("x");
|
||||||
|
// or if it's an internal paste
|
||||||
|
expect(
|
||||||
|
filterHTML('<span style="background-color: blue;"></span>', true, true),
|
||||||
|
).toBe('<span style=""></span>');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,63 +1,50 @@
|
||||||
// Copyright: Ankitects Pty Ltd and contributors
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
interface AllowPropertiesBlockValues {
|
/// Keep property if true.
|
||||||
[property: string]: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type BlockProperties = string[];
|
|
||||||
|
|
||||||
type StylingPredicate = (property: string, value: string) => boolean;
|
type StylingPredicate = (property: string, value: string) => boolean;
|
||||||
|
|
||||||
const stylingNightMode: AllowPropertiesBlockValues = {
|
const keep = (_key: string, _value: string) => true;
|
||||||
"font-weight": [],
|
const discard = (_key: string, _value: string) => false;
|
||||||
"font-style": [],
|
|
||||||
"text-decoration-line": [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const stylingLightMode: AllowPropertiesBlockValues = {
|
/// Return a function that filters out certain styles.
|
||||||
color: [],
|
/// - If the style is listed in `exceptions`, the provided predicate is used.
|
||||||
"background-color": ["transparent"],
|
/// - If the style is not listed, the default predicate is used instead.
|
||||||
...stylingNightMode,
|
function filterStyling(
|
||||||
};
|
defaultPredicate: StylingPredicate,
|
||||||
|
exceptions: Record<string, StylingPredicate>,
|
||||||
const stylingInternal: BlockProperties = [
|
): (element: HTMLElement) => void {
|
||||||
"background-color",
|
return (element: HTMLElement): void => {
|
||||||
"font-size",
|
// jsdom does not support @@iterator, so manually iterate
|
||||||
"font-family",
|
for (let i = 0; i < element.style.length; i++) {
|
||||||
"width",
|
const key = element.style.item(i);
|
||||||
"height",
|
const value = element.style.getPropertyValue(key);
|
||||||
"max-width",
|
const predicate = exceptions[key] ?? defaultPredicate;
|
||||||
"max-height",
|
if (!predicate(key, value)) {
|
||||||
];
|
element.style.removeProperty(key);
|
||||||
|
|
||||||
const allowPropertiesBlockValues =
|
|
||||||
(allowBlock: AllowPropertiesBlockValues): StylingPredicate =>
|
|
||||||
(property: string, value: string): boolean =>
|
|
||||||
Object.prototype.hasOwnProperty.call(allowBlock, property) &&
|
|
||||||
!allowBlock[property].includes(value);
|
|
||||||
|
|
||||||
const blockProperties =
|
|
||||||
(block: BlockProperties): StylingPredicate =>
|
|
||||||
(property: string): boolean =>
|
|
||||||
!block.includes(property);
|
|
||||||
|
|
||||||
const filterStyling =
|
|
||||||
(predicate: (property: string, value: string) => boolean) =>
|
|
||||||
(element: HTMLElement): void => {
|
|
||||||
for (const property of [...element.style]) {
|
|
||||||
const value = element.style.getPropertyValue(property);
|
|
||||||
|
|
||||||
if (!predicate(property, value)) {
|
|
||||||
element.style.removeProperty(property);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const filterStylingNightMode = filterStyling(
|
const nightModeExceptions = {
|
||||||
allowPropertiesBlockValues(stylingNightMode),
|
"font-weight": keep,
|
||||||
);
|
"font-style": keep,
|
||||||
export const filterStylingLightMode = filterStyling(
|
"text-decoration-line": keep,
|
||||||
allowPropertiesBlockValues(stylingLightMode),
|
};
|
||||||
);
|
|
||||||
export const filterStylingInternal = filterStyling(blockProperties(stylingInternal));
|
export const filterStylingNightMode = filterStyling(discard, nightModeExceptions);
|
||||||
|
export const filterStylingLightMode = filterStyling(discard, {
|
||||||
|
color: keep,
|
||||||
|
"background-color": (_key: string, value: string) => value != "transparent",
|
||||||
|
...nightModeExceptions,
|
||||||
|
});
|
||||||
|
export const filterStylingInternal = filterStyling(keep, {
|
||||||
|
"background-color": discard,
|
||||||
|
"font-size": discard,
|
||||||
|
"font-family": discard,
|
||||||
|
width: discard,
|
||||||
|
height: discard,
|
||||||
|
"max-width": discard,
|
||||||
|
"max-height": discard,
|
||||||
|
});
|
||||||
|
|
|
@ -5,3 +5,4 @@ export const fontFamilyKey = Symbol("fontFamily");
|
||||||
export const fontSizeKey = Symbol("fontSize");
|
export const fontSizeKey = Symbol("fontSize");
|
||||||
export const directionKey = Symbol("direction");
|
export const directionKey = Symbol("direction");
|
||||||
export const descriptionKey = Symbol("description");
|
export const descriptionKey = Symbol("description");
|
||||||
|
export const collapsedKey = Symbol("collapsed");
|
||||||
|
|
|
@ -4,5 +4,5 @@
|
||||||
/// <reference types="../../lib/image-import" />
|
/// <reference types="../../lib/image-import" />
|
||||||
|
|
||||||
export { default as dotsIcon } from "@mdi/svg/svg/dots-vertical.svg";
|
export { default as dotsIcon } from "@mdi/svg/svg/dots-vertical.svg";
|
||||||
export { default as tagIcon } from "@mdi/svg/svg/tag.svg";
|
export { default as tagIcon } from "@mdi/svg/svg/tag-outline.svg";
|
||||||
export { default as addTagIcon } from "@mdi/svg/svg/tag-plus.svg";
|
export { default as addTagIcon } from "@mdi/svg/svg/tag-plus-outline.svg";
|
||||||
|
|
Loading…
Reference in a new issue