//! Creating, renaming and naming collections, and the row context menu //! that reaches most of it. //! //! `apply_rename` and `unique_name` are kept apart from the callbacks that //! call them because they are the part a user notices being wrong, and the //! part that can be tested without a window. use std::rc::Rc; use dr_catalog::collections::{self as coll, CollectionKind}; use dr_catalog::Catalog; use dr_types::CollectionId; use rusqlite::OptionalExtension as _; use slint::{ComponentHandle as _, Model as _}; use crate::{AppWindow, Collections}; use super::controller::{delete_warning, menu_detail, CollectionsController}; use super::tree_sync::{direct_holdings, refresh_tree}; /// TRACES: FR-CAT-7 /// Open the row menu on `id`, or leave it shut if the collection is gone. /// /// Everything the sheet draws is computed here, in one place, so the menu /// cannot offer "Move to top level" on a collection already at the top, or a /// delete warning about children it no longer has. Reopened from scratch after /// every action for the same reason. pub(super) fn open_row_menu( window: &AppWindow, ctl: &Rc, catalog: &Catalog, id: CollectionId, ) { let conn = catalog.connection(); let row: Option<(String, Option, i64)> = conn .query_row( "SELECT name, parent_id, kind FROM collections WHERE id = ?1 AND deleted = 0", [id.0 as i64], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .optional() .unwrap_or(None); let Some((name, parent, kind)) = row else { // Deleted underneath us — by a merge, or by the action that just ran. close_row_menu(window, ctl); return; }; let (holds, children) = direct_holdings(conn, id).unwrap_or((0, 0)); *ctl.menu_for.borrow_mut() = Some(id); ctl.menu_confirming.set(false); window .global::() .set_collection_menu_confirming(false); window .global::() .set_collection_menu_title(name.as_str().into()); window .global::() .set_collection_menu_detail(menu_detail(holds, children).into()); window .global::() .set_collection_menu_nested(parent.is_some()); window .global::() .set_collection_menu_smart(kind == CollectionKind::Smart as i64); window .global::() .set_collection_menu_confirm_detail(delete_warning(&name, holds, children).into()); window .global::() .set_collection_menu_delete_label("Delete".into()); // TRACES: FR-NC-6a // Read off the row the sidebar already drew rather than recomputed: the // roll-up is one pass over the whole tree, and a menu that computed its own // could disagree with the tray beside the name it is titled with. let pinned = ctl .row_ids .borrow() .iter() .position(|&c| c == id) .and_then(|row| { window .global::() .get_collection_rows() .row_data(row) }) .is_some_and(|r| r.pinned); window .global::() .set_collection_menu_pinned(pinned); } /// Shut the row menu, both halves. /// /// The controller's copy is what the handlers act on and the window property is /// what draws the sheet; clearing one without the other leaves either an /// invisible menu that still answers, or a visible one that acts on nothing. pub(super) fn close_row_menu(window: &AppWindow, ctl: &Rc) { *ctl.menu_for.borrow_mut() = None; ctl.menu_confirming.set(false); window .global::() .set_collection_menu_title(slint::SharedString::new()); window .global::() .set_collection_menu_confirming(false); } /// Close the rename field, whatever the outcome. /// /// Both halves together, always: the controller's copy is what a stray second /// commit is tested against, and the window property is what draws the field. /// Clearing one without the other either leaves a field open that nothing will /// close, or closes one that Rust still believes is open. pub(super) fn close_rename(window: &AppWindow, ctl: &Rc) { *ctl.renaming.borrow_mut() = None; window.global::().set_collection_renaming(0); } /// What a committed rename did. #[derive(Debug, PartialEq, Eq)] pub(super) enum Rename { /// Written, with the name as stored — trimmed. Applied(String), /// The name was the one it already had, so nothing was written. /// /// Distinguished from `Applied` because every write bumps the revision, and /// a rename to the same name would let a device that changed nothing win a /// merge against one that did real work. Unchanged, } /// Validate a typed name and store it. /// /// Split out so the rules are testable without a window — they are the part a /// user runs into: /// /// - **blank is refused.** A nameless row is unclickable and unfindable, and /// the schema is happy to store one. /// - **a sibling's name is refused.** Two identically-named collections in one /// parent are indistinguishable in the sidebar, which is how images end up in /// the wrong one. The same reasoning as [`unique_name`], enforced here rather /// than silently suffixing: the user typed a specific name and quietly /// storing a different one is worse than saying no. /// /// Case-insensitive against siblings, because the sidebar sorts that way and /// "Iceland" beside "iceland" is the same trap as an exact duplicate. pub(super) fn apply_rename( conn: &rusqlite::Connection, id: CollectionId, typed: &str, ) -> Result { let name = typed.trim(); if name.is_empty() { return Err(dr_catalog::CatalogError::BadName( "a collection needs a name".into(), )); } // The current name, which also proves the collection is still there. let current: String = conn.query_row( "SELECT name FROM collections WHERE id = ?1 AND deleted = 0", [id.0 as i64], |r| r.get(0), )?; if current == name { return Ok(Rename::Unchanged); } // Siblings, excluding this collection: a rename that only changes case is a // real rename, and must not be refused as a clash with itself. let clash: Option = conn .query_row( "SELECT 1 FROM collections WHERE deleted = 0 AND id != ?1 AND name = ?2 COLLATE NOCASE AND parent_id IS (SELECT parent_id FROM collections WHERE id = ?1)", rusqlite::params![id.0 as i64, name], |r| r.get(0), ) .optional()?; if clash.is_some() { return Err(dr_catalog::CatalogError::BadName(format!( "there is already a “{name}” here" ))); } coll::rename(conn, id, name)?; Ok(Rename::Applied(name.to_string())) } /// TRACES: FR-CAT-7 /// Make a collection inside `parent` and open its name field. /// /// Shared by the header's `+` and the row menu's "New collection inside", /// which differ only in where the parent comes from — and must not differ in /// anything else, or building a tree from the tree would behave unlike /// building one from the header. pub(super) fn create_child( window: &AppWindow, ctl: &Rc, catalog: &Catalog, parent: Option, ) { let name = unique_name(catalog.connection(), parent); match coll::create(catalog.connection(), &name, parent, CollectionKind::Manual) { Ok(id) => { window .global::() .set_collection_error(slint::SharedString::new()); // A new child is useless if its parent is collapsed. if let Some(p) = parent { ctl.collapsed.borrow_mut().remove(&p); } // Straight into the name field, with "New collection" selected. // The name is a placeholder nobody wants to keep, so making the // user find the rename gesture afterwards is asking them to finish // a job we started. // // Opened *after* the rebuild, and the order is load-bearing: // `refresh_tree` replaces the row model, which destroys and // recreates every row. A field opened before it would be torn down // along with the `init` that focuses it, leaving an edit box // nothing had typed into. Setting the property afterwards puts the // field on a row that already exists. refresh_tree(window, ctl, catalog); *ctl.renaming.borrow_mut() = Some(id); window .global::() .set_collection_renaming(id.0 as i32); log::info!("created collection {} ({name})", id.0); } Err(e) => window .global::() .set_collection_error(format!("creating collection: {e}").into()), } } /// A name no sibling is already using. /// /// Duplicate names are legal in the schema, and two identically-named /// collections in one parent are indistinguishable in the sidebar — which is /// how images end up in the wrong one. pub(super) fn unique_name(conn: &rusqlite::Connection, parent: Option) -> String { let taken: Vec = { let sql = match parent { Some(_) => "SELECT name FROM collections WHERE deleted = 0 AND parent_id = ?1", None => "SELECT name FROM collections WHERE deleted = 0 AND parent_id IS NULL", }; let Ok(mut stmt) = conn.prepare(sql) else { return "New collection".into(); }; // Collected inside each arm: the two `query_map` calls bind different // parameter types, so their iterators are different types and cannot // be the arms of one `match`. match parent { Some(p) => stmt .query_map([p.0 as i64], |r| r.get::<_, String>(0)) .map(|rows| rows.flatten().collect()) .unwrap_or_default(), None => stmt .query_map([], |r| r.get::<_, String>(0)) .map(|rows| rows.flatten().collect()) .unwrap_or_default(), } }; let base = "New collection"; if !taken.iter().any(|t| t == base) { return base.into(); } for n in 2..1000 { let candidate = format!("{base} {n}"); if !taken.contains(&candidate) { return candidate; } } base.into() } #[cfg(test)] mod tests { use super::*; #[test] fn new_collections_do_not_share_a_name_with_a_sibling() { // Two identically-named collections in one parent are // indistinguishable in the sidebar, which is how images land in the // wrong one. let cat = Catalog::in_memory().unwrap(); let c = cat.connection(); let first = unique_name(c, None); coll::create(c, &first, None, CollectionKind::Manual).unwrap(); let second = unique_name(c, None); coll::create(c, &second, None, CollectionKind::Manual).unwrap(); assert_ne!(first, second); assert_eq!(first, "New collection"); assert_eq!(second, "New collection 2"); } /// A catalog holding one top-level collection, and its id. fn with_one(name: &str) -> (Catalog, CollectionId) { let cat = Catalog::in_memory().unwrap(); let id = coll::create(cat.connection(), name, None, CollectionKind::Manual).unwrap(); (cat, id) } fn name_of(cat: &Catalog, id: CollectionId) -> String { cat.connection() .query_row( "SELECT name FROM collections WHERE id = ?1", [id.0 as i64], |r| r.get(0), ) .unwrap() } #[test] fn a_rename_stores_the_new_name() { let (cat, id) = with_one("Untitled"); let out = apply_rename(cat.connection(), id, "Iceland").unwrap(); assert_eq!(out, Rename::Applied("Iceland".into())); assert_eq!(name_of(&cat, id), "Iceland"); } #[test] fn surrounding_whitespace_is_trimmed_rather_than_stored() { // A trailing space is invisible in the sidebar and makes two // collections look identical while sorting them apart. let (cat, id) = with_one("Untitled"); assert_eq!( apply_rename(cat.connection(), id, " Iceland ").unwrap(), Rename::Applied("Iceland".into()) ); assert_eq!(name_of(&cat, id), "Iceland"); } #[test] fn a_blank_name_is_refused() { // A nameless row cannot be read or aimed at, and the schema would take // one happily. let (cat, id) = with_one("Iceland"); assert!(matches!( apply_rename(cat.connection(), id, " "), Err(dr_catalog::CatalogError::BadName(_)) )); assert_eq!(name_of(&cat, id), "Iceland", "the old name stands"); } #[test] fn renaming_to_the_current_name_writes_nothing() { // Every write bumps the revision, and a no-op rename would let an idle // device beat one that did real work at the next merge. let (cat, id) = with_one("Iceland"); let rev = |c: &Catalog| -> i64 { c.connection() .query_row( "SELECT revision FROM collections WHERE id = ?1", [id.0 as i64], |r| r.get(0), ) .unwrap() }; let before = rev(&cat); assert_eq!( apply_rename(cat.connection(), id, "Iceland").unwrap(), Rename::Unchanged ); assert_eq!(rev(&cat), before); } #[test] fn a_siblings_name_is_refused() { // Two identically-named collections in one parent are // indistinguishable in the sidebar, which is how images land in the // wrong one. let cat = Catalog::in_memory().unwrap(); let c = cat.connection(); coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap(); let japan = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap(); assert!(matches!( apply_rename(c, japan, "Iceland"), Err(dr_catalog::CatalogError::BadName(_)) )); assert_eq!(name_of(&cat, japan), "Japan"); } #[test] fn a_siblings_name_is_refused_in_a_different_case_too() { // The sidebar sorts case-insensitively, so "iceland" beside "Iceland" // is the same trap as an exact duplicate. let cat = Catalog::in_memory().unwrap(); let c = cat.connection(); coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap(); let japan = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap(); assert!(matches!( apply_rename(c, japan, "iceland"), Err(dr_catalog::CatalogError::BadName(_)) )); } #[test] fn changing_only_the_case_of_a_name_is_allowed() { // The clash check excludes the collection itself, or fixing the // capitalisation of a name would be refused as a clash with itself. let (cat, id) = with_one("iceland"); assert_eq!( apply_rename(cat.connection(), id, "Iceland").unwrap(), Rename::Applied("Iceland".into()) ); assert_eq!(name_of(&cat, id), "Iceland"); } #[test] fn a_name_used_under_a_different_parent_is_free() { // Uniqueness is per parent: "Selects" inside two different trips is // unambiguous and normal. let cat = Catalog::in_memory().unwrap(); let c = cat.connection(); let trips = coll::create(c, "Trips", None, CollectionKind::Manual).unwrap(); coll::create(c, "Selects", Some(trips), CollectionKind::Manual).unwrap(); let top = coll::create(c, "Loose", None, CollectionKind::Manual).unwrap(); assert_eq!( apply_rename(c, top, "Selects").unwrap(), Rename::Applied("Selects".into()) ); } #[test] fn two_top_level_collections_still_clash_despite_a_null_parent() { // `parent_id IS NULL` never matches with `=`, so a naive clash query // would silently allow every duplicate at the top level — which is // where most collections live. let cat = Catalog::in_memory().unwrap(); let c = cat.connection(); coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap(); let other = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap(); assert!( apply_rename(c, other, "Iceland").is_err(), "two top-level collections must not share a name" ); } #[test] fn renaming_a_deleted_collection_fails_rather_than_resurrecting_it() { let (cat, id) = with_one("Gone"); coll::delete(cat.connection(), id).unwrap(); assert!(apply_rename(cat.connection(), id, "Back").is_err()); } #[test] fn a_saved_filter_can_be_renamed() { // Its *membership* is computed; its name is not, and a smart // collection the user cannot label is worse than no smart collection. let cat = Catalog::in_memory().unwrap(); let c = cat.connection(); let s = coll::create(c, "Untitled", None, CollectionKind::Smart).unwrap(); assert_eq!( apply_rename(c, s, "Five star").unwrap(), Rename::Applied("Five star".into()) ); } #[test] fn the_same_name_is_free_again_under_a_different_parent() { // Uniqueness is per parent, not global: "Selects" inside two different // trips is unambiguous and normal. let cat = Catalog::in_memory().unwrap(); let c = cat.connection(); let top = coll::create(c, "New collection", None, CollectionKind::Manual).unwrap(); assert_eq!(unique_name(c, Some(top)), "New collection"); } }