Files
DarkRoom/ui/dr-ui/src/collections_ui/tree_sync.rs
T
dtourolle 7cbcacc02e Export to an album instead of a folder in the settings
Export took a path typed into the settings page, or a folder inside the
library on the server. The first is how exports end up somewhere nobody
looks; the second put JPEGs into the tree a scan catalogues, where they
came back as photographs beside the RAWs they were made from.

The destination is now an album (FR-EXP-10), chosen by name in the
export sheet. Albums are listed under the collections in the sidebar;
"+" there, or "New album…" in the sheet, opens a sheet for its name and
its folder — on this device through the platform's dialogue, or on the
server through the browser with "New folder". A server folder inside
the library is refused, and the sheet says why. Selecting an album
narrows the grid to the photographs behind its files: library::Scope
is Collection or Album, and scope_clause is the one place the two are
spelled, which also retires the two copies of the collection predicate
total_images_scoped and read_cells_scoped had inlined.

A batch resolves the album when it starts, and refuses in words when
none is chosen, it has gone, or its folder is local to another device.
Each item reports the image it came from, and the files written are
recorded against the album in one transaction when the batch ends.

A server album lives outside the library, so its queued uploads are
relative to the account root. That is a third line in the outbox's
.dest record rather than a leading slash, because a record written
before albums may carry a stray slash and must keep the meaning it was
written with.

An export folder set before albums becomes an album called "Exports"
on first open, so upgrading does not lose where exports were going.
The old destination fields stay in ExportSettings so older settings
files still read.
2026-09-26 14:13:53 -04:00

517 lines
19 KiB
Rust

