Skip to content

Commit 905245f

Browse files
committed
More on downloads
1 parent c0f5834 commit 905245f

File tree

6 files changed

+252
-52
lines changed

6 files changed

+252
-52
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ Released on Thursday, May 2 2019.
55
- Reference latest AngleSharp
66
- Added `InputFile` class as a standard `IFile` implementation
77
- Added `AppendFile` extensions for `IHtmlInputElement`
8+
- Included `WithDownload` and `WithStandardDownload` configuration
89
- Added ability for binary data restore on assets (#19)
10+
- Added `DownloadAsync` extension method to `IUrlUtilities` elements
11+
- Added more extension methods to `IResponse` (e.g., `SaveToAsync`)
912

1013
# 0.10.1
1114

src/AngleSharp.Io.Tests/Integration/DownloadTest.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,23 @@ public async Task DownloadBinaryNotReceived()
5353
Assert.IsNotNull(linkedDocument);
5454
Assert.AreNotEqual(document, context.Active);
5555
}
56+
57+
[Test]
58+
public async Task StandardDownloadBinary()
59+
{
60+
var downloadSeen = default(string);
61+
var config = Configuration.Default.WithStandardDownload((name, content) =>
62+
{
63+
downloadSeen = name;
64+
content.Dispose();
65+
});
66+
var context = BrowsingContext.New(config);
67+
var document = await context.OpenAsync(req => req.Content("<a href=\"http://example.com/setup.exe\">Download setup</a>"));
68+
var linkedDownload = await document.QuerySelector<IHtmlAnchorElement>("a").NavigateAsync();
69+
70+
Assert.AreEqual("setup.exe", downloadSeen);
71+
Assert.IsNull(linkedDownload);
72+
Assert.AreEqual(document, context.Active);
73+
}
5674
}
5775
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
namespace AngleSharp.Io.Dom
2+
{
3+
using AngleSharp.Dom;
4+
using AngleSharp.Html;
5+
using AngleSharp.Html.Dom;
6+
using System;
7+
using System.IO;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
11+
/// <summary>
12+
/// Extensions for DOM elements.
13+
/// </summary>
14+
public static class ElementExtensions
15+
{
16+
/// <summary>
17+
/// Appends a file to the input element.
18+
/// Requires the input element to be of type "file".
19+
/// </summary>
20+
/// <typeparam name="TElement">The type of element.</typeparam>
21+
/// <param name="input">The input to append to.</param>
22+
/// <param name="file">The file to append.</param>
23+
/// <returns>The input itself for chaining.</returns>
24+
public static TElement AppendFile<TElement>(this TElement input, InputFile file)
25+
where TElement : class, IHtmlInputElement
26+
{
27+
input = input ?? throw new ArgumentNullException(nameof(input));
28+
29+
if (input.Type == InputTypeNames.File)
30+
{
31+
input.Files.Add(file ?? throw new ArgumentNullException(nameof(file)));
32+
}
33+
34+
return input;
35+
}
36+
37+
/// <summary>
38+
/// Appends a file to the input element.
39+
/// Requires the input element to be of type "file".
40+
/// </summary>
41+
/// <typeparam name="TElement">The type of element.</typeparam>
42+
/// <param name="input">The input to append to.</param>
43+
/// <param name="filePath">The path to the file, which should be appended.</param>
44+
/// <returns>The input itself for chaining.</returns>
45+
public static TElement AppendFile<TElement>(this TElement input, String filePath)
46+
where TElement : class, IHtmlInputElement
47+
{
48+
filePath = filePath ?? throw new ArgumentNullException(nameof(filePath));
49+
var name = Path.GetFileName(filePath);
50+
var ext = Path.GetExtension(filePath);
51+
var type = MimeTypeNames.FromExtension(ext);
52+
var stream = File.OpenRead(filePath);
53+
var modified = File.GetLastWriteTimeUtc(filePath);
54+
var file = new InputFile(name, type, stream, modified);
55+
return input.AppendFile(file);
56+
}
57+
58+
/// <summary>
59+
/// Appends a file to the input element.
60+
/// Requires the input element to be of type "file".
61+
/// </summary>
62+
/// <typeparam name="TElement">The type of element.</typeparam>
63+
/// <param name="input">The input to append to.</param>
64+
/// <param name="fileName">The name to the file, which should be appended.</param>
65+
/// <param name="content">The content to the file, which should be appended.</param>
66+
/// <param name="mimeType">
67+
/// The MIME type of the file, which should be appended.
68+
/// If not given the default value is maps to an unknown binary (octet stream).
69+
/// </param>
70+
/// <returns>The input itself for chaining.</returns>
71+
public static TElement AppendFile<TElement>(this TElement input, String fileName, Stream content, String mimeType = null)
72+
where TElement : class, IHtmlInputElement
73+
{
74+
fileName = fileName ?? throw new ArgumentNullException(nameof(fileName));
75+
content = content ?? throw new ArgumentNullException(nameof(content));
76+
var type = mimeType ?? MimeTypeNames.Binary;
77+
var file = new InputFile(fileName, type, content);
78+
return input.AppendFile(file);
79+
}
80+
81+
/// <summary>
82+
/// Downloads the content from to the hyper reference given by the provided
83+
/// element.
84+
/// </summary>
85+
/// <typeparam name="TElement">The type of element.</typeparam>
86+
/// <param name="element">The element referencing the link to follow.</param>
87+
/// <param name="cancellationToken">The token to cancel the download.</param>
88+
/// <returns>The task eventually resulting in the response.</returns>
89+
public static Task<IResponse> DownloadAsync<TElement>(this TElement element, CancellationToken cancellationToken = default(CancellationToken))
90+
where TElement : class, IUrlUtilities, IElement
91+
{
92+
var context = element?.Owner.Context ?? throw new InvalidOperationException("The element needs to be inside a browsing context.");
93+
var loader = context.GetService<IDocumentLoader>() ?? throw new InvalidOperationException("A document loader is required. Check your configuration.");
94+
var download = loader.FetchAsync(new DocumentRequest(new Url(element.Href)));
95+
cancellationToken.Register(download.Cancel);
96+
return download.Task;
97+
}
98+
}
99+
}

