diff --git a/src/commands/albums.rs b/src/commands/albums.rs index 240e545..a665f44 100644 --- a/src/commands/albums.rs +++ b/src/commands/albums.rs @@ -1,6 +1,6 @@ //! Albums, and the links that publish them. -use anyhow::Result; +use anyhow::{bail, Result}; use imogen_sdk::{AlbumCreate, AlbumUpdate, AssetFilter, AssetSelection}; use serde_json::json; @@ -201,24 +201,54 @@ async fn delete(ctx: &Context, reference: &str, yes: bool) -> Result<()> { async fn add(ctx: &Context, reference: &str, assets: &[String], query: &QueryArgs) -> Result<()> { let album = ctx.find_album(reference).await?; - let ids = ctx.select(assets, query, None).await?; - if ids.is_empty() { + + if !assets.is_empty() { + let mut added = 0u64; + let mut skipped = 0u64; + let mut count = 0u64; + for chunk in assets.chunks(500) { + let result = ctx + .client + .albums + .add_assets(&album.id, &AssetSelection::ids(chunk)) + .await?; + added += result.added; + skipped += result.skipped; + count = result.asset_count; + } + return report_added(ctx, &album.name, added, skipped, count); + } + if query.is_empty() { + bail!("Name some asset ids, or give a filter such as --query or --album"); + } + + let filter = ctx.to_filter(query).await?; + let matched = ctx.count(&filter).await?; + if matched == 0 { ctx.out.note("Nothing matched."); return Ok(()); } - let mut added = 0u64; - let mut skipped = 0u64; - let mut count = 0u64; - for chunk in ids.chunks(500) { - let result = ctx - .client - .albums - .add_assets(&album.id, &AssetSelection::ids(chunk)) - .await?; - added += result.added; - skipped += result.skipped; - count = result.asset_count; - } + let result = ctx + .client + .albums + .add_assets( + &album.id, + &AssetSelection { + query: Some(filter), + ..Default::default() + }, + ) + .await?; + report_added( + ctx, + &album.name, + result.added, + result.skipped, + result.asset_count, + ) +} + +fn report_added(ctx: &Context, name: &str, added: u64, skipped: u64, count: u64) -> Result<()> { if ctx.out.is_json() { return ctx.out.json(&json!({ "added": added, @@ -228,8 +258,7 @@ async fn add(ctx: &Context, reference: &str, assets: &[String], query: &QueryArg } ctx.out.note(ctx.out.paint( &format!( - "Added {added} to “{}”{}.", - album.name, + "Added {added} to “{name}”{}.", if skipped > 0 { format!(", {skipped} were already in it") } else { @@ -242,18 +271,26 @@ async fn add(ctx: &Context, reference: &str, assets: &[String], query: &QueryArg } async fn remove(ctx: &Context, reference: &str, assets: &[String]) -> Result<()> { + if assets.is_empty() { + ctx.out.note("Nothing matched."); + return Ok(()); + } let album = ctx.find_album(reference).await?; - let result = ctx - .client - .albums - .remove_assets(&album.id, &AssetSelection::ids(assets)) - .await?; + let mut removed = 0u64; + for chunk in assets.chunks(500) { + let result = ctx + .client + .albums + .remove_assets(&album.id, &AssetSelection::ids(chunk)) + .await?; + removed += result.removed; + } if ctx.out.is_json() { - return ctx.out.json(&result); + return ctx.out.json(&json!({ "removed": removed })); } - ctx.out.note(ctx.out.paint( - &format!("Took {} out of “{}”.", result.removed, album.name), - GREEN, - )); + ctx.out.note( + ctx.out + .paint(&format!("Took {removed} out of “{}”.", album.name), GREEN), + ); Ok(()) } diff --git a/src/commands/assets.rs b/src/commands/assets.rs index f0af1d7..c093c03 100644 --- a/src/commands/assets.rs +++ b/src/commands/assets.rs @@ -584,20 +584,31 @@ pub fn parse_location(input: &str) -> Result { } pub async fn trash(ctx: &Context, args: &TrashArgs) -> Result<()> { - let targets = ctx.select(&args.ids, &args.query, None).await?; - if targets.is_empty() { + if !args.ids.is_empty() { + let mut count = 0u64; + for chunk in args.ids.chunks(500) { + let result = ctx.client.assets.trash(&AssetSelection::ids(chunk)).await?; + count += result.count; + } + return report_count(ctx, count, "moved to the trash"); + } + if args.query.is_empty() { + bail!("Name some asset ids, or give a filter such as --query or --album"); + } + + let filter = ctx.to_filter(&args.query).await?; + let count = ctx.count(&filter).await?; + if count == 0 { ctx.out.note("Nothing matched."); return Ok(()); } - if args.ids.is_empty() - && !ctx.confirm( - &format!( - "Move {} to the trash?", - output::plural(targets.len(), "photograph") - ), - args.yes || ctx.out.is_json(), - )? - { + if !ctx.confirm( + &format!( + "Move {} to the trash?", + output::plural(count as usize, "photograph") + ), + args.yes || ctx.out.is_json(), + )? { ctx.out.note("Left alone."); return Ok(()); } @@ -605,60 +616,65 @@ pub async fn trash(ctx: &Context, args: &TrashArgs) -> Result<()> { let result = ctx .client .assets - .trash(&AssetSelection::ids(&targets)) + .trash(&AssetSelection { + query: Some(filter), + ..Default::default() + }) .await?; - if ctx.out.is_json() { - return ctx.out.json(&result); - } - ctx.out.note(ctx.out.paint( - &format!( - "{} moved to the trash.", - output::plural(result.count as usize, "photograph") - ), - GREEN, - )); - Ok(()) + report_count(ctx, result.count, "moved to the trash") } pub async fn restore(ctx: &Context, args: &RestoreArgs) -> Result<()> { - let targets = if args.ids.is_empty() { - let query = crate::cli::QueryArgs { - trashed: true, - ..Default::default() - }; - let assets = ctx.matching(&query, None).await?; - if assets.is_empty() { - ctx.out.note("The trash is empty."); - return Ok(()); - } - if !ctx.confirm( - &format!( - "Restore all {} from the trash?", - output::plural(assets.len(), "photograph") - ), - args.yes || ctx.out.is_json(), - )? { - ctx.out.note("Left alone."); - return Ok(()); + if !args.ids.is_empty() { + let mut count = 0u64; + for chunk in args.ids.chunks(500) { + let result = ctx + .client + .assets + .restore(&AssetSelection::ids(chunk)) + .await?; + count += result.count; } - assets.into_iter().map(|asset| asset.id).collect() - } else { - args.ids.clone() + return report_count(ctx, count, "restored"); + } + + let filter = AssetFilter { + trashed: Some(true), + ..Default::default() }; + let count = ctx.count(&filter).await?; + if count == 0 { + ctx.out.note("The trash is empty."); + return Ok(()); + } + if !ctx.confirm( + &format!( + "Restore all {} from the trash?", + output::plural(count as usize, "photograph") + ), + args.yes || ctx.out.is_json(), + )? { + ctx.out.note("Left alone."); + return Ok(()); + } let result = ctx .client .assets - .restore(&AssetSelection::ids(&targets)) + .restore(&AssetSelection { + query: Some(filter), + ..Default::default() + }) .await?; + report_count(ctx, result.count, "restored") +} + +fn report_count(ctx: &Context, count: u64, verb: &str) -> Result<()> { if ctx.out.is_json() { - return ctx.out.json(&result); + return ctx.out.json(&json!({ "count": count })); } ctx.out.note(ctx.out.paint( - &format!( - "{} restored.", - output::plural(result.count as usize, "photograph") - ), + &format!("{} {verb}.", output::plural(count as usize, "photograph")), GREEN, )); Ok(()) diff --git a/src/context.rs b/src/context.rs index 9081f6b..69de761 100644 --- a/src/context.rs +++ b/src/context.rs @@ -7,7 +7,8 @@ use std::sync::Arc; use anyhow::{anyhow, bail, Context as _, Result}; use futures::StreamExt; use imogen_sdk::{ - Album, Asset, AssetQuery, AssetSort, AssetType, ClientOptions, ImogenClient, Person, SortOrder, + Album, Asset, AssetFilter, AssetQuery, AssetSort, AssetType, ClientOptions, ImogenClient, + Person, SortOrder, TimelineQuery, }; use crate::auth::ProfileTokens; @@ -107,16 +108,14 @@ impl Context { Ok(collected) } - /// Translates the command-line filters into the API's query, resolving an album given + /// Translates the command-line filters into the API's filter, resolving an album given /// by name into its id on the way through. - pub async fn to_query(&self, args: &QueryArgs) -> Result { + pub async fn to_filter(&self, args: &QueryArgs) -> Result { let album_id = match &args.album { Some(reference) => Some(self.find_album(reference).await?.id), None => None, }; - Ok(AssetQuery { - cursor: None, - limit: None, + Ok(AssetFilter { q: args.query.clone(), r#type: args.r#type.map(|t| match t { MediaType::Image => AssetType::Image, @@ -130,6 +129,25 @@ impl Context { taken_after: args.after.as_deref().map(crate::dates::to_start_of_day), taken_before: args.before.as_deref().map(crate::dates::to_end_of_day), bbox: args.bbox.clone(), + }) + } + + /// The same filters, in the shape a page listing wants rather than a bulk mutation. + pub async fn to_query(&self, args: &QueryArgs) -> Result { + let filter = self.to_filter(args).await?; + Ok(AssetQuery { + cursor: None, + limit: None, + q: filter.q, + r#type: filter.r#type, + album_id: filter.album_id, + person_id: filter.person_id, + favorite: filter.favorite, + archived: filter.archived, + trashed: filter.trashed, + taken_after: filter.taken_after, + taken_before: filter.taken_before, + bbox: filter.bbox, sort: args.sort.map(|s| match s { SortField::CapturedAt => AssetSort::CapturedAt, SortField::CreatedAt => AssetSort::CreatedAt, @@ -142,6 +160,21 @@ impl Context { }) } + /// How many photographs a filter matches, from the timeline's day buckets rather than + /// a walk of every page — the number a confirmation prompt needs, not the assets + /// themselves. + pub async fn count(&self, filter: &AssetFilter) -> Result { + let timeline = self + .client + .assets + .timeline(&TimelineQuery { + covers: None, + filter: filter.clone(), + }) + .await?; + Ok(timeline.buckets.iter().map(|bucket| bucket.count).sum()) + } + /// An album by id, or by enough of its name to be unambiguous. Naming one is what a /// person will actually do; refusing an ambiguous name is better than picking one. pub async fn find_album(&self, reference: &str) -> Result {