mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +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`). |
|
||||
| | 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`). |
|
||||
| | 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. |
|
||||
| **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). |
|
||||
|
||||
+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
|
||||
- **Reconciled subtree**: `client/cs/src` (was `client/cs/sdk/src` before the
|
||||
mid-2026 projects reorg; the script watches both)
|
||||
- **Pinned**: `df6b6bfde620c12868cd5a444d027f8d01974b75`
|
||||
- **Date**: 2026-08-10
|
||||
- **Pinned**: `c8d03244938a6b4d107c755df8904d7d971ed1c2`
|
||||
- **Date**: 2026-08-27
|
||||
|
||||
> **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.
|
||||
@@ -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 | — (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-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,
|
||||
BlockDto, BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockUploadTarget,
|
||||
BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, BookmarksResponse,
|
||||
CommonExtendedAttributes, ContextShareResponse, CreateBookmarkRequest, CreatePublicLinkRequest,
|
||||
CommonExtendedAttributes, ContextShareResponse, CopyPhotoContent, CopyPhotoRelatedItem,
|
||||
CopyPhotoRequest, CopyPhotoResponse, CreateBookmarkRequest, CreatePublicLinkRequest,
|
||||
CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse, DeviceCreationDeviceDto,
|
||||
DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto,
|
||||
DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes,
|
||||
@@ -70,8 +71,8 @@ use crate::dtos::{
|
||||
SharedByMeResponse, SharedWithMeResponse, SmallFileUploadMetadataRequest,
|
||||
SmallRevisionUploadMetadataRequest, SmallUploadResponse, ThumbnailBlockListRequest,
|
||||
ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse,
|
||||
UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse,
|
||||
VolumeTrashResponse,
|
||||
TransferPhotoLinkItem, TransferPhotosRequest, TransferPhotosResponse, UpdatePermissionsRequest,
|
||||
VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse,
|
||||
};
|
||||
use crate::events::{DriveEvent, DriveEventScopeId};
|
||||
use crate::node::{
|
||||
@@ -5971,12 +5972,6 @@ impl ProtonDriveClient {
|
||||
}
|
||||
|
||||
/// 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(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
@@ -5985,6 +5980,36 @@ impl ProtonDriveClient {
|
||||
email: &str,
|
||||
signing_key: &PrivateKey,
|
||||
) -> 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
|
||||
.get_photos_node(uid)
|
||||
.await?
|
||||
@@ -5996,7 +6021,7 @@ impl ProtonDriveClient {
|
||||
};
|
||||
let sha1 = content_sha1.clone().ok_or_else(|| {
|
||||
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
|
||||
.resolve_parent_key_ctx(&uid.volume_id, link, true)
|
||||
.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 {
|
||||
link_id: uid.link_id.clone(),
|
||||
name_hash: hex::encode(hmac_sha256(album_hash_key, node.name.as_bytes())),
|
||||
name: album_key.encrypt_and_sign(signing_key, node.name.as_bytes(), true, false)?,
|
||||
Ok(PhotoTargetPayload {
|
||||
uid: uid.clone(),
|
||||
parent_uid: link
|
||||
.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(),
|
||||
passphrase,
|
||||
content_hash: hex::encode(hmac_sha256(
|
||||
album_hash_key,
|
||||
target_hash_key,
|
||||
sha1.to_ascii_lowercase().as_bytes(),
|
||||
)),
|
||||
related: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6095,6 +6126,360 @@ impl ProtonDriveClient {
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
/// returned them.
|
||||
///
|
||||
@@ -7617,9 +8097,10 @@ mod tests {
|
||||
CONTEXT_SHARE_CACHE_CAP, DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP,
|
||||
MAX_NODE_NAME_LENGTH, NodeAction, aggregate_outcomes, alternate_names, assemble_manifest,
|
||||
context_share_path, drive_items, epoch_to_iso8601, 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, validate_node_name,
|
||||
is_listable_revision_state, is_upload_timeout, join_node_path, missing_related_links,
|
||||
path_segments, record_timeline_outcome, run_context_share_mutation,
|
||||
share_membership_from_dto, small_upload_applicable, take_block_target,
|
||||
take_thumbnail_target, to_drive_event, validate_node_name,
|
||||
};
|
||||
use crate::dtos::{
|
||||
AggregateLinksResponse, BlockUploadTarget, LinkIdResponsePair, ShareMembershipSummaryDto,
|
||||
@@ -8271,4 +8752,87 @@ mod tests {
|
||||
)));
|
||||
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)]
|
||||
pub struct AddPhotosToAlbumResponse {
|
||||
#[serde(rename = "Responses", default)]
|
||||
pub responses: Vec<AddPhotoToAlbumOutcome>,
|
||||
pub responses: Vec<PhotoLinkOutcome>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddPhotoToAlbumOutcome {
|
||||
pub struct PhotoLinkOutcome {
|
||||
#[serde(rename = "LinkID")]
|
||||
pub link_id: LinkId,
|
||||
#[serde(rename = "Response")]
|
||||
pub response: AddPhotoToAlbumOutcomeBody,
|
||||
pub response: PhotoLinkOutcomeBody,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddPhotoToAlbumOutcomeBody {
|
||||
pub struct PhotoLinkOutcomeBody {
|
||||
#[serde(rename = "Code", default)]
|
||||
pub code: i32,
|
||||
#[serde(rename = "Error", default)]
|
||||
pub error: Option<String>,
|
||||
/// Carries `Missing` — related photos the server demands alongside this one.
|
||||
#[serde(rename = "Details", default)]
|
||||
pub details: Option<AddPhotoToAlbumOutcomeDetails>,
|
||||
pub details: Option<PhotoLinkOutcomeDetails>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddPhotoToAlbumOutcomeDetails {
|
||||
pub struct PhotoLinkOutcomeDetails {
|
||||
#[serde(rename = "Missing", default)]
|
||||
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`).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PhotoTagsRequest {
|
||||
|
||||
@@ -15,10 +15,11 @@ use std::io::{Cursor, Read};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use proton_sdk::error::Result;
|
||||
use proton_sdk::ids::NodeUid;
|
||||
use proton_sdk::ids::{DriveEventId, NodeUid};
|
||||
use proton_sdk::session::ProtonApiSession;
|
||||
|
||||
use crate::client::ProtonDriveClient;
|
||||
use crate::events::{DriveEvent, DriveEventScopeId};
|
||||
use crate::node::{FileThumbnail, Node, Thumbnail, ThumbnailType};
|
||||
|
||||
/// One photos-timeline entry: a photo node and its capture time (epoch
|
||||
@@ -361,6 +362,34 @@ impl ProtonPhotosClient {
|
||||
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
|
||||
/// arrives as that photo's update finishes, mirroring the C#
|
||||
/// `IAsyncEnumerable<PhotoUpdateResult>`. The `Err` item is a failure that
|
||||
|
||||
Reference in new issue
Block a user