src/AngleSharp.Io/Dom/HtmlInputElementExtensions.cs

Lines changed: 0 additions & 51 deletions
This file was deleted.

src/AngleSharp.Io/IoConfigurationExtensions.cs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ namespace AngleSharp
44
using AngleSharp.Io;
55
using AngleSharp.Io.Network;
66
using System;
7+
using System.IO;
78
using System.Linq;
89
using System.Net.Http;
910

@@ -21,13 +22,39 @@ public static class IoConfigurationExtensions
2122
/// The callback to invoke when a download should be started. Returns true
2223
/// to signal an interest in downloading the response, otherwise false.
2324
/// </param>
24-
/// <returns>The configuration.</returns>
25+
/// <returns>The new configuration.</returns>
2526
public static IConfiguration WithDownload(this IConfiguration configuration, Func<MimeType, IResponse, Boolean> download)
2627
{
2728
var oldFactory = configuration.Services.OfType<IDocumentFactory>().FirstOrDefault();
2829
var newFactory = new DownloadFactory(oldFactory, download);
2930
return configuration.WithOnly<IDocumentFactory>(newFactory);
3031
}
32+
33+
/// <summary>
34+
/// Adds the standard download capability, i.e., when a binary or attachment
35+
/// is received the download callback is triggered.
36+
/// </summary>
37+
/// <param name="configuration">The configuration to extend.</param>
38+
/// <param name="download">
39+
/// The callback with filename and stream as parameters. The stream must be
40+
/// disposed / cleaned up after use.
41+
/// </param>
42+
/// <returns>The new configuration.</returns>
43+
public static IConfiguration WithStandardDownload(this IConfiguration configuration, Action<String, Stream> download)
44+
{
45+
var binary = new MimeType(MimeTypeNames.Binary);
46+
return configuration.WithDownload((type, response) =>
47+
{
48+
if (response.IsAttachment() || type == binary)
49+
{
50+
var fileName = response.GetAttachedFileName();
51+
download.Invoke(fileName, response.Content);
52+
return true;
53+
}
54+
55+
return false;
56+
});
57+
}
3158

