Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ManagedCode.Storage.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
<Project Path="Storages/ManagedCode.Storage.FileSystem/ManagedCode.Storage.FileSystem.csproj" />
<Project Path="Storages/ManagedCode.Storage.Sftp/ManagedCode.Storage.Sftp.csproj" />
<Project Path="Storages/ManagedCode.Storage.Google/ManagedCode.Storage.Google.csproj" />
<Project Path="Storages/ManagedCode.Storage.GoogleDrive/ManagedCode.Storage.GoogleDrive.csproj" />
<Project Path="Storages/ManagedCode.Storage.OneDrive/ManagedCode.Storage.OneDrive.csproj" />
<Project Path="Storages/ManagedCode.Storage.Dropbox/ManagedCode.Storage.Dropbox.csproj" />
</Folder>
<Folder Name="/Tests/">
<Project Path="Tests/ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj" />
Expand Down
154 changes: 154 additions & 0 deletions Storages/ManagedCode.Storage.Dropbox/Clients/DropboxClientWrapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Dropbox.Api;
using Dropbox.Api.Files;

namespace ManagedCode.Storage.Dropbox.Clients;

public class DropboxClientWrapper : IDropboxClientWrapper
{
private readonly DropboxClient _client;

public DropboxClientWrapper(DropboxClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}

public async Task EnsureRootAsync(string rootPath, bool createIfNotExists, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(rootPath))
{
return;
}

var normalized = Normalize(rootPath);
try
{
await _client.Files.GetMetadataAsync(normalized);
}
catch (ApiException<GetMetadataError> ex) when (ex.ErrorResponse.IsPath && ex.ErrorResponse.AsPath.Value.IsNotFound)
{
if (!createIfNotExists)
{
return;
}

await _client.Files.CreateFolderV2Async(normalized, autorename: false);
}
}
Comment on lines +22 to +43
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The EnsureRootAsync implementation creates a folder but doesn't verify if it already exists before creation, which could lead to exceptions if createIfNotExists is false but the folder doesn't exist. The method should check existence first and only create when both the folder is missing and createIfNotExists is true.

Copilot uses AI. Check for mistakes.

public async Task<DropboxItemMetadata> UploadAsync(string rootPath, string path, Stream content, string? contentType, CancellationToken cancellationToken)
{
var fullPath = Combine(rootPath, path);
var uploaded = await _client.Files.UploadAsync(fullPath, WriteMode.Overwrite.Instance, body: content);
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cancellationToken parameter is not used in any of the Stream or Content operations within the UploadAsync method, which means long-running uploads cannot be cancelled properly. The token should be passed to the UploadAsync call.

Suggested change
var uploaded = await _client.Files.UploadAsync(fullPath, WriteMode.Overwrite.Instance, body: content);
var uploaded = await _client.Files.UploadAsync(fullPath, WriteMode.Overwrite.Instance, body: content, cancellationToken: cancellationToken);

Copilot uses AI. Check for mistakes.
var metadata = (await _client.Files.GetMetadataAsync(uploaded.PathLower)).AsFile;
return ToItem(metadata);
}

public async Task<Stream> DownloadAsync(string rootPath, string path, CancellationToken cancellationToken)
{
var fullPath = Combine(rootPath, path);
var response = await _client.Files.DownloadAsync(fullPath);
return await response.GetContentAsStreamAsync();
Comment on lines +56 to +57
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cancellationToken is not used in the DownloadAsync call, preventing cancellation of long-running downloads. The Dropbox API supports cancellation tokens, so they should be passed through.

Suggested change
var response = await _client.Files.DownloadAsync(fullPath);
return await response.GetContentAsStreamAsync();
var response = await _client.Files.DownloadAsync(fullPath, cancellationToken: cancellationToken);
return await response.GetContentAsStreamAsync(cancellationToken);

Copilot uses AI. Check for mistakes.
}

public async Task<bool> DeleteAsync(string rootPath, string path, CancellationToken cancellationToken)
{
var fullPath = Combine(rootPath, path);
await _client.Files.DeleteV2Async(fullPath);
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cancellationToken parameter is not passed to the DeleteV2Async call, which means delete operations cannot be cancelled. Pass the cancellationToken to the API call.

Suggested change
await _client.Files.DeleteV2Async(fullPath);
await _client.Files.DeleteV2Async(fullPath, cancellationToken: cancellationToken);

Copilot uses AI. Check for mistakes.
return true;
}

public async Task<bool> ExistsAsync(string rootPath, string path, CancellationToken cancellationToken)
{
var fullPath = Combine(rootPath, path);
try
{
await _client.Files.GetMetadataAsync(fullPath);
return true;
}
catch (ApiException<GetMetadataError> ex) when (ex.ErrorResponse.IsPath && ex.ErrorResponse.AsPath.Value.IsNotFound)
{
return false;
}
}

