mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
validate_node_name, albums, photo tags
This commit is contained in:
1 parent
db66211a4d
commit
3616f15cfc
10 files changed
+830
-43
No files matched your search
@@ -272,6 +272,11 @@ Moving a file or folder within the same volume is an offline cryptographic opera
|
|||||||
| | Incoming Invites | ✅ | List, accept, or reject invitations shared *with* the current user. |
|
| | Incoming Invites | ✅ | List, accept, or reject invitations shared *with* the current user. |
|
||||||
| **Photos** | Photos Timeline | ✅ | `ProtonPhotosClient` maps photostream, timeline enumeration, and photo downloads. |
|
| **Photos** | Photos Timeline | ✅ | `ProtonPhotosClient` maps photostream, timeline enumeration, and photo downloads. |
|
||||||
| | Photo Uploads | ✅ | Uploading photos with `PhotoUploadMetadata` (capture time, tags, grouping). |
|
| | Photo Uploads | ✅ | Uploading photos with `PhotoUploadMetadata` (capture time, tags, grouping). |
|
||||||
|
| | Albums (read) | ✅ | List albums (`enumerate_album_node_uids`) and their photos (`enumerate_album`); album nodes carry `Node::album`. |
|
||||||
|
| | Photo Tags | ✅ | Add/remove classification tags (`update_photos`); `Favorite` on photos in our own timeline. |
|
||||||
|
| | Shared Photos | ✅ | Photos/albums shared *with* us (`enumerate_shared_with_me_node_uids`, `enumerate_shared_with_me_album_uids`). |
|
||||||
|
| | Favorite Shared Photos | ❌ | Needs the photo re-encrypted for our timeline root; not ported. |
|
||||||
|
| | Album Writes | ❌ | Creating albums and adding/removing photos is not ported. |
|
||||||
| | Photos Volume Create| ❌ | Volume creation is not yet ported. |
|
| | Photos Volume Create| ❌ | Volume creation is not yet ported. |
|
||||||
| **Caching** | Pluggable Cache | ✅ | Pluggable entity cache (`with_entity_cache`); keys/secrets remain strictly in memory. |
|
| **Caching** | Pluggable Cache | ✅ | Pluggable entity cache (`with_entity_cache`); keys/secrets remain strictly in memory. |
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -7,8 +7,8 @@ gitignored, so this file is the only durable record of what we last reviewed.
|
|||||||
- **Upstream**: https://github.com/ProtonDriveApps/sdk
|
- **Upstream**: https://github.com/ProtonDriveApps/sdk
|
||||||
- **Reconciled subtree**: `client/cs/src` (was `client/cs/sdk/src` before the
|
- **Reconciled subtree**: `client/cs/src` (was `client/cs/sdk/src` before the
|
||||||
mid-2026 projects reorg; the script watches both)
|
mid-2026 projects reorg; the script watches both)
|
||||||
- **Pinned**: `e0ad37e30ab16dd78899d1f4475fd6760b4c5c43`
|
- **Pinned**: `941aafa55fb179c8ee347c9d0b4660a3fc893f09`
|
||||||
- **Date**: 2026-07-24
|
- **Date**: 2026-07-31
|
||||||
|
|
||||||
> **Note**: upstream force-pushes — the SHAs in rows before 2026-07-14 no longer
|
> **Note**: upstream force-pushes — the SHAs in rows before 2026-07-14 no longer
|
||||||
> exist in its history. Triage by commit subject/date when a pin goes stale.
|
> exist in its history. Triage by commit subject/date when a pin goes stale.
|
||||||
@@ -31,3 +31,4 @@ gitignored, so this file is the only durable record of what we last reviewed.
|
|||||||
| 2026-07-14 | `f2496161` → `2219a42f` | `d15fa9b9`, `bead8f65`, `f6b12911`, `c0940f52`, `b88b8041`, `2eb69c46` | upstream force-pushed + moved the cs subtree, so the old pin was dangling and the script silently reported "up to date" (both fixed). ported: **devices** (`devices.rs` + `enumerate_devices` / `create_device` / `rename_device` / `delete_device`; volume-free `DeviceUid`), **node sharing flags** (`Node::is_shared` / `is_shared_publicly` from the link `Sharing` block), **revision state** (`NodeKind::File::active_revision_state`), **shared-with-me** (`enumerate_shared_with_me_node_uids`, `GET v2/sharedwithme`), **leave shared node** (`leave_shared_node`, `DELETE v2/shares/{sid}/members/{mid}`). no-op: `15fa845e` (non-JSON error bodies — `http.rs` already falls back to a status-only error when the envelope won't parse). diverged deliberately: `9a7f1d02` + `b4dadf02` removed the C# entity cache and its tags; ours stays. cosmetic, not ported: `30ab32c1` / `388d3900` (C# field renames), `1a781ade` (buffer lengths). |
|
| 2026-07-14 | `f2496161` → `2219a42f` | `d15fa9b9`, `bead8f65`, `f6b12911`, `c0940f52`, `b88b8041`, `2eb69c46` | upstream force-pushed + moved the cs subtree, so the old pin was dangling and the script silently reported "up to date" (both fixed). ported: **devices** (`devices.rs` + `enumerate_devices` / `create_device` / `rename_device` / `delete_device`; volume-free `DeviceUid`), **node sharing flags** (`Node::is_shared` / `is_shared_publicly` from the link `Sharing` block), **revision state** (`NodeKind::File::active_revision_state`), **shared-with-me** (`enumerate_shared_with_me_node_uids`, `GET v2/sharedwithme`), **leave shared node** (`leave_shared_node`, `DELETE v2/shares/{sid}/members/{mid}`). no-op: `15fa845e` (non-JSON error bodies — `http.rs` already falls back to a status-only error when the envelope won't parse). diverged deliberately: `9a7f1d02` + `b4dadf02` removed the C# entity cache and its tags; ours stays. cosmetic, not ported: `30ab32c1` / `388d3900` (C# field renames), `1a781ade` (buffer lengths). |
|
||||||
| 2026-07-24 | `1b6ff835` → `e0ad37e3` | `d2ffde20`, `724b6771` | both features were already ported from the TypeScript SDK, so this closed the remaining parity deltas. ported: **shared-by-me on the photos volume** (`SharingOperations.EnumerateSharedNodeUidsAsync` now takes a volume resolver — `page_shared_by_me(volume_id)` + `ProtonPhotosClient::enumerate_shared_node_uids`, empty when the account has no photos volume) and `SharedByMeLinkDto.ContextShareID` (kept `Option` — recent upstream addition, older backends omit it); **per-node move outcomes** (`move_nodes` now returns `Vec<(NodeUid, Result<()>)>` in input order, mirroring the breaking `MoveNodesAsync` result map; per-link envelope codes via the new `aggregate_outcomes`, which `check_aggregate` now sits on). cosmetic, not ported: `AnchorID` retyped `string` → `LinkId` (ours is `String`). diverged deliberately: we keep the batch endpoint `PUT volumes/{vid}/links/move-multiple` — upstream still loops per node behind a `// FIXME: … use the batch move endpoint`; and the drive-side listing keeps the `_by_me` suffix (upstream renamed both to `EnumerateSharedNodeUids`) because `proton-drive-linux` already consumes that name. |
|
| 2026-07-24 | `1b6ff835` → `e0ad37e3` | `d2ffde20`, `724b6771` | both features were already ported from the TypeScript SDK, so this closed the remaining parity deltas. ported: **shared-by-me on the photos volume** (`SharingOperations.EnumerateSharedNodeUidsAsync` now takes a volume resolver — `page_shared_by_me(volume_id)` + `ProtonPhotosClient::enumerate_shared_node_uids`, empty when the account has no photos volume) and `SharedByMeLinkDto.ContextShareID` (kept `Option` — recent upstream addition, older backends omit it); **per-node move outcomes** (`move_nodes` now returns `Vec<(NodeUid, Result<()>)>` in input order, mirroring the breaking `MoveNodesAsync` result map; per-link envelope codes via the new `aggregate_outcomes`, which `check_aggregate` now sits on). cosmetic, not ported: `AnchorID` retyped `string` → `LinkId` (ours is `String`). diverged deliberately: we keep the batch endpoint `PUT volumes/{vid}/links/move-multiple` — upstream still loops per node behind a `// FIXME: … use the batch move endpoint`; and the drive-side listing keeps the `_by_me` suffix (upstream renamed both to `EnumerateSharedNodeUids`) because `proton-drive-linux` already consumes that name. |
|
||||||
| 2026-07-22 | `2219a42f` → `1b6ff835` | `ded1b716`, `071f976f` | ported: **atomic small uploads** for buffered new files and revisions (`DriveSmallFileUpload` is an explicit opt-in because this crate has no remote feature-flag provider), and **photo duplicate detection** using name/content HMAC matching. already covered/no-op: `ee0b3fba` (Rust rename accepts `Option<&str>`), `193c26f1` (Rust in-memory cache already has bounded LRU eviction), `29d359e0` (digest representation refactor retains hex JSON), `5c370289` (C# disposal lifetime only), `95752055` (Photos cache construction only). deliberately not ported: `2bf49aae` / `2590d227` cache storage-format changes; Rust exposes a distinct string repository contract and ships no persistent backend to migrate/reset. |
|
| 2026-07-22 | `2219a42f` → `1b6ff835` | `ded1b716`, `071f976f` | ported: **atomic small uploads** for buffered new files and revisions (`DriveSmallFileUpload` is an explicit opt-in because this crate has no remote feature-flag provider), and **photo duplicate detection** using name/content HMAC matching. already covered/no-op: `ee0b3fba` (Rust rename accepts `Option<&str>`), `193c26f1` (Rust in-memory cache already has bounded LRU eviction), `29d359e0` (digest representation refactor retains hex JSON), `5c370289` (C# disposal lifetime only), `95752055` (Photos cache construction only). deliberately not ported: `2bf49aae` / `2590d227` cache storage-format changes; Rust exposes a distinct string repository contract and ships no persistent backend to migrate/reset. |
|
||||||
|
| 2026-07-31 | `e0ad37e3` → `941aafa5` | `42526703`, `f2fa871b`, `814ae781`, `c528a376` | ported: **node-name validation** (`validate_node_name`: empty or >255 chars rejected before create/rename; counted in `char`s — C# counts UTF-16 units, and neither is the server's own accounting); **album read ops** (`AlbumDto` + `enumerate_album_node_uids` / `enumerate_album`, `GET photos/volumes/{vid}/albums` and `…/albums/{lid}/children?Sort=Captured&Desc=1`, both paged on `AnchorID`; `LinkDetailsDto::folder_properties` reads an album's hash key out of the `Album` block, which also makes `parent_hash_key` work for album children); **photo + album node metadata** (`Node::photo` / `Node::album` — capture time, content hash, main/related photos, tags, album inclusions; photo count, cover photo, last activity — added as optional `Node` fields rather than `NodeKind` variants so persisted node blobs keep deserializing, mirroring how `membership` landed); **photo tag updates** (`update_photos`, `POST`/`DELETE photos/volumes/{vid}/links/{lid}/tags` via the new `ApiHttpClient::delete_with_body`, `POST …/favorite` for photos on our own photos volume, one outcome per input update like `move_nodes`); **shared-with-me photos/albums** (`ShareTargetType::is_photos_item` over `v2/sharedwithme` + the dedicated `GET photos/albums/shared-with-me`, whose rows carry their own `VolumeID`). deferred: `03b1cb7f` **favoriting shared photos** — needs `PhotosCrypto` + `PhotoTransferPayloadBuilder` (name/passphrase session keys rewrapped to the timeline root, related-photo fan-out, batches of 20); our `rewrap_message_to` / `recover_message_session_key` make it feasible, but until it lands `update_photos` fails that one update explicitly instead of silently no-oping. not ported: `4c1dd783` (`IsSharedPublicly` → `IsSharedByUrl` rename — `proton-drive-linux` consumes `is_shared_publicly`), `1477ea45` (C# user-facing error strings; our error text is our own), the second half of `42526703` (server-supplied names validated into a per-node `InvalidNameError` — we have no node error list to put it in). no-op: `82d605b7` (C# `FifoFlexibleSemaphore` cancel race — tokio's `Semaphore` is cancel-safe and drops queued waiters), `8bdd9420` (throw on resume-after-dispose; Rust ownership makes it unreachable), `ca11470f` + `c947fce7` (CExports/interop only). Album *writes* (create, add/remove photos) remain unported — upstream has no C# public API for them either. Live validation pending: every photos endpoint here needs a photos-volume account. |
|
||||||
@@ -166,6 +166,8 @@ mod tests {
|
|||||||
is_shared_publicly: false,
|
is_shared_publicly: false,
|
||||||
signature_email: None,
|
signature_email: None,
|
||||||
membership: None,
|
membership: None,
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
verification: crate::node::NodeVerification::default(),
|
verification: crate::node::NodeVerification::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,35 +44,41 @@ use crate::crypto::{
|
|||||||
};
|
};
|
||||||
use crate::devices::{Device, DeviceMetadata, DeviceType};
|
use crate::devices::{Device, DeviceMetadata, DeviceType};
|
||||||
use crate::dtos::{
|
use crate::dtos::{
|
||||||
AcceptInvitationRequest, AggregateLinksResponse, BlockCreationRequest, BlockDto,
|
AcceptInvitationRequest, AggregateLinksResponse, AlbumItemListResponse, AlbumListResponse,
|
||||||
BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockUploadTarget,
|
BlockCreationRequest, BlockDto, BlockUploadPreparationRequest, BlockUploadPreparationResponse,
|
||||||
BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, BookmarksResponse,
|
BlockUploadTarget, BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto,
|
||||||
CommonExtendedAttributes, ContextShareResponse, CreateBookmarkRequest, CreatePublicLinkRequest,
|
BookmarksResponse, CommonExtendedAttributes, ContextShareResponse, CreateBookmarkRequest,
|
||||||
CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse, DeviceCreationDeviceDto,
|
CreatePublicLinkRequest, CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse,
|
||||||
DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto,
|
DeviceCreationDeviceDto, DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse,
|
||||||
DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes,
|
DeviceCreationShareDto, DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto,
|
||||||
ExternalInvitationDto, ExternalInvitationResponseDto, ExternalInvitationsResponse,
|
ExtendedAttributes, ExternalInvitationDto, ExternalInvitationResponseDto,
|
||||||
FileContentDigests, FileCreationRequest, FileCreationResponse, FindPhotoDuplicatesRequest,
|
ExternalInvitationsResponse, FileContentDigests, FileCreationRequest, FileCreationResponse,
|
||||||
FindPhotoDuplicatesResponse, FolderChildrenResponse, FolderCreationRequest,
|
FindPhotoDuplicatesRequest, FindPhotoDuplicatesResponse, FolderChildrenResponse,
|
||||||
FolderCreationResponse, InvitationDetailsResponse, InvitationsListResponse,
|
FolderCreationRequest, FolderCreationResponse, InvitationDetailsResponse,
|
||||||
InviteEmailDetailsDto, InviteExternalUserRequest, InviteExternalUserResponse,
|
InvitationsListResponse, InviteEmailDetailsDto, InviteExternalUserRequest,
|
||||||
InviteProtonUserInvitationDto, InviteProtonUserRequest, InviteProtonUserResponse,
|
InviteExternalUserResponse, InviteProtonUserInvitationDto, InviteProtonUserRequest,
|
||||||
LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse, LinkDto,
|
InviteProtonUserResponse, LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest,
|
||||||
LinkType, ModulusResponse, MoveLinkRequest, MoveMultipleLinksItem, MoveMultipleLinksRequest,
|
LinkDetailsResponse, LinkDto, LinkType, ModulusResponse, MoveLinkRequest,
|
||||||
MultipleLinksRequest, MyFilesShareResponse, NodeNameAvailabilityRequest,
|
MoveMultipleLinksItem, MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse,
|
||||||
NodeNameAvailabilityResponse, PhotosAttributesDto, RenameLinkRequest, RevisionConflict,
|
NodeNameAvailabilityRequest, NodeNameAvailabilityResponse, PhotoTagsRequest,
|
||||||
RevisionCreationRequest, RevisionCreationResponse, RevisionDto, RevisionListItemDto,
|
PhotosAttributesDto, RenameLinkRequest, RevisionConflict, RevisionCreationRequest,
|
||||||
RevisionListResponse, RevisionMetadataResponse, RevisionResponse, RevisionUpdateRequest,
|
RevisionCreationResponse, RevisionDto, RevisionListItemDto, RevisionListResponse,
|
||||||
ShareInvitationDto, ShareInvitationsResponse, ShareMembersResponse, ShareMembershipSummaryDto,
|
RevisionMetadataResponse, RevisionResponse, RevisionUpdateRequest, ShareInvitationDto,
|
||||||
ShareResponse, ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedByMeResponse,
|
ShareInvitationsResponse, ShareMembersResponse, ShareMembershipSummaryDto, ShareResponse,
|
||||||
|
ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedAlbumsResponse, SharedByMeResponse,
|
||||||
SharedWithMeResponse, SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest,
|
SharedWithMeResponse, SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest,
|
||||||
SmallUploadResponse, ThumbnailBlockListRequest, ThumbnailBlockListResponse,
|
SmallUploadResponse, ThumbnailBlockListRequest, ThumbnailBlockListResponse,
|
||||||
ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, UpdatePermissionsRequest,
|
ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, UpdatePermissionsRequest,
|
||||||
VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse,
|
VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse,
|
||||||
};
|
};
|
||||||
use crate::events::{DriveEvent, DriveEventScopeId};
|
use crate::events::{DriveEvent, DriveEventScopeId};
|
||||||
use crate::node::{FileThumbnail, Node, NodeKind, RevisionState, Thumbnail, ThumbnailType};
|
use crate::node::{
|
||||||
use crate::photos::{PhotoUploadMetadata, PhotosTimelineItem};
|
AlbumProperties, FileThumbnail, Node, NodeKind, PhotoProperties, RevisionState, Thumbnail,
|
||||||
|
ThumbnailType,
|
||||||
|
};
|
||||||
|
use crate::photos::{
|
||||||
|
AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem,
|
||||||
|
};
|
||||||
use crate::revision::{
|
use crate::revision::{
|
||||||
MAX_CONCURRENT_BLOCK_DOWNLOADS, Revision, RevisionReader, digest_and_decrypt_block_blocking,
|
MAX_CONCURRENT_BLOCK_DOWNLOADS, Revision, RevisionReader, digest_and_decrypt_block_blocking,
|
||||||
};
|
};
|
||||||
@@ -178,6 +184,29 @@ enum NodeDetail {
|
|||||||
/// `NodeOperations.GetAvailableNameAsync` `batchSize`).
|
/// `NodeOperations.GetAvailableNameAsync` `batchSize`).
|
||||||
const NAME_AVAILABILITY_BATCH: usize = 10;
|
const NAME_AVAILABILITY_BATCH: usize = 10;
|
||||||
|
|
||||||
|
/// Longest node name the API accepts (C# `NodeOperations.MaxNodeNameLength`).
|
||||||
|
const MAX_NODE_NAME_LENGTH: usize = 255;
|
||||||
|
|
||||||
|
/// Reject a caller-supplied node name before it is encrypted and sent.
|
||||||
|
///
|
||||||
|
/// C# `NodeOperations.ValidateNodeName` (itself mirroring the JS SDK's
|
||||||
|
/// `validateNodeName`), applied on create, rename and rename-during-move. The
|
||||||
|
/// length is counted in `char`s: C# counts UTF-16 code units, which differs only
|
||||||
|
/// for astral-plane characters, and neither matches the server's own byte
|
||||||
|
/// accounting — this is a client-side guard against obviously bad input, not the
|
||||||
|
/// authority.
|
||||||
|
fn validate_node_name(name: &str) -> Result<()> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(ProtonError::invalid_operation("Name must not be empty"));
|
||||||
|
}
|
||||||
|
if name.chars().count() > MAX_NODE_NAME_LENGTH {
|
||||||
|
return Err(ProtonError::invalid_operation(format!(
|
||||||
|
"Name must be {MAX_NODE_NAME_LENGTH} characters long at most"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// High-level Proton Drive client.
|
/// High-level Proton Drive client.
|
||||||
///
|
///
|
||||||
/// Holds an authenticated session plus an [`AccountClient`]. Because Proton's
|
/// Holds an authenticated session plus an [`AccountClient`]. Because Proton's
|
||||||
@@ -1207,7 +1236,7 @@ impl ProtonDriveClient {
|
|||||||
let link = detail.link;
|
let link = detail.link;
|
||||||
let file = detail
|
let file = detail
|
||||||
.file
|
.file
|
||||||
.or(detail.photo)
|
.or(detail.photo.map(|photo| photo.file))
|
||||||
.ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?;
|
.ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?;
|
||||||
|
|
||||||
let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| {
|
let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| {
|
||||||
@@ -1299,7 +1328,7 @@ impl ProtonDriveClient {
|
|||||||
let link = detail.link;
|
let link = detail.link;
|
||||||
let file = detail
|
let file = detail
|
||||||
.file
|
.file
|
||||||
.or(detail.photo)
|
.or(detail.photo.map(|photo| photo.file))
|
||||||
.ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?;
|
.ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?;
|
||||||
|
|
||||||
let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| {
|
let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| {
|
||||||
@@ -1926,7 +1955,7 @@ impl ProtonDriveClient {
|
|||||||
let link = detail.link;
|
let link = detail.link;
|
||||||
let file = detail
|
let file = detail
|
||||||
.file
|
.file
|
||||||
.or(detail.photo)
|
.or(detail.photo.map(|photo| photo.file))
|
||||||
.ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?;
|
.ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?;
|
||||||
|
|
||||||
let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| {
|
let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| {
|
||||||
@@ -2413,6 +2442,7 @@ impl ProtonDriveClient {
|
|||||||
last_modification_time: Option<i64>,
|
last_modification_time: Option<i64>,
|
||||||
) -> Result<NodeUid> {
|
) -> Result<NodeUid> {
|
||||||
let mut timer = self.telemetry.start("create_folder");
|
let mut timer = self.telemetry.start("create_folder");
|
||||||
|
validate_node_name(name)?;
|
||||||
let volume_id = parent_uid.volume_id.clone();
|
let volume_id = parent_uid.volume_id.clone();
|
||||||
|
|
||||||
// Resolve the parent folder key + hash key and the membership address.
|
// Resolve the parent folder key + hash key and the membership address.
|
||||||
@@ -2544,6 +2574,7 @@ impl ProtonDriveClient {
|
|||||||
new_media_type: Option<&str>,
|
new_media_type: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut timer = self.telemetry.start("rename_node");
|
let mut timer = self.telemetry.start("rename_node");
|
||||||
|
validate_node_name(new_name)?;
|
||||||
let details = self
|
let details = self
|
||||||
.get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id))
|
.get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id))
|
||||||
.await?;
|
.await?;
|
||||||
@@ -4959,13 +4990,18 @@ impl ProtonDriveClient {
|
|||||||
for_photos,
|
for_photos,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let folder = details
|
let detail = details
|
||||||
.links
|
.links
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.next()
|
.next()
|
||||||
.and_then(|d| d.folder)
|
|
||||||
.ok_or_else(|| ProtonError::invalid_operation("parent node is not a folder"))?;
|
.ok_or_else(|| ProtonError::invalid_operation("parent node is not a folder"))?;
|
||||||
Ok(parent_key.decrypt_armored_message(&folder.hash_key)?)
|
// An album carries its hash key in the `Album` block instead of `Folder`
|
||||||
|
// (C# builds a synthetic `FolderDto` from `AlbumDto`), and album children
|
||||||
|
// are hashed under it exactly as a folder's are.
|
||||||
|
let folder = detail
|
||||||
|
.folder_properties()
|
||||||
|
.ok_or_else(|| ProtonError::invalid_operation("parent node is not a folder"))?;
|
||||||
|
Ok(parent_key.decrypt_armored_message(folder.hash_key)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the block verification code, validating that the returned content
|
/// Fetch the block verification code, validating that the returned content
|
||||||
@@ -5525,6 +5561,266 @@ impl ProtonDriveClient {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Page the albums on the account's photos volume.
|
||||||
|
///
|
||||||
|
/// Mirrors C# `PhotosNodeOperations.EnumerateAlbumNodeUidsAsync`:
|
||||||
|
/// `GET photos/volumes/{vid}/albums`, paged on `AnchorID`. Empty when the
|
||||||
|
/// account has no photos volume (C# yields nothing rather than erroring).
|
||||||
|
pub(crate) async fn enumerate_album_node_uids(&self) -> Result<Vec<NodeUid>> {
|
||||||
|
let mut timer = self.telemetry.start("enumerate_album_node_uids");
|
||||||
|
if !self.ensure_photos().await? {
|
||||||
|
timer.success();
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let volume_id = self
|
||||||
|
.cache
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.photos_root
|
||||||
|
.clone()
|
||||||
|
.expect("ensure_photos populated the photos root")
|
||||||
|
.volume_id;
|
||||||
|
|
||||||
|
let mut uids = Vec::new();
|
||||||
|
let mut anchor: Option<LinkId> = None;
|
||||||
|
loop {
|
||||||
|
let path = match &anchor {
|
||||||
|
Some(anchor_id) => {
|
||||||
|
format!("photos/volumes/{volume_id}/albums?AnchorID={anchor_id}")
|
||||||
|
}
|
||||||
|
None => format!("photos/volumes/{volume_id}/albums"),
|
||||||
|
};
|
||||||
|
let page: AlbumListResponse = self.http.get(&path).await?;
|
||||||
|
|
||||||
|
for album in &page.albums {
|
||||||
|
uids.push(NodeUid::new(volume_id.clone(), album.id.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
anchor = page.anchor_id.filter(|id| !id.as_str().is_empty());
|
||||||
|
if !page.more || anchor.is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer.success();
|
||||||
|
Ok(uids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page one album's photos, newest capture first.
|
||||||
|
///
|
||||||
|
/// Mirrors C# `PhotosNodeOperations.EnumerateAlbumAsync`:
|
||||||
|
/// `GET photos/volumes/{vid}/albums/{lid}/children?Sort=Captured&Desc=1`,
|
||||||
|
/// paged on `AnchorID`. The album's own volume is used, so an album shared
|
||||||
|
/// with us (on the sharer's photos volume) enumerates the same way.
|
||||||
|
pub(crate) async fn enumerate_album(&self, album_uid: &NodeUid) -> Result<Vec<AlbumItem>> {
|
||||||
|
let mut timer = self.telemetry.start("enumerate_album");
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let mut anchor: Option<LinkId> = None;
|
||||||
|
loop {
|
||||||
|
let base = format!(
|
||||||
|
"photos/volumes/{}/albums/{}/children?Sort=Captured&Desc=1",
|
||||||
|
album_uid.volume_id, album_uid.link_id
|
||||||
|
);
|
||||||
|
let path = match &anchor {
|
||||||
|
Some(anchor_id) => format!("{base}&AnchorID={anchor_id}"),
|
||||||
|
None => base,
|
||||||
|
};
|
||||||
|
let page: AlbumItemListResponse = self.http.get(&path).await?;
|
||||||
|
|
||||||
|
for photo in &page.photos {
|
||||||
|
items.push(AlbumItem {
|
||||||
|
uid: NodeUid::new(album_uid.volume_id.clone(), photo.id.clone()),
|
||||||
|
capture_time: photo.capture_time,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
anchor = page.anchor_id.filter(|id| !id.as_str().is_empty());
|
||||||
|
if !page.more || anchor.is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer.success();
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page the albums other users share with us.
|
||||||
|
///
|
||||||
|
/// Mirrors C# `PhotoOperations.EnumerateSharedWithMeAlbumUidsAsync`:
|
||||||
|
/// `GET photos/albums/shared-with-me`, paged on `AnchorID`. Unlike
|
||||||
|
/// `v2/sharedwithme` each row carries its own `VolumeID` — a shared album
|
||||||
|
/// lives on the sharer's photos volume — and the listing is account-wide, so
|
||||||
|
/// it works even when *we* have no photos volume.
|
||||||
|
pub(crate) async fn enumerate_shared_with_me_album_uids(&self) -> Result<Vec<NodeUid>> {
|
||||||
|
let mut timer = self.telemetry.start("enumerate_shared_with_me_album_uids");
|
||||||
|
let mut uids = Vec::new();
|
||||||
|
let mut anchor: Option<LinkId> = None;
|
||||||
|
loop {
|
||||||
|
let path = match &anchor {
|
||||||
|
Some(anchor_id) => format!("photos/albums/shared-with-me?AnchorID={anchor_id}"),
|
||||||
|
None => "photos/albums/shared-with-me".to_string(),
|
||||||
|
};
|
||||||
|
let page: SharedAlbumsResponse = self.http.get(&path).await?;
|
||||||
|
|
||||||
|
for album in &page.albums {
|
||||||
|
uids.push(NodeUid::new(album.volume_id.clone(), album.link_id.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
anchor = page.anchor_id.filter(|id| !id.as_str().is_empty());
|
||||||
|
if !page.more || anchor.is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer.success();
|
||||||
|
Ok(uids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The photos and albums shared *with* us, from `v2/sharedwithme`.
|
||||||
|
///
|
||||||
|
/// The same listing [`enumerate_shared_with_me_node_uids`](Self::enumerate_shared_with_me_node_uids)
|
||||||
|
/// pages, kept to the target types the Photos client owns (C#
|
||||||
|
/// `ProtonPhotosClient.ShareTargetTypes` = Photo + Album). Exposed through
|
||||||
|
/// [`ProtonPhotosClient`](crate::ProtonPhotosClient).
|
||||||
|
pub(crate) async fn enumerate_photos_shared_with_me_node_uids(&self) -> Result<Vec<NodeUid>> {
|
||||||
|
let mut timer = self
|
||||||
|
.telemetry
|
||||||
|
.start("enumerate_photos_shared_with_me_node_uids");
|
||||||
|
let mut uids = Vec::new();
|
||||||
|
let mut anchor: Option<String> = None;
|
||||||
|
loop {
|
||||||
|
let path = match &anchor {
|
||||||
|
Some(anchor_id) => format!("v2/sharedwithme?AnchorID={anchor_id}"),
|
||||||
|
None => "v2/sharedwithme".to_string(),
|
||||||
|
};
|
||||||
|
let page: SharedWithMeResponse = self.http.get(&path).await?;
|
||||||
|
|
||||||
|
for link in &page.links {
|
||||||
|
if ShareTargetType::from_raw(link.share_target_type)
|
||||||
|
.is_some_and(ShareTargetType::is_photos_item)
|
||||||
|
{
|
||||||
|
uids.push(NodeUid::new(link.volume_id.clone(), link.link_id.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
anchor = page.anchor_id.filter(|id| !id.is_empty());
|
||||||
|
if !page.more || anchor.is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer.success();
|
||||||
|
Ok(uids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply tag additions/removals to photos.
|
||||||
|
///
|
||||||
|
/// Mirrors C# `PhotoOperations.UpdatePhotosAsync`: one outcome per input
|
||||||
|
/// update, in input order, so a photo that fails does not stop the others —
|
||||||
|
/// the outer `Err` is reserved for what makes the whole call impossible.
|
||||||
|
/// `Favorite` is special-cased exactly as upstream: it is *set* through
|
||||||
|
/// `POST photos/volumes/{vid}/links/{lid}/favorite` and only for photos on
|
||||||
|
/// our own photos volume, while every other tag (and every removal,
|
||||||
|
/// `Favorite` included) goes through the tags endpoints.
|
||||||
|
///
|
||||||
|
/// Favoriting a photo on someone else's volume needs the photo — and its
|
||||||
|
/// related photos — re-encrypted for our timeline root and sent as the
|
||||||
|
/// favorite request body (upstream `03b1cb7f`, `PhotoTransferPayloadBuilder`).
|
||||||
|
/// That is not ported, so such an update fails with an explicit error rather
|
||||||
|
/// than silently doing nothing.
|
||||||
|
pub(crate) async fn update_photos(
|
||||||
|
&self,
|
||||||
|
updates: &[PhotoTagsUpdate],
|
||||||
|
) -> Result<Vec<(NodeUid, Result<()>)>> {
|
||||||
|
let mut timer = self.telemetry.start("update_photos");
|
||||||
|
timer.attr("photo_count", updates.len());
|
||||||
|
if updates.is_empty() {
|
||||||
|
timer.success();
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolved once: the volume a bodyless favorite is valid on. `None` when
|
||||||
|
// the account has no photos volume, which makes every favorite fail below.
|
||||||
|
let own_photos_volume = if self.ensure_photos().await? {
|
||||||
|
self.cache
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.photos_root
|
||||||
|
.clone()
|
||||||
|
.map(|root| root.volume_id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut outcomes = Vec::with_capacity(updates.len());
|
||||||
|
for update in updates {
|
||||||
|
let outcome = self
|
||||||
|
.apply_tag_update(update, own_photos_volume.as_ref())
|
||||||
|
.await;
|
||||||
|
outcomes.push((update.node_uid.clone(), outcome));
|
||||||
|
}
|
||||||
|
timer.success();
|
||||||
|
Ok(outcomes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One photo's tag update (C# `PhotoOperations.ApplyTagUpdateAsync`).
|
||||||
|
async fn apply_tag_update(
|
||||||
|
&self,
|
||||||
|
update: &PhotoTagsUpdate,
|
||||||
|
own_photos_volume: Option<&VolumeId>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let volume_id = &update.node_uid.volume_id;
|
||||||
|
let link_id = &update.node_uid.link_id;
|
||||||
|
|
||||||
|
if update.tags_to_add.contains(&PhotoTag::Favorite) {
|
||||||
|
match own_photos_volume {
|
||||||
|
Some(own) if own == volume_id => {
|
||||||
|
let path = format!("photos/volumes/{volume_id}/links/{link_id}/favorite");
|
||||||
|
// An empty object, not `()`: serde renders the unit type as
|
||||||
|
// `null`, which the API rejects.
|
||||||
|
let _: proton_sdk::api::ApiResponse =
|
||||||
|
self.http.post(&path, &serde_json::json!({})).await?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(ProtonError::invalid_operation(
|
||||||
|
"favoriting a photo that is not on this account's photos volume is not supported yet",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `Favorite` has its own endpoint and is never sent as a plain tag.
|
||||||
|
let tags_to_add: Vec<i32> = update
|
||||||
|
.tags_to_add
|
||||||
|
.iter()
|
||||||
|
.filter(|&&tag| tag != PhotoTag::Favorite)
|
||||||
|
.map(|&tag| tag as i32)
|
||||||
|
.collect();
|
||||||
|
if !tags_to_add.is_empty() {
|
||||||
|
let path = format!("photos/volumes/{volume_id}/links/{link_id}/tags");
|
||||||
|
let _: proton_sdk::api::ApiResponse = self
|
||||||
|
.http
|
||||||
|
.post(&path, &PhotoTagsRequest { tags: tags_to_add })
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !update.tags_to_remove.is_empty() {
|
||||||
|
let tags_to_remove: Vec<i32> = update
|
||||||
|
.tags_to_remove
|
||||||
|
.iter()
|
||||||
|
.map(|&tag| tag as i32)
|
||||||
|
.collect();
|
||||||
|
let path = format!("photos/volumes/{volume_id}/links/{link_id}/tags");
|
||||||
|
let _: proton_sdk::api::ApiResponse = self
|
||||||
|
.http
|
||||||
|
.delete_with_body(
|
||||||
|
&path,
|
||||||
|
&PhotoTagsRequest {
|
||||||
|
tags: tags_to_remove,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Download and decrypt a photo's active revision into `output` (photos
|
/// Download and decrypt a photo's active revision into `output` (photos
|
||||||
/// routing). C# `PhotosFileDownloader`: the node is resolved via the photos
|
/// routing). C# `PhotosFileDownloader`: the node is resolved via the photos
|
||||||
/// endpoint; blocks are fetched from their absolute storage URLs exactly as
|
/// endpoint; blocks are fetched from their absolute storage URLs exactly as
|
||||||
@@ -5545,7 +5841,7 @@ impl ProtonDriveClient {
|
|||||||
let link = detail.link;
|
let link = detail.link;
|
||||||
let file = detail
|
let file = detail
|
||||||
.file
|
.file
|
||||||
.or(detail.photo)
|
.or(detail.photo.map(|photo| photo.file))
|
||||||
.ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?;
|
.ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?;
|
||||||
|
|
||||||
let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| {
|
let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| {
|
||||||
@@ -5847,6 +6143,11 @@ impl ProtonDriveClient {
|
|||||||
.clone()
|
.clone()
|
||||||
.map(|pid| NodeUid::new(volume_id.clone(), pid));
|
.map(|pid| NodeUid::new(volume_id.clone(), pid));
|
||||||
|
|
||||||
|
// Album- and photo-only metadata, layered onto the folder/file node the
|
||||||
|
// way C# layers `AlbumNode : FolderNode` / `PhotoNode : FileNode`.
|
||||||
|
let mut album = None;
|
||||||
|
let mut photo = None;
|
||||||
|
|
||||||
let kind = match link.parsed_type() {
|
let kind = match link.parsed_type() {
|
||||||
LinkType::Folder | LinkType::Album => {
|
LinkType::Folder | LinkType::Album => {
|
||||||
let node_key = node_key.as_ref().ok_or_else(|| {
|
let node_key = node_key.as_ref().ok_or_else(|| {
|
||||||
@@ -5858,6 +6159,15 @@ impl ProtonDriveClient {
|
|||||||
.await
|
.await
|
||||||
.folder_keys
|
.folder_keys
|
||||||
.put(uid.clone(), node_key.clone());
|
.put(uid.clone(), node_key.clone());
|
||||||
|
album = details.album.as_ref().map(|album| AlbumProperties {
|
||||||
|
photo_count: album.photo_count,
|
||||||
|
cover_photo_uid: album
|
||||||
|
.cover_link_id
|
||||||
|
.clone()
|
||||||
|
.map(|link_id| NodeUid::new(volume_id.clone(), link_id)),
|
||||||
|
last_activity_time: (album.last_activity_time != 0)
|
||||||
|
.then_some(album.last_activity_time),
|
||||||
|
});
|
||||||
NodeKind::Folder
|
NodeKind::Folder
|
||||||
}
|
}
|
||||||
LinkType::File => {
|
LinkType::File => {
|
||||||
@@ -5927,6 +6237,31 @@ impl ProtonDriveClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
photo = details.photo.as_ref().map(|photo| PhotoProperties {
|
||||||
|
capture_time: photo.capture_time,
|
||||||
|
content_hash: photo.content_hash.clone(),
|
||||||
|
main_photo_uid: photo
|
||||||
|
.main_photo_link_id
|
||||||
|
.clone()
|
||||||
|
.map(|link_id| NodeUid::new(volume_id.clone(), link_id)),
|
||||||
|
related_photo_uids: photo
|
||||||
|
.related_photo_link_ids
|
||||||
|
.iter()
|
||||||
|
.map(|link_id| NodeUid::new(volume_id.clone(), link_id.clone()))
|
||||||
|
.collect(),
|
||||||
|
// An unknown tag discriminant is dropped: the server may add
|
||||||
|
// tags this SDK predates, and that must not fail the node.
|
||||||
|
tags: photo
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&t| PhotoTag::from_raw(t))
|
||||||
|
.collect(),
|
||||||
|
album_uids: photo
|
||||||
|
.album_inclusions
|
||||||
|
.iter()
|
||||||
|
.map(|album| NodeUid::new(volume_id.clone(), album.album_link_id.clone()))
|
||||||
|
.collect(),
|
||||||
|
});
|
||||||
NodeKind::File {
|
NodeKind::File {
|
||||||
media_type: file.media_type.clone(),
|
media_type: file.media_type.clone(),
|
||||||
total_size_on_storage: file.total_size_on_storage,
|
total_size_on_storage: file.total_size_on_storage,
|
||||||
@@ -5967,6 +6302,8 @@ impl ProtonDriveClient {
|
|||||||
// Present only when the node is shared *with* us; it is what says
|
// Present only when the node is shared *with* us; it is what says
|
||||||
// whether we may write to it.
|
// whether we may write to it.
|
||||||
membership: details.membership.as_ref().map(share_membership_from_dto),
|
membership: details.membership.as_ref().map(share_membership_from_dto),
|
||||||
|
photo,
|
||||||
|
album,
|
||||||
verification,
|
verification,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -6700,11 +7037,13 @@ fn drive_items(page: &SharedWithMeResponse) -> Vec<SharedWithMeItem> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
CONTEXT_SHARE_CACHE_CAP, DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP, aggregate_outcomes,
|
CONTEXT_SHARE_CACHE_CAP, DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP,
|
||||||
alternate_names, assemble_manifest, check_aggregate, context_share_path, drive_items,
|
MAX_NODE_NAME_LENGTH, aggregate_outcomes, alternate_names, assemble_manifest,
|
||||||
epoch_to_iso8601, is_expired_upload_token, is_listable_revision_state, is_upload_timeout,
|
check_aggregate, context_share_path, drive_items, epoch_to_iso8601,
|
||||||
join_node_path, path_segments, run_context_share_mutation, share_membership_from_dto,
|
is_expired_upload_token, is_listable_revision_state, is_upload_timeout, join_node_path,
|
||||||
|
path_segments, run_context_share_mutation, share_membership_from_dto,
|
||||||
small_upload_applicable, take_block_target, take_thumbnail_target, to_drive_event,
|
small_upload_applicable, take_block_target, take_thumbnail_target, to_drive_event,
|
||||||
|
validate_node_name,
|
||||||
};
|
};
|
||||||
use crate::dtos::{
|
use crate::dtos::{
|
||||||
AggregateLinksResponse, BlockUploadTarget, LinkIdResponsePair, ShareMembershipSummaryDto,
|
AggregateLinksResponse, BlockUploadTarget, LinkIdResponsePair, ShareMembershipSummaryDto,
|
||||||
@@ -7138,6 +7477,22 @@ mod tests {
|
|||||||
assert!(to_drive_event(&vid, &event(99, None)).is_err());
|
assert!(to_drive_event(&vid, &event(99, None)).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn node_names_are_validated_before_they_are_sent() {
|
||||||
|
// C# `NodeOperations.ValidateNodeName`: empty and over-long names are
|
||||||
|
// rejected client-side rather than round-tripping to a server error.
|
||||||
|
assert!(validate_node_name("report.txt").is_ok());
|
||||||
|
assert!(validate_node_name("").is_err());
|
||||||
|
|
||||||
|
let longest = "a".repeat(MAX_NODE_NAME_LENGTH);
|
||||||
|
assert!(validate_node_name(&longest).is_ok());
|
||||||
|
assert!(validate_node_name(&format!("{longest}a")).is_err());
|
||||||
|
|
||||||
|
// Counted in characters, not bytes: a 255-emoji name is 1020 bytes and
|
||||||
|
// still legal.
|
||||||
|
assert!(validate_node_name(&"🙂".repeat(MAX_NODE_NAME_LENGTH)).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn alternate_names_keeps_extension() {
|
fn alternate_names_keeps_extension() {
|
||||||
let got: Vec<String> = alternate_names("report.txt").take(3).collect();
|
let got: Vec<String> = alternate_names("report.txt").take(3).collect();
|
||||||
|
|||||||
@@ -142,10 +142,15 @@ pub struct LinkDetailsDto {
|
|||||||
#[serde(rename = "File")]
|
#[serde(rename = "File")]
|
||||||
pub file: Option<FileDto>,
|
pub file: Option<FileDto>,
|
||||||
/// Photos-volume `/links` returns file properties under `Photo` (a superset
|
/// Photos-volume `/links` returns file properties under `Photo` (a superset
|
||||||
/// of `File`) rather than `File`. Deserialized as [`FileDto`]; the extra
|
/// of `File`) rather than `File`. C# `linkDetailsDto.File ?? linkDetailsDto.Photo`.
|
||||||
/// photo fields are ignored. C# `linkDetailsDto.File ?? linkDetailsDto.Photo`.
|
|
||||||
#[serde(rename = "Photo")]
|
#[serde(rename = "Photo")]
|
||||||
pub photo: Option<FileDto>,
|
pub photo: Option<PhotoDto>,
|
||||||
|
/// Album properties, for a link of type [`LinkType::Album`]. An album
|
||||||
|
/// decrypts exactly like a folder — the block simply carries the hash key
|
||||||
|
/// under the same name plus the album-only metadata (C# `AlbumDto`, layered
|
||||||
|
/// onto a `FolderNode` by `DtoToMetadataConverter.ConvertAlbumMetadataAsync`).
|
||||||
|
#[serde(rename = "Album")]
|
||||||
|
pub album: Option<AlbumDto>,
|
||||||
/// Present when the node is shared (with members and/or via a public link).
|
/// Present when the node is shared (with members and/or via a public link).
|
||||||
#[serde(rename = "Sharing", default)]
|
#[serde(rename = "Sharing", default)]
|
||||||
pub sharing: Option<LinkSharingDto>,
|
pub sharing: Option<LinkSharingDto>,
|
||||||
@@ -180,8 +185,153 @@ impl LinkDetailsDto {
|
|||||||
/// File properties for a file/photo node, preferring `File` and falling back
|
/// File properties for a file/photo node, preferring `File` and falling back
|
||||||
/// to the photos-volume `Photo` block.
|
/// to the photos-volume `Photo` block.
|
||||||
pub fn file_properties(&self) -> Option<&FileDto> {
|
pub fn file_properties(&self) -> Option<&FileDto> {
|
||||||
self.file.as_ref().or(self.photo.as_ref())
|
self.file
|
||||||
|
.as_ref()
|
||||||
|
.or_else(|| self.photo.as_ref().map(|photo| &photo.file))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Folder properties for a folder/album node. An album's hash key and
|
||||||
|
/// extended attributes live in the `Album` block, which is otherwise a
|
||||||
|
/// superset of `Folder` — C# builds a synthetic `FolderDto` from it.
|
||||||
|
pub fn folder_properties(&self) -> Option<FolderProperties<'_>> {
|
||||||
|
if let Some(folder) = self.folder.as_ref() {
|
||||||
|
return Some(FolderProperties {
|
||||||
|
hash_key: &folder.hash_key,
|
||||||
|
extended_attributes: folder.extended_attributes.as_deref(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.album.as_ref().map(|album| FolderProperties {
|
||||||
|
hash_key: &album.hash_key,
|
||||||
|
extended_attributes: album.extended_attributes.as_deref(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The folder-shaped part of a `Folder` or `Album` block (C#
|
||||||
|
/// `ConvertAlbumMetadataAsync`'s synthetic `FolderDto`).
|
||||||
|
pub struct FolderProperties<'a> {
|
||||||
|
pub hash_key: &'a str,
|
||||||
|
pub extended_attributes: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Photo properties on a photos-volume link (C# `PhotoDto : FileDto`).
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PhotoDto {
|
||||||
|
/// The file properties a photo shares with any other file.
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub file: FileDto,
|
||||||
|
/// Capture time in seconds since the Unix epoch.
|
||||||
|
#[serde(rename = "CaptureTime", default)]
|
||||||
|
pub capture_time: i64,
|
||||||
|
/// Lowercase-hex HMAC of the plaintext SHA-1 under the photos root's hash
|
||||||
|
/// key — the second half of duplicate detection.
|
||||||
|
#[serde(rename = "ContentHash", default)]
|
||||||
|
pub content_hash: Option<String>,
|
||||||
|
/// Set when this photo is a *related* photo (live-photo video, burst
|
||||||
|
/// sibling): the link id of the main photo it belongs to.
|
||||||
|
#[serde(rename = "MainPhotoLinkID", default)]
|
||||||
|
pub main_photo_link_id: Option<LinkId>,
|
||||||
|
/// The related photos of this (main) photo.
|
||||||
|
#[serde(rename = "RelatedPhotosLinkIDs", default)]
|
||||||
|
pub related_photo_link_ids: Vec<LinkId>,
|
||||||
|
/// Classification tags as their `PhotoTag` discriminants; unknown values are
|
||||||
|
/// dropped when the node is built.
|
||||||
|
#[serde(rename = "Tags", default)]
|
||||||
|
pub tags: Vec<i32>,
|
||||||
|
/// The albums this photo has been added to.
|
||||||
|
#[serde(rename = "Albums", default)]
|
||||||
|
pub album_inclusions: Vec<PhotoAlbumInclusionDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One album a photo belongs to (C# `PhotoAlbumInclusionDto`).
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PhotoAlbumInclusionDto {
|
||||||
|
#[serde(rename = "AlbumLinkID")]
|
||||||
|
pub album_link_id: LinkId,
|
||||||
|
#[serde(rename = "AddedTime", default)]
|
||||||
|
pub creation_time: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Album properties on a photos-volume link (C# `AlbumDto`).
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AlbumDto {
|
||||||
|
/// The album's child-name hash key — same field a folder carries.
|
||||||
|
#[serde(rename = "NodeHashKey")]
|
||||||
|
pub hash_key: String,
|
||||||
|
#[serde(rename = "XAttr")]
|
||||||
|
pub extended_attributes: Option<String>,
|
||||||
|
#[serde(rename = "PhotoCount", default)]
|
||||||
|
pub photo_count: i32,
|
||||||
|
#[serde(rename = "CoverLinkID", default)]
|
||||||
|
pub cover_link_id: Option<LinkId>,
|
||||||
|
/// Epoch seconds of the last change to the album's contents.
|
||||||
|
#[serde(rename = "LastActivityTime", default)]
|
||||||
|
pub last_activity_time: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET photos/volumes/{vid}/albums` — the albums on a photos volume, paged on
|
||||||
|
/// `AnchorID` (C# `AlbumListResponse`).
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AlbumListResponse {
|
||||||
|
#[serde(rename = "Albums", default)]
|
||||||
|
pub albums: Vec<AlbumListItemDto>,
|
||||||
|
#[serde(rename = "More", default)]
|
||||||
|
pub more: bool,
|
||||||
|
#[serde(rename = "AnchorID", default)]
|
||||||
|
pub anchor_id: Option<LinkId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AlbumListItemDto {
|
||||||
|
#[serde(rename = "LinkID")]
|
||||||
|
pub id: LinkId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET photos/volumes/{vid}/albums/{lid}/children` — an album's photos, newest
|
||||||
|
/// capture first, paged on `AnchorID` (C# `AlbumItemListResponse`).
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AlbumItemListResponse {
|
||||||
|
#[serde(rename = "Photos", default)]
|
||||||
|
pub photos: Vec<AlbumItemDto>,
|
||||||
|
#[serde(rename = "More", default)]
|
||||||
|
pub more: bool,
|
||||||
|
#[serde(rename = "AnchorID", default)]
|
||||||
|
pub anchor_id: Option<LinkId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AlbumItemDto {
|
||||||
|
#[serde(rename = "LinkID")]
|
||||||
|
pub id: LinkId,
|
||||||
|
#[serde(rename = "CaptureTime", default)]
|
||||||
|
pub capture_time: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET photos/albums/shared-with-me` — albums other users share with us. Unlike
|
||||||
|
/// `v2/sharedwithme` each row carries its own volume (C# `SharedAlbumsResponse`).
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SharedAlbumsResponse {
|
||||||
|
#[serde(rename = "Albums", default)]
|
||||||
|
pub albums: Vec<SharedAlbumDto>,
|
||||||
|
#[serde(rename = "More", default)]
|
||||||
|
pub more: bool,
|
||||||
|
#[serde(rename = "AnchorID", default)]
|
||||||
|
pub anchor_id: Option<LinkId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SharedAlbumDto {
|
||||||
|
#[serde(rename = "VolumeID")]
|
||||||
|
pub volume_id: VolumeId,
|
||||||
|
#[serde(rename = "LinkID")]
|
||||||
|
pub link_id: LinkId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST`/`DELETE photos/volumes/{vid}/links/{lid}/tags` (C# `PhotoTagsRequest`).
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct PhotoTagsRequest {
|
||||||
|
#[serde(rename = "Tags")]
|
||||||
|
pub tags: Vec<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -1136,6 +1286,12 @@ impl ShareTargetType {
|
|||||||
pub fn is_drive_item(self) -> bool {
|
pub fn is_drive_item(self) -> bool {
|
||||||
matches!(self, Self::Folder | Self::File | Self::ProtonVendor)
|
matches!(self, Self::Folder | Self::File | Self::ProtonVendor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the Photos client owns this kind (C#
|
||||||
|
/// `ProtonPhotosClient.ShareTargetTypes`).
|
||||||
|
pub fn is_photos_item(self) -> bool {
|
||||||
|
matches!(self, Self::Album | Self::Photo)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET devices` — the account's registered devices. C# `DeviceListResponse`.
|
/// `GET devices` — the account's registered devices. C# `DeviceListResponse`.
|
||||||
@@ -1275,6 +1431,119 @@ mod tests {
|
|||||||
assert_eq!(response.context_share_id, ShareId::new("context-share-1"));
|
assert_eq!(response.context_share_id, ShareId::new("context-share-1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_link_details_expose_folder_properties() {
|
||||||
|
// An album's hash key arrives in the `Album` block, not `Folder`; the
|
||||||
|
// rest of the client reads it through `folder_properties` (C# builds a
|
||||||
|
// synthetic `FolderDto` from `AlbumDto`).
|
||||||
|
let raw = r#"{
|
||||||
|
"Link": {
|
||||||
|
"LinkID": "album-1", "Type": 3, "ParentLinkID": "root", "State": 1,
|
||||||
|
"CreateTime": 1, "ModifyTime": 2, "Name": "enc", "NodeKey": "k",
|
||||||
|
"NodePassphrase": "p"
|
||||||
|
},
|
||||||
|
"Album": {
|
||||||
|
"NodeHashKey": "hash-key", "XAttr": "xattr", "PhotoCount": 7,
|
||||||
|
"CoverLinkID": "photo-9", "LastActivityTime": 1700000000
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let details: LinkDetailsDto = serde_json::from_str(raw).unwrap();
|
||||||
|
let album = details.album.as_ref().expect("album block");
|
||||||
|
assert_eq!(album.photo_count, 7);
|
||||||
|
assert_eq!(album.cover_link_id.as_ref().unwrap().as_str(), "photo-9");
|
||||||
|
assert_eq!(album.last_activity_time, 1_700_000_000);
|
||||||
|
|
||||||
|
let folder = details
|
||||||
|
.folder_properties()
|
||||||
|
.expect("album reads as a folder");
|
||||||
|
assert_eq!(folder.hash_key, "hash-key");
|
||||||
|
assert_eq!(folder.extended_attributes, Some("xattr"));
|
||||||
|
assert!(matches!(details.link.parsed_type(), LinkType::Album));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn photo_link_details_carry_file_and_photo_properties() {
|
||||||
|
// The photos volume returns file properties under `Photo` — a superset
|
||||||
|
// of `File` — so both halves have to come out of the one block.
|
||||||
|
let raw = r#"{
|
||||||
|
"Link": {
|
||||||
|
"LinkID": "photo-1", "Type": 2, "ParentLinkID": "root", "State": 1,
|
||||||
|
"CreateTime": 1, "ModifyTime": 2, "Name": "enc", "NodeKey": "k",
|
||||||
|
"NodePassphrase": "p"
|
||||||
|
},
|
||||||
|
"Photo": {
|
||||||
|
"MediaType": "image/jpeg", "TotalEncryptedSize": 42,
|
||||||
|
"ContentKeyPacket": "packet",
|
||||||
|
"CaptureTime": 1700000000, "ContentHash": "abcd",
|
||||||
|
"MainPhotoLinkID": "photo-main",
|
||||||
|
"RelatedPhotosLinkIDs": ["photo-2"],
|
||||||
|
"Tags": [0, 2, 99],
|
||||||
|
"Albums": [{ "AlbumLinkID": "album-1", "AddedTime": 1700000001 }]
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let details: LinkDetailsDto = serde_json::from_str(raw).unwrap();
|
||||||
|
let file = details.file_properties().expect("file properties");
|
||||||
|
assert_eq!(file.media_type, "image/jpeg");
|
||||||
|
assert_eq!(file.total_size_on_storage, 42);
|
||||||
|
|
||||||
|
let photo = details.photo.as_ref().expect("photo block");
|
||||||
|
assert_eq!(photo.capture_time, 1_700_000_000);
|
||||||
|
assert_eq!(photo.content_hash.as_deref(), Some("abcd"));
|
||||||
|
assert_eq!(
|
||||||
|
photo.main_photo_link_id.as_ref().unwrap().as_str(),
|
||||||
|
"photo-main"
|
||||||
|
);
|
||||||
|
assert_eq!(photo.related_photo_link_ids.len(), 1);
|
||||||
|
// Unknown discriminants survive the wire; they are dropped where the
|
||||||
|
// node is built, not here.
|
||||||
|
assert_eq!(photo.tags, [0, 2, 99]);
|
||||||
|
assert_eq!(photo.album_inclusions[0].album_link_id.as_str(), "album-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_listings_page_on_anchor() {
|
||||||
|
let albums: AlbumListResponse = serde_json::from_str(
|
||||||
|
r#"{"Albums":[{"LinkID":"album-1"}],"More":true,"AnchorID":"album-1"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(albums.albums[0].id.as_str(), "album-1");
|
||||||
|
assert!(albums.more);
|
||||||
|
assert_eq!(albums.anchor_id.unwrap().as_str(), "album-1");
|
||||||
|
|
||||||
|
let items: AlbumItemListResponse = serde_json::from_str(
|
||||||
|
r#"{"Photos":[{"LinkID":"photo-1","CaptureTime":1700000000}],"More":false}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(items.photos[0].capture_time, 1_700_000_000);
|
||||||
|
assert!(!items.more);
|
||||||
|
assert!(items.anchor_id.is_none());
|
||||||
|
|
||||||
|
// Shared albums live on the sharer's volume, so each row carries one.
|
||||||
|
let shared: SharedAlbumsResponse = serde_json::from_str(
|
||||||
|
r#"{"Albums":[{"VolumeID":"vol-9","LinkID":"album-3"}],"More":false}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(shared.albums[0].volume_id.as_str(), "vol-9");
|
||||||
|
assert_eq!(shared.albums[0].link_id.as_str(), "album-3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_photos_share_targets_are_photos_items() {
|
||||||
|
// C# `ProtonPhotosClient.ShareTargetTypes` — the complement of the Drive
|
||||||
|
// client's set, minus the root share, which is neither's.
|
||||||
|
for (raw, expected) in [
|
||||||
|
(0, false),
|
||||||
|
(1, false),
|
||||||
|
(2, false),
|
||||||
|
(3, true),
|
||||||
|
(4, true),
|
||||||
|
(5, false),
|
||||||
|
] {
|
||||||
|
let kind = ShareTargetType::from_raw(raw).expect("known target type");
|
||||||
|
assert_eq!(kind.is_photos_item(), expected, "target type {raw}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_drive_share_targets_are_drive_items() {
|
fn only_drive_share_targets_are_drive_items() {
|
||||||
// C# `SharingOperations.DriveShareTargetTypes`; albums and photos belong
|
// C# `SharingOperations.DriveShareTargetTypes`; albums and photos belong
|
||||||
|
|||||||
@@ -54,8 +54,13 @@ pub use event_manager::{
|
|||||||
EventManagerConfig, MemoryCursorStore,
|
EventManagerConfig, MemoryCursorStore,
|
||||||
};
|
};
|
||||||
pub use events::{DriveEvent, DriveEventScopeId};
|
pub use events::{DriveEvent, DriveEventScopeId};
|
||||||
pub use node::{Node, NodeKind, RevisionState, Thumbnail, ThumbnailType};
|
pub use node::{
|
||||||
pub use photos::{PhotoTag, PhotoUploadMetadata, PhotosTimelineItem, ProtonPhotosClient};
|
AlbumProperties, Node, NodeKind, PhotoProperties, RevisionState, Thumbnail, ThumbnailType,
|
||||||
|
};
|
||||||
|
pub use photos::{
|
||||||
|
AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem,
|
||||||
|
ProtonPhotosClient,
|
||||||
|
};
|
||||||
pub use proton_sdk::account::KeySalt;
|
pub use proton_sdk::account::KeySalt;
|
||||||
pub use proton_sdk::cache::{CacheRepository, EncryptedCacheRepository, InMemoryCacheRepository};
|
pub use proton_sdk::cache::{CacheRepository, EncryptedCacheRepository, InMemoryCacheRepository};
|
||||||
pub use public_link::{ProtonDrivePublicLinkClient, PublicLinkInfo};
|
pub use public_link::{ProtonDrivePublicLinkClient, PublicLinkInfo};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use proton_sdk::crypto::VerificationStatus;
|
|||||||
use proton_sdk::ids::NodeUid;
|
use proton_sdk::ids::NodeUid;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::photos::PhotoTag;
|
||||||
use crate::sharing::ShareMembership;
|
use crate::sharing::ShareMembership;
|
||||||
|
|
||||||
/// A decrypted Drive node (folder or file).
|
/// A decrypted Drive node (folder or file).
|
||||||
@@ -37,6 +38,16 @@ pub struct Node {
|
|||||||
/// one written before this field existed must keep deserializing.
|
/// one written before this field existed must keep deserializing.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub membership: Option<ShareMembership>,
|
pub membership: Option<ShareMembership>,
|
||||||
|
/// Photo-only metadata, present on a photos-volume file node. C# layers this
|
||||||
|
/// as `PhotoNode : FileNode`; here it is an optional block so a `Node`
|
||||||
|
/// persisted before photos existed still deserializes.
|
||||||
|
#[serde(default)]
|
||||||
|
pub photo: Option<PhotoProperties>,
|
||||||
|
/// Album-only metadata, present on an album node. An album is otherwise a
|
||||||
|
/// folder (C# `AlbumNode : FolderNode`), so [`Node::kind`] stays
|
||||||
|
/// [`NodeKind::Folder`].
|
||||||
|
#[serde(default)]
|
||||||
|
pub album: Option<AlbumProperties>,
|
||||||
/// Per-field signature-verification results gathered while decrypting the
|
/// Per-field signature-verification results gathered while decrypting the
|
||||||
/// node. Non-fatal metadata (mirrors C# `AuthorshipVerificationFailure`):
|
/// node. Non-fatal metadata (mirrors C# `AuthorshipVerificationFailure`):
|
||||||
/// the node is always returned; the caller inspects this to decide trust.
|
/// the node is always returned; the caller inspects this to decide trust.
|
||||||
@@ -44,6 +55,39 @@ pub struct Node {
|
|||||||
pub verification: NodeVerification,
|
pub verification: NodeVerification,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Photo-only metadata on a photos-volume file node. Mirrors the fields C#
|
||||||
|
/// `PhotoNode` adds to `FileNode`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PhotoProperties {
|
||||||
|
/// Capture time in seconds since the Unix epoch.
|
||||||
|
pub capture_time: i64,
|
||||||
|
/// Lowercase-hex HMAC of the plaintext SHA-1 under the photos root's hash
|
||||||
|
/// key — what duplicate detection matches on. `None` on older photos.
|
||||||
|
pub content_hash: Option<String>,
|
||||||
|
/// Set when this is a *related* photo (live-photo video, burst sibling):
|
||||||
|
/// the main photo it belongs to.
|
||||||
|
pub main_photo_uid: Option<NodeUid>,
|
||||||
|
/// This (main) photo's related photos.
|
||||||
|
pub related_photo_uids: Vec<NodeUid>,
|
||||||
|
/// Classification tags. Values the server sends that this SDK does not know
|
||||||
|
/// are dropped rather than failing the node.
|
||||||
|
pub tags: Vec<PhotoTag>,
|
||||||
|
/// The albums this photo has been added to.
|
||||||
|
pub album_uids: Vec<NodeUid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Album-only metadata on an album node. Mirrors the fields C# `AlbumNode` adds
|
||||||
|
/// to `FolderNode`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AlbumProperties {
|
||||||
|
pub photo_count: i32,
|
||||||
|
/// The photo shown as the album's cover, when it has one.
|
||||||
|
pub cover_photo_uid: Option<NodeUid>,
|
||||||
|
/// Epoch seconds of the last change to the album's contents; `None` when the
|
||||||
|
/// server sends none.
|
||||||
|
pub last_activity_time: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Outcome of verifying the signatures encountered while decrypting a node.
|
/// Outcome of verifying the signatures encountered while decrypting a node.
|
||||||
///
|
///
|
||||||
/// Each field carries the [`VerificationStatus`] of one signed artifact. The
|
/// Each field carries the [`VerificationStatus`] of one signed artifact. The
|
||||||
@@ -310,6 +354,8 @@ mod tests {
|
|||||||
membership_id: "member-1".into(),
|
membership_id: "member-1".into(),
|
||||||
permissions: 6,
|
permissions: 6,
|
||||||
}),
|
}),
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
verification: NodeVerification::default(),
|
verification: NodeVerification::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,43 @@ pub enum PhotoTag {
|
|||||||
Raw = 9,
|
Raw = 9,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PhotoTag {
|
||||||
|
/// Map a wire tag discriminant, or `None` for one this SDK does not know
|
||||||
|
/// (the server may add tags; an unknown one is dropped, not an error).
|
||||||
|
pub fn from_raw(value: i32) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
0 => Some(Self::Favorite),
|
||||||
|
1 => Some(Self::Screenshot),
|
||||||
|
2 => Some(Self::Video),
|
||||||
|
3 => Some(Self::LivePhoto),
|
||||||
|
4 => Some(Self::MotionPhoto),
|
||||||
|
5 => Some(Self::Selfie),
|
||||||
|
6 => Some(Self::Portrait),
|
||||||
|
7 => Some(Self::Burst),
|
||||||
|
8 => Some(Self::Panorama),
|
||||||
|
9 => Some(Self::Raw),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One photo's tag changes, applied by
|
||||||
|
/// [`ProtonPhotosClient::update_photos`]. Mirrors C# `PhotoTagsUpdate`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PhotoTagsUpdate {
|
||||||
|
pub node_uid: NodeUid,
|
||||||
|
pub tags_to_add: Vec<PhotoTag>,
|
||||||
|
pub tags_to_remove: Vec<PhotoTag>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry of an album listing: a photo and its capture time (epoch seconds).
|
||||||
|
/// C# `AlbumItem(NodeUid Uid, DateTime CaptureTime)`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AlbumItem {
|
||||||
|
pub uid: NodeUid,
|
||||||
|
pub capture_time: i64,
|
||||||
|
}
|
||||||
|
|
||||||
/// Caller-supplied metadata for a photo upload. Mirrors C#
|
/// Caller-supplied metadata for a photo upload. Mirrors C#
|
||||||
/// `PhotosFileUploadMetadata`: all fields are optional. `capture_time` defaults
|
/// `PhotosFileUploadMetadata`: all fields are optional. `capture_time` defaults
|
||||||
/// to the upload time when unset; `main_photo_uid` links a related photo
|
/// to the upload time when unset; `main_photo_uid` links a related photo
|
||||||
@@ -220,6 +257,53 @@ impl ProtonPhotosClient {
|
|||||||
pub async fn find_duplicates(&self, name: &str, contents: &[u8]) -> Result<Vec<NodeUid>> {
|
pub async fn find_duplicates(&self, name: &str, contents: &[u8]) -> Result<Vec<NodeUid>> {
|
||||||
self.drive.find_photo_duplicates(name, contents).await
|
self.drive.find_photo_duplicates(name, contents).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
|
||||||
|
/// [`enumerate_nodes`](Self::enumerate_nodes) — an album node is a folder
|
||||||
|
/// carrying [`Node::album`](crate::Node::album).
|
||||||
|
pub async fn enumerate_album_node_uids(&self) -> Result<Vec<NodeUid>> {
|
||||||
|
self.drive.enumerate_album_node_uids().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The photos in `album_uid`, newest capture first.
|
||||||
|
/// C# `ProtonPhotosClient.EnumerateAlbumAsync`.
|
||||||
|
pub async fn enumerate_album(&self, album_uid: &NodeUid) -> Result<Vec<AlbumItem>> {
|
||||||
|
self.drive.enumerate_album(album_uid).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The photos and albums other users share with us, as [`NodeUid`]s — the
|
||||||
|
/// photos counterpart of `ProtonDriveClient::enumerate_shared_with_me_node_uids`
|
||||||
|
/// (C# `ProtonPhotosClient.EnumerateSharedWithMeNodeUidsAsync`, which filters
|
||||||
|
/// `v2/sharedwithme` to the Photo/Album target types).
|
||||||
|
pub async fn enumerate_shared_with_me_node_uids(&self) -> Result<Vec<NodeUid>> {
|
||||||
|
self.drive.enumerate_photos_shared_with_me_node_uids().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The albums shared with us, as [`NodeUid`]s, from the dedicated
|
||||||
|
/// `photos/albums/shared-with-me` listing (C#
|
||||||
|
/// `PhotoOperations.EnumerateSharedWithMeAlbumUidsAsync`). Each row carries
|
||||||
|
/// its own volume — these live on the sharer's photos volume.
|
||||||
|
pub async fn enumerate_shared_with_me_album_uids(&self) -> Result<Vec<NodeUid>> {
|
||||||
|
self.drive.enumerate_shared_with_me_album_uids().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add and/or remove classification tags on photos.
|
||||||
|
///
|
||||||
|
/// C# `ProtonPhotosClient.UpdatePhotosAsync`: one outcome per input update,
|
||||||
|
/// in input order — a photo that fails does not stop the others.
|
||||||
|
/// [`PhotoTag::Favorite`] is not a plain tag: it is set through the dedicated
|
||||||
|
/// `favorite` endpoint, and only for photos that already live on our own
|
||||||
|
/// photos volume. Favoriting a *shared* photo needs the photo re-encrypted
|
||||||
|
/// for our timeline root, which is not ported yet and fails that update.
|
||||||
|
/// Removing `Favorite` goes through the ordinary tag-removal endpoint.
|
||||||
|
pub async fn update_photos(
|
||||||
|
&self,
|
||||||
|
updates: &[PhotoTagsUpdate],
|
||||||
|
) -> Result<Vec<(NodeUid, Result<()>)>> {
|
||||||
|
self.drive.update_photos(updates).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -556,6 +556,10 @@ impl ProtonDrivePublicLinkClient {
|
|||||||
// A public-link visitor has no account membership — access comes
|
// A public-link visitor has no account membership — access comes
|
||||||
// from the link's own share password, not from a share member row.
|
// from the link's own share password, not from a share member row.
|
||||||
membership: None,
|
membership: None,
|
||||||
|
// A public link points at a Drive folder or file; the photos volume
|
||||||
|
// is not reachable this way, so there is no photo/album metadata.
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -567,7 +571,10 @@ impl ProtonDrivePublicLinkClient {
|
|||||||
/// (C# `linkDetailsDto.File ?? linkDetailsDto.Photo`) — a public link may point
|
/// (C# `linkDetailsDto.File ?? linkDetailsDto.Photo`) — a public link may point
|
||||||
/// into either.
|
/// into either.
|
||||||
fn file_properties(details: &LinkDetailsDto) -> Option<&FileDto> {
|
fn file_properties(details: &LinkDetailsDto) -> Option<&FileDto> {
|
||||||
details.file.as_ref().or(details.photo.as_ref())
|
details
|
||||||
|
.file
|
||||||
|
.as_ref()
|
||||||
|
.or_else(|| details.photo.as_ref().map(|photo| &photo.file))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode_base64(value: &str, what: &str) -> Result<Vec<u8>> {
|
fn decode_base64(value: &str, what: &str) -> Result<Vec<u8>> {
|
||||||
|
|||||||
@@ -320,6 +320,19 @@ impl ApiHttpClient {
|
|||||||
self.send::<(), T>(Method::DELETE, path, None).await
|
self.send::<(), T>(Method::DELETE, path, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `DELETE {path}` with a JSON body, returning a typed success body.
|
||||||
|
///
|
||||||
|
/// A few Drive endpoints take their operand in the body of a `DELETE`
|
||||||
|
/// (C# `HttpApiCallBuilder.DeleteAsync(route, payload, ...)`), e.g. removing
|
||||||
|
/// photo tags.
|
||||||
|
pub async fn delete_with_body<B: Serialize, T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
body: &B,
|
||||||
|
) -> Result<T> {
|
||||||
|
self.send::<B, T>(Method::DELETE, path, Some(body)).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn send<B: Serialize, T: DeserializeOwned>(
|
async fn send<B: Serialize, T: DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
method: Method,
|
method: Method,
|
||||||
|
|||||||
Reference in new issue
Block a user