How to save binary video data to azure blob? - javascript

I am currently using this code to select a file from local disk to my api:
<script language="javascript" type="text/javascript">
$(document).ready(function(){
$(':file').on('change', function () {
var file = this.files[0];
if (file.type !== "video/mp4" && file.type!== "video/quicktime") {
alert("Content must be video .mp4 or .mov")
}
$(':button').on('click', function () {
if (file.type == "video/mp4" || file.type == "video/quicktime"){
$.ajax({
// Your server script to process the upload
url: 'azureAPI',
type: 'POST',
// Form data
data: new FormData($('form')[0]),
// Tell jQuery not to process data or worry about content-type
// You *must* include these options!
cache: false,
contentType: false,
processData: false,
// Custom XMLHttpRequest
xhr: function () {
var myXhr = $.ajaxSettings.xhr();
if (myXhr.upload) {
// For handling the progress of the upload
myXhr.upload.addEventListener('progress', function (e) {
if (e.lengthComputable) {
$('progress').attr({
value: e.loaded,
max: e.total,
});
}
}, false);
}
return myXhr;
}
});
} else {
alert ("File type must be .mp4 or .mov")
}
});
});
});
</script>
This sends (what I am assuming is) binary data in the form of this:
���
1!QAa"q2B���R�#3br��u�����S6C$%��5�cts�T&D4��U��d���e!1AQa2"q�#����3��B���X"��?��!=��W�u�ٗ�-2���?����ۯ�Կ�i���t����M���Y�-��-Vdϊ�P�<�<U#TY]K��dW
���
I am using Azure, and now trying to send this to Microsoft Video Indexer, which says to send the data as multipart/form-data in the body. (see https://api-portal.videoindexer.ai/docs/services/Operations/operations/Upload-Video?)
I tried sending the binary data in the body, but it said it required string/buffer.
I then tried sending the binary data in the body as var body = Buffer.from(req.body,'binary')
Which sent, but VI responded saying that there was an issue indexing the data, perhaps as I sent with the wrong encoding?
To work around this, I am now trying to save that binary data to a block blob first, then I will call that url after, however I am having trouble saving binary data to Azure block blob using:
var buf = Buffer.from(req.body, 'binary');
blobService.createBlockBlobFromText(container, 'fileName.mp4', buf, {contentSettings: {contentType: 'video/mp4', contentEncoding: 'binary'}}, function (error, result, response) {
if(!error){
callback('uploaded');
} else {
callback('nope');
}
});
I tried this, without the contentSettings at first but that saved the data as contentType: application/octet-stream which wasn't opening as a video. I then added contentType, and lasted tried adding contentEncoding as well.
This saved the correct contentType but still the video could not be opened.
Does anyone know how to encode the data correctly to either send in the first instance straight to video indexer, or secondly to save binary data to Azure blob storage?
Thanks for any pointers, apologies if I left anything out.

According to my test, if you want to use Azure function to upload file to Azure blob, please refer to the following code.
front-end
<!DOCTYPE html>
<html>
<script type="text/javascript"
src="https://ajax.aspnetcdn.com/ajax/jQuery/jquery-3.4.1.js">
</script>
<body>
<form enctype="multipart/form-data">
<input name="file" type="file" accept="video/*"/>
<input type="button" value="Upload" />
</form>
<progress></progress>
<script language="javascript" type="text/javascript">
$(document).ready(function(){
$(':file').on('change', function () {
var file = this.files[0];
if (file.type !== "video/mp4" && file.type!== "video/quicktime") {
alert("Content must be video .mp4 or .mov")
}
$(':button').on('click', function () {
if (file.type == "video/mp4" || file.type == "video/quicktime"){
$.ajax({
// Your server script to process the upload
url: '',
type: 'POST',
crossDomain: true,
enctype: 'multipart/form-data',
// Form data
data: new FormData($('form')[0]),
// Tell jQuery not to process data or worry about content-type
// You *must* include these options!
cache: false,
contentType: false,
processData: false,
success : function(data){console.log(data);},
// Custom XMLHttpRequest
xhr: function () {
var myXhr = $.ajaxSettings.xhr();
if (myXhr.upload) {
// For handling the progress of the upload
myXhr.upload.addEventListener('progress', function (e) {
if (e.lengthComputable) {
$('progress').attr({
value: e.loaded,
max: e.total,
});
}
}, false);
}
return myXhr;
}
});
} else {
alert ("File type must be .mp4 or .mov")
}
});
});
});
</script>
</body>
</html>
Azure function
var multipart = require('parse-multipart')
var azure = require('azure-storage');
var getStream = require('into-stream')
module.exports = async function (context, request) {
context.log('JavaScript HTTP trigger function processed a request.');
// encode body to base64 string
var bodyBuffer = Buffer.from(request.body);
var boundary = multipart.getBoundary(request.headers['content-type']);
// parse the body
var parts = multipart.Parse(bodyBuffer, boundary);
const accountname ="blobstorage0516";
const key = "key";
const containerName="test";
var retryOperations = new azure.ExponentialRetryPolicyFilter();
const blobClient =azure.createBlobService(accountname,key).withFilter(retryOperations);
blobClient.createContainerIfNotExists(containerName, function (error) {
if (error) {
context.log(error);
}
});
var options = {
contentSettings:{contentType: parts[0].type},
metadata: { fileName: parts[0].filename },
blockSize:8*1024*1024,
parallelOperationThreadCount:20,
timeoutIntervalInMs:30*60*1000
};
var stream =getStream(parts[0].data)
context.log("start")
var result="ok"
var speedsummary= blobClient.createBlockBlobFromStream(containerName,parts[0].filename,stream,parts[0].data.length,options,function (error) {
if (error != null) {
result=error
} else {
}})
context.res = { body : { results : result}};
context.done();
};
You also can access the video from the bloburl

Related

Upload to Image Server using jQuery as Relay

Problem:
I have a situation where I'd like to upload a file (pdf, image, etc.) to an API Endpoint that accepts one of these types of files. However, the file is located on another web service somewhere. I'm trying to devise a clever solution that will allow me to (a) download the remote file (and store it as bytes in memory or something) then (b) upload that file through the API.
I have jQuery code that demonstrates how to upload a local file using jQuery with no backend code, but I'd like to extend it to allow me to upload something that is stored remotely.
Constraints:
I don't want to use any backend infrastructure on my image uploading page (ie. no php, python, ruby, etc.)
I don't want the end user of my form to need to download the file to their machine and upload the file as a two-step process.
What I've got so far:
I've seen some solutions on SO that kind-of connect the dots here in terms of downloading a file as a bytearray, but nothing that demonstrates how you might upload that.
Download File from Bytes in JavaScript
jQuery-only File Upload to Stripe API*
Keep in mind, Stripe is the example I have, but I'd like to try and replicate this on say Imgur or another API (if I can get this working). Hopefully someone else has some ideas!
$('#fileinfo').submit(function(event) {
event.preventDefault();
var data = new FormData();
var publishableKey = 'pk_test_***';
data.append('file', $('#file-box')[0].files[0]);
data.append('purpose', 'identity_document');
$.ajax({
url: 'https://uploads.stripe.com/v1/files',
data: data,
headers: {
'Authorization': 'Bearer ' + publishableKey,
// 'Stripe-Account': 'acct_STRIPE-ACCOUNT-ID'
},
cache: false,
contentType: false,
processData: false,
type: 'POST',
}).done(function(data) {
$('#label-results').text('Success!');
$('#upload-results').text(JSON.stringify(data, null, 3));
}).fail(function(response, type, message) {
$('#label-results').text('Failure: ' + type + ', ' + message);
$('#upload-results').text(JSON.stringify(response.responseJSON, null, 3));
});
return false;
});
I actually got this working for Stripe by doing this:
https://jsfiddle.net/andrewnelder/up59zght/
var publishableKey = "pk_test_xxx"; // Platform Publishable Key
var stripeAccount = "acct_xxx"; // Connected Account ID
$(document).ready(function () {
$('#file-upload').on('submit', function (e) {
e.preventDefault();
console.log('Clicked!');
var route = $('#file-route').val(); // URL OF FILE
var fname = route.split("/").slice(-1)[0].split("?")[0];
var blob = fetchBlob(route, fname, uploadBlob);
});
});
function fetchBlob(route, fname, uploadBlob) {
console.log('Fetching...')
var oReq = new XMLHttpRequest();
oReq.open("GET", route, true);
oReq.responseType = "blob";
oReq.onload = function(e) {
var blob = oReq.response;
console.log('Fetched!')
uploadBlob(fname, blob);
};
oReq.send();
}
function uploadBlob(fname, blob) {
var fd = new FormData();
fd.append('file', blob);
fd.append('purpose', 'identity_document');
console.log('Uploading...');
$.ajax({
url: 'https://uploads.stripe.com/v1/files',
data: fd,
headers: {
'Authorization': 'Bearer ' + publishableKey,
'Stripe-Account': stripeAccount
},
cache: false,
contentType: false,
processData: false,
type: 'POST',
}).done(function(data) {
console.log('Uploaded!')
}).fail(function(response, type, message) {
console.log(message);
});
}

Downloading Excel file xlsx in Angularjs and WebApi

I am working on a task, in which I have to download a report in xlsx format. The report file is generated successfully from server, and is received on client side as well. But it is not opening and producing invalid format error.Below is the code of server side.
var output = await reportObj.GetExcelData(rParams);
if (output != null){
var result = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(output.ConentBytes)
};
result.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
result.Content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment")
{
FileName = output.FileName
};
return result;
}
Here is the code for client side:
var saveData = function (response) {
if (response.status === 200) {
var reportData = response.data;
var b = new Blob([reportData], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
saveAs(b,"ReportFile.xlsx");//this is FileSaver.js function
} else {
console.log(response.statusText);
}
};
$scope.getExcelFile = function(reportName, reportParams) {
reportDataService.getExcelReportData(reportName, reportParams, saveData);
}
Below is the error message:
Excel could not open newFile.xlsx because some content is unreadable. Do you want to open and repair this workbook?
On clicking repair, following error appears:
Excel cannot open this file.
The file format or file extension is not valid. Verify that the file has not been corrupted and that the file extension matches the format of the file.
Can some one guide me what I am doing wrong? Meanwhile, the same server side file generator objects works smoothly in ASP.Net forms application, and the file opens without any error as well.
Thank you.
I expect your $http call is missing the response type configuration. This is the way I download office files:
function download(url, defaultFileName) {
var self = this;
var deferred = $q.defer();
$http.get(url, { responseType: "arraybuffer" }).then(
function (data, status, headers) {
var type = headers('Content-Type');
var disposition = headers('Content-Disposition');
if (disposition) {
var match = disposition.match(/.*filename=\"?([^;\"]+)\"?.*/);
if (match[1])
defaultFileName = match[1];
}
defaultFileName = defaultFileName.replace(/[<>:"\/\\|?*]+/g, '_');
var blob = new Blob([data], { type: type });
saveAs(blob, defaultFileName);
deferred.resolve(defaultFileName);
}, function (data, status) {
var e = /* error */
deferred.reject(e);
});
return deferred.promise;
}
I was facing the same error , content was in hexa format, so i added a response type as arraybuffer, problem got resolved. please see below.
$http({
url: '/api/sendPMOToBackendUpdate',
method: "POST",
headers: {'Content-type': 'application/json'},
data: backendTsData,
responseType: 'arraybuffer'
}).success(function(data, status, headers){
var file = new Blob([ data ], { type : 'application/vnd.ms-excel'});
var defaultFileName ="TSC-"+$scope.user.name+"-"+$scope.user.ohrId+".xls";
saveAs(file,defaultFileName);
}).error(function(err) {
console.log('Error: ' + err);
});
You a just need to do one thing only that.
include following js to save file locally. Download it from
"https://github.com/eligrey/FileSaver.js/"
your response data should be in blob type.
I have implemented it and its working.
function downloadfile(url,defaultFileName){
var self = this;
var deferred = $q.defer();
$http.get(url, { responseType: "blob" }).then(
function (data){
saveAs(data.data, defaultFileName)
deferred.resolve(defaultFileName);
}, function (data) {
var e = /* error */
deferred.reject(e);
});
return deferred.promise;
}
I encountered a similar problem when writing excel using Javascript library Excel Builder. At the end, I found the reason was that a control character '\u001a' was included in data.
The solution is to encode the control char in Excel's way as '_x001a_'.
The way I diagnosed the problem was like this:
.xlsx file is just a zipped xml file. You can open it with 7-zip. Inside the xl/ folder there is a file sharedString.xml containing all strings. Extract the file and open it with Notepad++. If you see any control character, then it might be the cause.
first install these module
import * as Excel from 'exceljs';
import * as fs from 'file-saver';
In your function write these
const workbook = new Excel.Workbook();
var worksheet = workbook.addWorksheet('sheet');
worksheet.columns = [
{ header: 'Id', key: 'id', width: 10 },
{ header: 'Name', key: 'name', width: 32 }
];
var buff = workbook.xlsx.writeBuffer().then(function (data) {
var blob = new Blob([data], {type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
fs.saveAs(blob, "publications.xlsx");
});
Fs is used to access file system and download file. You can also insert img
https://www.npmjs.com/package/exceljs#images

Inconsistent file contents on uploading through Dropbox API

I am using the Dropbox Core API to upload and download files via a chrome extension. When I upload text files such as with extensions .txt, .js, .json or .c the files get uploaded successfully but when I upload files with extensions .pdf, .jpg etc (media files) then the contents are disfigured or absent, though the file size is non-zero, sometimes even larger than the original file. This clearly means that the data that is read is being written as well but I guess there is some problem with the way I am reading or writing the data. The code is posted below for reference.
$(document).on("click", "#id_submit",uploadProcess);
function uploadProcess()
{
var file = $("#upload_file")[0].files[0];
console.log(file);
if (!file){
alert ("No file selected to upload.");
return false;
}
var reader = new FileReader();
//reader.readAsText(file, "UTF-8");
reader.readAsBinaryString(file);
reader.onload = function (evt) {
uploadFile(file.name, evt.target.result, file.size, file.type);
//console.log(evt.target.result);
var control = $("#upload_file");
control.replaceWith( control = control.clone( true ));
}
}
//function to upload file to folder
function uploadFile(filepath,data,contentLength,contentType){
var url = "https://api-content.dropbox.com/1/files_put/auto/"+filepath;
var headers = {
Authorization: 'Bearer ' + getAccessToken(),
contentLength: contentLength
}
var args = {
url: url,
headers: headers,
crossDomain: true,
crossOrigin: true,
type: 'PUT',
contentType: contentType,
data : data,
dataType: 'json',
success: function(data)
{
console.log(data);
},
error: function(jqXHR)
{
console.log(jqXHR);
}
};
$.ajax(args);
}
You can actually pass a file in your ajax request(depending on browser support). Just pass the files in your args object, also you need to set processDate and contentType to false to prevent $.ajax from manipulating the file object
var args = {
...
contentType: false,
data : file,
processData: false,
...
};

How to set a header for a HTTP GET request, and trigger file download?

Update 20140702:
The solution
Detailed answer as a blog post
(but I'm marking one of the other answers as accepted instead of my own,
as it got me halfway there, and to reward the effort)
It appears that setting a HTTP request header is not possible through links with <a href="...">, and can only be done using XMLHttpRequest.
However, the URL linked to is a file that should be downloaded (browser should not navigate to its URL), and I am not sure is this can be done using AJAX.
Additionally, the file being returned is a binary file, and AJAX is not intended for that.
How would one go about triggering a file download with a HTTP request that has a custom header added to it?
edit: fix broken link
There are two ways to download a file where the HTTP request requires that a header be set.
The credit for the first goes to #guest271314, and credit for the second goes to #dandavis.
The first method is to use the HTML5 File API to create a temporary local file,
and the second is to use base64 encoding in conjunction with a data URI.
The solution I used in my project uses the base64 encoding approach for small files,
or when the File API is not available,
otherwise using the the File API approach.
Solution:
var id = 123;
var req = ic.ajax.raw({
type: 'GET',
url: '/api/dowloads/'+id,
beforeSend: function (request) {
request.setRequestHeader('token', 'token for '+id);
},
processData: false
});
var maxSizeForBase64 = 1048576; //1024 * 1024
req.then(
function resolve(result) {
var str = result.response;
var anchor = $('.vcard-hyperlink');
var windowUrl = window.URL || window.webkitURL;
if (str.length > maxSizeForBase64 && typeof windowUrl.createObjectURL === 'function') {
var blob = new Blob([result.response], { type: 'text/bin' });
var url = windowUrl.createObjectURL(blob);
anchor.prop('href', url);
anchor.prop('download', id+'.bin');
anchor.get(0).click();
windowUrl.revokeObjectURL(url);
}
else {
//use base64 encoding when less than set limit or file API is not available
anchor.attr({
href: 'data:text/plain;base64,'+FormatUtils.utf8toBase64(result.response),
download: id+'.bin',
});
anchor.get(0).click();
}
}.bind(this),
function reject(err) {
console.log(err);
}
);
Note that I'm not using a raw XMLHttpRequest,
and instead using ic-ajax,
and should be quite similar to a jQuery.ajax solution.
Note also that you should substitute text/bin and .bin with whatever corresponds to the file type being downloaded.
The implementation of FormatUtils.utf8toBase64
can be found here
Try
html
<!-- placeholder ,
`click` download , `.remove()` options ,
at js callback , following js
-->
<a>download</a>
js
$(document).ready(function () {
$.ajax({
// `url`
url: '/echo/json/',
type: "POST",
dataType: 'json',
// `file`, data-uri, base64
data: {
json: JSON.stringify({
"file": "data:text/plain;base64,YWJj"
})
},
// `custom header`
headers: {
"x-custom-header": 123
},
beforeSend: function (jqxhr) {
console.log(this.headers);
alert("custom headers" + JSON.stringify(this.headers));
},
success: function (data) {
// `file download`
$("a")
.attr({
"href": data.file,
"download": "file.txt"
})
.html($("a").attr("download"))
.get(0).click();
console.log(JSON.parse(JSON.stringify(data)));
},
error: function (jqxhr, textStatus, errorThrown) {
console.log(textStatus, errorThrown)
}
});
});
jsfiddle http://jsfiddle.net/guest271314/SJYy3/
I'm adding another option. The answers above were very useful for me, but I wanted to use jQuery instead of ic-ajax (it seems to have a dependency with Ember when I tried to install through bower). Keep in mind that this solution only works on modern browsers.
In order to implement this on jQuery I used jQuery BinaryTransport. This is a nice plugin to read AJAX responses in binary format.
Then you can do this to download the file and send the headers:
$.ajax({
url: url,
type: 'GET',
dataType: 'binary',
headers: headers,
processData: false,
success: function(blob) {
var windowUrl = window.URL || window.webkitURL;
var url = windowUrl.createObjectURL(blob);
anchor.prop('href', url);
anchor.prop('download', fileName);
anchor.get(0).click();
windowUrl.revokeObjectURL(url);
}
});
The vars in the above script mean:
url: the URL of the file
headers: a Javascript object with the headers to send
fileName: the filename the user will see when downloading the file
anchor: it is a DOM element that is needed to simulate the download that must be wrapped with jQuery in this case. For example $('a.download-link').
i want to post my solution here which was done AngularJS, ASP.NET MVC. The code illustrates how to download file with authentication.
WebApi method along with helper class:
[RoutePrefix("filess")]
class FileController: ApiController
{
[HttpGet]
[Route("download-file")]
[Authorize(Roles = "admin")]
public HttpResponseMessage DownloadDocument([FromUri] int fileId)
{
var file = "someFile.docx"// asking storage service to get file path with id
return Request.ReturnFile(file);
}
}
static class DownloadFIleFromServerHelper
{
public static HttpResponseMessage ReturnFile(this HttpRequestMessage request, string file)
{
var result = request.CreateResponse(HttpStatusCode.OK);
result.Content = new StreamContent(new FileStream(file, FileMode.Open, FileAccess.Read));
result.Content.Headers.Add("x-filename", Path.GetFileName(file)); // letters of header names will be lowercased anyway in JS.
result.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
result.Content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment")
{
FileName = Path.GetFileName(file)
};
return result;
}
}
Web.config file changes to allow sending file name in custom header.
<configuration>
<system.webServer>
<httpProtocol>
<customHeaders>
<add name="Access-Control-Allow-Methods" value="POST,GET,PUT,PATCH,DELETE,OPTIONS" />
<add name="Access-Control-Allow-Headers" value="Authorization,Content-Type,x-filename" />
<add name="Access-Control-Expose-Headers" value="Authorization,Content-Type,x-filename" />
<add name="Access-Control-Allow-Origin" value="*" />
Angular JS Service Part:
function proposalService($http, $cookies, config, FileSaver) {
return {
downloadDocument: downloadDocument
};
function downloadFile(documentId, errorCallback) {
$http({
url: config.apiUrl + "files/download-file?documentId=" + documentId,
method: "GET",
headers: {
"Content-type": "application/json; charset=utf-8",
"Authorization": "Bearer " + $cookies.get("api_key")
},
responseType: "arraybuffer"
})
.success( function(data, status, headers) {
var filename = headers()['x-filename'];
var blob = new Blob([data], { type: "application/octet-binary" });
FileSaver.saveAs(blob, filename);
})
.error(function(data, status) {
console.log("Request failed with status: " + status);
errorCallback(data, status);
});
};
};
Module dependency for FileUpload: angular-file-download (gulp install angular-file-download --save). Registration looks like below.
var app = angular.module('cool',
[
...
require('angular-file-saver'),
])
. // other staff.
Pure jQuery.
$.ajax({
type: "GET",
url: "https://example.com/file",
headers: {
'Authorization': 'Bearer eyJraWQiFUDA.......TZxX1MGDGyg'
},
xhrFields: {
responseType: 'blob'
},
success: function (blob) {
var windowUrl = window.URL || window.webkitURL;
var url = windowUrl.createObjectURL(blob);
var anchor = document.createElement('a');
anchor.href = url;
anchor.download = 'filename.zip';
anchor.click();
anchor.parentNode.removeChild(anchor);
windowUrl.revokeObjectURL(url);
},
error: function (error) {
console.log(error);
}
});

Track ajax post progress for fileupload using jquery ajax and FormData

var files = [];
$(document).ready(function (){
dropArea = document.getElementById("droparea");
});
// when we drag and drop files into the div#droparea
dropArea.addEventListener("drop", function (evt) {
files = evt.dataTransfer.files;
}, false);
function uploadFiles(stepX) {
var url = "/ajax/uploadfiles.php";
var type = "POST";
if (files.length > 0) {
var data = new FormData(); // we use FormData here to send the multiple files data for upload
for (var i=0; i<files.length; i++) {
var file = files[i];
data.append(i, file);
}
//start the ajax
return $.ajax({
//this is the php file that processes the data and send mail
url: url,
//POST method is used
type: type,
//pass the data
data: data,
//Do not cache the page
cache: false,
// DO NOT set the contentType and processData
// see http://stackoverflow.com/a/5976031/80353
contentType: false,
processData: false,
//success
success: function (json) {
//if POST is a success expect no errors
if (json.error == null && json.result != null) {
data = json.result.data;
// error
} else {
alert(json.error);
}
}
});
}
return {'error' : 'No files', 'result' : null};
}
How do I track the progress of the file upload if I want to retain this method to upload files to server?
Note the comment with #TODO
//start the ajax
return $.ajax({
//this is the php file that processes the data and send mail
url: url,
//POST method is used
type: type,
//pass the data
data: data,
//Do not cache the page
cache: false,
//#TODO start here
xhr: function() { // custom xhr
myXhr = $.ajaxSettings.xhr();
if(myXhr.upload){ // check if upload property exists
myXhr.upload.addEventListener('progress',progressHandlingFunction, false); // for handling the progress of the upload
}
return myXhr;
},
//#TODO end here
// DO NOT set the contentType and processData
// see http://stackoverflow.com/a/5976031/80353
contentType: false,
processData: false,
Add a standalone function that updates the progress.
function updateProgress(evt) {
console.log('updateProgress');
if (evt.lengthComputable) {
var percentComplete = evt.loaded / evt.total;
console.log(percentComplete);
} else {
// Unable to compute progress information since the total size is unknown
console.log('unable to complete');
}
}
From https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest/Using_XMLHttpRequest > Monitoring progress

Categories