Developing Apps against the Office Graph – Part 2


Earlier this week I authored a blog post on Developing Apps against the Office Graph. In the post, I used static Graph Query Language (GQL) to display Office Graph results in a visualization different from Delve. In this post, I’ll take the solution further to utilize “edge weight” and dynamic GQL queries that include both object AND actors. Checkout the new solution in the video below, see how I built it in this post, and start contributing to the effort on GitHub!

[View:https://www.youtube.com/watch?v=MUbO6LRqlKY]

Dynamic GQL Queries

In my first post, I used a static GQL query that displayed trending content for the current user (GraphQuery:ACTOR(ME, action:1020)). I made no attempt to query other actors, actions, or combine GQL in complex AND/OR logic. It was a simple introduction into GQL with apps and served its purpose.

In the new solution, I wanted to add the ability query different actions of different actors (not just ME). I accomplished this by enabling actor navigation within the visualization and adding an actions filter panel. The active actor will always display in the “nucleus” and will default to the current user (similar to the first app).

Because the actor can change (through navigation) and multiple actions can be selected (through the filter panel), the app needed to support dynamic GQL queries. I broke the queries up into two REST calls…one for object actions types (ex: show trending content, show viewed content, etc) and one for actor action types (show direct reports, show colleagues, etc). This made it easy to parse query results with completely different managed properties. Some action types are considered Private and only work for the current user (ex: show viewed content) and other have specific Public action types (ex: 1019 = WorkingWith and 1033 = WorkingWithPublic). Notice how this is handled as the dynamic GQL query if constructed.

Building Dynamic GQL

//load the user by querying the Office Graph for trending content, Colleagues, WorkingWith, and Manager
var loadUser = function (actorId, callback) {
    var oLoaded = false, aLoaded = false, children = [], workingWithActionID = 1033; //1033 is the public WorkingWith action type
    if (actorId == 'ME')
        workingWithActionID = 1019; //use the private WorkingWith action type

    //build the object query
    var objectGQL = '', objectGQLcnt = 0;
    if ($('#showTrending').hasClass('selected')) {
        objectGQLcnt++;
        objectGQL += "ACTOR(" + actorId + "\\, action\\:1020)";
    }
    if ($('#showModified').hasClass('selected')) {
        objectGQLcnt++;
        if (objectGQLcnt > 1)
            objectGQL += "\\, ";
        objectGQL += "ACTOR(" + actorId + "\\, action\\:1003)";
    }
    if ($('#showViewed').hasClass('selected') && actorId == 'ME') {
        objectGQLcnt++;
        if (objectGQLcnt > 1)
            objectGQL += "\\, ";
        objectGQL += "ACTOR(" + actorId + "\\, action\\:1001)";
    }
    if (objectGQLcnt > 1)
        objectGQL = "OR(" + objectGQL + ")";

    //determine if the object query should be executed
    if (objectGQLcnt == 0)
        oLoaded = true;
    else {
        //get objects around the current actor
        $.ajax({
            url: appWebUrl + "/_api/search/query?Querytext='*'&Properties='GraphQuery:" + objectGQL + "'&RowLimit=50&SelectProperties='DocId,WebId,UniqueId,SiteID,ViewCountLifetime,Path,DisplayAuthor,FileExtension,Title,SiteTitle,SitePath'",
            method: 'GET',
            headers: { "Accept": "application/json; odata=verbose" },
            success: function (d) {
                if (d.d.query.PrimaryQueryResult != null &&
                    d.d.query.PrimaryQueryResult.RelevantResults != null &&
                    d.d.query.PrimaryQueryResult.RelevantResults.Table != null &&
                    d.d.query.PrimaryQueryResult.RelevantResults.Table.Rows != null &&
                    d.d.query.PrimaryQueryResult.RelevantResults.Table.Rows.results != null &&
                    d.d.query.PrimaryQueryResult.RelevantResults.Table.Rows.results.length > 0) {
                    $(d.d.query.PrimaryQueryResult.RelevantResults.Table.Rows.results).each(function (i, row) {
                        children.push(parseObjectResults(row));
                    });
                }

                oLoaded = true;
                if (aLoaded)
                    callback(children);
            },
            error: function (err) {
                showMessage('<div id="private" class="message">Errot calling the Office Graph for objects...refresh your browser and try again (<span class="hyperlink" onclick="javascript:$(this).parent().remove();">dismiss</span>).</div>');
            }
        });
    }

    //build the actor query
    var actorGQL = '', actorGQLcnt = 0;
    if ($('#showColleagues').hasClass('selected')) {
        actorGQLcnt++;
        actorGQL += "ACTOR(" + actorId + "\\, action\\:1015)";
    }
    if ($('#showWorkingwith').hasClass('selected')) {
        actorGQLcnt++;
        if (actorGQLcnt > 1)
            actorGQL += "\\, ";
        actorGQL += "ACTOR(" + actorId + "\\, action\\:" + workingWithActionID + ")";
    }
    if ($('#showManager').hasClass('selected')) {
        actorGQLcnt++;
        if (actorGQLcnt > 1)
            actorGQL += "\\, ";
        actorGQL += "ACTOR(" + actorId + "\\, action\\:1013)";
    }
    if ($('#showDirectreports').hasClass('selected')) {
        actorGQLcnt++;
        if (actorGQLcnt > 1)
            actorGQL += "\\, ";
        actorGQL += "ACTOR(" + actorId + "\\, action\\:1014)";
    }
    if (actorGQLcnt > 1)
        actorGQL = "OR(" + actorGQL + ")";

    //determine if the actor query should be executed
    if (actorGQLcnt == 0)
        aLoaded = true;
    else {
        //get actors around current actor
        $.ajax({
            url: appWebUrl + "/_api/search/query?Querytext='*'&Properties='GraphQuery:" + actorGQL + "'&RowLimit=200&SelectProperties='PictureURL,PreferredName,JobTitle,Path,Department'",
            method: 'GET',
            headers: { "Accept": "application/json; odata=verbose" },
            success: function (d) {
                if (d.d.query.PrimaryQueryResult != null &&
                    d.d.query.PrimaryQueryResult.RelevantResults != null &&
                    d.d.query.PrimaryQueryResult.RelevantResults.Table != null &&
                    d.d.query.PrimaryQueryResult.RelevantResults.Table.Rows != null &&
                    d.d.query.PrimaryQueryResult.RelevantResults.Table.Rows.results != null &&
                    d.d.query.PrimaryQueryResult.RelevantResults.Table.Rows.results.length > 0) {
                    $(d.d.query.PrimaryQueryResult.RelevantResults.Table.Rows.results).each(function (i, row) {
                        children.push(parseActorResults(row));
                    });
                }
                           
                aLoaded = true;
                if (oLoaded)
                    callback(children);
            },
            error: function (err) {
                showMessage('<div id="private" class="message">Error calling Office Graph for actors...refresh your browser and try again (<span class="hyperlink" onclick="javascript:$(this).parent().remove();">dismiss</span>).</div>');
            }
        });
    }
}

 

Using Edges

You might be wondering where ActorIDs come from in the code above. These are returned from the Office Graph as part of the “Edges” managed property that is included in Office Graph query results. In my last post, I provided an example of this property. The Edges value will be a JSON string that parses into an object array. Each item in the object array represents an edge that connects the actor to an object (the object could be any item/user in the Office Graph). The array can have multiple items if multiple edges/connections exist between the actor and object. Here are two examples described and as returned from the Office Graph.

Scenario 1: Bob (actor with ID of 111) might have an edge/connection to Frank (object with ID of 222) because Frank is his manager (action type 1013) and he works with him (action type 1019).

<d:element m:type="SP.KeyValue">
    <d:Key>Edges</d:Key>
    <d:Value>[{"ActorId":111,"ObjectId":222,
        "Properties":{"Action":1013,"Blob":[],
        "ObjectSource":1,"Time":"2014-08-19T10:46:49.0000000Z",
        "Weight":296}},
        {"ActorId":111,"ObjectId":222,
        "Properties":{"Action":1019,"Blob":[],
        "ObjectSource":1,"Time":"2014-08-17T18:16:21.0000000Z",
        "Weight":51}}]
    </d:Value>
    <d:ValueType>Edm.String</d:ValueType>
</d:element>

 

Scenario 2: Bob (actor with ID of 111) might have an edge/connection to Proposal.docx (object with ID of 333) because he viewed it recently (action type 1001) and is trending around him (1020).

<d:element m:type="SP.KeyValue">
    <d:Key>Edges</d:Key>
    <d:Value>[{"ActorId":111,"ObjectId":333,
        "Properties":{"Action":1001,"Blob":[],
        "ObjectSource":1,"Time":"2014-08-19T13:12:57.0000000Z",
        "Weight":7014}},
        {"ActorId":111,"ObjectId":333,
        "Properties":{"Action":1020,"Blob":[],
        "ObjectSource":1,"Time":"2014-08-17T11:44:34.0000000Z",
        "Weight":4056}}]
    </d:Value>
    <d:ValueType>Edm.String</d:ValueType>
</d:element>

 

To traverse the Office Graph through an actor, you need to use the ObjectId from the Edges managed property. The ActorId represents the actor the results came from. The new solution uses edge weight (ie – closeness) instead of view counts for bubble size. Because an object can have multiple edges, I decided to use the largest edge weight as seen in my parse function below:

Parse Query Results

//parse a search result row into an actor
var parseActorResults = function (row) {
    var o = {};
    o.type = 'actor';
    $(row.Cells.results).each(function (ii, ee) {
        if (ee.Key == 'PreferredName')
            o.title = ee.Value;
        else if (ee.Key == 'PictureURL')
            o.pic = ee.Value;
        else if (ee.Key == 'JobTitle')
            o.text1 = ee.Value;
        else if (ee.Key == 'Department')
            o.text2 = ee.Value;
        else if (ee.Key == 'Path')
            o.path = ee.Value;
        else if (ee.Key == 'DocId')
            o.docId = ee.Value;
        else if (ee.Key == 'Rank')
            o.rank = parseFloat(ee.Value);
        else if (ee.Key == 'Edges') {
            //get the highest edge weight
            var edges = JSON.parse(ee.Value);
            o.actorId = edges[0].ObjectId;
            $(edges).each(function (i, e) {
                var w = parseInt(e.Properties.Weight);
                if (o.edgeWeight == null || w > o.edgeWeight)
                    o.edgeWeight = w;
            });
        }
    });
    return o;
}

 

I also found that document objects had significantly larger edge weights than user objects. To adjust for this across two queries, I perform a normalization on user objects to keep their bubble size similar to document objects.

Edge Weight Normalization

//go through all children to counts and sum for edgeWeight normalization
var cntO = 0, totO = 0, cntA = 0, totA = 0;
$(entity.children).each(function (i, e) {
    if (e.type == 'actor') {
        totA += e.edgeWeight;
        cntA++;
    }
    else if (e.type == 'object') {
        totO += e.edgeWeight;
        cntO++;
    }
});

//normalize edgeWeight across objects and actors
totalEdgeWeight = 0;
$(entity.children).each(function (i, e) {
    //adjust edgeWeight for actors only
    if (e.type == 'actor') {
        //pct of average * average of objects
        e.edgeWeight = (e.edgeWeight / (totA / cntA)) * (totO / cntO);
    }
    totalEdgeWeight += e.edgeWeight
});

 

More on Preview Images

In the first post, I introduced the getpreview.ashx handler for generating on-demand preview images for documents. Some documents such as Excel and PDF don’t always render previews, so I added some logic for this. Ultimately, I try to pre-load the images (which I was already doing for SVG) and then revert to a static image if the pre-loaded image has a height or width of 0px. I also do this for users that don’t have a profile picture.

Handle Bad Preview Images

//load the images so we can get the natural dimensions
$('#divHide img').remove();
var hide = $('<div></div>');
hide.append('<img src="' + entity.pic + '" />');
$(entity.children).each(function (i, e) {
    hide.append('<img src="' + e.pic + '" />');
});
hide.appendTo('#divHide');
$('#divHide img').each(function (i, e) {
    if (i == 0) {
        entity.width = parseInt(e.naturalWidth);
        entity.height = parseInt(e.naturalHeight);
    }
    else {
        entity.children[i - 1].width = parseInt(e.naturalWidth);
        entity.children[i - 1].height = parseInt(e.naturalHeight);

        if (entity.children[i - 1].width == 0 ||
            entity.children[i - 1].height == 0) {
            if (entity.children[i - 1].type == 'actor') {
                entity.children[i - 1].width = 96;
                entity.children[i - 1].height = 96;
                entity.children[i - 1].pic = '../images/nopic.png';
            }
            else if (entity.children[i - 1].ext == 'xlsx' || entity.children[i - 1].ext == 'xls') {
                entity.children[i - 1].width = 300;
                entity.children[i - 1].height = 300;
                entity.children[i - 1].pic = '../images/excel.png';
            }
            else if (entity.children[i - 1].ext == 'docx' || entity.children[i - 1].ext == 'doc') {
                entity.children[i - 1].width = 300;
                entity.children[i - 1].height = 300;
                entity.children[i - 1].pic = '../images/word.png';
            }
            else if (entity.children[i - 1].ext == 'pdf') {
                entity.children[i - 1].width = 300;
                entity.children[i - 1].height = 300;
                entity.children[i - 1].pic = '../images/pdf.png';
            }
        }
    }
});

 

NOTE: the solution displays cross-domain user profile pictures, which can be buggy with Internet Explorer Security Zones. I’ve written a blog about handling cross-site images. However, I didn’t implement this pattern in the solution due to the potential volume of results. For best results, I recommend one of all of the following:

  • Sign into Office 365 with the “keep me signed in” option checked
  • Install the app in the MySiteHost or at least authenticate against the MySiteHost or OneDrive before opening the app
  • Make sure the app URL is in the same IE Security Zone as the hostweb and MySiteHost

 

Final Thoughts

Due to the popularity of the first post, I’ve decided to post the new solution on GitHub. Hopefully this will facilitate a community effort to add enhancements and fix bugs. Together, we can take the Office Graph to exciting new places.

Comments (0)

Skip to main content