Extending KeyConfigure with JavaScript

Starting with version 7.4, KeyConfigure can be extended via JavaScript. With the embedded JavaScript engine, you can write scripts that access, read, and modify the basic objects in KeyConfigure such as computers, policies, and products. You can also access and open new windows containing these objects.

This document describes how to create JavaScript bundles that will be added to KeyConfigure's Tasks menu and to contextual menus as appropriate. It gives a walk-through of several scripts, starting from the most basic through a more complex script that prompts the user and makes changes to selected computers.

A separate Scripting Reference provides detailed information on all of the objects and methods available within the scripting environment.

JavaScript Bundles

When it starts, KeyConfigure will load JavaScript “bundles” and add them to its menus. A bundle is a convenient way to gather together all the files for a particular JavaScript. Each bundle contains:

  • the main script
  • meta-data about the script such as the menu label to display
  • initial, default values for settings that the user might change
  • supporting data files
  • supporting script files

All of the files are placed into a folder with a name ending in “.jst”. Only the first two files are required. Other files can be included if they are needed by the main script. The meta-data file must be named “manifest.json”, and is a text file in JSON format with a few important values. Here is an exampe of a simple manifest:

{
  "id" : "com.sassafras.hello-world",
  "name" : "Hello World",
  "script" : "script.js"
}

This manifest contains the unique identifier for the script, which should be a uniform type identifier in reverse DNS format using your organization's domain name. The name string is what will be used by KeyConfigure for display in the user interface. The script string gives the name of the main script file, and can be any valid file name (although by convention and for simplicity, the filename is usually “script.js”).

Whatever the name of the main script file, it contains the JavaScript code that will be executed. This script can load other scripts from the support folder (see below), in cases where it is convenient to factor the code into separate files. In keeping with tradition, here is an example script that displays an alert with the text “Hello, World!”:

(function () {

  alert("Hello, World!");

})();

As simple as this script is, it demonstrates a few important things. First, lines 1 and 5 wrap the entire script in a closure that keeps the global namespace from getting polluted, and is required for when the script returns a value. Second, there are some global objects and methods (like alert) that are provided by the environment, although it is important to note that objects common in browsers, like window and document are not defined. Finally, it shows that we are just writing standard JavaScript.

This very simple bundle is available as a ZIP archive: hello-world.jst.zip.

Bundle Locations

KeyConfigure will look for JavaScript bundles in two locations, depending on the platform:

    Windows
  • C:\ProgramData\KeyConfigure\Scripts
  • C:\Users\[User]\AppData\Local\KeyConfigure\Scripts

    Mac OS X
  • /Library/Application Support/KeyConfigure/Scripts
  • /Users/[User]/Library/Application Support/KeyConfigure/Scripts

KeyConfigure will also download bundles that are stored centrally on KeyServer. Details on this will be covered below. If the same bundle is in more than one location (using the bundle identifier for comparison), only the last copy found will be loaded.

If you downloaded the hello-world.jst.zip archive above, you can extract its contents and place the resulting hello-world.jst folder in one of these locations. Run KeyConfigure, connect to your KeyServer, and then go to the Tasks menu where you should now see a new “Hello World” item. (Note: Some ZIP programs will extract the archive into a folder with the same name as the archive, creating an extra folder level. Be sure to use the innermost hello-world.jst folder.)

Persistent Settings

Some scripts will need to save information from one run to the next. For example, a script might need to store the last time it ran. For this purpose, a bundle can contain a storage template. Storage templates are placed in a storage folder within the bundle. When a bundle is first loaded by KeyConfigure, any storage templates will be copied to a user-writable location, and this copy is what the script will use. The storage information can be read and written within the script, and any data that is written will persist between separate runs.

The information in storage files is available to a script via globally-scoped Storage objects (Storage objects were formalized in the HTML5 spec). The name of the storage object is based on the name of the storage file, so if a bundle has a file named “localStorage.json” in the storage directory, the script will access the storage as “localStorage”. For example, here is a simple storage file:

{
  "message" : "Hello, World!"
}

We could modify the Hello World script to use the message from this storage object instead of the hard-coded message. Assuming the storage file is named localStorage.json, here's how we'd do that and also save a value back into the persistent storage.

(function () {

  var msg = localStorage.getItem("message");
  alert(msg);

  var now = new Date();
  localStorage.setItem("lastrun", now.toString());

})();

