Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
24 changes: 24 additions & 0 deletions Elsa.Extensions.sln
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "diagnostics", "diagnostics"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.OpenTelemetry", "src\modules\diagnostics\Elsa.OpenTelemetry\Elsa.OpenTelemetry.csproj", "{28D04FA3-4DCC-4137-8ED4-9F6F1A815909}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "io", "io", "{9B363CC3-1F38-409B-8E0D-E2C13019902A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.IO", "src\modules\io\Elsa.IO\Elsa.IO.csproj", "{D620FC48-5B8E-4010-86DE-20628364AA6E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.IO.Compression", "src\modules\io\Elsa.IO.Compression\Elsa.IO.Compression.csproj", "{53F9BF60-127F-4F86-9B33-34445BFA512D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.IO.Http", "src\modules\io\Elsa.IO.Http\Elsa.IO.Http.csproj", "{89D15E59-B0BF-4525-99FA-B61BA8451241}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -631,6 +639,18 @@ Global
{28D04FA3-4DCC-4137-8ED4-9F6F1A815909}.Debug|Any CPU.Build.0 = Debug|Any CPU
{28D04FA3-4DCC-4137-8ED4-9F6F1A815909}.Release|Any CPU.ActiveCfg = Release|Any CPU
{28D04FA3-4DCC-4137-8ED4-9F6F1A815909}.Release|Any CPU.Build.0 = Release|Any CPU
{D620FC48-5B8E-4010-86DE-20628364AA6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D620FC48-5B8E-4010-86DE-20628364AA6E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D620FC48-5B8E-4010-86DE-20628364AA6E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D620FC48-5B8E-4010-86DE-20628364AA6E}.Release|Any CPU.Build.0 = Release|Any CPU
{53F9BF60-127F-4F86-9B33-34445BFA512D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{53F9BF60-127F-4F86-9B33-34445BFA512D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{53F9BF60-127F-4F86-9B33-34445BFA512D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{53F9BF60-127F-4F86-9B33-34445BFA512D}.Release|Any CPU.Build.0 = Release|Any CPU
{89D15E59-B0BF-4525-99FA-B61BA8451241}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{89D15E59-B0BF-4525-99FA-B61BA8451241}.Debug|Any CPU.Build.0 = Debug|Any CPU
{89D15E59-B0BF-4525-99FA-B61BA8451241}.Release|Any CPU.ActiveCfg = Release|Any CPU
{89D15E59-B0BF-4525-99FA-B61BA8451241}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -755,6 +775,10 @@ Global
{8D0EC628-350F-47FC-8B36-DD98B4B7CC93} = {30CF0330-4B09-4784-B499-46BED303810B}
{28D04FA3-4DCC-4137-8ED4-9F6F1A815909} = {8D0EC628-350F-47FC-8B36-DD98B4B7CC93}
{04265302-AF6B-4627-807C-DE9E1699D7C9} = {30CF0330-4B09-4784-B499-46BED303810B}
{9B363CC3-1F38-409B-8E0D-E2C13019902A} = {30CF0330-4B09-4784-B499-46BED303810B}
{D620FC48-5B8E-4010-86DE-20628364AA6E} = {9B363CC3-1F38-409B-8E0D-E2C13019902A}
{53F9BF60-127F-4F86-9B33-34445BFA512D} = {9B363CC3-1F38-409B-8E0D-E2C13019902A}
{89D15E59-B0BF-4525-99FA-B61BA8451241} = {9B363CC3-1F38-409B-8E0D-E2C13019902A}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {11A771DA-B728-445E-8A88-AE1C84C3B3A6}
Expand Down
208 changes: 208 additions & 0 deletions src/modules/io/Elsa.IO.Compression/Activities/CreateZipArchive.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
using System.IO.Compression;
using System.Text.Json.Serialization;
using Elsa.Extensions;
using Elsa.IO.Contracts;
using Elsa.IO.Extensions;
using Elsa.Workflows;
using Elsa.Workflows.Attributes;
using Elsa.Workflows.Models;
using Elsa.Workflows.UIHints;
using Microsoft.Extensions.Logging;

namespace Elsa.IO.Compression.Activities;

/// <summary>
/// Creates a ZIP archive from a collection of entries.
/// </summary>
[Activity("Elsa", "Compression", "Creates a ZIP archive from a collection of entries.", DisplayName = "Create Zip Archive")]
public class CreateZipArchive : CodeActivity<Stream>
{
private const string DefaultArchiveName = "archive.zip";
private const string ZipExtension = ".zip";
private const string DefaultEntryNameFormat = "entry_{0}";

/// <inheritdoc />
[JsonConstructor]
public CreateZipArchive(string? source = null, int? line = null) : base(source, line)
{
}

/// <summary>
/// The entries to include in the ZIP archive. Can be byte[], Stream, file path, file URL, base64 string, ZipEntry objects, or arrays of these types.
/// </summary>
[Input(
Description = "The entries to include in the ZIP archive. Can be byte[], Stream, file path, file URL, base64 string, ZipEntry objects, or arrays of these types",
UIHint = InputUIHints.MultiLine
)]
public Input<object?> Entries { get; set; } = null!;

/// <summary>
/// The compression level for the Zip Entries. Default is Optimal
/// </summary>
[Input(
Description = "The compression level for the Zip Entries. Default is Optimal",
UIHint = InputUIHints.DropDown
)]
public Input<CompressionLevel> CompressionLevel { get; set; } = new(System.IO.Compression.CompressionLevel.Optimal);

/// <inheritdoc />
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
var entriesInput = Entries.Get(context);
var resolver = context.GetRequiredService<IContentResolver>();
var logger = context.GetRequiredService<ILogger<CreateZipArchive>>();

var entries = ParseEntries(entriesInput);

var zipStream = await CreateZipStreamFromEntries(entries, resolver, context, logger);

Result.Set(context, zipStream);
}

private static IEnumerable<object> ParseEntries(object? entriesInput)
{
return entriesInput switch
{
null => [],
IEnumerable<object> enumerable => enumerable,
Array array => array.Cast<object>(),
_ => [entriesInput]
};
}

private async Task<Stream> CreateZipStreamFromEntries(
IEnumerable<object> entries,
IContentResolver resolver,
ActivityExecutionContext context,
ILogger logger)
{
var zipStream = new MemoryStream();

try
{
using var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Update, leaveOpen: true);
var entryIndex = 0;

var compressionLevel = CompressionLevel.Get(context);
foreach (var entryContent in entries)
{
try
{
await ProcessZipEntry(entryContent, zipArchive, resolver, context, entryIndex, compressionLevel);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to add entry {EntryIndex} to ZIP archive. Reason: {ExceptionMessage}",
entryIndex, ex.Message);
}
entryIndex++;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create ZIP archive");
await zipStream.DisposeAsync();
throw;
}

// Reset stream position for reading
zipStream.Position = 0;
return zipStream;
}

/// <summary>
/// Processes a single zip entry and adds it to the archive.
/// </summary>
private static async Task ProcessZipEntry(
object entryContent,
ZipArchive zipArchive,
IContentResolver resolver,
ActivityExecutionContext context,
int entryIndex,
CompressionLevel compressionLevel)
{
var binaryContent = await resolver.ResolveAsync(entryContent, context.CancellationToken);

var entryName = binaryContent.Name?.GetNameAndExtension()
?? string.Format(DefaultEntryNameFormat, entryIndex + 1);

// Get a unique name following Windows convention
entryName = GetUniqueEntryName(zipArchive, entryName);

var archiveEntry = zipArchive.CreateEntry(entryName, compressionLevel);

await using var entryStream = archiveEntry.Open();
await binaryContent.Stream.CopyToAsync(entryStream, context.CancellationToken);
await entryStream.FlushAsync(context.CancellationToken);

if (entryContent is not Stream)
{
await binaryContent.Stream.DisposeAsync();
}
}

private static string GetUniqueEntryName(ZipArchive zipArchive, string originalName)
{
var filenameWithoutExtension = Path.GetFileNameWithoutExtension(originalName);
var extension = Path.GetExtension(originalName);

var originalExists = false;
var highestIndex = 0;

foreach (var entry in zipArchive.Entries)
{
if (!entry.Name.Equals(originalName, StringComparison.OrdinalIgnoreCase))
{
continue;
}

originalExists = true;

var entryNameWithoutExtension = Path.GetFileNameWithoutExtension(entry.Name);
var entryExtension = Path.GetExtension(entry.Name);

// Only process entries with the same extension
if (!entryExtension.Equals(extension, StringComparison.OrdinalIgnoreCase))
continue;

// Check if this entry follows our naming pattern
highestIndex = HighestEntryNameIndex(entryNameWithoutExtension, filenameWithoutExtension, highestIndex);
}

if (!originalExists)
{
return originalName;
}

return $"{filenameWithoutExtension}({highestIndex + 1}){extension}";
}

private static int HighestEntryNameIndex(string entryNameWithoutExtension, string filenameWithoutExtension,
int highestIndex)
{
if (!entryNameWithoutExtension.StartsWith(filenameWithoutExtension, StringComparison.OrdinalIgnoreCase) ||
entryNameWithoutExtension.Length <= filenameWithoutExtension.Length ||
entryNameWithoutExtension[filenameWithoutExtension.Length] != '(')
{
return highestIndex;
}

// Extract the number between parentheses
var closingParenIndex = entryNameWithoutExtension.LastIndexOf(')');
if (closingParenIndex <= filenameWithoutExtension.Length + 1)
{
return highestIndex;
}

var indexStr = entryNameWithoutExtension.Substring(
filenameWithoutExtension.Length + 1,
closingParenIndex - filenameWithoutExtension.Length - 1);

if (int.TryParse(indexStr, out var index))
{
highestIndex = Math.Max(highestIndex, index);
}

return highestIndex;
}
}
6 changes: 6 additions & 0 deletions src/modules/io/Elsa.IO.Compression/Common/Constants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Elsa.IO.Compression.Common;

Copy link

Copilot AI Jul 29, 2025

Choose a reason for hiding this comment

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

The public static class Constants is missing XML documentation. Consider adding a summary comment to describe the purpose of this constants class.

Suggested change
/// <summary>
/// Provides constants used in compression operations.
/// </summary>

Copilot uses AI. Check for mistakes.
public static class Constants
{
Copy link

Copilot AI Jul 29, 2025

Choose a reason for hiding this comment

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

The public constant ZipEntryStrategyPriority is missing XML documentation. Consider adding a summary comment to explain what this priority value represents.

Suggested change
{
{
/// <summary>
/// Represents the priority value for the zip entry strategy.
/// This value determines the precedence of this strategy when handling zip entries.
/// </summary>

Copilot uses AI. Check for mistakes.
public const float ZipEntryStrategyPriority = 0.5f;
}
24 changes: 24 additions & 0 deletions src/modules/io/Elsa.IO.Compression/Elsa.IO.Compression.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Description>
Provides compression and archiving activities for Elsa Workflows.
</Description>
<PackageTags>elsa module compression zip archive workflows</PackageTags>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Elsa.IO\Elsa.IO.csproj" />
</ItemGroup>

<ItemGroup Label="Elsa" Condition="'$(UseProjectReferences)' != 'true'">
<PackageReference Include="Elsa.Workflows.Management" />
</ItemGroup>

</Project>
20 changes: 20 additions & 0 deletions src/modules/io/Elsa.IO.Compression/Extensions/ModuleExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Elsa.IO.Compression.Features;
using Elsa.Features.Services;

// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;

/// <summary>
/// Provides extensions to install the <see cref="CompressionFeature"/> feature.
/// </summary>
public static class ModuleExtensions
{
/// <summary>
/// Install the <see cref="CompressionFeature"/> feature.
/// </summary>
public static IModule UseCompression(this IModule module, Action<CompressionFeature>? configure = default)
{
module.Configure(configure);
return module;
}
}
33 changes: 33 additions & 0 deletions src/modules/io/Elsa.IO.Compression/Features/CompressionFeature.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Elsa.Extensions;
using Elsa.Features.Abstractions;
using Elsa.Features.Attributes;
using Elsa.Features.Services;
using Elsa.IO.Compression.Models;
using Elsa.IO.Compression.Services.Strategies;
using Elsa.IO.Features;
using Elsa.IO.Services.Strategies;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;

namespace Elsa.IO.Compression.Features;

/// <summary>
/// Configures compression activities and services.
/// </summary>
[UsedImplicitly]
[DependsOn(typeof(IOFeature))]
public class CompressionFeature(IModule module) : FeatureBase(module)
{
/// <inheritdoc />
public override void Configure()
{
Module.AddActivitiesFrom<CompressionFeature>();
Module.AddVariableTypeAndAlias<ZipEntry>("ZipEntry", "Compression");
}

/// <inheritdoc />
public override void Apply()
{
Services.AddScoped<IContentResolverStrategy, ZipEntryContentStrategy>();
}
}
3 changes: 3 additions & 0 deletions src/modules/io/Elsa.IO.Compression/FodyWeavers.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>
11 changes: 11 additions & 0 deletions src/modules/io/Elsa.IO.Compression/Models/ZipEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using JetBrains.Annotations;

namespace Elsa.IO.Compression.Models;

/// <summary>
/// Represents a zip entry with content and metadata.
/// </summary>
/// <param name="Content">The content of the zip entry. Can be byte[], Stream, file path, file URL, or base64 string.</param>
/// <param name="EntryName">The name of the entry in the zip archive.</param>
[UsedImplicitly]
public record ZipEntry(object Content, string? EntryName = null);
Loading
Loading