AppWindow carried the sidebar's tree, its row menu, renaming, drag and drop between rows, the trash row, and the membership sheet as ~40 properties and callbacks on the root component, in the pattern CH-1 describes and the develop screen's globals (Adjustments, Framing, Steps, ...) already replaced. Collections.* in collections.slint now holds that state, declared next to the MembershipRow struct it and the membership sheet both use; Rust reaches it through window.global::<Collections>() instead of window.set_/get_/on_/invoke_ on the root. collection-selected, collection-select, and collection-offline-menu stay on AppWindow: library_ui.rs invokes collection-select directly and registers collection-offline-menu's handler, and lib.rs reads collection-selected for back-navigation, so moving them would have meant editing library_ui.rs, which another change on this branch is splitting into a module directory. collections-visible stays too — it is lib.rs's panel-layout state, seeded from the saved layout before the sidebar exists, and collections_ui never touches it. Everything prefixed library- (the grid's drag, selection and keyword state that the sidebar's Rust also wires for cross-feature gestures like filing a selection into a collection) stays on the window as well, since it belongs to the library screen, not the sidebar.
493 lines
18 KiB
Rust
493 lines
18 KiB
Rust
//! 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<CollectionsController>,
|
|
catalog: &Catalog,
|
|
id: CollectionId,
|
|
) {
|
|
let conn = catalog.connection();
|
|
let row: Option<(String, Option<i64>, 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::<Collections>()
|
|
.set_collection_menu_confirming(false);
|
|
window
|
|
.global::<Collections>()
|
|
.set_collection_menu_title(name.as_str().into());
|
|
window
|
|
.global::<Collections>()
|
|
.set_collection_menu_detail(menu_detail(holds, children).into());
|
|
window
|
|
.global::<Collections>()
|
|
.set_collection_menu_nested(parent.is_some());
|
|
window
|
|
.global::<Collections>()
|
|
.set_collection_menu_smart(kind == CollectionKind::Smart as i64);
|
|
window
|
|
.global::<Collections>()
|
|
.set_collection_menu_confirm_detail(delete_warning(&name, holds, children).into());
|
|
window
|
|
.global::<Collections>()
|
|
.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::<Collections>()
|
|
.get_collection_rows()
|
|
.row_data(row)
|
|
})
|
|
.is_some_and(|r| r.pinned);
|
|
window
|
|
.global::<Collections>()
|
|
.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<CollectionsController>) {
|
|
*ctl.menu_for.borrow_mut() = None;
|
|
ctl.menu_confirming.set(false);
|
|
window
|
|
.global::<Collections>()
|
|
.set_collection_menu_title(slint::SharedString::new());
|
|
window
|
|
.global::<Collections>()
|
|
.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<CollectionsController>) {
|
|
*ctl.renaming.borrow_mut() = None;
|
|
window.global::<Collections>().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<Rename, dr_catalog::CatalogError> {
|
|
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<i64> = 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<CollectionsController>,
|
|
catalog: &Catalog,
|
|
parent: Option<CollectionId>,
|
|
) {
|
|
let name = unique_name(catalog.connection(), parent);
|
|
|
|
match coll::create(catalog.connection(), &name, parent, CollectionKind::Manual) {
|
|
Ok(id) => {
|
|
window
|
|
.global::<Collections>()
|
|
.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::<Collections>()
|
|
.set_collection_renaming(id.0 as i32);
|
|
log::info!("created collection {} ({name})", id.0);
|
|
}
|
|
Err(e) => window
|
|
.global::<Collections>()
|
|
.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<CollectionId>) -> String {
|
|
let taken: Vec<String> = {
|
|
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");
|
|
}
|
|
}
|