3259
/// <summary>
3360
/// Adds the requesters from the AngleSharp.Io package.
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
namespace AngleSharp.Io.Network
2+
{
3+
using System;
4+
using System.IO;
5+
using System.Linq;
6+
using System.Threading.Tasks;
7+
8+
/// <summary>
9+
/// A set of useful extension methods for an IResponse.
10+
/// </summary>
11+
public static class ResponseExtensions
12+
{
13+
/// <summary>
14+
/// Saves the given response to the given file path.
15+
/// Disposes the response after saving has finished.
16+
/// </summary>
17+
/// <param name="response">The response to use.</param>
18+
/// <param name="filePath">The path where the response should be saved.</param>
19+
/// <returns>The task storing the file.</returns>
20+
public static async Task SaveToAsync(this IResponse response, String filePath)
21+
{
22+
using (var target = File.OpenWrite(filePath))
23+
{
24+
await response.CopyToAsync(target).ConfigureAwait(false);
25+
}
26+
}
27+
28+
/// <summary>
29+
/// Copies the given response to the provided stream.
30+
/// Disposes the response after saving has finished.
31+
/// </summary>
32+
/// <param name="response">The response to use.</param>
33+
/// <param name="stream">The stream where the response should be copied to.</param>
34+
/// <returns>The task copying to the stream.</returns>
35+
public static async Task CopyToAsync(this IResponse response, Stream stream)
36+
{
37+
using (response)
38+
{
39+
await response.Content.CopyToAsync(stream).ConfigureAwait(false);
40+
}
41+
}
42+
43+
/// <summary>
44+
/// Determines if the given response is provided as an attachment.
45+
/// </summary>
46+
/// <param name="response">The response to extend.</param>
47+
/// <returns>True if the content-disposition is attachment, otherwise false.</returns>
48+
public static Boolean IsAttachment(this IResponse response) =>
49+
response.Headers.TryGetValue(HeaderNames.ContentDisposition, out var disposition) &&
50+
disposition.StartsWith("attachment", StringComparison.InvariantCultureIgnoreCase);
51+
52+
/// <summary>
53+
/// Gets the filename of the content-disposition header or
54+
/// alternatively via a path analysis together with the MIME type.
55+
/// </summary>
56+
/// <param name="response">The response to extend.</param>
57+
/// <returns>The determined file name.</returns>
58+
public static String GetAttachedFileName(this IResponse response)
59+
{
60+
var dispositionFileName = default(String);
61+
62+
if (response.Headers.TryGetValue(HeaderNames.ContentDisposition, out var disposition))
63+
{
64+
dispositionFileName = GetFileNameFromDisposition(disposition);
65+
}
66+
67+
var filename = dispositionFileName ?? response.Address.Path.Split('/').LastOrDefault() ?? "_";
68+
var standardExtension = Path.GetExtension(filename);
69+
70+
if (String.IsNullOrEmpty(standardExtension))
71+
{
72+
var type = response.GetContentType(MimeTypeNames.Binary).Content;
73+
var extension = MimeTypeNames.GetExtension(type);
74+
return filename + extension;
75+
}
76+
77+
78+
return filename;
79+
}
80+
81+
private static String GetFileNameFromDisposition(String value)
82+
{
83+
if (!String.IsNullOrEmpty(value))
84+
{
85+
var head = "filename=\"";
86+
var start = value.IndexOf(head) + head.Length;
87+
88+
if (start >= head.Length)
89+
{
90+
var end = value.IndexOf("\"", start);
91+
92+
if (end == -1)
93+
{
94+
end = value.Length;
95+
}
96+
97+
return value.Substring(start, end - start);
98+
}
99+
}
100+
101+
return null;
102+
}
103+
}
104+
}

0 commit comments

Comments
 (0)