mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
feat(drive): album rename, delete and remove photos
Port AlbumsManager.updateAlbum (name only), deleteAlbum and removePhotos
from the TypeScript SDK: PUT photos/volumes/{vid}/albums/{lid},
DELETE ...?DeleteAlbumPhotos= and POST .../remove-multiple in batches of
ten.
This commit is contained in:
1 parent
1f76f33d49
commit
931a58794b
3 files changed
+248
-20
No files matched your search
@@ -47,16 +47,17 @@ use crate::devices::{Device, DeviceMetadata, DeviceType};
|
|||||||
use crate::dtos::{
|
use crate::dtos::{
|
||||||
AcceptInvitationRequest, AddPhotoToAlbumEntry, AddPhotosToAlbumRequest,
|
AcceptInvitationRequest, AddPhotoToAlbumEntry, AddPhotosToAlbumRequest,
|
||||||
AddPhotosToAlbumResponse, AggregateLinksResponse, AlbumCreationLink, AlbumCreationRequest,
|
AddPhotosToAlbumResponse, AggregateLinksResponse, AlbumCreationLink, AlbumCreationRequest,
|
||||||
AlbumCreationResponse, AlbumItemListResponse, AlbumListResponse, BlockCreationRequest,
|
AlbumCreationResponse, AlbumItemListResponse, AlbumListResponse, AlbumUpdateLink,
|
||||||
BlockDto, BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockUploadTarget,
|
AlbumUpdateRequest, BlockCreationRequest, BlockDto, BlockUploadPreparationRequest,
|
||||||
BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, BookmarksResponse,
|
BlockUploadPreparationResponse, BlockUploadTarget, BlockVerificationInputResponse,
|
||||||
CommonExtendedAttributes, ContextShareResponse, CopyPhotoContent, CopyPhotoRelatedItem,
|
BlockVerifier, BookmarkShareUrlDto, BookmarksResponse, CommonExtendedAttributes,
|
||||||
CopyPhotoRequest, CopyPhotoResponse, CreateBookmarkRequest, CreatePublicLinkRequest,
|
ContextShareResponse, CopyPhotoContent, CopyPhotoRelatedItem, CopyPhotoRequest,
|
||||||
CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse, DeviceCreationDeviceDto,
|
CopyPhotoResponse, CreateBookmarkRequest, CreatePublicLinkRequest, CreatePublicLinkResponse,
|
||||||
DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto,
|
CreateShareRequest, CreateShareResponse, DeviceCreationDeviceDto, DeviceCreationLinkDto,
|
||||||
DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes,
|
DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto, DeviceListResponse,
|
||||||
ExternalInvitationDto, ExternalInvitationResponseDto, ExternalInvitationsResponse,
|
DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes, ExternalInvitationDto,
|
||||||
FileContentDigests, FileCreationRequest, FileCreationResponse, FindPhotoDuplicatesRequest,
|
ExternalInvitationResponseDto, ExternalInvitationsResponse, FileContentDigests,
|
||||||
|
FileCreationRequest, FileCreationResponse, FindPhotoDuplicatesRequest,
|
||||||
FindPhotoDuplicatesResponse, FolderChildrenResponse, FolderCreationRequest,
|
FindPhotoDuplicatesResponse, FolderChildrenResponse, FolderCreationRequest,
|
||||||
FolderCreationResponse, InvitationDetailsResponse, InvitationsListResponse,
|
FolderCreationResponse, InvitationDetailsResponse, InvitationsListResponse,
|
||||||
InviteEmailDetailsDto, InviteExternalUserRequest, InviteExternalUserResponse,
|
InviteEmailDetailsDto, InviteExternalUserRequest, InviteExternalUserResponse,
|
||||||
@@ -64,16 +65,17 @@ use crate::dtos::{
|
|||||||
LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse, LinkDto,
|
LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse, LinkDto,
|
||||||
LinkType, ModulusResponse, MoveLinkRequest, MoveMultipleLinksItem, MoveMultipleLinksRequest,
|
LinkType, ModulusResponse, MoveLinkRequest, MoveMultipleLinksItem, MoveMultipleLinksRequest,
|
||||||
MultipleLinksRequest, MyFilesShareResponse, NodeNameAvailabilityRequest,
|
MultipleLinksRequest, MyFilesShareResponse, NodeNameAvailabilityRequest,
|
||||||
NodeNameAvailabilityResponse, PhotoTagsRequest, PhotosAttributesDto, RenameLinkRequest,
|
NodeNameAvailabilityResponse, PhotoTagsRequest, PhotosAttributesDto,
|
||||||
RevisionConflict, RevisionCreationRequest, RevisionCreationResponse, RevisionDto,
|
RemovePhotosFromAlbumRequest, RenameLinkRequest, RevisionConflict, RevisionCreationRequest,
|
||||||
RevisionListItemDto, RevisionListResponse, RevisionMetadataResponse, RevisionUpdateRequest,
|
RevisionCreationResponse, RevisionDto, RevisionListItemDto, RevisionListResponse,
|
||||||
ShareInvitationDto, ShareInvitationsResponse, ShareMembersResponse, ShareMembershipSummaryDto,
|
RevisionMetadataResponse, RevisionUpdateRequest, ShareInvitationDto, ShareInvitationsResponse,
|
||||||
ShareResponse, ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedAlbumsResponse,
|
ShareMembersResponse, ShareMembershipSummaryDto, ShareResponse, ShareTargetType, ShareUrlDto,
|
||||||
SharedByMeResponse, SharedWithMeResponse, SmallFileUploadMetadataRequest,
|
ShareUrlsResponse, SharedAlbumsResponse, SharedByMeResponse, SharedWithMeResponse,
|
||||||
SmallRevisionUploadMetadataRequest, SmallUploadResponse, ThumbnailBlockListRequest,
|
SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest, SmallUploadResponse,
|
||||||
ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse,
|
ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto,
|
||||||
TransferPhotoLinkItem, TransferPhotosRequest, TransferPhotosResponse, UpdatePermissionsRequest,
|
TimelinePhotoListResponse, TransferPhotoLinkItem, TransferPhotosRequest,
|
||||||
VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse,
|
TransferPhotosResponse, UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto,
|
||||||
|
VolumeEventListResponse, VolumeTrashResponse,
|
||||||
};
|
};
|
||||||
use crate::events::{DriveEvent, DriveEventScopeId};
|
use crate::events::{DriveEvent, DriveEventScopeId};
|
||||||
use crate::node::{
|
use crate::node::{
|
||||||
@@ -6293,6 +6295,130 @@ impl ProtonDriveClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename the album `album_uid` to `name`.
|
||||||
|
///
|
||||||
|
/// Ported from the TypeScript SDK (`AlbumsManager.updateAlbum` +
|
||||||
|
/// `AlbumsCryptoService.renameAlbum`). An album's name is encrypted to the
|
||||||
|
/// photos root like any child name, so this is a rename sent to the album
|
||||||
|
/// endpoint: the new name encrypted and signed by the photos share's
|
||||||
|
/// membership address, its hash under the root's hash key, and the current
|
||||||
|
/// hash as `OriginalHash`. The cover photo is left alone.
|
||||||
|
pub(crate) async fn rename_album(&self, album_uid: &NodeUid, name: &str) -> Result<()> {
|
||||||
|
let mut timer = self.telemetry.start("rename_album");
|
||||||
|
validate_node_name(name)?;
|
||||||
|
let details = self
|
||||||
|
.get_link_details_ctx(
|
||||||
|
&album_uid.volume_id,
|
||||||
|
std::slice::from_ref(&album_uid.link_id),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let link = details
|
||||||
|
.links
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| ProtonError::invalid_operation(format!("album {album_uid} not found")))?
|
||||||
|
.link;
|
||||||
|
let parent_id = link
|
||||||
|
.parent_id
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| ProtonError::invalid_operation("an album has a parent"))?;
|
||||||
|
let parent_uid = NodeUid::new(album_uid.volume_id.clone(), parent_id);
|
||||||
|
|
||||||
|
let parent_key = self.folder_node_key(&parent_uid).await?;
|
||||||
|
let parent_hash_key = self
|
||||||
|
.parent_hash_key_ctx(&parent_uid, &parent_key, true)
|
||||||
|
.await?;
|
||||||
|
let (_address_id, email, signing_key) = self.photos_membership_address().await?;
|
||||||
|
let original_hash = self
|
||||||
|
.original_name_hash(album_uid, &link, &parent_key, &parent_hash_key)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let request = AlbumUpdateRequest {
|
||||||
|
cover_link_id: None,
|
||||||
|
link: Some(AlbumUpdateLink {
|
||||||
|
name: parent_key.encrypt_and_sign(&signing_key, name.as_bytes(), true, false)?,
|
||||||
|
name_hash: hex::encode(hmac_sha256(&parent_hash_key, name.as_bytes())),
|
||||||
|
original_hash,
|
||||||
|
name_signature_email: email,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let path = format!(
|
||||||
|
"photos/volumes/{}/albums/{}",
|
||||||
|
album_uid.volume_id, album_uid.link_id
|
||||||
|
);
|
||||||
|
let _: proton_sdk::api::ApiResponse = self.http.put(&path, &request).await?;
|
||||||
|
timer.success();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the album `album_uid`.
|
||||||
|
///
|
||||||
|
/// Ported from the TypeScript SDK (`PhotosAPIService.deleteAlbum`). With
|
||||||
|
/// `delete_photos` false the photos stay in the timeline, and the server
|
||||||
|
/// refuses (code 200302) when the album holds photos that exist only in it,
|
||||||
|
/// such as ones saved from a shared album; with it true those are deleted
|
||||||
|
/// together with the album.
|
||||||
|
pub(crate) async fn delete_album(
|
||||||
|
&self,
|
||||||
|
album_uid: &NodeUid,
|
||||||
|
delete_photos: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut timer = self.telemetry.start("delete_album");
|
||||||
|
let path = format!(
|
||||||
|
"photos/volumes/{}/albums/{}?DeleteAlbumPhotos={}",
|
||||||
|
album_uid.volume_id,
|
||||||
|
album_uid.link_id,
|
||||||
|
u8::from(delete_photos)
|
||||||
|
);
|
||||||
|
let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?;
|
||||||
|
timer.success();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take photos out of `album_uid`, one outcome per input photo in input
|
||||||
|
/// order. The photos stay in the timeline.
|
||||||
|
///
|
||||||
|
/// Ported from the TypeScript SDK (`PhotosAPIService.removePhotosFromAlbum`):
|
||||||
|
/// batches of ten, and since the server reports no per-photo result, a
|
||||||
|
/// failed batch fails every photo in it.
|
||||||
|
pub(crate) async fn remove_photos_from_album(
|
||||||
|
&self,
|
||||||
|
album_uid: &NodeUid,
|
||||||
|
photo_uids: &[NodeUid],
|
||||||
|
) -> Result<Vec<(NodeUid, Result<()>)>> {
|
||||||
|
/// Photos per `remove-multiple` request (TS `batchSize`).
|
||||||
|
const REMOVE_BATCH: usize = 10;
|
||||||
|
|
||||||
|
let mut timer = self.telemetry.start("remove_photos_from_album");
|
||||||
|
let path = format!(
|
||||||
|
"photos/volumes/{}/albums/{}/remove-multiple",
|
||||||
|
album_uid.volume_id, album_uid.link_id
|
||||||
|
);
|
||||||
|
let mut outcomes = Vec::with_capacity(photo_uids.len());
|
||||||
|
for batch in photo_uids.chunks(REMOVE_BATCH) {
|
||||||
|
let request = RemovePhotosFromAlbumRequest {
|
||||||
|
link_ids: batch.iter().map(|uid| uid.link_id.clone()).collect(),
|
||||||
|
};
|
||||||
|
let result: Result<proton_sdk::api::ApiResponse> =
|
||||||
|
self.http.post(&path, &request).await;
|
||||||
|
match result {
|
||||||
|
Ok(_) => outcomes.extend(batch.iter().map(|uid| (uid.clone(), Ok(())))),
|
||||||
|
Err(error) => {
|
||||||
|
let message = error.to_string();
|
||||||
|
outcomes.extend(batch.iter().map(|uid| {
|
||||||
|
(
|
||||||
|
uid.clone(),
|
||||||
|
Err(ProtonError::invalid_operation(message.clone())),
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer.success();
|
||||||
|
Ok(outcomes)
|
||||||
|
}
|
||||||
|
|
||||||
/// Copy photos into this account's own timeline, keeping them even after
|
/// Copy photos into this account's own timeline, keeping them even after
|
||||||
/// the originals stop being shared. One outcome per input photo.
|
/// the originals stop being shared. One outcome per input photo.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -391,6 +391,39 @@ pub struct AlbumCreationLinkResponse {
|
|||||||
pub link_id: LinkId,
|
pub link_id: LinkId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `PUT photos/volumes/{vid}/albums/{lid}` — rename an album or change its
|
||||||
|
/// cover. Ported from the TypeScript SDK (`PhotosAPIService.updateAlbum`); a
|
||||||
|
/// `None` field leaves that part of the album as it is.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AlbumUpdateRequest {
|
||||||
|
#[serde(rename = "CoverLinkID")]
|
||||||
|
pub cover_link_id: Option<LinkId>,
|
||||||
|
#[serde(rename = "Link")]
|
||||||
|
pub link: Option<AlbumUpdateLink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The new name of an album, encrypted to the photos root like a rename.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AlbumUpdateLink {
|
||||||
|
#[serde(rename = "Name")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "Hash")]
|
||||||
|
pub name_hash: String,
|
||||||
|
#[serde(rename = "OriginalHash")]
|
||||||
|
pub original_hash: String,
|
||||||
|
#[serde(rename = "NameSignatureEmail")]
|
||||||
|
pub name_signature_email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST photos/volumes/{vid}/albums/{lid}/remove-multiple` — take photos out
|
||||||
|
/// of an album; they stay in the timeline. Ported from the TypeScript SDK
|
||||||
|
/// (`PhotosAPIService.removePhotosFromAlbum`).
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RemovePhotosFromAlbumRequest {
|
||||||
|
#[serde(rename = "LinkIDs")]
|
||||||
|
pub link_ids: Vec<LinkId>,
|
||||||
|
}
|
||||||
|
|
||||||
/// `POST photos/volumes/{vid}/albums/{lid}/add-multiple` — add photos that
|
/// `POST photos/volumes/{vid}/albums/{lid}/add-multiple` — add photos that
|
||||||
/// already live on the album's own volume. Ported from the TypeScript SDK
|
/// already live on the album's own volume. Ported from the TypeScript SDK
|
||||||
/// (`PhotosAPIService.addPhotosToAlbum`). Each entry re-encrypts one photo's
|
/// (`PhotosAPIService.addPhotosToAlbum`). Each entry re-encrypts one photo's
|
||||||
@@ -1665,6 +1698,42 @@ pub struct DeviceUpdateShareDto {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_rename_sends_the_name_and_no_cover() {
|
||||||
|
let request = AlbumUpdateRequest {
|
||||||
|
cover_link_id: None,
|
||||||
|
link: Some(AlbumUpdateLink {
|
||||||
|
name: "armored".to_string(),
|
||||||
|
name_hash: "new".to_string(),
|
||||||
|
original_hash: "old".to_string(),
|
||||||
|
name_signature_email: "me@example.com".to_string(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&request).expect("album update"),
|
||||||
|
serde_json::json!({
|
||||||
|
"CoverLinkID": null,
|
||||||
|
"Link": {
|
||||||
|
"Name": "armored",
|
||||||
|
"Hash": "new",
|
||||||
|
"OriginalHash": "old",
|
||||||
|
"NameSignatureEmail": "me@example.com",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_removal_lists_link_ids() {
|
||||||
|
let request = RemovePhotosFromAlbumRequest {
|
||||||
|
link_ids: vec![LinkId::new("a"), LinkId::new("b")],
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&request).expect("album removal"),
|
||||||
|
serde_json::json!({ "LinkIDs": ["a", "b"] })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn context_share_response_parses_required_share_id() {
|
fn context_share_response_parses_required_share_id() {
|
||||||
let response: ContextShareResponse =
|
let response: ContextShareResponse =
|
||||||
|
|||||||
@@ -315,6 +315,39 @@ impl ProtonPhotosClient {
|
|||||||
self.drive.add_photos_to_album(album_uid, photo_uids).await
|
self.drive.add_photos_to_album(album_uid, photo_uids).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename the album `album_uid` to `name`.
|
||||||
|
///
|
||||||
|
/// Ported from the TypeScript SDK (`AlbumsManager.updateAlbum`). Like
|
||||||
|
/// [`create_album`](Self::create_album), the name is not deduplicated.
|
||||||
|
pub async fn rename_album(&self, album_uid: &NodeUid, name: &str) -> Result<()> {
|
||||||
|
self.drive.rename_album(album_uid, name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the album `album_uid`.
|
||||||
|
///
|
||||||
|
/// Ported from the TypeScript SDK (`AlbumsManager.deleteAlbum`). With
|
||||||
|
/// `delete_photos` false the photos stay in the timeline; the server then
|
||||||
|
/// refuses to delete an album holding photos that exist only in it (API
|
||||||
|
/// code 200302). With it true those photos are deleted too.
|
||||||
|
pub async fn delete_album(&self, album_uid: &NodeUid, delete_photos: bool) -> Result<()> {
|
||||||
|
self.drive.delete_album(album_uid, delete_photos).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take photos out of `album_uid`, one outcome per input photo in input
|
||||||
|
/// order. The photos stay in the timeline; this is not a trash.
|
||||||
|
///
|
||||||
|
/// Ported from the TypeScript SDK (`AlbumsManager.removePhotos`). The
|
||||||
|
/// server answers per batch of ten, so a failure fails its whole batch.
|
||||||
|
pub async fn remove_photos_from_album(
|
||||||
|
&self,
|
||||||
|
album_uid: &NodeUid,
|
||||||
|
photo_uids: &[NodeUid],
|
||||||
|
) -> Result<Vec<(NodeUid, Result<()>)>> {
|
||||||
|
self.drive
|
||||||
|
.remove_photos_from_album(album_uid, photo_uids)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// The albums on the account's photos volume, as [`NodeUid`]s.
|
/// The albums on the account's photos volume, as [`NodeUid`]s.
|
||||||
/// C# `ProtonPhotosClient.EnumerateAlbumNodeUidsAsync`. Empty when the
|
/// C# `ProtonPhotosClient.EnumerateAlbumNodeUidsAsync`. Empty when the
|
||||||
/// account has no photos volume. Materialize with
|
/// account has no photos volume. Materialize with
|
||||||
|
|||||||
Reference in new issue
Block a user