Angular and C# implementation

I wanted to post our angular js and c# implementation of Groupdocs. I'm also posting some of the issues we ran into and how we solved them.

A lot of this code is not relevant to your project so pick out what you need and ignore the rest. I've left it in because I don't know what may be helpful to you.

Setup
-----
- Groupdocs requires a storage location for the generated document images. By default this is stored in the App_Data folder. This obviously doesn't work effectively in a load balanced environment. To resolve this, we created a distributed file share on Azure.
- Groupdocs uses Twitter Bootstrap. This caused a lot of styling conflicts since we are also using Bootstrap. To resolve this, we put Groupdocs in an iframe.
- We never could get the html helper version to work since it always took 30-60 seconds to render. The html helper generates javascript that we placed in a angular directive. You'll have to regenerate the javascript using the html helper and then manually update the angular directive. We used a temporary Visual Studio project to do this.

Application_Start

Groupdocs throws a lot of silent errors. This caused stability problems in our app, especially since you have to write code in the Application_Start method of Global.asax. We wrapped these calls in a try/catch.

protected void Application_Start()
{
// Groupdocs is in a separate try catch because it is fairly fragile and shouldn't
// stop the rest of the application from running.
try
{
// These Groupdocs lines have to be executed before anything else.
WidgetFactory.Initialize(this.Context, GroupdocsConnectorService.GetGroupdocsPath()); // See the GroupdocsConnectorService code below.
WidgetFactory.SetLicensePath(Server.MapPath(@"~/App_Data/GroupDocsTotal.lic"));
}
catch (Exception exception)
{
// Log exception
}

// The rest of your code.
}
IFrame Contents

@{ Layout = null; }

Groupdocs

// All of these scripts are normally generated by this html helper: @Html.Groupdocs().AnnotationScripts()
// However, this won't work if you are loading the html dynamically so I copy them here manually.
// If you update your Groupdocs.Web.Annotation dll, make sure you check these generated script and style tags in case there are changes.



Directive

This directive is applied to the iframe referencing the Groupdocs web page. Notice that the src is not set on the iframe element. We did this so that the iframe doesn't block the rest of the page from loading.


// Groupdocs iFrame
(function () {
// $memberService contains information about the logged in user. The only relevant part of this service is the user's id.
// The $groupdocsService is used to prepare the document for viewing.
// $rootScope is used to monitor events that will change the document.
// $window is used to get reference to the iframe.
function Directive($memberService, $groupdocsService, $rootScope, $window) {
return {
restrict: 'A',
scope: {
submission: '=' // submission contains the id of the document we want to load. You will probably have another way to get the document name.
},
link: function (scope, element) {
var isNewDocument = true, // There were multiple ways to trigger a document change in our solution. This variable keeps track of these conditions.
iFrameLoaded = false, // This is set to true once the iframe calls the groupdocsFrameLoaded() function.
queuedDocumentChange, // We ran into timing issue loading the iframe. To resolve this we queue the document change function so we can call it later.
changeDocument, // Reference to the changeDocument() method in the groupdocs iframe
hideViewer; // Reference to the hideViewer() method in the groupdocs iframe.

element[0].src = '/Section/Home/Groupdocs'; // The iframe is loaded async so it doesn't block the rest of the page from loading.

// This function is called from the groupdocs iframe once it has loaded.
$window.groupdocsFrameLoaded = function () {
changeDocument = $window.frames['groupdocs'].contentWindow.changeDocument; // Stores a reference to the changeDocument() function in the iframe.
hideViewer = $window.frames['groupdocs'].contentWindow.hideViewer; // Stores a reference to the hideViewer() function in the iframe.
iFrameLoaded = true;

if (queuedDocumentChange) { // If a document change has been queued, call it.
queuedDocumentChange();
queuedDocumentChange = null;
}
}

// Called when the document should be loaded for the first time or when the document changes.
function updateIframe(userId, userName, fileId) {
if (iFrameLoaded) { // If the iframe has already been loaded, call the changeDocument() method.
changeDocument(userId, userName, fileId);
} else {
// If the iframe isn't loaded, we queue requests to change documents until the iframe is loaded.
queuedDocumentChange = angular.bind(null, updateIframe, userId, userName, fileId);
}
}

// This happens when the user switches between submissions for the same student. This isn't relevant to your project but
// demonstrates how you can change documents based on an event happening elsewhere in the app.
$rootScope.$on('gradebook.student.submission.changed', function (event, reloadGroupdocs) {
if (reloadGroupdocs) { // We needed a way to explicitly state whether or not we wanted to reload groupdocs. This may not be relevant to your project.
isNewDocument = true;
hideViewer();
}
});

// You will be watching some other scope variable. In our proejct, submission.asset contained a reference to the document id.
scope.$watch('submission.asset', function (newAsset, oldAsset) {
// This happens when the user switches between students.
if (oldAsset && angular.isUndefined(newAsset)) {
isNewDocument = true;
}

if (newAsset && (angular.isUndefined(oldAsset) || newAsset.id !== oldAsset.id || isNewDocument)) {
// If the same student's submission has changed or it's a new student, we need to update the iframe.
$groupdocsService.prepareDocument(newAsset.id, function (userId) {
// This fuction is only called if the document is prepared for viewing on the server side. (See the angular service definition below)
if (userId) {
isNewDocument = false;
// userId is the user's id in the Groupdocs database.
// $memberService.getUserClaims().id is the user's name.
// newAsset.id + ".pdf" is the fileId Groupdocs will use to load the document.
updateIframe(userId, $memberService.getUserClaims().id, newAsset.id + ".pdf");
}
});
}
});
}
};
}

module.directive('groupdocs', ['$memberService', '$groupdocsService', '$rootScope', '$window', Directive]);
})();
Angular service:

/**
* $groupdocsService
* Manages interactions with Groupdocs
* @module $groupdocsService
*/
(function () {
// $requestUtilities is a service we use to manage our calls and is not relevant to your project. You'll need to make the POST call some other way.
function Service($requestUtilities, $parabolaUrls) {

/**
* Prepares the document for viewing.
* @param {string} assetId - The asset id. // asset id is not relevant to your project. Instead, it will be the name you use for your fileId.
* @param {function} callback - Function called with Groupdocs user guid.
*/
// Our team used callbacks instead of promises. You can probably use promises instead.
function prepareDocument(assetId, callback) {
// Lots of code cruft in here that you don't need. Ultimately, this method merely calls a c# service and passes the Groupdocs fileId.
// The reviewer's Groupdocs userId is the response returned from the c# service.
var url = $parabolaUrls.groupdocs.prepareDocument.replace(':assetId', assetId), // Use angular's standard resource instead of this
requestConfig = { // Redundant I know. Quiet you!
type: 'POST',
url: url,
config: {
method: 'POST',
url: url
}
};

function localCallback(response, error) {
if (response) {
callback(response.$IdFromLocationFunc());
} else {
callback(null, error);
}
}

$requestUtilities.resolveRequest(requestConfig, localCallback);
}

/**
* Sends the request to generate the annotated documents.
* @param {string} gradeId
* @param {array} submissionExpectationAssetIds
*/
// We hide the document explorer in the Groupdocs interface since we don't want users to see the contents of the Groupdocs storage folder.
// This function calls a c# service that generates the annotated PDFs programmatically.
function saveAnnotatedDocuments(sectionId, studentUserId, learningActivityId, gradeId, annotatedDocumentRequest) {
var url = $parabolaUrls.groupdocs.saveAnnotatedDocuments
.replace(':sectionId', sectionId) // Not relevant
.replace(':studentUserId', studentUserId) // Not relevant
.replace(':learningActivityId', learningActivityId) // Not relevant
.replace(':gradeId', gradeId), // Not relevant.
requestConfig = {
type: 'POST',
url: url,
config: {
method: 'POST',
url: url,
data: annotatedDocumentRequest, // Contains information about which annotated documents to generate.
headers: {
'Content-Type': 'application/json; charset=utf-8'
}
}
};

$requestUtilities.resolveRequest(requestConfig, angular.noop());
}

return {
prepareDocument: prepareDocument,
saveAnnotatedDocuments: saveAnnotatedDocuments
};
}

module.factory('$groupdocsService', ['$requestUtilities', '$parabolaUrls', Service]);
})();
C# controller and service
The controller is a pass-through to the service. You probably don't need both.