Line 3 retrieves the message string from the localStorage object, which gets its initial values from the storage template in the bundle. In line 4 the message is displayed in an alert. Lines 6-7 get the current date/time and save this in the localStorage object. The storage object is saved back to the writable, per-user copy of the backing storage file.

The HTML5 specification defines Storage objects to only store values that are strings. The implementation in KeyConfigure supports any basic type, so you can store strings as well as numbers, boolean values, and even objects. Essentially, anything that can be represented as a JSON string can be stored.

Prompting the User

While some scripts will perform the same exact task each time they are run, other scripts will require some initial input from the user in order to alter the task that is performed. In the example script, we could prompt the user for the message that will be displayed. Of course we'd also want to remember this message for the next run, but first we'll look at how the script's structure is changed to incorporate the user prompt.

(function () {

  return({
    // version of the object for behavior consistency
    version: 1,
    // setup object describing what we want from the user
    setup: {
      fields: [
        {
          // first and only field is for the message
          id: "msg",
          label: "Enter the message:",
          type: "text",
          required: true
        }
      ]
    },
    // proceed function to be called once input is complete
    proceed: function (params) {
      alert(params.msg);
    }
  });

})();

The difference between this script and the previous scripts should be obvious: the top-level function does not do any work other than to return an object. This object tells KeyConfigure that the user should be prompted for certain information, and once the information is entered should run a function that will handle the actual task. Going line-by-line: Line 1 starts the top-level function/closure, similar to previous scripts. In this case, the function is required so that the object can be returned, which starts on line 3. Line 5 gives the object a version so KeyConfigure behavior will be the same for future releases. Next on line 7 the setup member describes what to prompt the user for. The important member of setup is fields (beginning on line 6), which is an array of items to request. Lines 9-15 are properties for the one item we are prompting for. The properties are id, which will be used for returning the user-specified value, a label that will be displayed in the UI for the item, the type of the information we need, and an indication that this item is required.

On line 19 we define the proceed function, which will be called once the requested information is provided by the user. KeyConfigure will provide OK and Cancel buttons, and the proceed function is called only if the user clicks OK. The proceed function is passed an object that contains the values that were provided by the user. The object will have one member for each field specified in the setup. The name of the member is the id of that field.

The body of the proceed function (line 20) is very simple, like the first example script above. The alert function is called for the string that was entered by the user. Again, this string is available as a member of the single parameter passed into the proceed function. with the member name being msg as specified on line 11.

Now we'll make a small improvement on this script. We want to remember the message that the user enters, and use that same message by default when the script runs next. The message will be kept in the persistent localStorage file. In the script below we've added line 14, which retrieves the previously saved message and sets it as the default for the message field. On line 22 we save the new message to localStorage.

(function () {

  return({
    // version of the object for behavior consistency
    version: 1,
    // setup object describing what we want from the user
    setup: {
      fields: [
        {
          // first and only field is for the message
          id: "msg",
          label: "Enter the message:",
          type: "text",
          default: localStorage.getItem("message"),
          required: true
        }
      ]
    },
    // proceed function to be called once input is complete
    proceed: function (params) {
      alert(params.msg);
      localStorage.setItem("message", params.msg);
    }
  });

})();

Recall that storage objects, like localStorage in this script, are defined based on the bundle's storage templates, so the initial value of the message will come from there. If the bundle's localStorage.json file is like the example above, the message will initially be “Hello, World!”.

KeyConfigure can prompt for and return more than simple text fields. The fields described in the setup object can have other types, such as:

  • text — arbitrary text data
  • long — numerical value (whole numbers) optionally between a minimum and maximum
  • bool — a true/false value, shown as a checkbox in the UI
  • menu — a single choice from a list of options
  • date — a date/time value

These and more field types, along with their related settings, are described in the Prompt Field Types section of the Scripting Reference.

Views and Objects

Ultimately, the purpose of these scripts is to perform some action on a set of “objects” in KeyConfigure, where an object is something like a computer or a policy. Objects are collected into a “view”, which can be iterated over to apply some operation to each of the objects. The easiest way to get a view of objects is to use the current selection in the appropriate window. This selection view is available as part of the globally scoped app object. The following example shows how we can access the current selection of computers and just display a count. First, we need to create the manifest.json file for this new script. The important new item is target, which tells KeyConfigure what type of object this script will operate on. The required field is set to true since this script should only be available when at least one computer is selected, and the multiple field is also true since the script can handle more than one computer in the selection.

