Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 65 additions & 28 deletions src/commands/albums.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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(())
}
118 changes: 67 additions & 51 deletions src/commands/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -584,81 +584,97 @@ pub fn parse_location(input: &str) -> Result<GeoPoint> {
}

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(());
}

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(())
Expand Down
45 changes: 39 additions & 6 deletions src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AssetQuery> {
pub async fn to_filter(&self, args: &QueryArgs) -> Result<AssetFilter> {
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,
Expand All @@ -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<AssetQuery> {
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,
Expand All @@ -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<u64> {
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<Album> {
Expand Down