mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-04 17:06:59 +01:00
feat(photos): save photos to the timeline
Ports upstream C# e0e198e2 (SavePhotosToTimelineAsync, itself the JS
saveToTimeline). Photos already on our photos volume are moved into the
timeline root via PUT photos/volumes/{vid}/links/transfer-multiple,
batched so no request carries more than ten links and a photo is never
split from its related photos; photos on another volume — from an album
shared with us — are copied via POST volumes/{vid}/links/{lid}/copy, one
request each. That closes the cross-volume copy hole (TS copyPhoto).
The re-encryption is what add_photos_to_album already did, so it moves
into a shared photo_target_payload: name re-encrypted to the target key
and signed by the photos membership address, name/content hashes under
the target hash key, node passphrase rewrapped so the original
NodePassphraseSignature survives.
A photo the server reports an incomplete group for is re-queued once
with the related photos it named; only its second answer becomes an
outcome, so outcomes arrive in completion order, not input order.
Also adds ProtonPhotosClient::enumerate_events (c8d03244's only
non-interop hunk) and bumps the upstream pin to c8d03244. Upstream
29e58378 (upload telemetry) and 0537d906 (NodeProvider refactor) are
deliberately not ported — see UPSTREAM_SYNC.md.
This commit is contained in:
1 parent
61117fd7dc
commit
aa17b52692
5 files changed
+728
-29
No files matched your search
@@ -302,7 +302,8 @@ neither upstream SDK, original to `proton-sdk-rs` · ❌ not implemented.
|
|||||||
| | Shared Photos | ✅ | Photos/albums shared *with* us (`enumerate_shared_with_me_node_uids`, `enumerate_shared_with_me_album_uids`) and by us (`enumerate_shared_node_uids`). |
|
| | Shared Photos | ✅ | Photos/albums shared *with* us (`enumerate_shared_with_me_node_uids`, `enumerate_shared_with_me_album_uids`) and by us (`enumerate_shared_node_uids`). |
|
||||||
| | Duplicate Detection | ✅ | `find_duplicates` matches candidates on name/content HMAC before re-uploading. |
|
| | Duplicate Detection | ✅ | `find_duplicates` matches candidates on name/content HMAC before re-uploading. |
|
||||||
| | Favorite Shared Photos | ❌ | Needs the photo re-encrypted for our timeline root; not ported (upstream `03b1cb7f`). |
|
| | Favorite Shared Photos | ❌ | Needs the photo re-encrypted for our timeline root; not ported (upstream `03b1cb7f`). |
|
||||||
| | Album Writes | ❌ | Creating albums and adding/removing photos — no C# public API upstream either. |
|
| | Save to Timeline | ✅ | `save_photos_to_timeline` moves photos already on our photos volume into the timeline root and copies cross-volume ones (from albums shared with us). |
|
||||||
|
| | Album Writes | ⚠️ | `create_album` and `add_photos_to_album` are ported from the TypeScript SDK (C# has no public album write API); album rename, cover photo and delete are not. |
|
||||||
| | 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`, or `with_entity_repository` to chain one onto a `with_key_salts` client); keys/secrets remain strictly in memory. Retained deliberately — upstream C# removed its entity cache. |
|
| **Caching** | Pluggable Cache | ✅ | Pluggable entity cache (`with_entity_cache`, or `with_entity_repository` to chain one onto a `with_key_salts` client); keys/secrets remain strictly in memory. Retained deliberately — upstream C# removed its entity cache. |
|
||||||
| **Validation** | Node Names | ✅ | Empty or >255-char names are rejected client-side before create/rename (counted in `char`s; C# counts UTF-16 units). |
|
| **Validation** | Node Names | ✅ | Empty or >255-char names are rejected client-side before create/rename (counted in `char`s; C# counts UTF-16 units). |
|
||||||
|
|||||||
+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**: `df6b6bfde620c12868cd5a444d027f8d01974b75`
|
- **Pinned**: `c8d03244938a6b4d107c755df8904d7d971ed1c2`
|
||||||
- **Date**: 2026-08-10
|
- **Date**: 2026-08-27
|
||||||
|
|
||||||
> **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.
|
||||||
@@ -36,3 +36,4 @@ gitignored, so this file is the only durable record of what we last reviewed.
|
|||||||
| 2026-08-10 | `941aafa5` → `df6b6bfd` | `f493c5ee`, `62895702` | ported: **per-node trash/restore/delete outcomes, streamed**. Upstream turned `TrashNodesAsync` / `RestoreNodesAsync` / `DeleteNodesAsync` from `ValueTask<IReadOnlyDictionary<NodeUid, Result<Exception>>>` into `IAsyncEnumerable<NodeActionResult>`, and `UpdatePhotosAsync` into `IAsyncEnumerable<PhotoUpdateResult>`. Rust equivalent: `trash_nodes` / `restore_nodes` / `delete_nodes` now return `Vec<(NodeUid, Result<()>)>` (**breaking** — they returned `Result<()>` and failed the whole call through `check_aggregate`, which is now gone), plus `*_streaming` siblings returning `impl Stream<Item = Result<(NodeUid, Result<()>)>>` that yield each batch's outcomes as the batch lands; `update_photos_streaming` does the same for photo tag updates. Kept the `(NodeUid, Result<()>)` tuple used by `move_nodes` rather than adding `NodeActionResult` / `PhotoUpdateResult` record types — C# needs a named type for its iterators, Rust does not. Outcome mapping mirrors upstream exactly: only the links the aggregate envelope reports on produce an outcome, and the stream's outer `Err` (a request that never produced an envelope) ends it. diverged deliberately: upstream's rewrite also dropped its per-volume `Task.WhenAll`, so both sides are now sequential over volume groups — ours already was. not ported: `9dd63423` (explicit stream disposal through interop — CExports/proto only; Rust ownership drops readers/writers at scope end). |
|
| 2026-08-10 | `941aafa5` → `df6b6bfd` | `f493c5ee`, `62895702` | ported: **per-node trash/restore/delete outcomes, streamed**. Upstream turned `TrashNodesAsync` / `RestoreNodesAsync` / `DeleteNodesAsync` from `ValueTask<IReadOnlyDictionary<NodeUid, Result<Exception>>>` into `IAsyncEnumerable<NodeActionResult>`, and `UpdatePhotosAsync` into `IAsyncEnumerable<PhotoUpdateResult>`. Rust equivalent: `trash_nodes` / `restore_nodes` / `delete_nodes` now return `Vec<(NodeUid, Result<()>)>` (**breaking** — they returned `Result<()>` and failed the whole call through `check_aggregate`, which is now gone), plus `*_streaming` siblings returning `impl Stream<Item = Result<(NodeUid, Result<()>)>>` that yield each batch's outcomes as the batch lands; `update_photos_streaming` does the same for photo tag updates. Kept the `(NodeUid, Result<()>)` tuple used by `move_nodes` rather than adding `NodeActionResult` / `PhotoUpdateResult` record types — C# needs a named type for its iterators, Rust does not. Outcome mapping mirrors upstream exactly: only the links the aggregate envelope reports on produce an outcome, and the stream's outer `Err` (a request that never produced an envelope) ends it. diverged deliberately: upstream's rewrite also dropped its per-volume `Task.WhenAll`, so both sides are now sequential over volume groups — ours already was. not ported: `9dd63423` (explicit stream disposal through interop — CExports/proto only; Rust ownership drops readers/writers at scope end). |
|
||||||
| 2026-08-10 | — (no upstream delta) | — | **not an upstream port.** `ProtonDriveClient::with_entity_repository` — the chainable form of `with_entity_cache`, added because that one is a *constructor* and so could not be combined with `with_key_salts`; a client that resumes a persisted session needs the salts and wants a persistent entity cache, and upstream C# has no equivalent tension (its cache is set on the client options object). Additive only: `with_entity_cache` and the in-memory default are unchanged. Consumed by `proton-drive-linux`, which now backs the entity cache with an encrypted SQLite store. |
|
| 2026-08-10 | — (no upstream delta) | — | **not an upstream port.** `ProtonDriveClient::with_entity_repository` — the chainable form of `with_entity_cache`, added because that one is a *constructor* and so could not be combined with `with_key_salts`; a client that resumes a persisted session needs the salts and wants a persistent entity cache, and upstream C# has no equivalent tension (its cache is set on the client options object). Additive only: `with_entity_cache` and the in-memory default are unchanged. Consumed by `proton-drive-linux`, which now backs the entity cache with an encrypted SQLite store. |
|
||||||
| 2026-08-18 | — (no upstream delta) | — | **not an upstream port of C#** — ported from the **TypeScript** SDK, which is the only upstream with an album write API. Ported: **album creation** (`ProtonPhotosClient::create_album` — `POST photos/volumes/{vid}/albums`, from `AlbumsManager.createAlbum` + `AlbumsCryptoService.createAlbum`; an album is a folder node parented to the photos root, so the crypto is the folder create: generated node key, passphrase encrypted to the photos root key and signed by the photos share's membership address, plus the album's own child-name hash key, and TS's `Locked: false`) and **adding photos to an album** (`add_photos_to_album` — `POST photos/volumes/{vid}/albums/{lid}/add-multiple`, from `AddToAlbumProcess.processSameVolumeQueue` + `PhotoTransferPayloadBuilder`; per photo the name is re-encrypted to the album key, name/content hashes recomputed under the album hash key, and the node passphrase **rewrapped** via `rewrap_message_to` rather than re-encrypted from plaintext — the ciphertext and therefore the original `NodePassphraseSignature` survive, which is why the entry carries no signature of its own; related photos are flattened in beside their main photo, batches of 10 like TS's `ADD_PHOTOS_BATCH_SIZE`, one outcome per input photo in input order like `move_nodes`). Not ported: TS `copyPhoto` (adding a photo that lives on **another** volume, i.e. from an album shared with us — needs the cross-volume copy path; those inputs fail their own outcome), `updateAlbum` (rename / cover photo) and `deleteAlbum`. Also **ours, with no upstream counterpart**: `find_duplicates_many` and `name_collisions` — the duplicates endpoint already takes a `NameHashes` array but both SDKs only ever send one, and a Google Takeout import asks the question tens of thousands of times; `name_collisions` answers on the name hash alone so the caller only has to read and SHA-1 the files whose names actually collide. Live validation pending: everything here needs a photos-volume account. |
|
| 2026-08-18 | — (no upstream delta) | — | **not an upstream port of C#** — ported from the **TypeScript** SDK, which is the only upstream with an album write API. Ported: **album creation** (`ProtonPhotosClient::create_album` — `POST photos/volumes/{vid}/albums`, from `AlbumsManager.createAlbum` + `AlbumsCryptoService.createAlbum`; an album is a folder node parented to the photos root, so the crypto is the folder create: generated node key, passphrase encrypted to the photos root key and signed by the photos share's membership address, plus the album's own child-name hash key, and TS's `Locked: false`) and **adding photos to an album** (`add_photos_to_album` — `POST photos/volumes/{vid}/albums/{lid}/add-multiple`, from `AddToAlbumProcess.processSameVolumeQueue` + `PhotoTransferPayloadBuilder`; per photo the name is re-encrypted to the album key, name/content hashes recomputed under the album hash key, and the node passphrase **rewrapped** via `rewrap_message_to` rather than re-encrypted from plaintext — the ciphertext and therefore the original `NodePassphraseSignature` survive, which is why the entry carries no signature of its own; related photos are flattened in beside their main photo, batches of 10 like TS's `ADD_PHOTOS_BATCH_SIZE`, one outcome per input photo in input order like `move_nodes`). Not ported: TS `copyPhoto` (adding a photo that lives on **another** volume, i.e. from an album shared with us — needs the cross-volume copy path; those inputs fail their own outcome), `updateAlbum` (rename / cover photo) and `deleteAlbum`. Also **ours, with no upstream counterpart**: `find_duplicates_many` and `name_collisions` — the duplicates endpoint already takes a `NameHashes` array but both SDKs only ever send one, and a Google Takeout import asks the question tens of thousands of times; `name_collisions` answers on the name hash alone so the caller only has to read and SHA-1 the files whose names actually collide. Live validation pending: everything here needs a photos-volume account. |
|
||||||
|
| 2026-08-27 | `df6b6bfd` → `c8d03244` | `e0e198e2`, `c8d03244` | ported: **save photos to the timeline** (`ProtonPhotosClient::save_photos_to_timeline` — C# `PhotoOperations.SavePhotosToTimelineAsync`, itself the JS `saveToTimeline`). Photos already on our photos volume are *moved* into the timeline root through `PUT photos/volumes/{vid}/links/transfer-multiple`, batched so no request carries more than ten links (main photo + related photos counted together, never split across requests); photos on another volume — from a shared-with-me album — are *copied* through `POST volumes/{vid}/links/{lid}/copy`, one request each, with their related photos nested in `Photos`. This closes the **cross-volume copy** hole logged in the 2026-08-18 row (TS `copyPhoto`), so photos from albums shared with us can now be kept. The re-encryption is what `add_photos_to_album` already did — name re-encrypted to the target key and signed by the photos membership address, name/content hashes recomputed under the target hash key, node passphrase **rewrapped** via `rewrap_message_to` so the original `NodePassphraseSignature` survives — so it was lifted into a shared `photo_target_payload` (C# `PhotosCrypto.EncryptPhotoForTarget` + `PhotoTransferPayloadBuilder`) that both paths build on. Retry semantics mirror upstream exactly: a photo the server reports an incomplete group for is re-queued **once** with the related photos it named, and only its second answer becomes an outcome; a photo already parented to the timeline root fails its own outcome. Also ported: `ProtonPhotosClient::enumerate_events` (`c8d03244`'s only non-interop hunk — a passthrough to the Drive client's, which we already had). diverged deliberately: upstream returns `IAsyncEnumerable<NodeActionResult>`; ours collects `Vec<(NodeUid, Result<()>)>` like `move_nodes` and the other batch ops — and, because of the retry queue, outcome order follows completion, not input, which the API docs state. not ported: `29e58378` **per-file upload performance metrics** (`UploadPerformanceEvent`, size/route/block-count classification, active-vs-paused attempt timing on `UploadController`) — it is telemetry plumbing for C#'s interop metric callback; our `proton-sdk::telemetry` has its own `TelemetryEvent`/`OpTimer` shape and we have no pause/resume upload controller, and nothing about the upload itself changes. structural, not ported: `0537d906` (C# merges its batch loaders and `DtoToMetadataConverter` into one `NodeProvider`; the only behavior-adjacent bit is dropping the cache's claim/acquisition dance, which our single-flight already covers). Live validation pending: the whole path needs a photos-volume account, and the cross-volume half needs a second account sharing an album with it. |
|
||||||
@@ -49,7 +49,8 @@ use crate::dtos::{
|
|||||||
AlbumCreationResponse, AlbumItemListResponse, AlbumListResponse, BlockCreationRequest,
|
AlbumCreationResponse, AlbumItemListResponse, AlbumListResponse, BlockCreationRequest,
|
||||||
BlockDto, BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockUploadTarget,
|
BlockDto, BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockUploadTarget,
|
||||||
BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, BookmarksResponse,
|
BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, BookmarksResponse,
|
||||||
CommonExtendedAttributes, ContextShareResponse, CreateBookmarkRequest, CreatePublicLinkRequest,
|
CommonExtendedAttributes, ContextShareResponse, CopyPhotoContent, CopyPhotoRelatedItem,
|
||||||
|
CopyPhotoRequest, CopyPhotoResponse, CreateBookmarkRequest, CreatePublicLinkRequest,
|
||||||
CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse, DeviceCreationDeviceDto,
|
CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse, DeviceCreationDeviceDto,
|
||||||
DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto,
|
DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto,
|
||||||
DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes,
|
DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes,
|
||||||
@@ -70,8 +71,8 @@ use crate::dtos::{
|
|||||||
SharedByMeResponse, SharedWithMeResponse, SmallFileUploadMetadataRequest,
|
SharedByMeResponse, SharedWithMeResponse, SmallFileUploadMetadataRequest,
|
||||||
SmallRevisionUploadMetadataRequest, SmallUploadResponse, ThumbnailBlockListRequest,
|
SmallRevisionUploadMetadataRequest, SmallUploadResponse, ThumbnailBlockListRequest,
|
||||||
ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse,
|
ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse,
|
||||||
UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse,
|
TransferPhotoLinkItem, TransferPhotosRequest, TransferPhotosResponse, UpdatePermissionsRequest,
|
||||||
VolumeTrashResponse,
|
VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse,
|
||||||
};
|
};
|
||||||
use crate::events::{DriveEvent, DriveEventScopeId};
|
use crate::events::{DriveEvent, DriveEventScopeId};
|
||||||
use crate::node::{
|
use crate::node::{
|
||||||
@@ -5971,12 +5972,6 @@ impl ProtonDriveClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Re-encrypt one photo's name and passphrase to the album key.
|
/// Re-encrypt one photo's name and passphrase to the album key.
|
||||||
///
|
|
||||||
/// The passphrase is *rewrapped*, not re-encrypted from plaintext: only its
|
|
||||||
/// key packet changes recipient, so the locked node key still unlocks and
|
|
||||||
/// the original `NodePassphraseSignature` stays valid (which is why the
|
|
||||||
/// entry carries no signature of its own — TS sends one only for photos
|
|
||||||
/// whose key author is anonymous).
|
|
||||||
async fn album_photo_entry(
|
async fn album_photo_entry(
|
||||||
&self,
|
&self,
|
||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
@@ -5985,6 +5980,36 @@ impl ProtonDriveClient {
|
|||||||
email: &str,
|
email: &str,
|
||||||
signing_key: &PrivateKey,
|
signing_key: &PrivateKey,
|
||||||
) -> Result<AddPhotoToAlbumEntry> {
|
) -> Result<AddPhotoToAlbumEntry> {
|
||||||
|
let payload = self
|
||||||
|
.photo_target_payload(uid, album_key, album_hash_key, email, signing_key)
|
||||||
|
.await?;
|
||||||
|
Ok(AddPhotoToAlbumEntry {
|
||||||
|
link_id: payload.uid.link_id,
|
||||||
|
name_hash: payload.name_hash,
|
||||||
|
name: payload.name,
|
||||||
|
name_signature_email: payload.name_signature_email,
|
||||||
|
passphrase: payload.passphrase,
|
||||||
|
content_hash: payload.content_hash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-encrypt one photo's name, passphrase and hashes for a target node —
|
||||||
|
/// an album or the timeline root (C# `PhotosCrypto.EncryptPhotoForTarget`
|
||||||
|
/// via `PhotoTransferPayloadBuilder`, TS `preparePhotoPayload`).
|
||||||
|
///
|
||||||
|
/// The passphrase is *rewrapped*, not re-encrypted from plaintext: only its
|
||||||
|
/// key packet changes recipient, so the locked node key still unlocks and
|
||||||
|
/// the original `NodePassphraseSignature` stays valid (which is why the
|
||||||
|
/// payload carries no signature of its own — both upstreams sign one only
|
||||||
|
/// for photos whose key author is anonymous).
|
||||||
|
async fn photo_target_payload(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
target_key: &PrivateKey,
|
||||||
|
target_hash_key: &[u8],
|
||||||
|
email: &str,
|
||||||
|
signing_key: &PrivateKey,
|
||||||
|
) -> Result<PhotoTargetPayload> {
|
||||||
let node = self
|
let node = self
|
||||||
.get_photos_node(uid)
|
.get_photos_node(uid)
|
||||||
.await?
|
.await?
|
||||||
@@ -5996,7 +6021,7 @@ impl ProtonDriveClient {
|
|||||||
};
|
};
|
||||||
let sha1 = content_sha1.clone().ok_or_else(|| {
|
let sha1 = content_sha1.clone().ok_or_else(|| {
|
||||||
ProtonError::invalid_operation(format!(
|
ProtonError::invalid_operation(format!(
|
||||||
"photo {uid} has no content digest; it cannot be added to an album"
|
"photo {uid} has no content digest; it cannot be re-encrypted for another node"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -6012,18 +6037,24 @@ impl ProtonDriveClient {
|
|||||||
let parent_key = self
|
let parent_key = self
|
||||||
.resolve_parent_key_ctx(&uid.volume_id, link, true)
|
.resolve_parent_key_ctx(&uid.volume_id, link, true)
|
||||||
.await?;
|
.await?;
|
||||||
let passphrase = parent_key.rewrap_message_to(&link.passphrase, album_key)?;
|
let passphrase = parent_key.rewrap_message_to(&link.passphrase, target_key)?;
|
||||||
|
|
||||||
Ok(AddPhotoToAlbumEntry {
|
Ok(PhotoTargetPayload {
|
||||||
link_id: uid.link_id.clone(),
|
uid: uid.clone(),
|
||||||
name_hash: hex::encode(hmac_sha256(album_hash_key, node.name.as_bytes())),
|
parent_uid: link
|
||||||
name: album_key.encrypt_and_sign(signing_key, node.name.as_bytes(), true, false)?,
|
.parent_id
|
||||||
|
.clone()
|
||||||
|
.map(|parent_id| NodeUid::new(uid.volume_id.clone(), parent_id)),
|
||||||
|
original_name_hash: link.name_hash.clone(),
|
||||||
|
name_hash: hex::encode(hmac_sha256(target_hash_key, node.name.as_bytes())),
|
||||||
|
name: target_key.encrypt_and_sign(signing_key, node.name.as_bytes(), true, false)?,
|
||||||
name_signature_email: email.to_string(),
|
name_signature_email: email.to_string(),
|
||||||
passphrase,
|
passphrase,
|
||||||
content_hash: hex::encode(hmac_sha256(
|
content_hash: hex::encode(hmac_sha256(
|
||||||
album_hash_key,
|
target_hash_key,
|
||||||
sha1.to_ascii_lowercase().as_bytes(),
|
sha1.to_ascii_lowercase().as_bytes(),
|
||||||
)),
|
)),
|
||||||
|
related: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6095,6 +6126,360 @@ impl ProtonDriveClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy photos into this account's own timeline, keeping them even after
|
||||||
|
/// the originals stop being shared. One outcome per input photo.
|
||||||
|
///
|
||||||
|
/// Mirrors C# `PhotoOperations.SavePhotosToTimelineAsync` (itself the JS
|
||||||
|
/// `saveToTimeline`): a photo already on our photos volume is *moved* into
|
||||||
|
/// the timeline root via `PUT photos/volumes/{vid}/links/transfer-multiple`,
|
||||||
|
/// batched so no request carries more than ten links; a photo on another
|
||||||
|
/// volume — one from a shared-with-me album — is *copied* through
|
||||||
|
/// `POST volumes/{vid}/links/{lid}/copy`, one request each. Both re-encrypt
|
||||||
|
/// the photo's name and passphrase for the timeline root, related photos
|
||||||
|
/// included.
|
||||||
|
///
|
||||||
|
/// When the server answers that the photo's group is incomplete it names
|
||||||
|
/// the related photos it wants; that photo is re-queued **once** with them
|
||||||
|
/// and only its second answer becomes an outcome. A photo already parented
|
||||||
|
/// to the timeline root fails its own outcome, as upstream reports it.
|
||||||
|
/// Outcome order therefore follows completion, not input.
|
||||||
|
pub(crate) async fn save_photos_to_timeline(
|
||||||
|
&self,
|
||||||
|
photo_uids: &[NodeUid],
|
||||||
|
) -> Result<Vec<(NodeUid, Result<()>)>> {
|
||||||
|
/// Photos whose payloads are built before a round of requests goes out
|
||||||
|
/// (C# `SaveToTimelineBatchSize`).
|
||||||
|
const SAVE_BATCH: usize = 20;
|
||||||
|
/// Links — main photos plus related ones — per `transfer-multiple`
|
||||||
|
/// request (C# `MaxLinksPerTransferBatch`).
|
||||||
|
const MAX_LINKS_PER_TRANSFER: usize = 10;
|
||||||
|
|
||||||
|
let mut timer = self.telemetry.start("save_photos_to_timeline");
|
||||||
|
timer.attr("photo_count", photo_uids.len());
|
||||||
|
if photo_uids.is_empty() {
|
||||||
|
timer.success();
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The timeline root is the target for every photo, so a missing photos
|
||||||
|
// volume fails the whole call rather than each photo in turn.
|
||||||
|
if !self.ensure_photos().await? {
|
||||||
|
return Err(ProtonError::invalid_operation(
|
||||||
|
"this account has no photos volume to save photos to",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let target_uid = self
|
||||||
|
.cache
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.photos_root
|
||||||
|
.clone()
|
||||||
|
.expect("ensure_photos populated the photos root");
|
||||||
|
let target_key = self.folder_node_key(&target_uid).await?;
|
||||||
|
let target_hash_key = self
|
||||||
|
.parent_hash_key_ctx(&target_uid, &target_key, true)
|
||||||
|
.await?;
|
||||||
|
let (_address_id, email, signing_key) = self.photos_membership_address().await?;
|
||||||
|
let target = TimelineTarget {
|
||||||
|
uid: target_uid,
|
||||||
|
key: target_key,
|
||||||
|
hash_key: target_hash_key,
|
||||||
|
email,
|
||||||
|
signing_key,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut outcomes: Vec<(NodeUid, Result<()>)> = Vec::with_capacity(photo_uids.len());
|
||||||
|
// Each entry is a photo plus the related photos the server asked for on
|
||||||
|
// a previous attempt; `retried` is what keeps that a single retry.
|
||||||
|
let mut queue: VecDeque<(NodeUid, Vec<NodeUid>)> = photo_uids
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|uid| (uid, Vec::new()))
|
||||||
|
.collect();
|
||||||
|
let mut retried: HashSet<NodeUid> = HashSet::new();
|
||||||
|
|
||||||
|
while !queue.is_empty() {
|
||||||
|
let mut same_volume: Vec<PhotoTargetPayload> = Vec::new();
|
||||||
|
let mut cross_volume: Vec<PhotoTargetPayload> = Vec::new();
|
||||||
|
for _ in 0..SAVE_BATCH {
|
||||||
|
let Some((uid, extra_related)) = queue.pop_front() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
match self
|
||||||
|
.timeline_photo_payload(&uid, &extra_related, &target)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(payload) if payload.uid.volume_id == target.uid.volume_id => {
|
||||||
|
same_volume.push(payload)
|
||||||
|
}
|
||||||
|
Ok(payload) => cross_volume.push(payload),
|
||||||
|
Err(error) => outcomes.push((uid, Err(error))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A photo and its related photos must not be split across requests,
|
||||||
|
// so the batch closes before the one that would overflow it.
|
||||||
|
let mut batch: Vec<PhotoTargetPayload> = Vec::new();
|
||||||
|
let mut links = 0usize;
|
||||||
|
for payload in same_volume {
|
||||||
|
let count = 1 + payload.related.len();
|
||||||
|
if !batch.is_empty() && links + count > MAX_LINKS_PER_TRANSFER {
|
||||||
|
self.transfer_photos_to_timeline(
|
||||||
|
&target,
|
||||||
|
&batch,
|
||||||
|
&mut queue,
|
||||||
|
&mut retried,
|
||||||
|
&mut outcomes,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
batch.clear();
|
||||||
|
links = 0;
|
||||||
|
}
|
||||||
|
links += count;
|
||||||
|
batch.push(payload);
|
||||||
|
}
|
||||||
|
if !batch.is_empty() {
|
||||||
|
self.transfer_photos_to_timeline(
|
||||||
|
&target,
|
||||||
|
&batch,
|
||||||
|
&mut queue,
|
||||||
|
&mut retried,
|
||||||
|
&mut outcomes,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for payload in cross_volume {
|
||||||
|
self.copy_photo_to_timeline(
|
||||||
|
&target,
|
||||||
|
&payload,
|
||||||
|
&mut queue,
|
||||||
|
&mut retried,
|
||||||
|
&mut outcomes,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timer.success();
|
||||||
|
Ok(outcomes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One photo's timeline payload: the photo itself plus its related photos,
|
||||||
|
/// re-encrypted for the timeline root (C#
|
||||||
|
/// `PhotoTransferPayloadBuilder.BuildPayloadsAsync`).
|
||||||
|
///
|
||||||
|
/// `extra_related` are the ones the server demanded on a previous attempt,
|
||||||
|
/// merged with what the photo itself declares. A related photo that cannot
|
||||||
|
/// be built is skipped rather than failing its main photo, as upstream does.
|
||||||
|
async fn timeline_photo_payload(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
extra_related: &[NodeUid],
|
||||||
|
target: &TimelineTarget,
|
||||||
|
) -> Result<PhotoTargetPayload> {
|
||||||
|
let mut payload = self
|
||||||
|
.photo_target_payload(
|
||||||
|
uid,
|
||||||
|
&target.key,
|
||||||
|
&target.hash_key,
|
||||||
|
&target.email,
|
||||||
|
&target.signing_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if payload.parent_uid.as_ref() == Some(&target.uid) {
|
||||||
|
return Err(ProtonError::invalid_operation(format!(
|
||||||
|
"photo {uid} is already in the timeline"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let node = self
|
||||||
|
.get_photos_node(uid)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ProtonError::invalid_operation(format!("photo {uid} not found")))?;
|
||||||
|
let mut related: Vec<NodeUid> = node
|
||||||
|
.photo
|
||||||
|
.as_ref()
|
||||||
|
.map(|photo| photo.related_photo_uids.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
for extra in extra_related {
|
||||||
|
if !related.contains(extra) {
|
||||||
|
related.push(extra.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for related_uid in related {
|
||||||
|
if let Ok(related_payload) = self
|
||||||
|
.photo_target_payload(
|
||||||
|
&related_uid,
|
||||||
|
&target.key,
|
||||||
|
&target.hash_key,
|
||||||
|
&target.email,
|
||||||
|
&target.signing_key,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
payload.related.push(related_payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Post one `transfer-multiple` batch and record an outcome for each of its
|
||||||
|
/// main photos (C# `TransferSameVolumeBatchAsync` + `RecordTransferOutcomes`).
|
||||||
|
async fn transfer_photos_to_timeline(
|
||||||
|
&self,
|
||||||
|
target: &TimelineTarget,
|
||||||
|
payloads: &[PhotoTargetPayload],
|
||||||
|
queue: &mut VecDeque<(NodeUid, Vec<NodeUid>)>,
|
||||||
|
retried: &mut HashSet<NodeUid>,
|
||||||
|
outcomes: &mut Vec<(NodeUid, Result<()>)>,
|
||||||
|
) -> Result<()> {
|
||||||
|
/// `ResponseCode::Success` as it arrives inside a per-link sub-response.
|
||||||
|
const SUCCESS_CODE: i32 = 1000;
|
||||||
|
|
||||||
|
let mut links: Vec<TransferPhotoLinkItem> = Vec::new();
|
||||||
|
let mut sent: Vec<&PhotoTargetPayload> = Vec::new();
|
||||||
|
for payload in payloads {
|
||||||
|
// The endpoint matches each link on the hash it currently has, so a
|
||||||
|
// photo whose link carries none cannot be moved.
|
||||||
|
let items: Option<Vec<TransferPhotoLinkItem>> = std::iter::once(payload)
|
||||||
|
.chain(payload.related.iter())
|
||||||
|
.map(transfer_link_item)
|
||||||
|
.collect();
|
||||||
|
match items {
|
||||||
|
Some(items) => {
|
||||||
|
links.extend(items);
|
||||||
|
sent.push(payload);
|
||||||
|
}
|
||||||
|
None => outcomes.push((
|
||||||
|
payload.uid.clone(),
|
||||||
|
Err(ProtonError::invalid_operation(format!(
|
||||||
|
"photo {} has no name hash on its current parent; it cannot be moved",
|
||||||
|
payload.uid
|
||||||
|
))),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if links.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!(
|
||||||
|
"photos/volumes/{}/links/transfer-multiple",
|
||||||
|
target.uid.volume_id
|
||||||
|
);
|
||||||
|
let response: TransferPhotosResponse = self
|
||||||
|
.http
|
||||||
|
.put(
|
||||||
|
&path,
|
||||||
|
&TransferPhotosRequest {
|
||||||
|
parent_link_id: target.uid.link_id.clone(),
|
||||||
|
links,
|
||||||
|
name_signature_email: target.email.clone(),
|
||||||
|
signature_email: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Only the links the server reports on failed; a main photo without an
|
||||||
|
// entry succeeded, and a related photo's own failure is not reported
|
||||||
|
// (the incomplete-group case is keyed on the main link), exactly as C#
|
||||||
|
// and JS map it.
|
||||||
|
let mut failures: HashMap<String, (Vec<NodeUid>, ProtonError)> = HashMap::new();
|
||||||
|
for outcome in response.responses {
|
||||||
|
let body = &outcome.response;
|
||||||
|
if body.code == SUCCESS_CODE && body.error.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let missing: Vec<NodeUid> = body
|
||||||
|
.details
|
||||||
|
.as_ref()
|
||||||
|
.map(|details| details.missing.as_slice())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.map(|link_id| NodeUid::new(target.uid.volume_id.clone(), link_id.clone()))
|
||||||
|
.collect();
|
||||||
|
let error = if missing.is_empty() {
|
||||||
|
ProtonError::invalid_operation(
|
||||||
|
body.error
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("transfer failed with code {}", body.code)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
missing_related_photos_error(&missing)
|
||||||
|
};
|
||||||
|
failures.insert(outcome.link_id.to_string(), (missing, error));
|
||||||
|
}
|
||||||
|
|
||||||
|
for payload in sent {
|
||||||
|
let failure = failures.remove(&payload.uid.link_id.to_string());
|
||||||
|
record_timeline_outcome(payload.uid.clone(), failure, queue, retried, outcomes);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy one photo that lives on another volume into the timeline (C#
|
||||||
|
/// `CopyCrossVolumePhotoAsync`, JS `copyPhoto`).
|
||||||
|
async fn copy_photo_to_timeline(
|
||||||
|
&self,
|
||||||
|
target: &TimelineTarget,
|
||||||
|
payload: &PhotoTargetPayload,
|
||||||
|
queue: &mut VecDeque<(NodeUid, Vec<NodeUid>)>,
|
||||||
|
retried: &mut HashSet<NodeUid>,
|
||||||
|
outcomes: &mut Vec<(NodeUid, Result<()>)>,
|
||||||
|
) {
|
||||||
|
let path = format!(
|
||||||
|
"volumes/{}/links/{}/copy",
|
||||||
|
payload.uid.volume_id, payload.uid.link_id
|
||||||
|
);
|
||||||
|
let request = CopyPhotoRequest {
|
||||||
|
target_volume_id: target.uid.volume_id.clone(),
|
||||||
|
target_parent_link_id: target.uid.link_id.clone(),
|
||||||
|
name_hash: payload.name_hash.clone(),
|
||||||
|
name: payload.name.clone(),
|
||||||
|
name_signature_email: payload.name_signature_email.clone(),
|
||||||
|
passphrase: payload.passphrase.clone(),
|
||||||
|
passphrase_signature: None,
|
||||||
|
signature_email: None,
|
||||||
|
photos: CopyPhotoContent {
|
||||||
|
content_hash: payload.content_hash.clone(),
|
||||||
|
related_photos: payload
|
||||||
|
.related
|
||||||
|
.iter()
|
||||||
|
.map(|related| CopyPhotoRelatedItem {
|
||||||
|
link_id: related.uid.link_id.clone(),
|
||||||
|
name_hash: related.name_hash.clone(),
|
||||||
|
name: related.name.clone(),
|
||||||
|
passphrase: related.passphrase.clone(),
|
||||||
|
content_hash: related.content_hash.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let failure = match self
|
||||||
|
.http
|
||||||
|
.post::<_, CopyPhotoResponse>(&path, &request)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => None,
|
||||||
|
// The incomplete-group answer arrives as an ordinary API error whose
|
||||||
|
// `Details.Missing` names the related photos on the *source* volume.
|
||||||
|
Err(ProtonError::Api(error)) => {
|
||||||
|
let missing = missing_related_links(&error, &payload.uid.volume_id);
|
||||||
|
let error = if missing.is_empty() {
|
||||||
|
ProtonError::Api(error)
|
||||||
|
} else {
|
||||||
|
missing_related_photos_error(&missing)
|
||||||
|
};
|
||||||
|
Some((missing, error))
|
||||||
|
}
|
||||||
|
Err(error) => Some((Vec::new(), error)),
|
||||||
|
};
|
||||||
|
record_timeline_outcome(payload.uid.clone(), failure, queue, retried, outcomes);
|
||||||
|
}
|
||||||
|
|
||||||
/// Page the albums on the account's photos volume.
|
/// Page the albums on the account's photos volume.
|
||||||
///
|
///
|
||||||
/// Mirrors C# `PhotosNodeOperations.EnumerateAlbumNodeUidsAsync`:
|
/// Mirrors C# `PhotosNodeOperations.EnumerateAlbumNodeUidsAsync`:
|
||||||
@@ -7589,6 +7974,101 @@ fn share_membership_from_dto(dto: &ShareMembershipSummaryDto) -> ShareMembership
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One `transfer-multiple` link item, or `None` when the photo's link carries
|
||||||
|
/// no name hash for the parent it is leaving — the endpoint needs it to match.
|
||||||
|
fn transfer_link_item(payload: &PhotoTargetPayload) -> Option<TransferPhotoLinkItem> {
|
||||||
|
Some(TransferPhotoLinkItem {
|
||||||
|
link_id: payload.uid.link_id.clone(),
|
||||||
|
name_hash: payload.name_hash.clone(),
|
||||||
|
original_name_hash: payload.original_name_hash.clone()?,
|
||||||
|
name: payload.name.clone(),
|
||||||
|
passphrase: payload.passphrase.clone(),
|
||||||
|
content_hash: payload.content_hash.clone(),
|
||||||
|
passphrase_signature: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The related photos an error envelope's `Details.Missing` names, on the volume
|
||||||
|
/// the failing photo lives on (C# `MissingRelatedPhotosException`).
|
||||||
|
fn missing_related_links(error: &ProtonApiError, volume_id: &VolumeId) -> Vec<NodeUid> {
|
||||||
|
error
|
||||||
|
.details
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|details| details.get("Missing"))
|
||||||
|
.and_then(|missing| missing.as_array())
|
||||||
|
.map(|missing| {
|
||||||
|
missing
|
||||||
|
.iter()
|
||||||
|
.filter_map(|link_id| link_id.as_str())
|
||||||
|
.map(|link_id| NodeUid::new(volume_id.clone(), LinkId::from(link_id.to_string())))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_related_photos_error(missing: &[NodeUid]) -> ProtonError {
|
||||||
|
let missing: Vec<String> = missing.iter().map(|uid| uid.to_string()).collect();
|
||||||
|
ProtonError::invalid_operation(format!(
|
||||||
|
"server wants related photos saved with it: {}",
|
||||||
|
missing.join(", ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn one photo's answer into an outcome, or re-queue it once when the server
|
||||||
|
/// asked for its related photos (C# `PhotoOperations.RecordOutcome`). `failure`
|
||||||
|
/// carries the related photos the server named, when it named any.
|
||||||
|
fn record_timeline_outcome(
|
||||||
|
uid: NodeUid,
|
||||||
|
failure: Option<(Vec<NodeUid>, ProtonError)>,
|
||||||
|
queue: &mut VecDeque<(NodeUid, Vec<NodeUid>)>,
|
||||||
|
retried: &mut HashSet<NodeUid>,
|
||||||
|
outcomes: &mut Vec<(NodeUid, Result<()>)>,
|
||||||
|
) {
|
||||||
|
match failure {
|
||||||
|
None => outcomes.push((uid, Ok(()))),
|
||||||
|
// `insert` is false once this photo has already been re-queued, which is
|
||||||
|
// what stops a server that keeps asking from looping forever.
|
||||||
|
Some((missing, _error)) if !missing.is_empty() && retried.insert(uid.clone()) => {
|
||||||
|
queue.push_back((uid, missing));
|
||||||
|
}
|
||||||
|
Some((_, error)) => outcomes.push((uid, Err(error))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The timeline root and the keys photos are re-encrypted for on their way into
|
||||||
|
/// it (C# `PhotoOperations.TimelineTarget`).
|
||||||
|
struct TimelineTarget {
|
||||||
|
uid: NodeUid,
|
||||||
|
key: PrivateKey,
|
||||||
|
hash_key: Vec<u8>,
|
||||||
|
/// The photos share membership address: what signs the re-encrypted names.
|
||||||
|
email: String,
|
||||||
|
signing_key: PrivateKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One photo re-encrypted for a target node, plus its related photos (C#
|
||||||
|
/// `TransferEncryptedPhotoPayload`). Feeds both the album `add-multiple` entries
|
||||||
|
/// and the timeline transfer/copy requests, which all want the same ciphertext
|
||||||
|
/// and hashes in slightly different wire shapes.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct PhotoTargetPayload {
|
||||||
|
uid: NodeUid,
|
||||||
|
/// Where the photo lives now — the timeline path rejects a photo already
|
||||||
|
/// parented to the target.
|
||||||
|
parent_uid: Option<NodeUid>,
|
||||||
|
/// The photo's name hash under its *current* parent's hash key, straight
|
||||||
|
/// from the link. Only the transfer endpoint needs it.
|
||||||
|
original_name_hash: Option<String>,
|
||||||
|
name_hash: String,
|
||||||
|
name: String,
|
||||||
|
name_signature_email: String,
|
||||||
|
passphrase: String,
|
||||||
|
content_hash: String,
|
||||||
|
/// Live-photo video and burst siblings, re-encrypted the same way. The
|
||||||
|
/// server refuses a photo whose group is incomplete.
|
||||||
|
related: Vec<PhotoTargetPayload>,
|
||||||
|
}
|
||||||
|
|
||||||
/// The Drive-owned items of one `v2/sharedwithme` page, in the order the API
|
/// The Drive-owned items of one `v2/sharedwithme` page, in the order the API
|
||||||
/// returned them.
|
/// returned them.
|
||||||
///
|
///
|
||||||
@@ -7617,9 +8097,10 @@ mod tests {
|
|||||||
CONTEXT_SHARE_CACHE_CAP, DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP,
|
CONTEXT_SHARE_CACHE_CAP, DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP,
|
||||||
MAX_NODE_NAME_LENGTH, NodeAction, aggregate_outcomes, alternate_names, assemble_manifest,
|
MAX_NODE_NAME_LENGTH, NodeAction, aggregate_outcomes, alternate_names, assemble_manifest,
|
||||||
context_share_path, drive_items, epoch_to_iso8601, is_expired_upload_token,
|
context_share_path, drive_items, epoch_to_iso8601, is_expired_upload_token,
|
||||||
is_listable_revision_state, is_upload_timeout, join_node_path, path_segments,
|
is_listable_revision_state, is_upload_timeout, join_node_path, missing_related_links,
|
||||||
run_context_share_mutation, share_membership_from_dto, small_upload_applicable,
|
path_segments, record_timeline_outcome, run_context_share_mutation,
|
||||||
take_block_target, take_thumbnail_target, to_drive_event, validate_node_name,
|
share_membership_from_dto, 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,
|
||||||
@@ -8271,4 +8752,87 @@ mod tests {
|
|||||||
)));
|
)));
|
||||||
assert!(!is_upload_timeout(&api_error(ResponseCode::Success, 504)));
|
assert!(!is_upload_timeout(&api_error(ResponseCode::Success, 504)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_photo_the_server_wants_related_photos_for_is_requeued_once() {
|
||||||
|
let uid = NodeUid::new(VolumeId::new("vol"), LinkId::new("photo"));
|
||||||
|
let related = NodeUid::new(VolumeId::new("vol"), LinkId::new("related"));
|
||||||
|
let mut queue = std::collections::VecDeque::new();
|
||||||
|
let mut retried = std::collections::HashSet::new();
|
||||||
|
let mut outcomes = Vec::new();
|
||||||
|
|
||||||
|
let failure = || {
|
||||||
|
Some((
|
||||||
|
vec![related.clone()],
|
||||||
|
ProtonError::invalid_operation("missing"),
|
||||||
|
))
|
||||||
|
};
|
||||||
|
|
||||||
|
// First answer: re-queued with the related photo, no outcome yet.
|
||||||
|
record_timeline_outcome(
|
||||||
|
uid.clone(),
|
||||||
|
failure(),
|
||||||
|
&mut queue,
|
||||||
|
&mut retried,
|
||||||
|
&mut outcomes,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
queue.pop_front(),
|
||||||
|
Some((uid.clone(), vec![related.clone()]))
|
||||||
|
);
|
||||||
|
assert!(outcomes.is_empty());
|
||||||
|
|
||||||
|
// Second: the retry is spent, so it becomes a failed outcome instead of
|
||||||
|
// going round again.
|
||||||
|
record_timeline_outcome(
|
||||||
|
uid.clone(),
|
||||||
|
failure(),
|
||||||
|
&mut queue,
|
||||||
|
&mut retried,
|
||||||
|
&mut outcomes,
|
||||||
|
);
|
||||||
|
assert!(queue.is_empty());
|
||||||
|
assert_eq!(outcomes.len(), 1);
|
||||||
|
assert!(outcomes[0].1.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_photo_that_succeeds_records_one_successful_outcome() {
|
||||||
|
let uid = NodeUid::new(VolumeId::new("vol"), LinkId::new("photo"));
|
||||||
|
let mut queue = std::collections::VecDeque::new();
|
||||||
|
let mut retried = std::collections::HashSet::new();
|
||||||
|
let mut outcomes = Vec::new();
|
||||||
|
|
||||||
|
record_timeline_outcome(uid.clone(), None, &mut queue, &mut retried, &mut outcomes);
|
||||||
|
|
||||||
|
assert!(queue.is_empty());
|
||||||
|
assert_eq!(outcomes.len(), 1);
|
||||||
|
assert!(outcomes[0].1.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_related_links_reads_the_error_details() {
|
||||||
|
let volume_id = VolumeId::new("vol");
|
||||||
|
let error = ProtonApiError {
|
||||||
|
code: ResponseCode::InvalidRequirements,
|
||||||
|
http_status: 422,
|
||||||
|
message: "missing related photos".into(),
|
||||||
|
details: Some(serde_json::json!({ "Missing": ["a", "b"] })),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
missing_related_links(&error, &volume_id),
|
||||||
|
vec![
|
||||||
|
NodeUid::new(volume_id.clone(), LinkId::new("a")),
|
||||||
|
NodeUid::new(volume_id.clone(), LinkId::new("b")),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// An error that names nothing is an ordinary failure, not a retry.
|
||||||
|
let plain = ProtonApiError {
|
||||||
|
details: None,
|
||||||
|
..error
|
||||||
|
};
|
||||||
|
assert!(missing_related_links(&plain, &volume_id).is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -413,34 +413,138 @@ pub struct AddPhotoToAlbumEntry {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct AddPhotosToAlbumResponse {
|
pub struct AddPhotosToAlbumResponse {
|
||||||
#[serde(rename = "Responses", default)]
|
#[serde(rename = "Responses", default)]
|
||||||
pub responses: Vec<AddPhotoToAlbumOutcome>,
|
pub responses: Vec<PhotoLinkOutcome>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct AddPhotoToAlbumOutcome {
|
pub struct PhotoLinkOutcome {
|
||||||
#[serde(rename = "LinkID")]
|
#[serde(rename = "LinkID")]
|
||||||
pub link_id: LinkId,
|
pub link_id: LinkId,
|
||||||
#[serde(rename = "Response")]
|
#[serde(rename = "Response")]
|
||||||
pub response: AddPhotoToAlbumOutcomeBody,
|
pub response: PhotoLinkOutcomeBody,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct AddPhotoToAlbumOutcomeBody {
|
pub struct PhotoLinkOutcomeBody {
|
||||||
#[serde(rename = "Code", default)]
|
#[serde(rename = "Code", default)]
|
||||||
pub code: i32,
|
pub code: i32,
|
||||||
#[serde(rename = "Error", default)]
|
#[serde(rename = "Error", default)]
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
/// Carries `Missing` — related photos the server demands alongside this one.
|
/// Carries `Missing` — related photos the server demands alongside this one.
|
||||||
#[serde(rename = "Details", default)]
|
#[serde(rename = "Details", default)]
|
||||||
pub details: Option<AddPhotoToAlbumOutcomeDetails>,
|
pub details: Option<PhotoLinkOutcomeDetails>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct AddPhotoToAlbumOutcomeDetails {
|
pub struct PhotoLinkOutcomeDetails {
|
||||||
#[serde(rename = "Missing", default)]
|
#[serde(rename = "Missing", default)]
|
||||||
pub missing: Vec<LinkId>,
|
pub missing: Vec<LinkId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `PUT photos/volumes/{vid}/links/transfer-multiple` — move photos that
|
||||||
|
/// already live on our own photos volume into another node on it (C#
|
||||||
|
/// `PhotosApiClient.TransferPhotosAsync`, JS `transferPhotos`). Used to save
|
||||||
|
/// photos into the timeline root; a main photo's related photos travel in the
|
||||||
|
/// same `Links` list.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct TransferPhotosRequest {
|
||||||
|
#[serde(rename = "ParentLinkID")]
|
||||||
|
pub parent_link_id: LinkId,
|
||||||
|
#[serde(rename = "Links")]
|
||||||
|
pub links: Vec<TransferPhotoLinkItem>,
|
||||||
|
#[serde(rename = "NameSignatureEmail")]
|
||||||
|
pub name_signature_email: String,
|
||||||
|
/// Sent explicitly as null: the API wants it only for an anonymous node,
|
||||||
|
/// which this path never moves (C# sends the same explicit null).
|
||||||
|
#[serde(rename = "SignatureEmail")]
|
||||||
|
pub signature_email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TransferPhotoLinkItem {
|
||||||
|
#[serde(rename = "LinkID")]
|
||||||
|
pub link_id: LinkId,
|
||||||
|
/// Name hash under the *target's* hash key.
|
||||||
|
#[serde(rename = "Hash")]
|
||||||
|
pub name_hash: String,
|
||||||
|
/// The photo's current name hash, under the hash key of the parent it is
|
||||||
|
/// moving out of — the server matches the link on it.
|
||||||
|
#[serde(rename = "OriginalHash")]
|
||||||
|
pub original_name_hash: String,
|
||||||
|
#[serde(rename = "Name")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "NodePassphrase")]
|
||||||
|
pub passphrase: String,
|
||||||
|
#[serde(rename = "ContentHash")]
|
||||||
|
pub content_hash: String,
|
||||||
|
/// As `SignatureEmail` above: anonymous nodes only.
|
||||||
|
#[serde(rename = "NodePassphraseSignature")]
|
||||||
|
pub passphrase_signature: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct TransferPhotosResponse {
|
||||||
|
#[serde(rename = "Responses", default)]
|
||||||
|
pub responses: Vec<PhotoLinkOutcome>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST volumes/{vid}/links/{lid}/copy` — copy a photo that lives on *another*
|
||||||
|
/// volume (one shared with us) into our own (C# `PhotosApiClient.CopyPhotoAsync`,
|
||||||
|
/// JS `copyPhoto`). Unlike the transfer endpoint this takes one photo per
|
||||||
|
/// request, with its related photos nested in `Photos`.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CopyPhotoRequest {
|
||||||
|
#[serde(rename = "TargetVolumeID")]
|
||||||
|
pub target_volume_id: VolumeId,
|
||||||
|
#[serde(rename = "TargetParentLinkID")]
|
||||||
|
pub target_parent_link_id: LinkId,
|
||||||
|
#[serde(rename = "Hash")]
|
||||||
|
pub name_hash: String,
|
||||||
|
#[serde(rename = "Name")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "NameSignatureEmail")]
|
||||||
|
pub name_signature_email: String,
|
||||||
|
#[serde(rename = "NodePassphrase")]
|
||||||
|
pub passphrase: String,
|
||||||
|
#[serde(
|
||||||
|
rename = "NodePassphraseSignature",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub passphrase_signature: Option<String>,
|
||||||
|
#[serde(rename = "SignatureEmail", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub signature_email: Option<String>,
|
||||||
|
#[serde(rename = "Photos")]
|
||||||
|
pub photos: CopyPhotoContent,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CopyPhotoContent {
|
||||||
|
#[serde(rename = "ContentHash")]
|
||||||
|
pub content_hash: String,
|
||||||
|
#[serde(rename = "RelatedPhotos")]
|
||||||
|
pub related_photos: Vec<CopyPhotoRelatedItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CopyPhotoRelatedItem {
|
||||||
|
#[serde(rename = "LinkID")]
|
||||||
|
pub link_id: LinkId,
|
||||||
|
#[serde(rename = "Hash")]
|
||||||
|
pub name_hash: String,
|
||||||
|
#[serde(rename = "Name")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "NodePassphrase")]
|
||||||
|
pub passphrase: String,
|
||||||
|
#[serde(rename = "ContentHash")]
|
||||||
|
pub content_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CopyPhotoResponse {
|
||||||
|
#[serde(rename = "LinkID")]
|
||||||
|
pub link_id: LinkId,
|
||||||
|
}
|
||||||
|
|
||||||
/// `POST`/`DELETE photos/volumes/{vid}/links/{lid}/tags` (C# `PhotoTagsRequest`).
|
/// `POST`/`DELETE photos/volumes/{vid}/links/{lid}/tags` (C# `PhotoTagsRequest`).
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct PhotoTagsRequest {
|
pub struct PhotoTagsRequest {
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ use std::io::{Cursor, Read};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use proton_sdk::error::Result;
|
use proton_sdk::error::Result;
|
||||||
use proton_sdk::ids::NodeUid;
|
use proton_sdk::ids::{DriveEventId, NodeUid};
|
||||||
use proton_sdk::session::ProtonApiSession;
|
use proton_sdk::session::ProtonApiSession;
|
||||||
|
|
||||||
use crate::client::ProtonDriveClient;
|
use crate::client::ProtonDriveClient;
|
||||||
|
use crate::events::{DriveEvent, DriveEventScopeId};
|
||||||
use crate::node::{FileThumbnail, Node, Thumbnail, ThumbnailType};
|
use crate::node::{FileThumbnail, Node, Thumbnail, ThumbnailType};
|
||||||
|
|
||||||
/// One photos-timeline entry: a photo node and its capture time (epoch
|
/// One photos-timeline entry: a photo node and its capture time (epoch
|
||||||
@@ -361,6 +362,34 @@ impl ProtonPhotosClient {
|
|||||||
self.drive.update_photos(updates).await
|
self.drive.update_photos(updates).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy photos into this account's own timeline, keeping them even after
|
||||||
|
/// the originals stop being shared. One outcome per input photo.
|
||||||
|
///
|
||||||
|
/// C# `ProtonPhotosClient.SavePhotosToTimelineAsync`: a photo already on our
|
||||||
|
/// photos volume is moved into the timeline root, one from another volume
|
||||||
|
/// (a shared-with-me album) is copied. Related photos travel with their main
|
||||||
|
/// photo. A photo already in the timeline fails its own outcome, and a photo
|
||||||
|
/// whose group the server reports as incomplete is retried once with the
|
||||||
|
/// related photos it named — so outcomes arrive in completion order, not
|
||||||
|
/// input order.
|
||||||
|
pub async fn save_photos_to_timeline(
|
||||||
|
&self,
|
||||||
|
photo_uids: &[NodeUid],
|
||||||
|
) -> Result<Vec<(NodeUid, Result<()>)>> {
|
||||||
|
self.drive.save_photos_to_timeline(photo_uids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive events on a photos scope, from `cursor` onwards
|
||||||
|
/// (C# `ProtonPhotosClient.EnumerateEventsAsync`, which delegates to the
|
||||||
|
/// Drive client exactly as this does).
|
||||||
|
pub async fn enumerate_events(
|
||||||
|
&self,
|
||||||
|
scope: &DriveEventScopeId,
|
||||||
|
cursor: Option<&DriveEventId>,
|
||||||
|
) -> Result<Vec<DriveEvent>> {
|
||||||
|
self.drive.enumerate_events(scope, cursor).await
|
||||||
|
}
|
||||||
|
|
||||||
/// [`update_photos`](Self::update_photos) as a stream: each photo's outcome
|
/// [`update_photos`](Self::update_photos) as a stream: each photo's outcome
|
||||||
/// arrives as that photo's update finishes, mirroring the C#
|
/// arrives as that photo's update finishes, mirroring the C#
|
||||||
/// `IAsyncEnumerable<PhotoUpdateResult>`. The `Err` item is a failure that
|
/// `IAsyncEnumerable<PhotoUpdateResult>`. The `Err` item is a failure that
|
||||||
|
|||||||
Reference in new issue
Block a user