{
  "id" : "com.sassafras.example-selection",
  "name" : "Count Selected Computers",
  "script" : "script.js",
  "target" : {
    "table" : "computer",
    "required" : true,
    "multiple" : true
  }
}

In this script we go back to the simpler structure that does not prompt the user for information. We'll just count the objects in the selection and display the count in an alert.

(function () {

  // simple sanity check that there is a non-empty selection
  if (!app.selection || !app.selection.length)
    return;

  // initialize the count to 0
  var count = 0;
  // iterate through the objects in the selection, counting each one
  app.selection.forEach(function (obj) {
    count++;
  });

  // show the total count in an alert
  alert("Selection has " + count + " computers.");

})();

Lines 4-5 perform a sanity check on the selection, making sure that the selection is valid and non-empty. Since the manifest specifies that the selection is required, this sanity check is not strictly necessary. However, we include it here for completeness.

In line 10 the selection's forEach method is called to iterate over the selection. Each object in the selection will be passed to the provided function, which in this case simply increments the count. The last step is to show the final count in an alert.

Now we'll modify this script to count the number of computers with each version of KeyAccess found within the selection. This script shows how to access a property within the computer object that is passed into the iteration function. It also shows an alternative way to call the forEach method that allows us to set the iteration function's context (this).

(function () {

  // simple sanity check that there is a non-empty selection
  if (!app.selection || !app.selection.length)
    return;

  // initialize the version count object
  var ctx = { total: 0, versions: { } };
  // iterate through the objects in the selection
  app.selection.forEach({
    context: ctx,    // the ctx object will be this in the callback function
    callback: function (obj) {
      // keep a total count of computers in the selection
      this.total++;
      // get the clientversion from the object, if known
      var cver = obj.clientversion || "unknown";
      // add or increment a key value for each client version encountered
      if (!this.versions[cver])
        this.versions[cver] = 1;
      else
        this.versions[cver]++;
    }
  });

  // gather all the stats into one big string
  var stats = "Selection has " + ctx.total + " computers.\n";
  for (var key in ctx.versions) {
    stats += key + ": " + ctx.versions[key] + "\n";
  }
  // show the results in an alert
  alert(stats);

})();

Most of this script we've already seen, or is just plain JavaScript manipulation of data. There are two important changes. First, lines 10-12 show a way of using the forEach method where we can pass a value to be used as the callback function's this value. The second noteworthy change is on line 16, where we get the clientversion property from obj, the (computer) object that was passed into the callback function.

Modifying Objects

The previous script read the clientversion property in order to summarize information about it. This property happens to be read-only, but there are other properties that can be changed. The following script changes a property of a computer based on input from the user. The script prompts the user for a string, then sets the first custom computer field of each selected computer to this string. We'll assume the custom field has been created and named “Building”. First we have the manifest.json file:

{
  "id" : "com.sassafras.example-modbuilding",
  "name" : "Set Building...",
  "script" : "script.js",
  "target" : {
    "table" : "computer",
    "required" : true,
    "multiple" : true
  }
}

This manifest is like the previous one, but we've changed the identifier and the name. Notice that the name ends with “...”, which is the convention for menu items that will present a dialog to the user. The script follows the pattern of prompting the user.

(function () {

  // simple sanity check that there is a non-empty selection
  if (!app.selection || !app.selection.length)
    return;

  return({
    // version of the object for behavior consistency
    version: 1,
    // setup object describing what we want from the user
    setup: {
      fields: [
        {
          // first and only field is for the building
          id: "building",
          label: "Building:",
          type: "text",
          default: localStorage.getItem("building"),
          required: true
        }
      ]
    },
    // proceed function to be called once input is complete
    proceed: function (params) {
      localStorage.setItem("building", params.building);

      // iterate through the objects in the selection
      app.selection.forEach({
        context: params,    // params becomes this in the callback
        callback: function (obj) {
          // set the property
          obj.usr0 = this.building;
          // save the changes
          obj.commit();
        }
      });
    }
  });

})();

In lines 3-5 we check that there is a valid non-empty selection (again, as mentioned above, this is not really needed). Since this code runs before the prompt object is returned, we have a chance to check whatever preconditions there are, or run any code we need. Since the sanity check returns nothing on failure, the user will not be prompted in that case. There is nothing magical about the way scripts work when structured to prompt the user. A script could perform many tasks, then prompt the user if needed. The same script could even prompt the user in different ways depending on other conditions.

