diff --git a/build/configure/src/web.rs b/build/configure/src/web.rs index 95595cf03..2312eb541 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/importing.ftl b/ftl/core/importing.ftl index 6c0ab352b..c0f158deb 100644 --- a/ftl/core/importing.ftl +++ b/ftl/core/importing.ftl @@ -108,6 +108,11 @@ importing-tag-updated-notes = Tag updated notes importing-file = File importing-match-scope = Match scope importing-notetype-and-deck = Notetype and deck +importing-cards-added = + { $count -> + [one] { $count } card added. + *[other] { $count } cards added. + } ## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future. diff --git a/ftl/core/notetypes.ftl b/ftl/core/notetypes.ftl index 0b173fe45..353ea308d 100644 --- a/ftl/core/notetypes.ftl +++ b/ftl/core/notetypes.ftl @@ -35,3 +35,18 @@ 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-image = Image +notetypes-occlusion = Occlusion +notetypes-occlusion-mask = Mask +notetypes-occlusion-note = Note +notetypes-comments-field = Comments +notetypes-toggle-masks = Toggle Masks +notetypes-image-occlusion-name = Image Occlusion +notetypes-hide-all-guess-one = Hide All, Guess One +notetypes-hide-one-guess-one = Hide One, Guess One +notetypes-error-generating-cloze = An error occurred when generating an image occlusion note +notetypes-error-getting-imagecloze = An error occurred while fetching an image occlusion note +notetypes-error-loading-image-occlusion = Error loading image occlusion. Is your Anki version up to date? diff --git a/package.json b/package.json index 7833d1468..b9298e35f 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,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", @@ -82,6 +83,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 89c5f1f1e..8db0550a8 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 000000000..07c8f0571 --- /dev/null +++ b/proto/anki/image_occlusion.proto @@ -0,0 +1,67 @@ +// 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 GetImageForOcclusion(GetImageForOcclusionRequest) returns (ImageData); + rpc AddImageOcclusionNote(AddImageOcclusionNoteRequest) + returns (collection.OpChanges); + rpc GetImageClozeNote(GetImageOcclusionNoteRequest) + returns (ImageClozeNoteResponse); + rpc UpdateImageOcclusionNote(UpdateImageOcclusionNoteRequest) + returns (collection.OpChanges); +} + +message GetImageForOcclusionRequest { + string path = 1; +} + +message ImageData { + bytes data = 1; + string name = 2; +} + +message AddImageOcclusionNoteRequest { + string image_path = 1; + string occlusions = 2; + string header = 3; + string back_extra = 4; + repeated string tags = 5; +} + +message ImageClozeNote { + bytes image_data = 1; + string occlusions = 2; + string header = 3; + string back_extra = 4; + repeated string tags = 5; +} + +message GetImageOcclusionNoteRequest { + int64 note_id = 1; +} + +message UpdateImageOcclusionNoteRequest { + int64 note_id = 1; + string occlusions = 2; + string header = 3; + string back_extra = 4; + repeated string tags = 5; +} + +message ImageClozeNoteResponse { + oneof value { + ImageClozeNote note = 1; + string error = 2; + } +} diff --git a/proto/anki/notetypes.proto b/proto/anki/notetypes.proto index 1c837cd02..33259c3e1 100644 --- a/proto/anki/notetypes.proto +++ b/proto/anki/notetypes.proto @@ -124,6 +124,7 @@ message StockNotetype { BASIC_OPTIONAL_REVERSED = 2; BASIC_TYPING = 3; CLOZE = 4; + IMAGE_OCCLUSION = 5; } Kind kind = 1; diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index f3b58ae4b..748ae9824 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -10,6 +10,7 @@ from anki import ( collection_pb2, config_pb2, generic_pb2, + image_occlusion_pb2, import_export_pb2, links_pb2, search_pb2, @@ -40,6 +41,9 @@ CsvMetadata = import_export_pb2.CsvMetadata DupeResolution = CsvMetadata.DupeResolution Delimiter = import_export_pb2.CsvMetadata.Delimiter TtsVoice = card_rendering_pb2.AllTtsVoicesResponse.TtsVoice +ImageData = image_occlusion_pb2.ImageData +AddImageOcclusionNoteRequest = image_occlusion_pb2.AddImageOcclusionNoteRequest +ImageClozeNoteResponse = image_occlusion_pb2.ImageClozeNoteResponse import copy import os @@ -456,6 +460,46 @@ class Collection(DeprecatedNamesMixin): def import_json_string(self, json: str) -> ImportLogWithChanges: return self._backend.import_json_string(json) + # Image Occlusion + ########################################################################## + def get_image_for_occlusion(self, path: str | None) -> ImageData: + return self._backend.get_image_for_occlusion(path=path) + + def add_image_occlusion_note( + self, + image_path: str | None, + occlusions: str | None, + header: str | None, + back_extra: str | None, + tags: list[str] | None, + ) -> OpChanges: + return self._backend.add_image_occlusion_note( + image_path=image_path, + occlusions=occlusions, + header=header, + back_extra=back_extra, + tags=tags, + ) + + def get_image_cloze_note(self, note_id: int | None) -> ImageClozeNoteResponse: + return self._backend.get_image_cloze_note(note_id=note_id) + + def update_image_occlusion_note( + self, + note_id: int | None, + occlusions: str | None, + header: str | None, + back_extra: str | None, + tags: list[str] | None, + ) -> OpChanges: + return self._backend.update_image_occlusion_note( + note_id=note_id, + occlusions=occlusions, + header=header, + back_extra=back_extra, + tags=tags, + ) + # Object helpers ########################################################################## diff --git a/pylib/tools/genbackend.py b/pylib/tools/genbackend.py index 36856ccbb..2b2d3d0ae 100644 --- a/pylib/tools/genbackend.py +++ b/pylib/tools/genbackend.py @@ -19,6 +19,7 @@ import anki.config_pb2 import anki.deckconfig_pb2 import anki.decks_pb2 import anki.i18n_pb2 +import anki.image_occlusion_pb2 import anki.import_export_pb2 import anki.links_pb2 import anki.media_pb2 @@ -187,6 +188,7 @@ service_modules = dict( 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 @@ import anki.card_rendering_pb2 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/mediasrv.py b/qt/aqt/mediasrv.py index b0a6298e2..6e2e2086d 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -479,6 +479,11 @@ exposed_backend_list = [ "set_graph_preferences", # TagsService "complete_tag", + # ImageOcclusionService + "get_image_for_occlusion", + "add_image_occlusion_note", + "get_image_cloze_note", + "update_image_occlusion_note", ] diff --git a/rslib/src/backend/image_occlusion.rs b/rslib/src/backend/image_occlusion.rs new file mode 100644 index 000000000..fe549e21e --- /dev/null +++ b/rslib/src/backend/image_occlusion.rs @@ -0,0 +1,55 @@ +// 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,}; +use crate::prelude::*; + +impl ImageOcclusionService for Backend { + fn get_image_for_occlusion( + &self, + input: pb::image_occlusion::GetImageForOcclusionRequest, + ) -> Result { + self.with_col(|col| col.get_image_for_occlusion(&input.path)) + } + + fn add_image_occlusion_note( + &self, + input: pb::image_occlusion::AddImageOcclusionNoteRequest, + ) -> Result { + self.with_col(|col| { + col.add_image_occlusion_note( + &input.image_path, + &input.occlusions, + &input.header, + &input.back_extra, + input.tags, + ) + }) + .map(Into::into) + } + + fn get_image_cloze_note( + &self, + input: pb::image_occlusion::GetImageOcclusionNoteRequest, + ) -> Result { + self.with_col(|col| col.get_image_cloze_note(input.note_id.into())) + } + + fn update_image_occlusion_note( + &self, + input: pb::image_occlusion::UpdateImageOcclusionNoteRequest, + ) -> Result { + self.with_col(|col| { + col.update_image_occlusion_note( + input.note_id.into(), + &input.occlusions, + &input.header, + &input.back_extra, + input.tags, + ) + }) + .map(Into::into) + } +} diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 0af56e3a6..ba7387d4e 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,9 @@ 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/cloze.rs b/rslib/src/cloze.rs index 1e091e533..2983c5b4a 100644 --- a/rslib/src/cloze.rs +++ b/rslib/src/cloze.rs @@ -15,6 +15,7 @@ use nom::IResult; use regex::Captures; use regex::Regex; +use crate::image_occlusion::imageocclusion::get_image_cloze_data; use crate::latex::contains_latex; use crate::template::RenderContext; use crate::text::strip_html_preserving_entities; @@ -138,6 +139,13 @@ impl ExtractedCloze<'_> { buf.into() } + + /// If cloze starts with image-occlusion:, return the text following that. + fn image_occlusion(&self) -> Option<&str> { + let Some(first_node) = self.nodes.get(0) else { return None }; + let TextOrCloze::Text(text) = first_node else { return None }; + text.strip_prefix("image-occlusion:") + } } fn parse_text_with_clozes(text: &str) -> Vec> { @@ -212,6 +220,14 @@ fn reveal_cloze( ) { let active = cloze.ordinal == cloze_ord; *active_cloze_found_in_text |= active; + if let Some(image_occlusion_text) = cloze.image_occlusion() { + buf.push_str(&render_image_occlusion( + image_occlusion_text, + question, + active, + )); + return; + } match (question, active) { (true, true) => { // question side with active cloze; all inner content is elided @@ -275,6 +291,22 @@ fn reveal_cloze( } } +fn render_image_occlusion(text: &str, question_side: bool, active: bool) -> String { + if question_side && active { + format!( + r#"
"#, + &get_image_cloze_data(text) + ) + } else if !active { + format!( + r#"
"#, + &get_image_cloze_data(text) + ) + } else { + "".into() + } +} + pub fn reveal_cloze_text(text: &str, cloze_ord: u16, question: bool) -> Cow { let mut buf = String::new(); let mut active_cloze_found_in_text = false; @@ -530,4 +562,18 @@ mod test { fn non_latin() { assert!(cloze_numbers_in_string("öaöaöööaö").is_empty()); } + + #[test] + fn image_cloze() { + assert_eq!( + reveal_cloze_text( + "{{c1::image-occlusion:rect:left=10.0:top=20:width=30:height=10}}", + 1, + true + ), + format!( + r#"
"#, + ) + ); + } } diff --git a/rslib/src/error/file_io.rs b/rslib/src/error/file_io.rs index 2200a179a..bc3689ac8 100644 --- a/rslib/src/error/file_io.rs +++ b/rslib/src/error/file_io.rs @@ -33,6 +33,7 @@ pub enum FileOp { CopyFrom(PathBuf), Persist, Sync, + Metadata, /// For legacy errors without any context. Unknown, } @@ -57,6 +58,7 @@ impl FileIoError { FileOp::CopyFrom(p) => format!("copy from '{}' to", p.to_string_lossy()), FileOp::Persist => "persist".into(), FileOp::Sync => "sync".into(), + FileOp::Metadata => "get metadata".into(), }, self.path.to_string_lossy(), self.source diff --git a/rslib/src/image_occlusion/image_occlusion_styling.css b/rslib/src/image_occlusion/image_occlusion_styling.css new file mode 100644 index 000000000..ac8a7e25b --- /dev/null +++ b/rslib/src/image_occlusion/image_occlusion_styling.css @@ -0,0 +1,41 @@ +.image-occlusion-canvas { + --inactive-shape-color: #ffeba2; + --active-shape-color: #ff8e8e; + --inactive-shape-border: 1px #212121; + --active-shape-border: 1px #212121; +} + +.card { + font-family: arial; + font-size: 20px; + text-align: center; + color: black; + background-color: white; +} + +.cloze { + font-weight: bold; + color: blue; +} + +.nightMode .cloze { + color: lightblue; +} + +#container { + position: relative; +} + +img { + position: absolute; + top: 0; + left: 50%; + transform: translate(-50%, 0); +} + +#canvas { + position: absolute; + top: 0; + left: 50%; + transform: translate(-50%, 0); +} diff --git a/rslib/src/image_occlusion/imagedata.rs b/rslib/src/image_occlusion/imagedata.rs new file mode 100644 index 000000000..6e4878d9a --- /dev/null +++ b/rslib/src/image_occlusion/imagedata.rs @@ -0,0 +1,246 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use regex::Regex; + +use crate::io::metadata; +use crate::io::read_file; +use crate::media::MediaManager; +use crate::notetype::CardGenContext; +use crate::notetype::Notetype; +use crate::notetype::NotetypeConfig; +use crate::pb::image_occlusion::image_cloze_note_response::Value; +use crate::pb::image_occlusion::ImageClozeNote; +use crate::pb::image_occlusion::ImageClozeNoteResponse; +pub use crate::pb::image_occlusion::ImageData; +use crate::prelude::*; + +impl Collection { + pub fn get_image_for_occlusion(&mut self, path: &str) -> Result { + let mut metadata = ImageData { + ..Default::default() + }; + metadata.data = read_file(path)?; + Ok(metadata) + } + + #[allow(clippy::too_many_arguments)] + pub fn add_image_occlusion_note( + &mut self, + image_path: &str, + occlusions: &str, + header: &str, + back_extra: &str, + tags: Vec, + ) -> Result> { + // image file + let image_bytes = read_file(image_path)?; + let image_filename = Path::new(&image_path) + .file_name() + .or_not_found("expected filename")? + .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 + ); + + let current_deck = self.get_current_deck()?; + 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, &image_tag)?; + note.set_field(2, header)?; + note.set_field(3, back_extra)?; + 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, current_deck.id, norm)?; + + Ok(()) + }) + } + + fn get_or_create_io_notetype(&mut self) -> Result> { + let tr = &self.tr; + let name = format!("{}", tr.notetypes_image_occlusion_name()); + let nt = match self.get_notetype_by_name(&name)? { + Some(nt) => nt, + None => { + self.add_io_notetype()?; + if let Some(nt) = self.get_notetype_by_name(&name)? { + nt + } else { + return Err(AnkiError::TemplateError { + info: "IO notetype not found".to_string(), + }); + } + } + }; + 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) -> Result<()> { + let usn = self.usn()?; + let mut nt = self.image_occlusion_notetype(); + self.add_notetype_inner(&mut nt, usn, false)?; + Ok(()) + } + + pub fn get_image_cloze_note(&mut self, note_id: NoteId) -> Result { + let value = match self.get_image_cloze_note_inner(note_id) { + Ok(note) => Value::Note(note), + Err(err) => Value::Error(format!("{:?}", err)), + }; + Ok(ImageClozeNoteResponse { value: Some(value) }) + } + + pub fn get_image_cloze_note_inner(&mut self, note_id: NoteId) -> Result { + let note = self.storage.get_note(note_id)?.or_not_found(note_id)?; + let mut cloze_note = ImageClozeNote::default(); + + let fields = note.fields(); + if fields.len() < 4 { + invalid_input!("Note does not have 4 fields"); + } + + cloze_note.occlusions = fields[0].clone(); + cloze_note.header = fields[2].clone(); + cloze_note.back_extra = fields[3].clone(); + cloze_note.image_data = "".into(); + cloze_note.tags = note.tags.clone(); + + let image_file_name = fields[1].clone(); + let src = self + .extract_img_src(&image_file_name) + .unwrap_or_else(|| "".to_owned()); + let final_path = self.media_folder.join(src); + + if self.is_image_file(&final_path)? { + cloze_note.image_data = read_file(&final_path)?; + } + + Ok(cloze_note) + } + + pub fn update_image_occlusion_note( + &mut self, + note_id: NoteId, + occlusions: &str, + header: &str, + back_extra: &str, + tags: Vec, + ) -> Result> { + let mut note = self.storage.get_note(note_id)?.or_not_found(note_id)?; + self.transact(Op::ImageOcclusion, |col| { + note.set_field(0, occlusions)?; + note.set_field(2, header)?; + note.set_field(3, back_extra)?; + note.tags = tags; + col.update_note_inner(&mut note)?; + Ok(()) + }) + } + + fn extract_img_src(&mut self, html: &str) -> Option { + let re = Regex::new(r#"]*src\s*=\s*"([^"]+)"#).unwrap(); + re.captures(html).map(|cap| cap[1].to_owned()) + } + + fn is_image_file(&mut self, path: &PathBuf) -> Result { + let file_path = Path::new(&path); + let supported_extensions = vec![ + "jpg", "jpeg", "png", "tif", "tiff", "gif", "svg", "webp", "ico", + ]; + + if file_path.exists() { + let meta = metadata(file_path)?; + if meta.is_file() { + if let Some(ext_osstr) = file_path.extension() { + if let Some(ext_str) = ext_osstr.to_str() { + if supported_extensions.contains(&ext_str) { + return Ok(true); + } + } + } + } + } + + Ok(false) + } + + fn image_occlusion_notetype(&mut self) -> Notetype { + let tr = &self.tr; + const IMAGE_CLOZE_CSS: &str = include_str!("image_occlusion_styling.css"); + let mut nt = Notetype { + name: tr.notetypes_image_occlusion_name().into(), + config: NotetypeConfig::new_cloze(), + ..Default::default() + }; + nt.config.css = IMAGE_CLOZE_CSS.to_string(); + let occlusion = tr.notetypes_occlusion(); + nt.add_field(occlusion.as_ref()); + let image = tr.notetypes_image(); + nt.add_field(image.as_ref()); + let header = tr.notetypes_header(); + nt.add_field(header.as_ref()); + let back_extra = tr.notetypes_back_extra_field(); + nt.add_field(back_extra.as_ref()); + let comments = tr.notetypes_comments_field(); + nt.add_field(comments.as_ref()); + let qfmt = format!( + "
{{{{cloze:{}}}}}
+
+ {{{{{}}}}} + +
+
+ +", + occlusion, + image, + tr.notetypes_error_loading_image_occlusion(), + ); + let afmt = format!( + "{{{{{}}}}} +{} + +
+{{{{{}}}}} +
+{{{{{}}}}}", + header, + qfmt, + tr.notetypes_toggle_masks(), + back_extra, + comments, + ); + nt.add_template(nt.name.clone(), qfmt, afmt); + nt + } +} diff --git a/rslib/src/image_occlusion/imageocclusion.rs b/rslib/src/image_occlusion/imageocclusion.rs new file mode 100644 index 000000000..f2c3b41ee --- /dev/null +++ b/rslib/src/image_occlusion/imageocclusion.rs @@ -0,0 +1,107 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::fmt::Write; + +// split following +// text = "rect:399.01,99.52,167.09,33.78:fill=#0a2cee:stroke=1" +// with +// result = "data-shape="rect" data-left="399.01" data-top="99.52" data-width="167.09" data-height="33.78" data-fill="\#0a2cee" data-stroke="1"" +pub fn get_image_cloze_data(text: &str) -> String { + let mut result = String::new(); + let parts: Vec<&str> = text.split(':').collect(); + + if parts.len() >= 2 { + if !parts[0].is_empty() + && (parts[0] == "rect" || parts[0] == "ellipse" || parts[0] == "polygon") + { + result.push_str(&format!("data-shape=\"{}\" ", parts[0])); + } + + for part in parts[1..].iter() { + let values: Vec<&str> = part.split('=').collect(); + if values.len() >= 2 { + match values[0] { + "left" => { + if !values[1].is_empty() { + result.push_str(&format!("data-left=\"{}\" ", values[1])); + } + } + "top" => { + if !values[1].is_empty() { + result.push_str(&format!("data-top=\"{}\" ", values[1])); + } + } + "width" => { + if !is_empty_or_zero(values[1]) { + result.push_str(&format!("data-width=\"{}\" ", values[1])); + } + } + "height" => { + if !is_empty_or_zero(values[1]) { + result.push_str(&format!("data-height=\"{}\" ", values[1])); + } + } + "rx" => { + if !is_empty_or_zero(values[1]) { + result.push_str(&format!("data-rx=\"{}\" ", values[1])); + } + } + "ry" => { + if !is_empty_or_zero(values[1]) { + result.push_str(&format!("data-ry=\"{}\" ", values[1])); + } + } + "points" => { + if !values[1].is_empty() { + let mut point_str = String::new(); + for point_pair in values[1].split(' ') { + let Some((x, y)) = point_pair.split_once(',') else { continue }; + write!(&mut point_str, "[{},{}],", x, y).unwrap(); + } + // remove the trailing comma + point_str.pop(); + if !point_str.is_empty() { + result.push_str(&format!("data-points=\"[{}]\" ", point_str)); + } + } + } + "hideinactive" => { + if !values[1].is_empty() { + result.push_str(&format!("data-hideinactive=\"{}\" ", values[1])); + } + } + _ => {} + } + } + } + } + + result +} + +fn is_empty_or_zero(text: &str) -> bool { + text.is_empty() || text == "0" +} + +//---------------------------------------- +// Tests +//---------------------------------------- + +#[test] +fn test_get_image_cloze_data() { + assert_eq!( + get_image_cloze_data("rect:left=10:top=20:width=30:height=10"), + format!( + r#"data-shape="rect" data-left="10" data-top="20" data-width="30" data-height="10" "#, + ) + ); + assert_eq!( + get_image_cloze_data("ellipse:left=15:top=20:width=10:height=20:rx=10:ry=5"), + r#"data-shape="ellipse" data-left="15" data-top="20" data-width="10" data-height="20" data-rx="10" data-ry="5" "#, + ); + assert_eq!( + get_image_cloze_data("polygon:points=0,0 10,10 20,0"), + r#"data-shape="polygon" data-points="[[0,0],[10,10],[20,0]]" "#, + ); +} diff --git a/rslib/src/image_occlusion/mod.rs b/rslib/src/image_occlusion/mod.rs new file mode 100644 index 000000000..d71f09987 --- /dev/null +++ b/rslib/src/image_occlusion/mod.rs @@ -0,0 +1,5 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +pub mod imagedata; +pub mod imageocclusion; diff --git a/rslib/src/io.rs b/rslib/src/io.rs index 0e2243be7..b97715650 100644 --- a/rslib/src/io.rs +++ b/rslib/src/io.rs @@ -93,6 +93,14 @@ fn read_locked_db_file_inner(path: impl AsRef) -> std::io::Result> Ok(buf) } +/// See [std::fs::metadata]. +pub(crate) fn metadata(path: impl AsRef) -> Result { + std::fs::metadata(&path).context(FileIoSnafu { + path: path.as_ref(), + op: FileOp::Metadata, + }) +} + pub(crate) fn new_tempfile() -> Result { NamedTempFile::new().context(FileIoSnafu { path: std::env::temp_dir(), diff --git a/rslib/src/lib.rs b/rslib/src/lib.rs index bf7582263..eefeb01c6 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/notetype/mod.rs b/rslib/src/notetype/mod.rs index d017e9673..aa5fd7985 100644 --- a/rslib/src/notetype/mod.rs +++ b/rslib/src/notetype/mod.rs @@ -9,7 +9,7 @@ mod notetypechange; mod render; mod schema11; mod schemachange; -mod stock; +pub(crate) mod stock; mod templates; pub(crate) mod undo; diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index 3e5842e8f..cd1826cc1 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.notetypes_image_occlusion_name(), } .into() } diff --git a/rslib/src/pb.rs b/rslib/src/pb.rs index bcb2f34c4..017e689ca 100644 --- a/rslib/src/pb.rs +++ b/rslib/src/pb.rs @@ -19,6 +19,7 @@ protobuf!(deckconfig, "deckconfig"); protobuf!(decks, "decks"); protobuf!(generic, "generic"); protobuf!(i18n, "i18n"); +protobuf!(image_occlusion, "image_occlusion"); protobuf!(import_export, "import_export"); protobuf!(links, "links"); protobuf!(media, "media"); diff --git a/ts/image-occlusion/ImageOcclusionPage.svelte b/ts/image-occlusion/ImageOcclusionPage.svelte new file mode 100644 index 000000000..02502953d --- /dev/null +++ b/ts/image-occlusion/ImageOcclusionPage.svelte @@ -0,0 +1,106 @@ + + + + +
    + {#each items as item} +
  • + {item.label} +
  • + {/each} +
+ + + + +
+ + diff --git a/ts/image-occlusion/MaskEditor.svelte b/ts/image-occlusion/MaskEditor.svelte new file mode 100644 index 000000000..a598246d7 --- /dev/null +++ b/ts/image-occlusion/MaskEditor.svelte @@ -0,0 +1,81 @@ + + + + +
+
+ + + +
+
+ + diff --git a/ts/image-occlusion/Notes.svelte b/ts/image-occlusion/Notes.svelte new file mode 100644 index 000000000..71d308e54 --- /dev/null +++ b/ts/image-occlusion/Notes.svelte @@ -0,0 +1,93 @@ + + + +
+ +
+ + + {#each notesFields as field} + + + + {field.title} + + + +
+
{ + field.textareaValue = field.divValue; + }} + contenteditable + /> +