//! Rebuilding the sidebar from the catalog, and pushing catalog-derived
//! state into the grid: the selection flags, the lifted cells, the
//! per-cell collection badges, and the membership sheet's own rows.
//!
//! `offline_state` and `direct_holdings` live here rather than beside the
//! menu or the tray that reads them, because both are catalog reads over
//! the tree — the same shape as `refresh_tree` itself, just narrower.
use std::rc::Rc;
use dr_catalog::collections::{self as coll, CollectionKind};
use dr_catalog::Catalog;
use dr_types::{CollectionId, ImageId};
use slint::{ComponentHandle as _, Model as _};
use crate::{AppWindow, CollectionRow, Collections, Library};
use super::controller::CollectionsController;
/// Rebuild the sidebar from the catalog.
///
/// Called after every edit. The whole tree rather than a patch: a rename can
/// reorder siblings, a delete promotes children, and a drop changes counts on
/// every ancestor — diffing that against the model would be more code than the
/// query costs, and the tree is tens of rows, not thousands.
pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog: &Catalog) {
// The albums share the sidebar, and every caller of this is a moment
// they may have changed too — a merge brings both.
if let Some(albums) = ctl.albums.borrow().as_ref() {
crate::albums_ui::refresh_from(window, albums, catalog);
}
let rows = match coll::tree(catalog.connection()) {
Ok(r) => r,
Err(e) => {
window
.global::<Collections>()
.set_collection_error(format!("reading collections: {e}").into());
return;
}
};
let collapsed = ctl.collapsed.borrow();
// TRACES: FR-NC-6a | FR-NC-6c
// How much of each collection is already on the device, rolled up the tree
// — computed once for the whole sidebar rather than per row.
let held = offline_state(catalog, &rows);
// A row is hidden when any ancestor is collapsed. The tree arrives
// depth-first, so tracking the shallowest collapsed depth seen is enough —
// no ancestor lookup per row.
let mut hide_below: Option<usize> = None;
let mut ids = Vec::new();
let mut smart = Vec::new();
let mut has_kids = Vec::new();
let mut nested = Vec::new();
let mut out = Vec::new();
for row in &rows {
if let Some(depth) = hide_below {
if row.depth > depth {
continue;
}
hide_below = None;
}
let id = row.collection.id;
let expanded = !collapsed.contains(&id);
if row.has_children && !expanded {
hide_below = Some(row.depth);
}
// Deep counts are one query per row. That is fine at sidebar scale and
// wrong at grid scale, which is why the grid does not do this.
let deep =
coll::deep_count(catalog.connection(), id).unwrap_or(row.collection.direct_count);
let offline = held.get(&id).copied().unwrap_or_default();
ids.push(id);
smart.push(row.collection.kind == CollectionKind::Smart);
has_kids.push(row.has_children);
nested.push(row.collection.parent.is_some());
out.push(CollectionRow {
id: id.0 as i32,
name: row.collection.name.as_str().into(),
depth: row.depth as i32,
direct_count: row.collection.direct_count as i32,
deep_count: deep as i32,
has_children: row.has_children,
expanded,
smart: row.collection.kind == CollectionKind::Smart,
// An empty collection is never "kept": every one of its zero
// photographs being here is true and says nothing, and a full tray
// beside an empty collection is a lie about what a trip would cost
// to take.
pinned: offline.total > 0 && offline.pinned == offline.total,
partly_pinned: offline.pinned > 0 && offline.pinned < offline.total,
});
}
*ctl.row_ids.borrow_mut() = ids;
*ctl.row_smart.borrow_mut() = smart;
*ctl.row_has_children.borrow_mut() = has_kids;
*ctl.row_nested.borrow_mut() = nested;
window
.global::<Collections>()
.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out)));
// The rows are what `sync_reorderable` reads, so it has to be told they
// changed: a collection that gains a child, or one that is deleted out from
// under the scope, changes whether the grid can be reordered without the
// scope itself moving.
sync_reorderable(window);
}
/// TRACES: FR-NC-6a | FR-NC-6c
/// How many of a collection's photographs are pinned, and how many there are.
///
/// Counted over *members*, not distinct images: an image filed in both a parent
/// and a child is counted in each, which is what makes the roll-up below a sum
/// rather than a set union. That inflates the totals of a tree that files the
/// same photograph twice, and it does not matter here — the only questions
/// asked of these numbers are "all of them?" and "none of them?", and both
/// survive the double count.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct OfflineCount {
total: usize,
pinned: usize,
}
/// The offline state of every collection in the tree, descendants included.
///
/// One query for the direct membership, then a single reverse pass to fold each
/// row into its parent. The alternative — `descendants` plus a count per row —
/// is two queries per collection, and the sidebar rebuilds after every drop,
/// rename and rating change.
///
/// The reverse pass is correct because `tree` emits parents before children:
/// walking it backwards means every child has already been folded in by the
/// time its parent is reached, however deep the nesting goes.
fn offline_state(
catalog: &Catalog,
rows: &[coll::TreeRow],
) -> std::collections::HashMap<CollectionId, OfflineCount> {
let mut counts: std::collections::HashMap<CollectionId, OfflineCount> =
std::collections::HashMap::new();
// `tier_actual`, not `pinned`: the question a user is asking of this icon is
// "will these open on the aeroplane", and a pin whose download has not run
// yet answers no (see the note on `tier_actual` in `dr_catalog::cache`).
let sql = "SELECT cm.collection_id,
count(*),
coalesce(sum(CASE WHEN ic.tier_actual >= ?1 THEN 1 ELSE 0 END), 0)
FROM collection_members cm
LEFT JOIN image_cache ic ON ic.image_id = cm.image_id
GROUP BY cm.collection_id";
match catalog.connection().prepare(sql).and_then(|mut stmt| {
let rows = stmt.query_map(rusqlite::params![dr_types::Tier::Original.stored()], |r| {
Ok((
CollectionId(r.get::<_, i64>(0)? as u64),
r.get::<_, i64>(1)? as usize,
r.get::<_, i64>(2)? as usize,
))
})?;
rows.collect::<Result<Vec<_>, _>>()
}) {
Ok(direct) => {
for (id, total, pinned) in direct {
counts.insert(id, OfflineCount { total, pinned });
}
}
Err(e) => {
// Not fatal: the tray then reads "nothing kept", which is the safe
// direction — it offers a download rather than claiming a trip is
// already on the device.
log::debug!("reading offline state: {e}");
return counts;
}
}
for row in rows.iter().rev() {
let Some(parent) = row.collection.parent else {
continue;
};
let child = counts.get(&row.collection.id).copied().unwrap_or_default();
if child.total == 0 {
continue;
}
let entry = counts.entry(parent).or_default();
entry.total += child.total;
entry.pinned += child.pinned;
}
counts
}
/// Mark which cells are currently lifted out by a drag.
///
/// Separate from [`sync_selection`] because the two differ: the selection
/// persists after the drag, the lift lasts only while it is in flight.
pub fn sync_lifted(window: &AppWindow, lifted: &[ImageId], ids: &[ImageId]) {
let model = window.global::<Library>().get_library_cells();
for (row, id) in ids.iter().enumerate() {
let want = lifted.contains(id);
if let Some(mut cell) = model.row_data(row) {
if cell.lifted != want {
cell.lifted = want;
model.set_row_data(row, cell);
}
}
}
}
/// Push the current selection into the grid model's `selected` flags.
///
/// The model carries the flag per cell so Slint can style without a lookup;
/// this is what keeps the two in step after a scrub, a drop, or a click.
pub fn sync_selection(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[ImageId]) {
let selection = ctl.selection.borrow();
let model = window.global::<Library>().get_library_cells();
// The anchor is deliberately **not** pushed to the grid.
//
// It used to be, and it was drawn as a thin ring inside the cell. Two
// things were wrong with that. It was the clearest mark on the cell, so it
// read as *the* selection to anyone who had not written it; and an anchor
// outlives a deselection, so the ring sat around the last photograph
// touched while nothing was selected at all — indistinguishable from a cell
// that had stayed behind.
//
// The ordinal still lives in the controller and still decides where a range
// extends from. What is gone is the claim that the user needs to see it:
// the selection bar says "Tap the last photograph" while a range is armed,
// which answers the question the ring was there to answer.
for (row, id) in ids.iter().enumerate() {
let want = selection.contains(id);
if let Some(mut cell) = model.row_data(row) {
if cell.selected != want {
cell.selected = want;
model.set_row_data(row, cell);
}
}
}
window
.global::<Library>()
.set_library_selected_count(selection.len() as i32);
}
/// TRACES: FR-CAT-7
/// Whether what the grid is showing has an order the user can change.
///
/// A single manual collection: not the whole library, not a saved filter whose
/// membership is a rule, and not a set — a set draws its descendants' images
/// too, and two children's `position` columns are unrelated integers that would
/// interleave arbitrarily.
///
/// Read off the tree rows the sidebar already draws rather than asked of the
/// catalog. `smart` and `has_children` are the only two facts it needs and both
/// are in the model already, so this cannot disagree with what the user is
/// looking at, and a scope change costs no extra query.
pub(super) fn sync_reorderable(window: &AppWindow) {
let id = window.get_collection_selected();
let rows = window.global::<Collections>().get_collection_rows();
let manual = id > 0
&& (0..rows.row_count())
.filter_map(|i| rows.row_data(i))
.find(|r| r.id == id)
.is_some_and(|r| !r.smart && !r.has_children);
window.global::<Library>().set_library_reorderable(manual);
}
/// Refresh the per-cell "in this many collections" badges.
///
/// One query for the whole window rather than one per cell: 120 cells is 120
/// round trips otherwise, on every drop.
pub fn sync_badges(window: &AppWindow, catalog: &Catalog, ids: &[ImageId]) {
if ids.is_empty() {
return;
}
let placeholders = std::iter::repeat_n("?", ids.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT m.image_id, count(*)
FROM collection_members m
JOIN collections c ON c.id = m.collection_id
WHERE m.image_id IN ({placeholders}) AND c.deleted = 0
GROUP BY m.image_id"
);
let params: Vec<rusqlite::types::Value> = ids
.iter()
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
.collect();
let mut counts = std::collections::HashMap::new();
if let Ok(mut stmt) = catalog.connection().prepare(&sql) {
if let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))
}) {
for (id, n) in rows.flatten() {
counts.insert(id, n as i32);
}
}
}
let model = window.global::<Library>().get_library_cells();
for (row, id) in ids.iter().enumerate() {
let want = counts.get(&(id.0 as i64)).copied().unwrap_or(0);
if let Some(mut cell) = model.row_data(row) {
if cell.collection_count != want {
cell.collection_count = want;
model.set_row_data(row, cell);
}
}
}
}
/// TRACES: FR-CAT-7
/// What a collection directly holds: its own member rows, and its own children.
///
/// One query each rather than reusing `deep_count`, which counts descendants'
/// photographs — see [`menu_detail`] for why the menu needs the direct numbers.
pub(super) fn direct_holdings(
conn: &rusqlite::Connection,
id: CollectionId,
) -> Result<(usize, usize), dr_catalog::CatalogError> {
let holds: i64 = conn.query_row(
"SELECT count(*) FROM collection_members WHERE collection_id = ?1",
[id.0 as i64],
|r| r.get(0),
)?;
let children: i64 = conn.query_row(
"SELECT count(*) FROM collections WHERE parent_id = ?1 AND deleted = 0",
[id.0 as i64],
|r| r.get(0),
)?;
Ok((holds as usize, children as usize))
}
/// TRACES: FR-CAT-7 | FR-UI-4
/// Rebuild the membership sheet from the selection as it stands.
///
/// Called on open and after every removal rather than patched: a removal can
/// take the last selected photograph out of a collection, and that row then has
/// to go — a patched model would leave a "Remove" that removes nothing.
pub(super) fn refresh_membership(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Catalog,
) {
let chosen = ctl.selected();
let total = chosen.len();
let rows = match coll::membership_of(catalog.connection(), &chosen) {
Ok(r) => r,
Err(e) => {
window
.global::<Collections>()
.set_collection_error(format!("reading collections: {e}").into());
Vec::new()
}
};
let out: Vec<crate::MembershipRow> = rows
.into_iter()
.map(|m| crate::MembershipRow {
id: m.id.0 as i32,
name: m.name.as_str().into(),
holding: m.holding as i32,
// "1 of 1" is arithmetic nobody asked for; with one photograph
// selected the name already says everything true about it.
detail: if total <= 1 {
slint::SharedString::new()
} else {
format!("{} of {total}", m.holding).into()
},
})
.collect();
window
.global::<Collections>()
.set_membership_rows(slint::ModelRc::new(slint::VecModel::from(out)));
}
#[cfg(test)]
mod tests {
use super::*;
/// TRACES: FR-NC-6a | FR-NC-6c
/// A catalog with `n` images, for the offline roll-up tests.
fn catalog_with_images(n: usize) -> (Catalog, Vec<ImageId>) {
let cat = Catalog::in_memory().unwrap();
cat.connection()
.execute(
"INSERT INTO roots (id, kind, label) VALUES (1, 'remote', 'test')",
[],
)
.unwrap();
let mut ids = Vec::new();
for i in 0..n {
cat.connection()
.execute(
"INSERT INTO images (root_id, source_ref, added_at)
VALUES (1, ?1, 0)",
rusqlite::params![format!("Photos/img{i:03}.CR2")],
)
.unwrap();
ids.push(ImageId(cat.connection().last_insert_rowid() as u64));
}
(cat, ids)
}
/// Record an image as held on this device, as a finished download would.
fn mark_held(cat: &Catalog, image: ImageId) {
cat.connection()
.execute(
"INSERT INTO image_cache (image_id, tier_actual, tier_desired, bytes, pinned)
VALUES (?1, ?2, ?2, 100, 1)",
rusqlite::params![image.0 as i64, dr_types::Tier::Original.stored()],
)
.unwrap();
}
#[test]
fn a_parent_reads_as_kept_only_when_its_children_are_too() {
// The tray on a parent answers for the subtree, because that is what
// keeping a parent downloads. A parent that showed a full tray over a
// child with nothing downloaded would be a promise the aeroplane
// breaks.
let (cat, images) = catalog_with_images(4);
let c = cat.connection();
let parent = coll::create(c, "Trip", None, CollectionKind::Manual).unwrap();
let child = coll::create(c, "Day one", Some(parent), CollectionKind::Manual).unwrap();
coll::add_images(c, parent, &images[0..2]).unwrap();
coll::add_images(c, child, &images[2..4]).unwrap();
// Everything the parent holds directly, and nothing of the child's.
mark_held(&cat, images[0]);
mark_held(&cat, images[1]);
let rows = coll::tree(c).unwrap();
let state = offline_state(&cat, &rows);
assert_eq!(
state[&parent],
OfflineCount {
total: 4,
pinned: 2
}
);
assert_eq!(
state[&child],
OfflineCount {
total: 2,
pinned: 0
}
);
mark_held(&cat, images[2]);
mark_held(&cat, images[3]);
let state = offline_state(&cat, &coll::tree(c).unwrap());
assert_eq!(
state[&parent],
OfflineCount {
total: 4,
pinned: 4
}
);
}
#[test]
fn a_grandchild_rolls_all_the_way_up() {
// The reverse pass has to survive depth: a photograph three levels down
// is still part of what the top-level collection would download.
let (cat, images) = catalog_with_images(1);
let c = cat.connection();
let top = coll::create(c, "2024", None, CollectionKind::Manual).unwrap();
let mid = coll::create(c, "Corsica", Some(top), CollectionKind::Manual).unwrap();
let leaf = coll::create(c, "Bonifacio", Some(mid), CollectionKind::Manual).unwrap();
coll::add_images(c, leaf, &images).unwrap();
let state = offline_state(&cat, &coll::tree(c).unwrap());
assert_eq!(
state[&top],
OfflineCount {
total: 1,
pinned: 0
}
);
assert_eq!(
state[&mid],
OfflineCount {
total: 1,
pinned: 0
}
);
}
#[test]
fn an_empty_collection_has_nothing_to_keep() {
// Guards the reading that would put a full tray beside an empty
// collection: every one of its zero photographs is here, which is true
// and useless. `refresh_tree` requires a non-zero total before drawing
// one, and this is the fact it relies on.
let (cat, _) = catalog_with_images(0);
let c = cat.connection();
let empty = coll::create(c, "Nothing yet", None, CollectionKind::Manual).unwrap();
let state = offline_state(&cat, &coll::tree(c).unwrap());
assert_eq!(state.get(&empty).copied().unwrap_or_default().total, 0);
}
}