The setup object in lines 11-22 is not much different from the prevous prompting example. Note that the string for the Building is retrieved from the localStorage object, and also saved back there for the next run of the script. The bundle will need to contain a localStorage.json file in its storage folder for this string to persist.

In the proceed function we iterate over the selection. On line 32 the object's usr0 property is set. We need to use this property name even though in the UI the property is shown with the customized name “Building”. Once the property is set we have to save the changes by calling the commit method of obj, line 34. If we were setting multiple fields on the same object we'd only have to call commit once after all changes have been made.

We'll make further improvements on this script below after covering some additional functionality.

Viewports

The current selection is not the only way to get a view of objects. Views can also be created based on a filter, a list of object IDs, a list of property values, and other criteria. Such views can be iterated over like we've done with the selection view. This code section shows how you create a new view of computer objects and then populate it with a filter:

  var view = new KSView("computer");
  view.select("(usr0=NULL)");

The filter selects all computers that have no value for the usr0 field. Instead of iterating over this view with the forEach method, in this example we want to display the view in a new window within KeyConfigure. New windows are created using the KSViewport class, and can be given whatever title is needed. There can only be one viewport with a given name (for an object type), so if you create a second viewport object using the same name it will refer to the same window. Viewports are not visible initially, so in this sample we show the viewport after creating it.

  var viewport = new KSViewport("computer", "Computers without a Building");
  viewport.show();

The viewport that is created above will be empty. To add objects, we can select a view into the viewport. Putting the above two samples together, we get:

  var viewport = new KSViewport("computer", "Computers without a Building");
  var view = new KSView("computer");
  view.select("(usr0=NULL)");
  viewport.select(view);
  viewport.show();

The current contents of a viewport are available as the view property of the viewport. Also, the current selection can be retrieved from the viewport using the getSelection mehod. This method returns a view that contains just those objects that are in the viewport's selection.

It should be apparent that a viewport corresponds to a window in KeyConfigure. So why not call these objects “windows”? When using JavaScript in a browser, there is always an object named “window” that has certain well-defined properties and methods that are specific to browsers, HTML, and the DOM. Since KeyConfigure scripts do not run in the context of a browser, there is no window object. So to avoid confusion, and to suggest the association with views, we use “viewport” — but really, a viewport is a window.

Support Files and the bundle object

One part of a script bundle we have not yet covered is the support folder and contents. The files in this folder can be loaded into the script environment in a format that makes sense given the file contents. First we need to introduce the globally-scoped bundle object, which is present for all .jst-bundled scripts. The bundle object contains information about the bundle (the full manifest is available as bundle.manifest), and methods for accessing bundle resources.

Files in the support folder are loaded using the load method of the bundle. How a file is loaded depends on its filename extension:

  • .js — JavaScript files are parsed and executed within the main script's context
  • .json — JSON files are parsed and returned from the load method as an object
  • .plist — plist files are parsed and returned from the load method as an object
  • other — any other file is loaded as a text file whose lines can be iterated over (more on this below)

The load method takes as its single argument the name of the file to load. This file must be in the support folder, and the name must be all printable ASCII characters excluding “:”, “/”, and “\”. So if we add a file named “sample.json” to the support folder and give it the contents:

{
  "something" : "sometext"
}

we could load this JSON file as an object and access its values in a script like this:

  var myobj = bundle.load("sample.json");
  alert(myobj.something);

The result of this script would be to show an alert with the message “sometext”.

For non-parsed text files (files that do not have the .js, .json, or .plist extension), the load method returns an object that has a forEach method that will iterate over the lines of the file. If there is a file named “sample.txt” in the support folder with the contents:

Apples
Oranges
Pears

we could load this file and iterate over the lines like this:

  var file = bundle.load("sample.txt");
  var list = [];
  file.forEach(function (line) {
    list.push(line);
  });
  alert(JSON.stringify(list));

Running this script would show an alert with the message: [ "Apples", "Oranges", "Pears" ]

In this case the load method has returned a KSFile object, which has the single method forEach, as we have used. KSFile objects cannot be created by JavaScript code for security reasons. However, in addition to the load method, a KSFile object can be obtained via user input.

User Prompt Field Types

As mentioned in the Prompting the User section above, there are several types of input that can be requested from the user. The “menu” type is a convenient way to ask for one of a specified list of values. The values can be just about anything, with a text string that will be shown in the pop-up menu. Here is a script that prompts the user to select an item from a menu, then shows an alert with that value.

