diff --git a/build/configure/src/web.rs b/build/configure/src/web.rs index 95595cf03c9..2312eb54138 100644 --- a/build/configure/src/web.rs +++ b/build/configure/src/web.rs @@ -334,6 +334,18 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> { ":sass" ], )?; + build_page( + "image-occlusion", + true, + inputs![ + // + ":ts:lib", + ":ts:components", + ":ts:sveltelib", + ":ts:tag-editor", + ":sass" + ], + )?; Ok(()) } diff --git a/ftl/core/editing.ftl b/ftl/core/editing.ftl index f3447f71644..d0caf015f2d 100644 --- a/ftl/core/editing.ftl +++ b/ftl/core/editing.ftl @@ -68,3 +68,13 @@ editing-close-html-tags = Auto-close HTML tags ## You don't need to translate these strings, as they will be replaced with different ones soon. editing-html-editor = HTML Editor + +## Image Occlusion editing + +editing-image-occlusion = Image Occlusion +editing-masks = Masks +editing-notes = Notes +editing-hide-all-guess-one = Hide All, Guess One +editing-hide-one-guess-one = Hide One, Guess One +editing-question-mask-color = Question Mask Color +editing-answer-mask-color = Answer Mask Color diff --git a/ftl/core/notetypes.ftl b/ftl/core/notetypes.ftl index 0b173fe4589..800d88b15c7 100644 --- a/ftl/core/notetypes.ftl +++ b/ftl/core/notetypes.ftl @@ -35,3 +35,9 @@ notetypes-note-types = Note Types notetypes-options = Options notetypes-please-add-another-note-type-first = Please add another note type first. notetypes-type = Type + +## Image Occlusion + +notetypes-occlusions-field = Occlusions +notetypes-image-field = Image +notetypes-notes-field = Notes diff --git a/package.json b/package.json index 0dc92ad59d5..b0cc6cb9911 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "codemirror": "^5.63.1", "css-browser-selector": "^0.6.5", "d3": "^7.0.0", + "fabric": "^5.3.0", "fuse.js": "^6.6.2", "gemoji": "^7.1.0", "intl-pluralrules": "^1.2.2", @@ -83,6 +84,7 @@ "lodash-es": "^4.17.21", "marked": "^4.0.0", "mathjax": "^3.1.2", + "panzoom": "^9.4.3", "protobufjs": "^7" }, "resolutions": { diff --git a/proto/anki/backend.proto b/proto/anki/backend.proto index f87ca224a82..f91fa943802 100644 --- a/proto/anki/backend.proto +++ b/proto/anki/backend.proto @@ -31,6 +31,7 @@ enum ServiceIndex { SERVICE_INDEX_LINKS = 15; SERVICE_INDEX_IMPORT_EXPORT = 16; SERVICE_INDEX_ANKIDROID = 17; + SERVICE_INDEX_IMAGE_OCCLUSION = 18; } message BackendInit { diff --git a/proto/anki/image_occlusion.proto b/proto/anki/image_occlusion.proto new file mode 100644 index 00000000000..ed2856813ed --- /dev/null +++ b/proto/anki/image_occlusion.proto @@ -0,0 +1,37 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +syntax = "proto3"; + +option java_multiple_files = true; + +package anki.image_occlusion; + +import "anki/cards.proto"; +import "anki/collection.proto"; +import "anki/notes.proto"; +import "anki/generic.proto"; + +service ImageOcclusionService { + rpc GetImageClozeMetadata(ImageClozeMetadataRequest) returns (ImageClozeMetadata); + rpc AddImageOcclusionNotes(AddImageOcclusionNotesRequest) returns (collection.OpChanges); +} + +message ImageClozeMetadataRequest { + string path = 1; +} + +message ImageClozeMetadata { + bytes data = 1; + string name = 2; + int64 deck_id = 3; +} + +message AddImageOcclusionNotesRequest { + string image_path = 1; + int64 deck_id = 2; + string occlusions = 3; + string header = 4; + string notes = 5; + repeated string tags = 6; +} \ No newline at end of file diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 0992cabf17f..fff91c2b37c 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -11,6 +11,7 @@ config_pb2, generic_pb2, import_export_pb2, + image_occlusion_pb2, links_pb2, search_pb2, stats_pb2, @@ -39,6 +40,8 @@ CsvMetadata = import_export_pb2.CsvMetadata DupeResolution = CsvMetadata.DupeResolution Delimiter = import_export_pb2.CsvMetadata.Delimiter +ImageClozeMetadata = image_occlusion_pb2.ImageClozeMetadata +AddImageOcclusionNotesRequest = image_occlusion_pb2.AddImageOcclusionNotesRequest import copy import os @@ -455,6 +458,32 @@ def import_json_file(self, path: str) -> ImportLogWithChanges: def import_json_string(self, json: str) -> ImportLogWithChanges: return self._backend.import_json_string(json) + # Image Occlusion + def get_image_cloze_metadata(self, path: str | None) -> ImageClozeMetadata: + request = image_occlusion_pb2.ImageClozeMetadataRequest(path=path) + return self._backend.get_image_cloze_metadata(request) + + def add_image_occlusion_notes( + self, + image_path: str | None, + deck_id: int | None, + notes_data: bytes | None, + occlusions: str | None, + header: str | None, + notes: str | None, + tags: list[str] | None + ) -> bool: + request = image_occlusion_pb2.AddImageOcclusionNotesRequest( + image_path=image_path, + deck_id=deck_id, + notes_data=notes_data, + occlusions=occlusions, + header=header, + notes=notes, + tags=tags + ) + return self._backend.add_image_occlusion_notes(request) + # Object helpers ########################################################################## diff --git a/pylib/tools/genbackend.py b/pylib/tools/genbackend.py index 36856ccbb5c..318b424cde5 100644 --- a/pylib/tools/genbackend.py +++ b/pylib/tools/genbackend.py @@ -20,6 +20,7 @@ import anki.decks_pb2 import anki.i18n_pb2 import anki.import_export_pb2 +import anki.image_occlusion_pb2 import anki.links_pb2 import anki.media_pb2 import anki.notes_pb2 @@ -187,6 +188,7 @@ def render_service( MEDIA=anki.media_pb2, LINKS=anki.links_pb2, IMPORT_EXPORT=anki.import_export_pb2, + IMAGE_OCCLUSION=anki.image_occlusion_pb2, ) for service in anki.backend_pb2.ServiceIndex.DESCRIPTOR.values: @@ -238,6 +240,7 @@ def render_service( import anki.tags_pb2 import anki.media_pb2 import anki.import_export_pb2 +import anki.image_occlusion_pb2 class RustBackendGenerated: def _run_command(self, service: int, method: int, input: Any) -> bytes: diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index 54758c40ebc..12d08ddeff1 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -56,6 +56,7 @@ tr, ) from aqt.webview import AnkiWebView +from aqt.imageocclusion import ImageOcclusionDialog pics = ("jpg", "jpeg", "png", "tif", "tiff", "gif", "svg", "webp", "ico") audio = ( @@ -1171,6 +1172,27 @@ def collapseTags(self) -> None: def expandTags(self) -> None: aqt.mw.pm.set_tags_collapsed(self.editorMode, False) + def onImageOcclusion(self) -> None: + """Show a file selection screen, then get selected image path.""" + extension_filter = " ".join( + f"*.{extension}" for extension in sorted(itertools.chain(pics)) + ) + filter = f"{tr.editing_media()} ({extension_filter})" + + def accept(file: str) -> None: + diag = ImageOcclusionDialog(self.mw, file) + + + file = getFile( + parent=self.widget, + title=tr.editing_add_media(), + cb=cast(Callable[[Any], None], accept), + filter=filter, + key="media", + ) + + self.parentWindow.activateWindow() + # Links from HTML ###################################################################### @@ -1202,6 +1224,7 @@ def _init_links(self) -> None: toggleCloseHTMLTags=Editor.toggleCloseHTMLTags, expandTags=Editor.expandTags, collapseTags=Editor.collapseTags, + imageOcclusion=Editor.onImageOcclusion, ) diff --git a/qt/aqt/imageocclusion.py b/qt/aqt/imageocclusion.py new file mode 100644 index 00000000000..30004be9614 --- /dev/null +++ b/qt/aqt/imageocclusion.py @@ -0,0 +1,53 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + +import aqt +import aqt.deckconf +import aqt.main +import aqt.operations +from aqt.qt import * +from aqt.utils import addCloseShortcut, disable_help_button, restoreGeom, saveGeom, tr +from aqt.webview import AnkiWebView + + +class ImageOcclusionDialog(QDialog): + + TITLE = "image occlusion" + silentlyClose = True + + def __init__( + self, + mw: aqt.main.AnkiQt, + path: str, + ) -> None: + QDialog.__init__(self, mw) + self.mw = mw + self._setup_ui(path) + self.show() + + def _setup_ui(self, path: str) -> None: + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.mw.garbage_collect_on_dialog_finish(self) + self.setMinimumSize(400, 300) + disable_help_button(self) + restoreGeom(self, self.TITLE) + addCloseShortcut(self) + + self.web = AnkiWebView(title=self.TITLE) + self.web.setVisible(False) + self.web.load_ts_page("image-occlusion") + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.web) + self.setLayout(layout) + + self.web.eval(f"anki.setupImageOcclusion('{path}');") + self.setWindowTitle(self.TITLE) + + def reject(self) -> None: + self.web.cleanup() + self.web = None + saveGeom(self, self.TITLE) + QDialog.reject(self) diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index 25e603f0221..3187a9ae819 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -447,6 +447,17 @@ def handle_on_main() -> None: return b"" +def image_occlusion() -> bytes: + data = request.data + + def handle_on_main() -> None: + window = aqt.mw.app.activeWindow() + if isinstance(window, ImageOcclusionDialog): + window.do_image_occlusion(data) + + aqt.mw.taskman.run_on_main(handle_on_main) + return b"" + post_handler_list = [ congrats_info, get_deck_configs_for_update, @@ -455,6 +466,7 @@ def handle_on_main() -> None: set_scheduling_states, change_notetype, import_csv, + image_occlusion, ] @@ -478,6 +490,9 @@ def handle_on_main() -> None: "set_graph_preferences", # TagsService "complete_tag", + # ImageOcclusionService + "get_image_cloze_metadata", + "add_image_occlusion_notes" ] diff --git a/rslib/src/backend/image_occlusion.rs b/rslib/src/backend/image_occlusion.rs new file mode 100644 index 00000000000..b5d61496773 --- /dev/null +++ b/rslib/src/backend/image_occlusion.rs @@ -0,0 +1,35 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::Backend; +pub(super) use crate::pb::image_occlusion::imageocclusion_service::Service as ImageOcclusionService; +use crate::{ + pb::{self as pb}, + prelude::*, +}; + +impl ImageOcclusionService for Backend { + fn get_image_cloze_metadata( + &self, + input: pb::image_occlusion::ImageClozeMetadataRequest, + ) -> Result { + self.with_col(|col| col.get_image_cloze_metadata(&input.path)) + } + + fn add_image_occlusion_notes( + &self, + input: pb::image_occlusion::AddImageOcclusionNotesRequest, + ) -> Result { + self.with_col(|col| { + col.add_image_occlusion_notes( + &input.image_path, + input.deck_id.into(), + &input.occlusions, + &input.header, + &input.notes, + input.tags, + ) + }) + .map(Into::into) + } +} diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 0af56e3a6a6..2283a1eb95a 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -16,6 +16,7 @@ mod decks; mod error; mod generic; mod i18n; +mod image_occlusion; mod import_export; mod links; mod media; @@ -48,6 +49,7 @@ use self::config::ConfigService; use self::deckconfig::DeckConfigService; use self::decks::DecksService; use self::i18n::I18nService; +use self::image_occlusion::ImageOcclusionService; use self::import_export::ImportExportService; use self::links::LinksService; use self::media::MediaService; @@ -142,6 +144,7 @@ impl Backend { ServiceIndex::Collection => CollectionService::run_method(self, method, input), ServiceIndex::Cards => CardsService::run_method(self, method, input), ServiceIndex::ImportExport => ImportExportService::run_method(self, method, input), + ServiceIndex::ImageOcclusion => ImageOcclusionService::run_method(self, method, input), }) .map_err(|err| { let backend_err = err.into_protobuf(&self.tr); diff --git a/rslib/src/image_occlusion/imagedata.rs b/rslib/src/image_occlusion/imagedata.rs new file mode 100644 index 00000000000..fef4b9ac5e6 --- /dev/null +++ b/rslib/src/image_occlusion/imagedata.rs @@ -0,0 +1,120 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::{fs::read, path::Path, sync::Arc}; + +use crate::notetype::{CardGenContext, Notetype, NotetypeConfig}; +pub use crate::pb::image_occlusion::ImageClozeMetadata; +use crate::{media::MediaManager, prelude::*}; + +impl Collection { + pub fn get_image_cloze_metadata(&mut self, path: &str) -> Result { + let mut metadata = ImageClozeMetadata { + ..Default::default() + }; + let deck_name = self.get_current_deck().unwrap(); + metadata.deck_id = deck_name.id.0; + metadata.data = read(path)?; + Ok(metadata) + } + + pub fn add_image_occlusion_notes( + &mut self, + image_path: &str, + target_deck: DeckId, + occlusions: &str, + header: &str, + notes: &str, + tags: Vec, + ) -> Result> { + // image file + let image_bytes = read(image_path)?; + let image_filename = Path::new(&image_path) + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_string(); + + let mgr = MediaManager::new(&self.media_folder, &self.media_db)?; + let actual_image_name_after_adding = mgr.add_file(&image_filename, &image_bytes)?; + + let image_tag = format!( + "", + &actual_image_name_after_adding + ); + + self.transact(Op::ImageOcclusion, |col| { + let nt = col.get_or_create_io_notetype()?; + + let mut note = nt.new_note(); + note.set_field(0, occlusions)?; + note.set_field(1, header)?; + note.set_field(2, &image_tag)?; + note.set_field(3, notes)?; + note.tags = tags; + + let last_deck = col.get_last_deck_added_to_for_notetype(note.notetype_id); + let ctx = CardGenContext::new(nt.as_ref(), last_deck, col.usn()?); + let norm = col.get_config_bool(BoolKey::NormalizeNoteText); + col.add_note_inner(&ctx, &mut note, target_deck, norm)?; + + Ok(()) + }) + } + + fn get_or_create_io_notetype(&mut self) -> Result> { + let name = self.tr.editing_image_occlusion().to_string(); + let nt = match self.get_notetype_by_name(&name)? { + Some(nt) => nt, + None => { + self.add_io_notetype(&name)?; + self.get_notetype_by_name(&name).unwrap().unwrap() + } + }; + if nt.fields.len() < 4 { + Err(AnkiError::TemplateError { + info: "IO notetype must have 4+ fields".to_string(), + }) + } else { + Ok(nt) + } + } + + fn add_io_notetype(&mut self, name: &str) -> Result<()> { + let usn = self.usn()?; + let mut notetype = Notetype { + name: name.into(), + config: NotetypeConfig::new_cloze(), + ..Default::default() + }; + + let occlusion_field = self.tr.notetypes_occlusions_field().to_string(); + let header_field = self.tr.notetypes_header().to_string(); + let image_field = self.tr.notetypes_image_field().to_string(); + let notes_field = self.tr.notetypes_notes_field().to_string(); + + notetype.set_modified(usn); + + notetype.add_field(&occlusion_field); + notetype.add_field(&header_field); + notetype.add_field(&image_field); + notetype.add_field(¬es_field); + + notetype.add_template( + notetype.name.clone(), + // include front template and replace with i18n fields name + include_str!("io_front_template.html") + .replace("Occlusions", &occlusion_field) + .replacen("Image", &image_field, 1), + // include back template and replace with i18n fields name + include_str!("io_back_template.html") + .replace("Occlusions", &occlusion_field) + .replace("Header", &header_field) + .replace("Notes", ¬es_field) + .replacen("Image", &image_field, 1), + ); + self.add_notetype_inner(&mut notetype, usn, false)?; + Ok(()) + } +} diff --git a/rslib/src/image_occlusion/io_back_template.html b/rslib/src/image_occlusion/io_back_template.html new file mode 100644 index 00000000000..a11967ea336 --- /dev/null +++ b/rslib/src/image_occlusion/io_back_template.html @@ -0,0 +1,88 @@ +
{{Header}}
+
{{cloze:Occlusions}}
+ + +
{{Image}}
+
{{Notes}}
+ + \ No newline at end of file diff --git a/rslib/src/image_occlusion/io_front_template.html b/rslib/src/image_occlusion/io_front_template.html new file mode 100644 index 00000000000..70b6e00199d --- /dev/null +++ b/rslib/src/image_occlusion/io_front_template.html @@ -0,0 +1,88 @@ +
{{cloze:Occlusions}}
+ + +
{{Image}}
+ + \ No newline at end of file diff --git a/rslib/src/image_occlusion/mod.rs b/rslib/src/image_occlusion/mod.rs new file mode 100644 index 00000000000..64594a8cdb9 --- /dev/null +++ b/rslib/src/image_occlusion/mod.rs @@ -0,0 +1 @@ +mod imagedata; \ No newline at end of file diff --git a/rslib/src/lib.rs b/rslib/src/lib.rs index bf7582263d1..eefeb01c649 100644 --- a/rslib/src/lib.rs +++ b/rslib/src/lib.rs @@ -17,6 +17,7 @@ pub mod decks; pub mod error; pub mod findreplace; pub mod i18n; +pub mod image_occlusion; pub mod import_export; mod io; pub mod latex; diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index 4aadac3a8fb..0f109b1df16 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -17,6 +17,7 @@ pub enum Op { CreateCustomStudy, EmptyFilteredDeck, FindAndReplace, + ImageOcclusion, Import, RebuildFilteredDeck, RemoveDeck, @@ -90,6 +91,7 @@ impl Op { Op::Custom(name) => name.into(), Op::ChangeNotetype => tr.browsing_change_notetype(), Op::SkipUndo => return "".to_string(), + Op::ImageOcclusion => tr.editing_image_occlusion(), } .into() } diff --git a/rslib/src/pb.rs b/rslib/src/pb.rs index bcb2f34c40a..b4374f7e3c3 100644 --- a/rslib/src/pb.rs +++ b/rslib/src/pb.rs @@ -20,6 +20,7 @@ protobuf!(decks, "decks"); protobuf!(generic, "generic"); protobuf!(i18n, "i18n"); protobuf!(import_export, "import_export"); +protobuf!(image_occlusion, "image_occlusion"); protobuf!(links, "links"); protobuf!(media, "media"); protobuf!(notes, "notes"); diff --git a/ts/editor/editor-toolbar/ImageOcclusionButton.svelte b/ts/editor/editor-toolbar/ImageOcclusionButton.svelte new file mode 100644 index 00000000000..b8e2da00ff2 --- /dev/null +++ b/ts/editor/editor-toolbar/ImageOcclusionButton.svelte @@ -0,0 +1,17 @@ + + + + { + bridgeCommand("imageOcclusion"); + }} +> + {@html imagePlus} + diff --git a/ts/editor/editor-toolbar/TemplateButtons.svelte b/ts/editor/editor-toolbar/TemplateButtons.svelte index c7ec19c02eb..25d357c53e8 100644 --- a/ts/editor/editor-toolbar/TemplateButtons.svelte +++ b/ts/editor/editor-toolbar/TemplateButtons.svelte @@ -23,6 +23,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { RichTextInputAPI } from "../rich-text-input"; import { editingInputIsRichText } from "../rich-text-input"; import { micIcon, paperclipIcon } from "./icons"; + import ImageOcclusionButton from "./ImageOcclusionButton.svelte"; import LatexButton from "./LatexButton.svelte"; const { focusedInput } = context.get(); @@ -120,5 +121,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + + + diff --git a/ts/editor/editor-toolbar/icons.ts b/ts/editor/editor-toolbar/icons.ts index 159712b7535..e8e7ebf6348 100644 --- a/ts/editor/editor-toolbar/icons.ts +++ b/ts/editor/editor-toolbar/icons.ts @@ -25,3 +25,4 @@ export { default as justifyRightIcon } from "bootstrap-icons/icons/text-right.sv export { default as boldIcon } from "bootstrap-icons/icons/type-bold.svg"; export { default as italicIcon } from "bootstrap-icons/icons/type-italic.svg"; export { default as underlineIcon } from "bootstrap-icons/icons/type-underline.svg"; +export { default as imagePlus } from "@mdi/svg/svg/image-plus.svg"; diff --git a/ts/image-occlusion/ColorDialog.svelte b/ts/image-occlusion/ColorDialog.svelte new file mode 100644 index 00000000000..f5458723c29 --- /dev/null +++ b/ts/image-occlusion/ColorDialog.svelte @@ -0,0 +1,54 @@ + + +
+ + + + + + setColor("ques-color", e)} + /> + + + + + + + + setColor("ans-color", e)} + /> + + +
+ + diff --git a/ts/image-occlusion/DeckSelector.svelte b/ts/image-occlusion/DeckSelector.svelte new file mode 100644 index 00000000000..fe325787e6a --- /dev/null +++ b/ts/image-occlusion/DeckSelector.svelte @@ -0,0 +1,29 @@ + + + + + + {tr.decksDeck()} + + + + + \ No newline at end of file diff --git a/ts/image-occlusion/Header.svelte b/ts/image-occlusion/Header.svelte new file mode 100644 index 00000000000..1d0222c2c11 --- /dev/null +++ b/ts/image-occlusion/Header.svelte @@ -0,0 +1,21 @@ + + + + + {#if deckId} + + {/if} + + + diff --git a/ts/image-occlusion/ImageOcclusionPage.svelte b/ts/image-occlusion/ImageOcclusionPage.svelte new file mode 100644 index 00000000000..f7cf8e2f516 --- /dev/null +++ b/ts/image-occlusion/ImageOcclusionPage.svelte @@ -0,0 +1,97 @@ + + + + +
    + {#each items as item} +
  • + {item.label} +
  • + {/each} +
+ + + + +
+ + diff --git a/ts/image-occlusion/MasksEditor.svelte b/ts/image-occlusion/MasksEditor.svelte new file mode 100644 index 00000000000..c07334d0dd5 --- /dev/null +++ b/ts/image-occlusion/MasksEditor.svelte @@ -0,0 +1,82 @@ + + +
+
+
+ +
+
+ + diff --git a/ts/image-occlusion/Notes.svelte b/ts/image-occlusion/Notes.svelte new file mode 100644 index 00000000000..43cc3eba9a0 --- /dev/null +++ b/ts/image-occlusion/Notes.svelte @@ -0,0 +1,97 @@ + + +
+ +
+ + + + + {#if deckId} + + {/if} + + + {#each notesFields as field} + + + + {field.title} + + + +
+
{ + contentEditable(field.id); + }} + class="text-editor" + id="img-occ-preview-{field.id}" + contenteditable + /> +