diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index e724fee..bd67397 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -47,16 +47,17 @@ use crate::devices::{Device, DeviceMetadata, DeviceType}; use crate::dtos::{ AcceptInvitationRequest, AddPhotoToAlbumEntry, AddPhotosToAlbumRequest, AddPhotosToAlbumResponse, AggregateLinksResponse, AlbumCreationLink, AlbumCreationRequest, - AlbumCreationResponse, AlbumItemListResponse, AlbumListResponse, BlockCreationRequest, - BlockDto, BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockUploadTarget, - BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, BookmarksResponse, - CommonExtendedAttributes, ContextShareResponse, CopyPhotoContent, CopyPhotoRelatedItem, - CopyPhotoRequest, CopyPhotoResponse, CreateBookmarkRequest, CreatePublicLinkRequest, - CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse, DeviceCreationDeviceDto, - DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto, - DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes, - ExternalInvitationDto, ExternalInvitationResponseDto, ExternalInvitationsResponse, - FileContentDigests, FileCreationRequest, FileCreationResponse, FindPhotoDuplicatesRequest, + AlbumCreationResponse, AlbumItemListResponse, AlbumListResponse, AlbumUpdateLink, + AlbumUpdateRequest, BlockCreationRequest, BlockDto, BlockUploadPreparationRequest, + BlockUploadPreparationResponse, BlockUploadTarget, BlockVerificationInputResponse, + BlockVerifier, BookmarkShareUrlDto, BookmarksResponse, CommonExtendedAttributes, + ContextShareResponse, CopyPhotoContent, CopyPhotoRelatedItem, CopyPhotoRequest, + CopyPhotoResponse, CreateBookmarkRequest, CreatePublicLinkRequest, CreatePublicLinkResponse, + CreateShareRequest, CreateShareResponse, DeviceCreationDeviceDto, DeviceCreationLinkDto, + DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto, DeviceListResponse, + DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes, ExternalInvitationDto, + ExternalInvitationResponseDto, ExternalInvitationsResponse, FileContentDigests, + FileCreationRequest, FileCreationResponse, FindPhotoDuplicatesRequest, FindPhotoDuplicatesResponse, FolderChildrenResponse, FolderCreationRequest, FolderCreationResponse, InvitationDetailsResponse, InvitationsListResponse, InviteEmailDetailsDto, InviteExternalUserRequest, InviteExternalUserResponse, @@ -64,16 +65,17 @@ use crate::dtos::{ LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse, LinkDto, LinkType, ModulusResponse, MoveLinkRequest, MoveMultipleLinksItem, MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse, NodeNameAvailabilityRequest, - NodeNameAvailabilityResponse, PhotoTagsRequest, PhotosAttributesDto, RenameLinkRequest, - RevisionConflict, RevisionCreationRequest, RevisionCreationResponse, RevisionDto, - RevisionListItemDto, RevisionListResponse, RevisionMetadataResponse, RevisionUpdateRequest, - ShareInvitationDto, ShareInvitationsResponse, ShareMembersResponse, ShareMembershipSummaryDto, - ShareResponse, ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedAlbumsResponse, - SharedByMeResponse, SharedWithMeResponse, SmallFileUploadMetadataRequest, - SmallRevisionUploadMetadataRequest, SmallUploadResponse, ThumbnailBlockListRequest, - ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, - TransferPhotoLinkItem, TransferPhotosRequest, TransferPhotosResponse, UpdatePermissionsRequest, - VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse, + NodeNameAvailabilityResponse, PhotoTagsRequest, PhotosAttributesDto, + RemovePhotosFromAlbumRequest, RenameLinkRequest, RevisionConflict, RevisionCreationRequest, + RevisionCreationResponse, RevisionDto, RevisionListItemDto, RevisionListResponse, + RevisionMetadataResponse, RevisionUpdateRequest, ShareInvitationDto, ShareInvitationsResponse, + ShareMembersResponse, ShareMembershipSummaryDto, ShareResponse, ShareTargetType, ShareUrlDto, + ShareUrlsResponse, SharedAlbumsResponse, SharedByMeResponse, SharedWithMeResponse, + SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest, SmallUploadResponse, + ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, + TimelinePhotoListResponse, TransferPhotoLinkItem, TransferPhotosRequest, + TransferPhotosResponse, UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto, + VolumeEventListResponse, VolumeTrashResponse, }; use crate::events::{DriveEvent, DriveEventScopeId}; use crate::node::{ @@ -6293,6 +6295,130 @@ impl ProtonDriveClient { 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)>> { + /// 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 = + 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 /// the originals stop being shared. One outcome per input photo. /// diff --git a/crates/proton-drive-rs/src/dtos.rs b/crates/proton-drive-rs/src/dtos.rs index 63de54c..a12a7c2 100644 --- a/crates/proton-drive-rs/src/dtos.rs +++ b/crates/proton-drive-rs/src/dtos.rs @@ -391,6 +391,39 @@ pub struct AlbumCreationLinkResponse { 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, + #[serde(rename = "Link")] + pub link: Option, +} + +/// 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, +} + /// `POST photos/volumes/{vid}/albums/{lid}/add-multiple` — add photos that /// already live on the album's own volume. Ported from the TypeScript SDK /// (`PhotosAPIService.addPhotosToAlbum`). Each entry re-encrypts one photo's @@ -1665,6 +1698,42 @@ pub struct DeviceUpdateShareDto { mod tests { 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] fn context_share_response_parses_required_share_id() { let response: ContextShareResponse = diff --git a/crates/proton-drive-rs/src/photos.rs b/crates/proton-drive-rs/src/photos.rs index f929799..2064bfb 100644 --- a/crates/proton-drive-rs/src/photos.rs +++ b/crates/proton-drive-rs/src/photos.rs @@ -315,6 +315,39 @@ impl ProtonPhotosClient { 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)>> { + self.drive + .remove_photos_from_album(album_uid, photo_uids) + .await + } + /// The albums on the account's photos volume, as [`NodeUid`]s. /// C# `ProtonPhotosClient.EnumerateAlbumNodeUidsAsync`. Empty when the /// account has no photos volume. Materialize with