public async Task<DropboxItemMetadata?> GetMetadataAsync(string rootPath, string path, CancellationToken cancellationToken)
{
var fullPath = Combine(rootPath, path);
try
{
var metadata = await _client.Files.GetMetadataAsync(fullPath);
return metadata.IsFile ? ToItem(metadata.AsFile) : null;
}
catch (ApiException<GetMetadataError> ex) when (ex.ErrorResponse.IsPath && ex.ErrorResponse.AsPath.Value.IsNotFound)
{
return null;
}
}
Comment on lines +67 to +93
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GetMetadataAsync and ExistsAsync methods don't pass the cancellationToken to the API calls, preventing cancellation of these operations.

Copilot uses AI. Check for mistakes.

public async IAsyncEnumerable<DropboxItemMetadata> ListAsync(string rootPath, string? directory, [EnumeratorCancellation] CancellationToken cancellationToken)
{
var fullPath = Combine(rootPath, directory ?? string.Empty);
var list = await _client.Files.ListFolderAsync(fullPath);
foreach (var item in list.Entries)
{
if (item.IsFile)
{
yield return ToItem(item.AsFile);
}
}

while (list.HasMore)
{
list = await _client.Files.ListFolderContinueAsync(list.Cursor);
foreach (var item in list.Entries)
{
if (item.IsFile)
{
yield return ToItem(item.AsFile);
}
Comment on lines +99 to +115
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Suggested change
foreach (var item in list.Entries)
{
if (item.IsFile)
{
yield return ToItem(item.AsFile);
}
}
while (list.HasMore)
{
list = await _client.Files.ListFolderContinueAsync(list.Cursor);
foreach (var item in list.Entries)
{
if (item.IsFile)
{
yield return ToItem(item.AsFile);
}
foreach (var item in list.Entries.Where(item => item.IsFile))
{
yield return ToItem(item.AsFile);
}
while (list.HasMore)
{
list = await _client.Files.ListFolderContinueAsync(list.Cursor);
foreach (var item in list.Entries.Where(item => item.IsFile))
{
yield return ToItem(item.AsFile);

Copilot uses AI. Check for mistakes.
Comment on lines +99 to +115
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Suggested change
foreach (var item in list.Entries)
{
if (item.IsFile)
{
yield return ToItem(item.AsFile);
}
}
while (list.HasMore)
{
list = await _client.Files.ListFolderContinueAsync(list.Cursor);
foreach (var item in list.Entries)
{
if (item.IsFile)
{
yield return ToItem(item.AsFile);
}
foreach (var item in list.Entries.Where(item => item.IsFile))
{
yield return ToItem(item.AsFile);
}
while (list.HasMore)
{
list = await _client.Files.ListFolderContinueAsync(list.Cursor);
foreach (var item in list.Entries.Where(item => item.IsFile))
{
yield return ToItem(item.AsFile);

Copilot uses AI. Check for mistakes.
}
}
}
Comment on lines +95 to +118
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ListFolderAsync and ListFolderContinueAsync calls don't pass the cancellationToken, and the while loop doesn't check for cancellation between pages. This means large folder listings cannot be cancelled mid-operation.

Copilot uses AI. Check for mistakes.

private static DropboxItemMetadata ToItem(FileMetadata file)
{
return new DropboxItemMetadata
{
Name = file.Name,
Path = file.PathLower ?? file.PathDisplay ?? string.Empty,
Size = file.Size,
ClientModified = file.ClientModified,
ServerModified = file.ServerModified
};
}

private static string Normalize(string path)
{
var normalized = path.Replace("\\", "/");
if (!normalized.StartsWith('/'))
{
normalized = "/" + normalized;
}

return normalized.TrimEnd('/') == string.Empty ? "/" : normalized.TrimEnd('/');
}

private static string Combine(string root, string path)
{
var normalizedRoot = Normalize(root);
var normalizedPath = path.Replace("\\", "/").Trim('/');
if (string.IsNullOrWhiteSpace(normalizedPath))
{
return normalizedRoot;
}

return normalizedRoot.EndsWith("/") ? normalizedRoot + normalizedPath : normalizedRoot + "/" + normalizedPath;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System;

namespace ManagedCode.Storage.Dropbox.Clients;

public class DropboxItemMetadata
{
public required string Name { get; set; }
public required string Path { get; set; }
public ulong Size { get; set; }
public DateTime ClientModified { get; set; }
public DateTime ServerModified { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Dropbox.Api.Files;

namespace ManagedCode.Storage.Dropbox.Clients;

public interface IDropboxClientWrapper
{
Task EnsureRootAsync(string rootPath, bool createIfNotExists, CancellationToken cancellationToken);

Task<DropboxItemMetadata> UploadAsync(string rootPath, string path, Stream content, string? contentType, CancellationToken cancellationToken);

Task<Stream> DownloadAsync(string rootPath, string path, CancellationToken cancellationToken);

Task<bool> DeleteAsync(string rootPath, string path, CancellationToken cancellationToken);

Task<bool> ExistsAsync(string rootPath, string path, CancellationToken cancellationToken);

Task<DropboxItemMetadata?> GetMetadataAsync(string rootPath, string path, CancellationToken cancellationToken);

IAsyncEnumerable<DropboxItemMetadata> ListAsync(string rootPath, string? directory, CancellationToken cancellationToken);
}
Loading
Loading