(function () {

  return({
    version: 1,
    setup: {
      fields: [
        {
          id: "favefabfour",
          label: "Favorite Beatle:",
          type: "menu",
          options: [ "John", "Paul", "George", "Ringo" ],
          required: true
        }
      ]
    },
    proceed: function (params) {
      alert(params.favefabfour);
    }
  });

})();

The important lines here are 10-11, where we specify that the field is type “menu”, and provide the list of options that will be available in the pop-up menu. This script will actually display the 0-based index of the selected option, but in this case we want to show some meaningful text. We could create a separate array that matched the options array, or we could have KeyConfigure just return the value we want:

(function () {

  return({
    version: 1,
    setup: {
      fields: [
        {
          id: "favefabfour",
          label: "Favorite Beatle:",
          type: "menu",
          options: [
            { label:"John", value:"Lennon" },
            { label:"Paul", value:"McCartney" },
            { label:"George", value:"Harrison" },
            { label:"Ringo", value:"Starr" }
          ],
          required: true
        }
      ]
    },
    proceed: function (params) {
      alert(params.favefabfour);
    }
  });

})();

Lines 12-15 give a list of options with a label that will be added to the menu and a value that will be returned in the resulting input object. The value can be anything, including an object, and it will be returned when the corresponding label is selected from the options menu.

Another useful input type is “file”. For this type, KeyConfigure will display an area into which a file can be dragged, and a “Browse” button for those who prefer that UI. When a file is provided by the user, the resulting input field will be a KSFile (regardless of filename extension or content). The lines of this KSFile object can be iterated over with the forEach method. The field description for the “file” input type looks like this:

  {
    id: "favefabfour",
    label: "Favorite Beatle:",
    type: "file",
    required: true
  }

The required value is more important in this case than for other input types (for instance, for the “menu” type there will always be something selected in the pop-up menu, so the required value is somewhat meaningless unless you take special measures). If a file is not provided by the user, the corresponding field of the input object will be undefined unless required is true.

A Final Example

Now we'll take the topics we covered above and combine them all into a script (and bundle). This script will:

  • start with the computers selected by the user
  • prompt the user for a Building taken from a list stored in a support file
  • set the (custom) Building property to the selected value
  • display the changed computers in a new KeyConfigure window

The manifest is the same as for the simpler version of this script:

{
  "id" : "com.sassafras.example-modbuilding",
  "name" : "Set Building...",
  "script" : "script.js",
  "target" : {
    "table" : "computer",
    "required" : true,
    "multiple" : true
  }
}

The support file, which we'll name “buildings.txt”, has the list of buildings. We put it in a separate file so the list can be changed as needed without editing the script code:

Headquarters
Annex
Library
Garage

We also have a simple storage template file, which we name “localStorage.json” so the storage object is localStorage:

{
  "building" : "Headquarters"
}

The script has nothing we haven't seen before:

(function () {

  // simple sanity check that there is a non-empty selection
  if (!app.selection || !app.selection.length)
    return;

  // load the building list, create an options array
  var buildinglist = [];
  var buildingfile = bundle.load("buildings.txt");
  buildingfile.forEach(function (line) {
    if (line && line.length)
      buildinglist.push({ label: line, value: line });
  });

  return({
    // version of the object for behavior consistency
    version: 1,
    // setup object describing what we want from the user
    setup: {
      fields: [
        {
          // first and only field is for the building
          id: "building",
          label: "Building:",
          type: "menu",
          options: buildinglist,
          default: localStorage.getItem("building"),
          required: true
        }
      ]
    },
    // proceed function to be called once input is complete
    proceed: function (params) {
      localStorage.setItem("building", params.building);

      // iterate through the objects in the selection
      app.selection.forEach({
        context: params,    // params becomes this in the callback
        callback: function (obj) {
          // set the property
          obj.usr0 = this.building;
          // save the changes
          obj.commit();
        }
      });

      var viewport = new KSViewport("computer", "Building Set to " + params.building);
      viewport.select(app.selection);
      viewport.show();
    }
  });

})();

You can get the bundle with all these files as a ZIP archive: example-modbuilding.jst.zip. (Note: Some ZIP programs will extract the archive into a folder with the same name as the archive, creating an extra folder level. Be sure to use the innermost example-modbuilding.jst folder.)