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.
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
//! for, trash listing, and the ordinal a single photograph sits at.
|
||||
|
||||
use dr_catalog::Catalog;
|
||||
|
||||
use super::Scope;
|
||||
#[cfg(test)]
|
||||
use dr_sync::RemotePath;
|
||||
#[cfg(test)]
|
||||
@@ -82,39 +84,29 @@ pub fn read_cells(
|
||||
/// positions are unrelated integers.
|
||||
pub fn read_cells_scoped(
|
||||
catalog: &Catalog,
|
||||
scope: Option<dr_types::CollectionId>,
|
||||
scope: Option<Scope>,
|
||||
filter: &RatingFilter,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
|
||||
let Some(scope) = scope else {
|
||||
if scope.is_none() {
|
||||
return read_cells_all(catalog, filter, offset, limit);
|
||||
};
|
||||
}
|
||||
|
||||
let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?;
|
||||
// Placeholders are generated from the *count* of ids, never from user text.
|
||||
let placeholders = std::iter::repeat_n("?", ids.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let (clause, mut params) = scope_clause(catalog, scope)?;
|
||||
let rated = filter.sql();
|
||||
let folded = uncollapsed("i");
|
||||
let (order, order_params) = grid_order_for(catalog, Some(scope));
|
||||
let (order, order_params) = grid_order_for(catalog, scope);
|
||||
let sql = format!(
|
||||
"SELECT {CELL_COLUMNS}
|
||||
FROM images i
|
||||
WHERE {VISIBLE}{rated}{folded}
|
||||
AND i.id IN (SELECT image_id FROM collection_members
|
||||
WHERE collection_id IN ({placeholders}))
|
||||
WHERE {VISIBLE}{rated}{folded}{clause}
|
||||
{order}
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
|
||||
// Bound in the order the `?`s appear: the scope's ids in the WHERE, then
|
||||
// Bound in the order the `?`s appear: the scope's in the WHERE, then
|
||||
// whatever the ORDER BY needs, then the window.
|
||||
let mut params: Vec<rusqlite::types::Value> = ids
|
||||
.iter()
|
||||
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
|
||||
.collect();
|
||||
params.extend(order_params);
|
||||
params.push(rusqlite::types::Value::Integer(limit as i64));
|
||||
params.push(rusqlite::types::Value::Integer(offset as i64));
|
||||
@@ -225,7 +217,7 @@ pub fn total_trashed(catalog: &Catalog) -> Result<usize, dr_catalog::CatalogErro
|
||||
/// the two the same, because both leave the selection where it was.
|
||||
pub fn read_ids_span(
|
||||
catalog: &Catalog,
|
||||
scope: Option<dr_types::CollectionId>,
|
||||
scope: Option<Scope>,
|
||||
filter: &RatingFilter,
|
||||
trash: bool,
|
||||
first: usize,
|
||||
@@ -310,7 +302,7 @@ pub fn read_ids_span(
|
||||
/// narrows. Swapping the two silently looks up a collection by an image id.
|
||||
pub fn ordinal_of_path(
|
||||
catalog: &Catalog,
|
||||
scope: Option<dr_types::CollectionId>,
|
||||
scope: Option<Scope>,
|
||||
filter: &RatingFilter,
|
||||
trash: bool,
|
||||
path: &str,
|
||||
@@ -669,18 +661,18 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let filter = RatingFilter::default();
|
||||
let cells = read_cells_scoped(&catalog, Some(coll), &filter, 0, 100).unwrap();
|
||||
let cells = read_cells_scoped(&catalog, Some(Scope::Collection(coll)), &filter, 0, 100).unwrap();
|
||||
assert_eq!(cells.len(), 2);
|
||||
for (i, cell) in cells.iter().enumerate() {
|
||||
assert_eq!(
|
||||
ordinal_of_path(&catalog, Some(coll), &filter, false, &cell.remote_path).unwrap(),
|
||||
ordinal_of_path(&catalog, Some(Scope::Collection(coll)), &filter, false, &cell.remote_path).unwrap(),
|
||||
Some(i),
|
||||
"{} is drawn at row {i} of the collection",
|
||||
cell.remote_path
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
ordinal_of_path(&catalog, Some(coll), &filter, false, "2019/a.CR2").unwrap(),
|
||||
ordinal_of_path(&catalog, Some(Scope::Collection(coll)), &filter, false, "2019/a.CR2").unwrap(),
|
||||
None,
|
||||
"not a member"
|
||||
);
|
||||
|
||||
@@ -49,6 +49,18 @@ pub use thumbnails_fetch::*;
|
||||
pub use thumbnails_gen::*;
|
||||
pub use xmp::*;
|
||||
|
||||
/// What narrows the grid, when something does.
|
||||
///
|
||||
/// A collection is a set of originals the photographer files; an album is the
|
||||
/// photographs whose exports went into one folder. The grid reads either the
|
||||
/// same way — see `spans::scope_clause`, the one place the difference is
|
||||
/// spelled — and the whole library is `None`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Scope {
|
||||
Collection(dr_types::CollectionId),
|
||||
Album(dr_catalog::AlbumId),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
//! Fixtures shared with the modules that build on this one's catalog
|
||||
|
||||
+100
-55
@@ -3,6 +3,8 @@
|
||||
|
||||
use dr_catalog::Catalog;
|
||||
|
||||
use super::Scope;
|
||||
|
||||
use super::cells::uncollapsed;
|
||||
#[cfg(test)]
|
||||
use super::cells::{read_cells_all, read_cells_scoped, read_ids_span};
|
||||
@@ -16,36 +18,26 @@ use super::filters::{RatingFilter, GRID_ORDER, VISIBLE};
|
||||
/// the user scrolling through empty rows.
|
||||
pub fn total_images_scoped(
|
||||
catalog: &Catalog,
|
||||
scope: Option<dr_types::CollectionId>,
|
||||
scope: Option<Scope>,
|
||||
filter: &RatingFilter,
|
||||
) -> Result<usize, dr_catalog::CatalogError> {
|
||||
let Some(scope) = scope else {
|
||||
if scope.is_none() {
|
||||
return total_images_filtered(catalog, filter);
|
||||
};
|
||||
|
||||
let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?;
|
||||
let placeholders = std::iter::repeat_n("?", ids.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
}
|
||||
let (clause, params) = scope_clause(catalog, scope)?;
|
||||
let rated = filter.sql();
|
||||
// DISTINCT: an image in both a parent and a child is one photograph, and a
|
||||
// count that disagrees with the number of cells drawn is worse than either
|
||||
// number alone.
|
||||
//
|
||||
// Counted through `images` rather than over `collection_members` alone, so
|
||||
// Counted through `images` rather than over the membership table alone, so
|
||||
// `VISIBLE` applies — a trashed photograph is still a member row, and
|
||||
// counting it made the header claim images the grid would not draw.
|
||||
let folded = uncollapsed("i");
|
||||
let sql = format!(
|
||||
"SELECT count(DISTINCT i.id) FROM images i
|
||||
WHERE {VISIBLE}{rated}{folded}
|
||||
AND i.id IN (SELECT image_id FROM collection_members
|
||||
WHERE collection_id IN ({placeholders}))"
|
||||
WHERE {VISIBLE}{rated}{folded}{clause}"
|
||||
);
|
||||
let params: Vec<rusqlite::types::Value> = ids
|
||||
.iter()
|
||||
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
|
||||
.collect();
|
||||
let n: i64 =
|
||||
catalog
|
||||
.connection()
|
||||
@@ -162,9 +154,11 @@ pub fn reordered(
|
||||
/// `collection_members`' primary key, which is `(collection_id, image_id)`.
|
||||
pub(super) fn grid_order_for(
|
||||
catalog: &Catalog,
|
||||
scope: Option<dr_types::CollectionId>,
|
||||
scope: Option<Scope>,
|
||||
) -> (String, Vec<rusqlite::types::Value>) {
|
||||
let Some(id) = scope else {
|
||||
// An album has no order of its own to keep: it is a record of what was
|
||||
// exported, and capture time is how its photographs read best.
|
||||
let Some(Scope::Collection(id)) = scope else {
|
||||
return (GRID_ORDER.to_string(), Vec::new());
|
||||
};
|
||||
|
||||
@@ -195,32 +189,47 @@ pub(super) fn grid_order_for(
|
||||
)
|
||||
}
|
||||
|
||||
/// The SQL restricting a query to `scope` and its descendants, with the bound
|
||||
/// parameters to go with it.
|
||||
/// The SQL restricting a query to `scope`, with the bound parameters to go
|
||||
/// with it: a collection and its descendants, or the photographs an album's
|
||||
/// files were exported from.
|
||||
///
|
||||
/// Shared by the span and the histogram so the two cannot drift: an axis drawn
|
||||
/// over one set of images and bars counted over another puts the bars in the
|
||||
/// wrong place.
|
||||
/// Every scoped reader goes through this, so the count, the cells, the span
|
||||
/// and the histogram cannot drift: an axis drawn over one set of images and
|
||||
/// bars counted over another puts the bars in the wrong place, and a header
|
||||
/// count that disagrees with the cells drawn leaves empty rows to scroll.
|
||||
pub(super) fn scope_clause(
|
||||
catalog: &Catalog,
|
||||
scope: Option<dr_types::CollectionId>,
|
||||
scope: Option<Scope>,
|
||||
) -> Result<(String, Vec<rusqlite::types::Value>), dr_catalog::CatalogError> {
|
||||
let Some(scope) = scope else {
|
||||
return Ok((String::new(), Vec::new()));
|
||||
};
|
||||
let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?;
|
||||
let placeholders = std::iter::repeat_n("?", ids.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
Ok((
|
||||
format!(
|
||||
" AND i.id IN (SELECT image_id FROM collection_members
|
||||
WHERE collection_id IN ({placeholders}))"
|
||||
),
|
||||
ids.iter()
|
||||
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
|
||||
.collect(),
|
||||
))
|
||||
match scope {
|
||||
None => Ok((String::new(), Vec::new())),
|
||||
Some(Scope::Collection(scope)) => {
|
||||
let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?;
|
||||
// Placeholders are generated from the *count* of ids, never from
|
||||
// user text.
|
||||
let placeholders = std::iter::repeat_n("?", ids.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
Ok((
|
||||
format!(
|
||||
" AND i.id IN (SELECT image_id FROM collection_members
|
||||
WHERE collection_id IN ({placeholders}))"
|
||||
),
|
||||
ids.iter()
|
||||
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
Some(Scope::Album(album)) => {
|
||||
// The table exists only once an album has; an album scope with no
|
||||
// table behind it has nothing in it, and the query must still run.
|
||||
dr_catalog::albums::ensure_tables(catalog.connection())?;
|
||||
Ok((
|
||||
" AND i.id IN (SELECT image_id FROM album_exports WHERE album_id = ?)".to_string(),
|
||||
vec![rusqlite::types::Value::Integer(album.0 as i64)],
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Earliest and latest capture time within `scope`, honouring the filter.
|
||||
@@ -231,7 +240,7 @@ pub(super) fn scope_clause(
|
||||
/// collection's bars crushed into a sliver of it.
|
||||
pub fn span_scoped(
|
||||
catalog: &Catalog,
|
||||
scope: Option<dr_types::CollectionId>,
|
||||
scope: Option<Scope>,
|
||||
filter: &RatingFilter,
|
||||
) -> Option<(i64, i64)> {
|
||||
let (clause, params) = scope_clause(catalog, scope).ok()?;
|
||||
@@ -281,7 +290,7 @@ pub fn span_scoped(
|
||||
/// SQL, and the caller's number comes from a hand-editable settings file.
|
||||
pub fn timeline_uniform(
|
||||
catalog: &Catalog,
|
||||
scope: Option<dr_types::CollectionId>,
|
||||
scope: Option<Scope>,
|
||||
filter: &RatingFilter,
|
||||
from: i64,
|
||||
to: i64,
|
||||
@@ -400,6 +409,42 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::library::test_support::*;
|
||||
|
||||
#[test]
|
||||
fn an_album_scopes_the_grid_to_what_was_exported_into_it() {
|
||||
use dr_catalog::albums::{self, Place};
|
||||
|
||||
let catalog = with_images(10);
|
||||
let ids = image_ids(&catalog);
|
||||
let album = albums::create(catalog.connection(), "Web", &Place::Local("/out".into()))
|
||||
.unwrap();
|
||||
// Two files from one photograph, one from another: two cells.
|
||||
albums::record_exports(
|
||||
catalog.connection(),
|
||||
album,
|
||||
&[
|
||||
(ids[1], "a.jpg".into()),
|
||||
(ids[1], "a-crop.jpg".into()),
|
||||
(ids[7], "b.jpg".into()),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let scope = Some(Scope::Album(album));
|
||||
let cells = read_cells_scoped(&catalog, scope, &RatingFilter::default(), 0, 120).unwrap();
|
||||
assert_eq!(cells.len(), 2);
|
||||
assert_eq!(
|
||||
total_images_scoped(&catalog, scope, &RatingFilter::default()).unwrap(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
read_ids_span(&catalog, scope, &RatingFilter::default(), false, 0, 1)
|
||||
.unwrap()
|
||||
.len(),
|
||||
2,
|
||||
"a shift-click over the album selects what the grid drew"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scoped_grid_shows_only_that_collections_images() {
|
||||
use dr_catalog::collections::{self as coll, CollectionKind};
|
||||
@@ -415,10 +460,10 @@ mod tests {
|
||||
.unwrap();
|
||||
coll::add_images(catalog.connection(), c, &ids[2..5]).unwrap();
|
||||
|
||||
let cells = read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 0, 120).unwrap();
|
||||
let cells = read_cells_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default(), 0, 120).unwrap();
|
||||
assert_eq!(cells.len(), 3);
|
||||
assert_eq!(
|
||||
total_images_scoped(&catalog, Some(c), &RatingFilter::default()).unwrap(),
|
||||
total_images_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default()).unwrap(),
|
||||
3
|
||||
);
|
||||
// Unscoped is still the whole library.
|
||||
@@ -449,10 +494,10 @@ mod tests {
|
||||
|
||||
// The parent itself has no direct members at all.
|
||||
let cells =
|
||||
read_cells_scoped(&catalog, Some(trips), &RatingFilter::default(), 0, 120).unwrap();
|
||||
read_cells_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default(), 0, 120).unwrap();
|
||||
assert_eq!(cells.len(), 4, "the set shows what its children hold");
|
||||
assert_eq!(
|
||||
total_images_scoped(&catalog, Some(trips), &RatingFilter::default()).unwrap(),
|
||||
total_images_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default()).unwrap(),
|
||||
4
|
||||
);
|
||||
}
|
||||
@@ -478,10 +523,10 @@ mod tests {
|
||||
coll::add_images(catalog.connection(), iceland, &ids[0..3]).unwrap();
|
||||
|
||||
let cells =
|
||||
read_cells_scoped(&catalog, Some(trips), &RatingFilter::default(), 0, 120).unwrap();
|
||||
read_cells_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default(), 0, 120).unwrap();
|
||||
assert_eq!(cells.len(), 3, "images 0..3, each once");
|
||||
assert_eq!(
|
||||
total_images_scoped(&catalog, Some(trips), &RatingFilter::default()).unwrap(),
|
||||
total_images_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default()).unwrap(),
|
||||
3
|
||||
);
|
||||
}
|
||||
@@ -497,7 +542,7 @@ mod tests {
|
||||
let c = coll::create(catalog.connection(), "Big", None, CollectionKind::Manual).unwrap();
|
||||
coll::add_images(catalog.connection(), c, &ids).unwrap();
|
||||
|
||||
let page = read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 10, 5).unwrap();
|
||||
let page = read_cells_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default(), 10, 5).unwrap();
|
||||
assert_eq!(page.len(), 5);
|
||||
assert_eq!(page[0].name, "img010.CR2");
|
||||
}
|
||||
@@ -512,12 +557,12 @@ mod tests {
|
||||
let c = coll::create(catalog.connection(), "Empty", None, CollectionKind::Manual).unwrap();
|
||||
|
||||
assert!(
|
||||
read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 0, 120)
|
||||
read_cells_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default(), 0, 120)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
total_images_scoped(&catalog, Some(c), &RatingFilter::default()).unwrap(),
|
||||
total_images_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default()).unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
@@ -626,7 +671,7 @@ mod tests {
|
||||
let wanted: Vec<_> = all.iter().rev().copied().collect();
|
||||
dr_catalog::collections::set_order(catalog.connection(), id, &wanted).unwrap();
|
||||
|
||||
let cells = read_cells_scoped(&catalog, Some(id), &RatingFilter::default(), 0, 50).unwrap();
|
||||
let cells = read_cells_scoped(&catalog, Some(Scope::Collection(id)), &RatingFilter::default(), 0, 50).unwrap();
|
||||
let drawn: Vec<_> = cells
|
||||
.iter()
|
||||
.map(|c| dr_types::ImageId(c.image_id as u64))
|
||||
@@ -634,7 +679,7 @@ mod tests {
|
||||
assert_eq!(drawn, wanted, "the grid draws the order that was written");
|
||||
|
||||
let spanned =
|
||||
read_ids_span(&catalog, Some(id), &RatingFilter::default(), false, 0, 4).unwrap();
|
||||
read_ids_span(&catalog, Some(Scope::Collection(id)), &RatingFilter::default(), false, 0, 4).unwrap();
|
||||
assert_eq!(spanned, wanted, "and a range resolves through the same one");
|
||||
|
||||
assert_eq!(
|
||||
@@ -671,7 +716,7 @@ mod tests {
|
||||
dr_catalog::collections::set_order(catalog.connection(), parent, &reversed).unwrap();
|
||||
|
||||
let cells =
|
||||
read_cells_scoped(&catalog, Some(parent), &RatingFilter::default(), 0, 50).unwrap();
|
||||
read_cells_scoped(&catalog, Some(Scope::Collection(parent)), &RatingFilter::default(), 0, 50).unwrap();
|
||||
let drawn: Vec<_> = cells
|
||||
.iter()
|
||||
.map(|c| dr_types::ImageId(c.image_id as u64))
|
||||
@@ -691,7 +736,7 @@ mod tests {
|
||||
dr_catalog::collections::CollectionKind::Smart,
|
||||
)
|
||||
.unwrap();
|
||||
let (order, params) = grid_order_for(&catalog, Some(id));
|
||||
let (order, params) = grid_order_for(&catalog, Some(Scope::Collection(id)));
|
||||
assert_eq!(order, GRID_ORDER);
|
||||
assert!(params.is_empty());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user