namespace Controllers.Integration
{
[RoutePrefix("api/groupdocs")]
public class GroupdocsConnectorController : ApiController
{
private readonly ILog _log;
private readonly IGroupdocsConnectorService _groupdocsConnectorService;

public GroupdocsConnectorController(ILog log, IGroupdocsConnectorService groupdocsConnectorService)
{
_log = log;
_groupdocsConnectorService = groupdocsConnectorService;
}
[HttpPost, Route("prepareDocument/{assetId}")]
public HttpResponseMessage PostPrepareDocument([FromUri] Guid assetId)
{
if (ModelState.IsValid)
{
try
{
var groupDocsUserId = _groupdocsConnectorService.PrepareDocument(assetId);
return this.ReturnCreated(groupDocsUserId);
}
catch (LoggedException)
{
throw this.ThrowGeneralHttpException();
}
catch (Exception exception)
{
_log.Error(exception);
throw this.ThrowGeneralHttpException();
}
}
else
{
throw this.ThrowInvalidModelHttpException(ModelState);
}
}

[HttpPost, Route("saveAnnotatedDocument/{sectionId:guid}/studentUser/{studentUserId:guid}/learningActivity/{learningActivityId:guid}/grade/{gradeId:guid}")]
public HttpResponseMessage PostAnnotatedDocument(Guid sectionId, Guid studentUserId, Guid learningActivityId, Guid gradeId, AnnotatedDocumentRequest annotatedDocumentRequest)
{
if (ModelState.IsValid)
{
try
{
_groupdocsConnectorService.SaveAnnotatedDocument(sectionId, studentUserId, learningActivityId, gradeId,
annotatedDocumentRequest.UpdateGradeRequest, annotatedDocumentRequest.SubmissionExpectationAssets);
return new HttpResponseMessage(HttpStatusCode.Accepted);
}
catch (LoggedException)
{
throw this.ThrowGeneralHttpException();
}
catch (Exception exception)
{
_log.Error(exception);
throw this.ThrowGeneralHttpException();
}
}
else
{
throw this.ThrowInvalidModelHttpException(ModelState);
}
}
}
}

The service interface
----

namespace Service.Contract.Integration
{
public interface IGroupdocsConnectorService
{
string PrepareDocument(Guid assetId);

void SaveAnnotatedDocument(Guid sectionId, Guid studentUserId, Guid learningActivityId, Guid gradeId, UpdateGradeRequest updateGradeRequest, SubmissionExpectationAsset[] submissionExpectationAssets);
}
}

The service
Ack! This solution is larger than I thought. I'm losing resolve on this write up but I will continue.

