Visualize Page Hiearachy in Confluence

Overview

We wanted to be able to create a visual of the organization of any section of our Confluance instance. This would mean visualizing all the pages under any given page. So I wrote a script that would visually depict the hierarchy using shapes.

Org chart style visuals are perfect for describing this sort of a hierarchy in an "upside-down tree" manner, so I decided to display the information in using an Org Chart template. As a bonus, I added hyperlinks directly to the pages in my visual.

Jira page hierarchy

The user interface I created for end-users requires a space name (or key) and a page title to use as the "root" of the hierarchy. The space is needed to distinguish a page with title "Critical Tasks" in the Development space from the one with the same name in the Marketing space.

Page hierarchy UI

How I Implemented It

Obtaining the Data

The first step to visualizing data is to grab the relevant data from Confluence using VSCallFunctionForConfluenceQuery to query the Confluence REST API. The parameter passed is the Confluence Query Language (CQL) query to select the page with the parameter title in the space with the key that matches the parameter. We're allowing the user to enter they space as either a key or as a name, so if this query doesn't produce any results then we'll re-run the same query interpreting the space parameter as a space name. The handleResults function is called when the results are returned from the server.

// Using the page title and space, query for the page information VSCallFunctionForConfluenceQuery(localhost, "title=\"" + paramsObj.title + "\" AND space= \"" + paramsObj.space + "\"", handleResults);

In handleResults, if I didn't receive any results from the query, I retry with a slightly different form of the query which interprets the user's space parameter as a space name (title); in that case, the results of that 2nd query are passed to function handlePageInformationResponse. Otherwise, if there are results, I call handlePageInformationResponse directly to begin to process the data returned from Confluence into the visual output.

