diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs index 24fd15f5b..b7602d170 100644 --- a/rslib/src/decks/mod.rs +++ b/rslib/src/decks/mod.rs @@ -4,6 +4,7 @@ mod counts; mod current; mod filtered; +mod name; mod schema11; mod tree; pub(crate) mod undo; @@ -24,12 +25,15 @@ use crate::{ error::{AnkiError, Result}, markdown::render_markdown, prelude::*, - text::normalize_to_nfc, text::sanitize_html_no_images, timestamp::TimestampSecs, types::Usn, }; pub(crate) use counts::DueCounts; +use name::normalize_native_name; +pub(crate) use name::{ + human_deck_name_to_native, immediate_parent_name, native_deck_name_to_human, reparented_name, +}; pub use schema11::DeckSchema11; use std::{borrow::Cow, sync::Arc}; @@ -123,10 +127,6 @@ impl Deck { } } - pub fn human_name(&self) -> String { - self.name.replace("\x1f", "::") - } - pub(crate) fn set_modified(&mut self, usn: Usn) { self.mtime_secs = TimestampSecs::now(); self.usn = usn; @@ -156,60 +156,6 @@ impl Deck { String::new() } } - - // Mutate name to human representation for sharing. - pub fn with_human_name(mut self) -> Self { - self.name = self.human_name(); - self - } -} - -fn invalid_char_for_deck_component(c: char) -> bool { - c.is_ascii_control() || c == '"' -} - -fn normalized_deck_name_component(comp: &str) -> Cow { - let mut out = normalize_to_nfc(comp); - if out.contains(invalid_char_for_deck_component) { - out = out.replace(invalid_char_for_deck_component, "").into(); - } - let trimmed = out.trim(); - if trimmed.is_empty() { - "blank".to_string().into() - } else if trimmed.len() != out.len() { - trimmed.to_string().into() - } else { - out - } -} - -fn normalize_native_name(name: &str) -> Cow { - if name - .split('\x1f') - .any(|comp| matches!(normalized_deck_name_component(comp), Cow::Owned(_))) - { - let comps: Vec<_> = name - .split('\x1f') - .map(normalized_deck_name_component) - .collect::>(); - comps.join("\x1f").into() - } else { - // no changes required - name.into() - } -} - -pub(crate) fn human_deck_name_to_native(name: &str) -> String { - let mut out = String::with_capacity(name.len()); - for comp in name.split("::") { - out.push_str(&normalized_deck_name_component(comp)); - out.push('\x1f'); - } - out.trim_end_matches('\x1f').into() -} - -pub(crate) fn native_deck_name_to_human(name: &str) -> String { - name.replace('\x1f', "::") } impl Collection { @@ -227,30 +173,6 @@ impl Collection { } } -pub(crate) fn immediate_parent_name(machine_name: &str) -> Option<&str> { - machine_name.rsplitn(2, '\x1f').nth(1) -} - -/// Determine name to rename a deck to, when `dragged` is dropped on `dropped`. -/// `dropped` being unset represents a drop at the top or bottom of the deck list. -/// The returned name should be used to rename `dragged`. -/// Arguments are expected in 'machine' form with an \x1f separator. -pub(crate) fn reparented_name(dragged: &str, dropped: Option<&str>) -> Option { - let dragged_base = dragged.rsplit('\x1f').next().unwrap(); - if let Some(dropped) = dropped { - if dropped.starts_with(dragged) { - // foo onto foo::bar, or foo onto itself -> no-op - None - } else { - // foo::bar onto baz -> baz::bar - Some(format!("{}\x1f{}", dropped, dragged_base)) - } - } else { - // foo::bar onto top level -> bar - Some(dragged_base.into()) - } -} - impl Collection { pub(crate) fn default_deck_is_empty(&self) -> Result { self.storage.deck_is_empty(DeckId(1)) @@ -301,15 +223,6 @@ impl Collection { }) } - pub fn rename_deck(&mut self, did: DeckId, new_human_name: &str) -> Result> { - self.transact(Op::RenameDeck, |col| { - let existing_deck = col.storage.get_deck(did)?.ok_or(AnkiError::NotFound)?; - let mut deck = existing_deck.clone(); - deck.name = human_deck_name_to_native(new_human_name); - col.update_deck_inner(&mut deck, existing_deck, col.usn()?) - }) - } - pub(crate) fn update_deck_inner( &mut self, deck: &mut Deck, @@ -346,22 +259,6 @@ impl Collection { self.add_or_update_deck_with_existing_id_undoable(deck) } - pub(crate) fn ensure_deck_name_unique(&self, deck: &mut Deck, usn: Usn) -> Result<()> { - loop { - match self.storage.get_deck_id(&deck.name)? { - Some(did) if did == deck.id => { - break; - } - None => break, - _ => (), - } - deck.name += "+"; - deck.set_modified(usn); - } - - Ok(()) - } - pub(crate) fn recover_missing_deck(&mut self, did: DeckId, usn: Usn) -> Result<()> { let mut deck = Deck::new_normal(); deck.id = did; @@ -382,23 +279,6 @@ impl Collection { } } - fn rename_child_decks(&mut self, old: &Deck, new_name: &str, usn: Usn) -> Result<()> { - let children = self.storage.child_decks(old)?; - let old_component_count = old.name.matches('\x1f').count() + 1; - - for mut child in children { - let original = child.clone(); - let child_components: Vec<_> = child.name.split('\x1f').collect(); - let child_only = &child_components[old_component_count..]; - let new_name = format!("{}\x1f{}", new_name, child_only.join("\x1f")); - child.name = new_name; - child.set_modified(usn); - self.update_single_deck_undoable(&mut child, original)?; - } - - Ok(()) - } - /// Add a single, normal deck with the provided name for a child deck. /// Caller must have done necessarily validation on name. fn add_parent_deck(&mut self, machine_name: &str, usn: Usn) -> Result<()> { @@ -523,31 +403,6 @@ impl Collection { Ok(cids.len()) } - pub fn get_all_deck_names(&self, skip_empty_default: bool) -> Result> { - if skip_empty_default && self.default_deck_is_empty()? { - Ok(self - .storage - .get_all_deck_names()? - .into_iter() - .filter(|(id, _name)| id.0 != 1) - .collect()) - } else { - self.storage.get_all_deck_names() - } - } - - pub fn get_all_normal_deck_names(&mut self) -> Result> { - Ok(self - .storage - .get_all_deck_names()? - .into_iter() - .filter(|(id, _name)| match self.get_deck(*id) { - Ok(Some(deck)) => !deck.is_filtered(), - _ => true, - }) - .collect()) - } - /// Apply input delta to deck, and its parents. /// Caller should ensure transaction. pub(crate) fn update_deck_stats( @@ -685,8 +540,6 @@ impl Collection { #[cfg(test)] mod test { - use super::{human_deck_name_to_native, immediate_parent_name, normalize_native_name}; - use crate::decks::reparented_name; use crate::{ collection::{open_test_collection, Collection}, error::Result, @@ -702,33 +555,6 @@ mod test { .collect() } - #[test] - fn parent() { - assert_eq!(immediate_parent_name("foo"), None); - assert_eq!(immediate_parent_name("foo\x1fbar"), Some("foo")); - assert_eq!( - immediate_parent_name("foo\x1fbar\x1fbaz"), - Some("foo\x1fbar") - ); - } - - #[test] - fn from_human() { - assert_eq!(&human_deck_name_to_native("foo"), "foo"); - assert_eq!(&human_deck_name_to_native("foo::bar"), "foo\x1fbar"); - assert_eq!(&human_deck_name_to_native("fo\x1fo::ba\nr"), "foo\x1fbar"); - assert_eq!( - &human_deck_name_to_native("foo::::baz"), - "foo\x1fblank\x1fbaz" - ); - } - - #[test] - fn normalize() { - assert_eq!(&normalize_native_name("foo\x1fbar"), "foo\x1fbar"); - assert_eq!(&normalize_native_name("fo\u{a}o\x1fbar"), "foo\x1fbar"); - } - #[test] fn adding_updating() -> Result<()> { let mut col = open_test_collection(); @@ -852,40 +678,4 @@ mod test { Ok(()) } - - #[test] - fn drag_drop() { - // use custom separator to make the tests easier to read - fn n(s: &str) -> String { - s.replace(":", "\x1f") - } - - #[allow(clippy::unnecessary_wraps)] - fn n_opt(s: &str) -> Option { - Some(n(s)) - } - - assert_eq!(reparented_name("drag", Some("drop")), n_opt("drop:drag")); - assert_eq!(reparented_name("drag", None), n_opt("drag")); - assert_eq!(reparented_name(&n("drag:child"), None), n_opt("child")); - assert_eq!( - reparented_name(&n("drag:child"), Some(&n("drop:deck"))), - n_opt("drop:deck:child") - ); - assert_eq!( - reparented_name(&n("drag:child"), Some("drag")), - n_opt("drag:child") - ); - assert_eq!( - reparented_name(&n("drag:child:grandchild"), Some("drag")), - n_opt("drag:grandchild") - ); - // drops to child not supported - assert_eq!( - reparented_name(&n("drag"), Some(&n("drag:child:grandchild"))), - None - ); - // name doesn't change when deck dropped on itself - assert_eq!(reparented_name(&n("foo:bar"), Some(&n("foo:bar"))), None); - } } diff --git a/rslib/src/decks/name.rs b/rslib/src/decks/name.rs new file mode 100644 index 000000000..ff0dc7294 --- /dev/null +++ b/rslib/src/decks/name.rs @@ -0,0 +1,230 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +use crate::{prelude::*, text::normalize_to_nfc}; +use std::borrow::Cow; + +impl Deck { + pub fn human_name(&self) -> String { + self.name.replace("\x1f", "::") + } + + // Mutate name to human representation for sharing. + pub fn with_human_name(mut self) -> Self { + self.name = self.human_name(); + self + } +} + +impl Collection { + pub fn get_all_normal_deck_names(&mut self) -> Result> { + Ok(self + .storage + .get_all_deck_names()? + .into_iter() + .filter(|(id, _name)| match self.get_deck(*id) { + Ok(Some(deck)) => !deck.is_filtered(), + _ => true, + }) + .collect()) + } + + pub fn rename_deck(&mut self, did: DeckId, new_human_name: &str) -> Result> { + self.transact(Op::RenameDeck, |col| { + let existing_deck = col.storage.get_deck(did)?.ok_or(AnkiError::NotFound)?; + let mut deck = existing_deck.clone(); + deck.name = human_deck_name_to_native(new_human_name); + col.update_deck_inner(&mut deck, existing_deck, col.usn()?) + }) + } + + pub(super) fn rename_child_decks( + &mut self, + old: &Deck, + new_name: &str, + usn: Usn, + ) -> Result<()> { + let children = self.storage.child_decks(old)?; + let old_component_count = old.name.matches('\x1f').count() + 1; + + for mut child in children { + let original = child.clone(); + let child_components: Vec<_> = child.name.split('\x1f').collect(); + let child_only = &child_components[old_component_count..]; + let new_name = format!("{}\x1f{}", new_name, child_only.join("\x1f")); + child.name = new_name; + child.set_modified(usn); + self.update_single_deck_undoable(&mut child, original)?; + } + + Ok(()) + } + + pub(crate) fn ensure_deck_name_unique(&self, deck: &mut Deck, usn: Usn) -> Result<()> { + loop { + match self.storage.get_deck_id(&deck.name)? { + Some(did) if did == deck.id => { + break; + } + None => break, + _ => (), + } + deck.name += "+"; + deck.set_modified(usn); + } + + Ok(()) + } + + pub fn get_all_deck_names(&self, skip_empty_default: bool) -> Result> { + if skip_empty_default && self.default_deck_is_empty()? { + Ok(self + .storage + .get_all_deck_names()? + .into_iter() + .filter(|(id, _name)| id.0 != 1) + .collect()) + } else { + self.storage.get_all_deck_names() + } + } +} + +fn invalid_char_for_deck_component(c: char) -> bool { + c.is_ascii_control() || c == '"' +} + +fn normalized_deck_name_component(comp: &str) -> Cow { + let mut out = normalize_to_nfc(comp); + if out.contains(invalid_char_for_deck_component) { + out = out.replace(invalid_char_for_deck_component, "").into(); + } + let trimmed = out.trim(); + if trimmed.is_empty() { + "blank".to_string().into() + } else if trimmed.len() != out.len() { + trimmed.to_string().into() + } else { + out + } +} + +pub(super) fn normalize_native_name(name: &str) -> Cow { + if name + .split('\x1f') + .any(|comp| matches!(normalized_deck_name_component(comp), Cow::Owned(_))) + { + let comps: Vec<_> = name + .split('\x1f') + .map(normalized_deck_name_component) + .collect::>(); + comps.join("\x1f").into() + } else { + // no changes required + name.into() + } +} + +pub(crate) fn human_deck_name_to_native(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + for comp in name.split("::") { + out.push_str(&normalized_deck_name_component(comp)); + out.push('\x1f'); + } + out.trim_end_matches('\x1f').into() +} + +pub(crate) fn native_deck_name_to_human(name: &str) -> String { + name.replace('\x1f', "::") +} + +pub(crate) fn immediate_parent_name(machine_name: &str) -> Option<&str> { + machine_name.rsplitn(2, '\x1f').nth(1) +} + +/// Determine name to rename a deck to, when `dragged` is dropped on `dropped`. +/// `dropped` being unset represents a drop at the top or bottom of the deck list. +/// The returned name should be used to rename `dragged`. +/// Arguments are expected in 'machine' form with an \x1f separator. +pub(crate) fn reparented_name(dragged: &str, dropped: Option<&str>) -> Option { + let dragged_base = dragged.rsplit('\x1f').next().unwrap(); + if let Some(dropped) = dropped { + if dropped.starts_with(dragged) { + // foo onto foo::bar, or foo onto itself -> no-op + None + } else { + // foo::bar onto baz -> baz::bar + Some(format!("{}\x1f{}", dropped, dragged_base)) + } + } else { + // foo::bar onto top level -> bar + Some(dragged_base.into()) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn parent() { + assert_eq!(immediate_parent_name("foo"), None); + assert_eq!(immediate_parent_name("foo\x1fbar"), Some("foo")); + assert_eq!( + immediate_parent_name("foo\x1fbar\x1fbaz"), + Some("foo\x1fbar") + ); + } + + #[test] + fn from_human() { + assert_eq!(&human_deck_name_to_native("foo"), "foo"); + assert_eq!(&human_deck_name_to_native("foo::bar"), "foo\x1fbar"); + assert_eq!(&human_deck_name_to_native("fo\x1fo::ba\nr"), "foo\x1fbar"); + assert_eq!( + &human_deck_name_to_native("foo::::baz"), + "foo\x1fblank\x1fbaz" + ); + } + + #[test] + fn normalize() { + assert_eq!(&normalize_native_name("foo\x1fbar"), "foo\x1fbar"); + assert_eq!(&normalize_native_name("fo\u{a}o\x1fbar"), "foo\x1fbar"); + } + + #[test] + fn drag_drop() { + // use custom separator to make the tests easier to read + fn n(s: &str) -> String { + s.replace(":", "\x1f") + } + + #[allow(clippy::unnecessary_wraps)] + fn n_opt(s: &str) -> Option { + Some(n(s)) + } + + assert_eq!(reparented_name("drag", Some("drop")), n_opt("drop:drag")); + assert_eq!(reparented_name("drag", None), n_opt("drag")); + assert_eq!(reparented_name(&n("drag:child"), None), n_opt("child")); + assert_eq!( + reparented_name(&n("drag:child"), Some(&n("drop:deck"))), + n_opt("drop:deck:child") + ); + assert_eq!( + reparented_name(&n("drag:child"), Some("drag")), + n_opt("drag:child") + ); + assert_eq!( + reparented_name(&n("drag:child:grandchild"), Some("drag")), + n_opt("drag:grandchild") + ); + // drops to child not supported + assert_eq!( + reparented_name(&n("drag"), Some(&n("drag:child:grandchild"))), + None + ); + // name doesn't change when deck dropped on itself + assert_eq!(reparented_name(&n("foo:bar"), Some(&n("foo:bar"))), None); + } +}