namespace Service.Integration
{
///
/// Manages interactions with Groupdocs.
///
public class GroupdocsConnectorService : IGroupdocsConnectorService
{
private readonly ILog _log; // Not relevant
private readonly IPlatformHttpClient _platformHttpClient; // Not relevant.
private readonly IPrincipalFactory _principalFactory; // Contains information about the logged in user.
private readonly IFile _file; // Asine wrapper around System.IO.File to make it "testable." http://www.rbcs-us.com/documents/Why-Most-Unit-Testing-is-Waste.pdf
private readonly IHttpClientWrapper _httpClientWrapper; // Ditto
private readonly IGroupdocsWrapper _groupdocsWrapper; // Ditto
private readonly IAzureWrapper _azureWrapper; // Ditto
private readonly IAssetService _assetService; // Ditto

public static string ContainerName
{
get { return "groupdocs"; } // Azure container name.
}

public GroupdocsConnectorService(ILog log, IPlatformHttpClient platformHttpClient, IPrincipalFactory principalFactory, IFile file,
IHttpClientWrapper httpClientWrapper, IGroupdocsWrapper groupdocsWrapper, IAzureWrapper azureWrapper, IAssetService assetService)
{
_log = log;
_platformHttpClient = platformHttpClient;
_principalFactory = principalFactory;
_file = file;
_httpClientWrapper = httpClientWrapper;
_groupdocsWrapper = groupdocsWrapper;
_azureWrapper = azureWrapper;
_assetService = assetService;
}

///
/// Downloads the asset from Azure to the Groupdocs network share and adds the current user as a collaborator.
///
/// The asset to prepare.
/// The Groupdocs user id (Which is a Guid fragment string).
public string PrepareDocument(Guid assetId) // assetId is the Groupdocs fileId
{
var principal = _principalFactory.Get(); // Get the logged in user.
var documentName = String.Format("{0}.pdf", assetId); // The fileId

// TODO: Groupdocs - 2014-07-02 - Delete this after successfully integrating with Azure
TEMP_DownloadAssetToFile(assetId); // This was a temp solution until we had Azure integration up. We never got that working so this function is still named TEMP_. I'm not changing it for this write up lest I miss a reference and cause confusion.
var webAnnotationService = _groupdocsWrapper.GetWebAnnotationService(); // Use ObjectFactory.GetInstance(); instead
var setCollaboratorResult = webAnnotationService.AddCollaborator(documentName, principal.Id, principal.DisplayName, "", null);
webAnnotationService.SetCollaboratorColor(documentName, principal.Id.ToString(), 16711680);
webAnnotationService.SetCollaboratorRights(documentName, principal.Id.ToString(),
AnnotationReviewerRights.CanAnnotate | AnnotationReviewerRights.CanDelete | AnnotationReviewerRights.CanRedact | AnnotationReviewerRights.CanView);

var reviewerInfo = setCollaboratorResult.Collaborators.First(ri => ri.PrimaryEmail.Equals(principal.Id.ToString()));

return reviewerInfo.Guid; // This is the id from the Groupdocs database. It's returned to the client so it can be passed to the iframe.
}

#region Save annotated document

///
/// 1. Generates the annotated PDFs.
/// 2. Saves the PDFs to the Groupdocs network share.
/// 3. Uploads the annotated PDF to the Asset service.
/// 4. Updates the grade.
///
/// Id of the student who is being graded.
/// Information about the grade that is being updated.
/// List of submission expectation Ids and their corresponding asset ids
public void SaveAnnotatedDocument(Guid sectionId, Guid studentUserId, Guid learningActivityId, Guid gradeId, UpdateGradeRequest updateGradeRequest, SubmissionExpectationAsset[] submissionExpectationAssets)
{
var generatedPDFAssetIds = new Dictionary();

foreach (var submissionExpectationAsset in submissionExpectationAssets)
{
var asset = _assetService.GetAsset(submissionExpectationAsset.AssetId); // asset.id is used as the fileId
if (asset.Type.Equals("File", StringComparison.OrdinalIgnoreCase)) // Not relevant
{
string tempAnnotatedPDFFilePath = GenerateAnnotatedPDF(submissionExpectationAsset.AssetId); // Generates the annotated PDF on the groupdocs DFS on Azure.
if (!string.IsNullOrWhiteSpace(tempAnnotatedPDFFilePath))
{
var uploadRequestInfo = _assetService.GetUploadRequestInfo(); // Not relevant.

using (var fileStream = (Stream)_file.Open(tempAnnotatedPDFFilePath, FileMode.Open))
{
_assetService.UploadToAzure(fileStream, uploadRequestInfo.UploadRequestUrl); // Uploads the file to Azure. Not relevant.
var newAsset = CreateNewAsset(asset, uploadRequestInfo.UploadRequestId.ToString()); // Not relevant.
var newAssetId = _assetService.PostAsset(newAsset); // Not relevant.

// ...store the returned asset's id so we can use it when updating the grade...
generatedPDFAssetIds.Add(submissionExpectationAsset, newAssetId); // Not relevant.
// ...and move the generated file to the Groupdocs file share.
SaveAnnotatedPDFToGroupdocsShare(newAssetId, tempAnnotatedPDFFilePath);
}

_file.Delete(tempAnnotatedPDFFilePath);
}
}
}

UpdateGrade(sectionId, studentUserId, learningActivityId, gradeId, updateGradeRequest, generatedPDFAssetIds); // Not relevant.
}

private string GenerateAnnotatedPDF(Guid assetId)
{
string tempFilePath = String.Empty;

var documentName = assetId.ToString() + ".pdf";
long sessionId = _groupdocsWrapper.GetSessionId(documentName);

if (sessionId > 0) // sessionId will only be > 0 if the teacher has looked at the document.
{
var coreAnnotationService = _groupdocsWrapper.GetCoreAnnotationService(); // Use ObjectFactory.GetInstance(); instead.
var sessionAnnotations = coreAnnotationService.GetSessionAnnotations(sessionId);

// We only generate the annotated document if the teacher actually made annotations.
if (sessionAnnotations.Length > 0)
{
tempFilePath = TEMP_DownloadAssetToFile(assetId);
string tempFileName = _groupdocsWrapper.ExportAnnotations(sessionId, documentName, DocumentType.Pdf, AnnotationMode.TrackChanges); // Use AnnotationsExporter.Perform(sessionId, documentName, outputType, mode); instead
// AnnotationsExporter.Perform is hard-coded to write the file to the user's temp directory.
tempFilePath = Path.Combine(Path.GetTempPath(), "Saaspose", tempFileName);
}
}

return tempFilePath;
}

// Entire method is not relevant.
private Web.Contract.Asset.Asset CreateNewAsset(Web.Contract.Asset.Asset asset, string uploadRequestId)
{
// Set the new asset's name...
var assetName = asset.Name;
var newAssetName = assetName.Substring(0, assetName.LastIndexOf(".")) + "_graded.pdf";

// ...and populate the new asset request object...
var newAsset = new Web.Contract.Asset.Asset();
newAsset.UploadRequestId = new Guid(uploadRequestId);
newAsset.Name = newAssetName;
newAsset.Type = asset.Type;

var metaDataJson = JObject.Parse(asset.MetaData);
metaDataJson["fileType"] = "pdf";
metaDataJson["fileName"] = newAssetName;

newAsset.MetaData = Regex.Replace(metaDataJson.ToString(), @"\s+", "");

return newAsset;
}

// Entire method is not relevant.
private void UpdateGrade(Guid sectionId, Guid studentUserId, Guid learningActivityId, Guid gradeId, UpdateGradeRequest updateGradeRequest, Dictionary generatedPDFAssetIds)
{
if (generatedPDFAssetIds.Count > 0)
{
updateGradeRequest.AnnotatedAssets = new List();

foreach (var generatedPDFAssetId in generatedPDFAssetIds)
{
updateGradeRequest.AnnotatedAssets.Add(new AnnotatedAssetRequest()
{
SubmissionExpectationAssetId = generatedPDFAssetId.Key.SubmissionExpectationAssetId,
AssetId = generatedPDFAssetId.Value
});
}

var updateGradeRequestJson = JObject.FromObject(updateGradeRequest);

// Update the final grade with the new asset IDs
//todo: Update this to use a repository
var annotatedAssetResult = _platformHttpClient.PutAsync(
String.Format("section/section/{0}/user/{1}/learningActivity/{2}/grade/{3}", sectionId, studentUserId, learningActivityId, gradeId),
new StringContent(updateGradeRequestJson.ToString(), Encoding.UTF8, "application/json")).Result;

if (!annotatedAssetResult.IsSuccessStatusCode)
{
throw new Exception(String.Format("Call to platform grade service (update grade) failed while generating annotated PDF: {0} ({1}). \n\r{2}",
(int)annotatedAssetResult.StatusCode, annotatedAssetResult.ReasonPhrase, annotatedAssetResult.Content.ReadAsStringAsync().Result));
}
}
}

// Entire method is not relevant.
private void SaveAnnotatedPDFToGroupdocsShare(Guid assetId, string tempFilePath)
{
var groupdocsFilePath = Path.Combine(GetGroupdocsPath(), assetId.ToString() + ".pdf");
if (!_file.Exists(groupdocsFilePath)) // Use File.Exists instead
{
_file.Copy(tempFilePath, groupdocsFilePath, true); // Use File.Copy instead
}
}

#endregion

public static string GetGroupdocsPath()
{
var path = ConfigurationManager.AppSettings["GroupdocsPath"]; // The path to the DFS is stored in the app.config.

if (path.StartsWith("~"))
{
return HttpContextFactory.Current.Server.MapPath(path);
}

return path;
}

// TODO: Groupdocs - 2014-07-02 - Delete this after successfully integrating with Azure
// I think entire method is not relevant.
private string TEMP_DownloadAssetToFile(Guid assetId)
{
string filePath = Path.Combine(GetGroupdocsPath(), assetId.ToString() + ".pdf");
if (!_file.Exists(filePath))
{
var httpResponse = _platformHttpClient.GetAsync(String.Format("Asset/Asset/{0}/ReadToken?format=PDF", assetId));
if (httpResponse == null)
{
throw new Exception("Error retrieving user from platform account service, response was null.");
}

var response = httpResponse.Result;

if (response.IsSuccessStatusCode)
{
var sourceBlob = _azureWrapper.GetCloudBlockBlob(new Uri(response.Content.ReadAsStringAsync().Result.Replace("\"", "")));

// Write it to the Groupdocs path
sourceBlob.DownloadToFile(filePath, FileMode.Create);
}
else
{
throw new ApplicationException(String.Format("Call to platform user service API failed: {0} ({1}). \n\r{2}",
(int)response.StatusCode, response.ReasonPhrase, response.Content.ReadAsStringAsync().Result));
}
}

return filePath;
}
}
}
Addendum

Groupdocs allows you to use Azure as the document location. We didn't have time to implement this but it may work better for you than using a DFS.

We ran into scalability problems generating the annotated PDFs on our web servers. It may take more time, but consider putting that functionality in its own service outside of the web server. For example, a 20 MB Word document on disk can take up to 250 MB of RAM. If you are trying to convert many documents at once, you will run out of RAM.

Hello Jason,

Thank you for such detailed post, for providing the source code and describing the issues. Our developers are investigating the problem. We will notify you about any update regarding this.

BTW, about the HTML helper which takes 30-90 seconds to work. This can be fixed by specifying the

in the web.config.

Thanks and sorry for the inconvenience.