function handleResults(results) { if (results) { handlePageInformationResponse(results); } else { // Retry the same query, but treat the parameter as a space title VSCallFunctionForConfluenceQuery(localhost, "title=\"" + paramsObj.title + "\" AND space.title ~ \"" + paramsObj.space + "\"", handlePageInformationResponse); } }

Unfortunately, this isn't all the information I need to build our model. Everything I've done to this point was just to get the page ID of the page the user's parameters describe. Now we use that ID to get the list of pages that have our target page as an ancestor. I again use the VSCallFunctionForConfluenceQuery, passing a CQL parameter of "ancestor="+targetInfo.id. I also pass the option on the query to "expand=ancestors", which requests that Confluence return extra information about each page's ancestors.

// Handle the response to the page information request. Need the ID of the page in order to make the version history call function handlePageInformationResponse(results) { if (!results || (results.length == 0)) { callback(null); return; } // Store information about the target targetInfo = results[0]; // Get the version history of the page. Use the results to build a model VSCallFunctionForConfluenceQuery(localhost, "ancestor=" + targetInfo.id, processResults, "expand=ancestors"); }

When the results list of pages that have our target page's ID in its ancestor list is passed to the processResults function, we have the information from Confluence required to build our model, then process that model into VisualScript and finally to display the VisualScript.

// Process the results of the query function processResults(results) { var model = buildModel(results); var visualScript = buildVisualScript(model); if (visualScript) { callback(visualScript.toJSON()); } else { callback(null); alert("No results were found"); } }

At this point, I have all the information I need to build my model.

Building the Model

To build the model, I iterate through each page in the results list, adding an entry in the model for it. I also process its ancestor list to find its immediate parent (which will be the last entry in the page's ancestor list). I add an entry to the model for that parent, and add the current page to the parent's list of child pages. When this process is complete, the model is an array of page information entries, each one containing a list of its immediate children (if any), sorted by page title. This is the model I'll need to build VisualScript.

// Build the model from the results function buildModel(results) { var parentInfo; var resultsLen = 0, model = {}; if (!results || (results.length === 0)) { return null; } model[targetInfo.id] = buildModelEntry(targetInfo); resultsLen = results.length; for (var i = 0; i < resultsLen; i++) { if (!results[i].ancestors || (results[i].ancestors.length === 0)) { continue; // Should not occur } // Add an entry in the model for this page if (!model[results[i].id]) { model[results[i].id] = buildModelEntry(results[i]); } // The parent of this page is the bottommost ancestor in the ancestor list parentInfo = results[i].ancestors[results[i].ancestors.length - 1]; if (!model[parentInfo.id]) { model[parentInfo.id] = buildModelEntry(parentInfo); } // Add this page to its parent (in the model) child list model[parentInfo.id].children.push(results[i].id); } // Now sort the children sortChildren(model, targetInfo.id); return model; }

Constructing the VisualScript from the Model

Now that the model is built, I use it to construct VisualScript SDK objects. After setting up my new VS.Document(), I get the "main shape", then pass it (along with the ID of the target "root" page for the hierarchy) to function buildShape. The buildShape function builds a builds out the shape that it's passed by looking up information in the model for the page id it is passed. It sets the shape's label to the title of the page and sets up a hyperlink to the page. If that page has any children, a shapeConnector is added, then buildShape is called recursively for each child in the list. When the recursive processing reaches the end of every sub-tree, it finally returns back to buildVisualScript, which returns the VisualScript (variable vs, below).

function buildVisualScript(model) { var vs = {}; if (!model) { return null; } vs = new VS.Document() .SetTemplate(VS.Templates.OrgChart); var mainShape = vs.GetTheShape() .SetFillColor("#FBD585"); buildShape(mainShape, targetInfo.id, model); return vs; } // Build a shape (and recurse for its children) function buildShape(shape, id, model) { shape.SetLabel(model[id].title) .SetHyperlink(localhost + "/" + model[id].webui); if (model[id].children.length > 0) { var connector = shape.AddShapeConnector(); for (var i = 0; i < model[id].children.length; i++) { buildShape(connector.AddShape(), model[id].children[i], model); } } }

Rendering the VisualScript

The last step is to generate the visual using callback function.

// Process the results of the query
function processResults(results) {
    var model = buildModel(results);
 
    var visualScript = buildVisualScript(model);
 
    if (visualScript) {
        callback(visualScript.toJSON());
    }
    else {
        callback(null);
        alert("No results were found");
    }
}

Creating the UI

After the script is complete, I set up the UI, so users can easily provide the page title and the space information to the script. In the top section of the VisualScript Editor page, you'll see a field labeled "Parameter Template". The specification string for the UI Builder’s “Parameter Template” is:

Page hierarchy parameter UI

As soon as I enter the 2nd "}" character at the end of each "parm", a form appears that allows me to customize the way the user sees the UI for these parameters:

Page hierarchy form builder

I override the displayed name to a more user-friendly string, and I add some description text.

Page hierarchy form new name

More Information

This is just one example of the useful visuals that can be produced using VisualScript and VisualScript Studio with a minimum of development effort.

The full source for this script can be seen here. To make the code easier to understand, I highlighted the part used for querying the data in green, the part for building the model in orange, and the part that creates VisualScript from the data in blue.

function VisualScript(localhost, params, callback) { var targetInfo; var paramsObj = JSON.parse(params); // Using the page title and space, query for the page information VSCallFunctionForConfluenceQuery(localhost, "title=\"" + paramsObj.title + "\" AND space= \"" + paramsObj.space + "\"", handleResults); function handleResults(results) { if (results) { handlePageInformationResponse(results); } else { // Retry the same query, but treat the parameter as a space title VSCallFunctionForConfluenceQuery(localhost, "title=\"" + paramsObj.title + "\" AND space.title ~ \"" + paramsObj.space + "\"", handlePageInformationResponse); } } // Handle the response to the page information request. Need the ID of the page in order to make the version history call function handlePageInformationResponse(results) { if (!results || (results.length == 0)) { callback(null); return; } // Store information about the target targetInfo = results[0]; // Get the version history of the page. Use the results to build a model VSCallFunctionForConfluenceQuery(localhost, "ancestor=" + targetInfo.id, processResults, "expand=ancestors"); } // Process the results of the query function processResults(results) { var model = buildModel(results); var visualScript = buildVisualScript(model); if (visualScript) { callback(visualScript.toJSON()); } else { callback(null); alert("No results were found"); } }
// Build the model from the results function buildModel(results) { var parentInfo; var resultsLen = 0, model = {}; if (!results || (results.length === 0)) { return null; } model[targetInfo.id] = buildModelEntry(targetInfo); resultsLen = results.length; for (var i = 0; i < resultsLen; i++) { if (!results[i].ancestors || (results[i].ancestors.length === 0)) { continue; // Should not occur } // Add an entry in the model for this page if (!model[results[i].id]) { model[results[i].id] = buildModelEntry(results[i]); } // The parent of this page is the bottommost ancestor in the ancestor list parentInfo = results[i].ancestors[results[i].ancestors.length - 1]; if (!model[parentInfo.id]) { model[parentInfo.id] = buildModelEntry(parentInfo); } // Add this page to its parent (in the model) child list model[parentInfo.id].children.push(results[i].id); } // Now sort the children sortChildren(model, targetInfo.id); return model; }
function buildVisualScript(model) { var vs = {}; if (!model) { return null; } vs = new VS.Document() .SetTemplate(VS.Templates.OrgChart); var mainShape = vs.GetTheShape() .SetFillColor("#FBD585"); buildShape(mainShape, targetInfo.id, model); return vs; } // Build a shape (and recurse for its children) function buildShape(shape, id, model) { shape.SetLabel(model[id].title) .SetHyperlink(localhost + "/" + model[id].webui); if (model[id].children.length > 0) { var connector = shape.AddShapeConnector(); for (var i = 0; i < model[id].children.length; i++) { buildShape(connector.AddShape(), model[id].children[i], model); } } } // Build a model entry from results function buildModelEntry(result) { modelEntry = { title: result.title, webui: result._links.webui, children: [] }; return modelEntry; } // Sort the children in the model by their titles function sortChildren(model, id) { var node = model[id]; if (node.children && node.children.length > 0) { for (var i = 0; i < node.children.length; i++) { sortChildren(model, node.children[i]); } node.children.sort((a, b) => { return (a.title > b.title ? 1 : -1); }); } } }