How to Get and Parse ServiceNow Journal Entries as Strings/HTML

I come across this question all the time in the ServiceNow community forums, and in the ServiceNow developer community Discord, Slack, and Telegram groups.

“How do I get the last N journal entries from a record in ServiceNow, in a script?”
“How do I print the last N journal entries in my notification email or mail script in ServiceNow?”
“How do I parse journal entries for HTML so special characters and line-breaks will show up properly in HTML or an email?”

I’ve helped people create so many one-off solutions to these questions that it felt more efficient to, finally, just create a single function that can do it all!

It’s certainly not the most monadic thing I’ve ever written, but it gets the job done. It’s also very well-documented so that if you want to know how it works (which, if you’re using it, you should), you can read through the code and comments, and understand exactly what it’s doing!

Scroll down to see the code, information on how to add this free tool to your instance, and instructions for use.

The code

/**
* Get the journal entries from a given record, and optionally parse and convert line breaks and
* HTML and wokkas (< and >) to HTML (<br />\n and HTML-ized character codes).
* @param {GlideRecord} current - A GlideRecord object positioned to the record you want to get the
* journal entries from.
* @param {String} journalFieldName - The journal field name (e.g. "work_notes", "comments",
* "comments_and_work_notes", etc.).
* @param {Boolean} [convertLineBreaksToHTML=false] - Set this to true, to convert line-breaks
* (\r\n) to HTML (<br />\n).
* @param {Boolean} [convertWokkasToHTML=false] - Set this to true, to convert wokkas ("<" and ">")
* to HTML ("&#[char_code];").
* @param {Number} [getLastNEntries=-1] - Specify the number of journal entries you'd like to
* retrieve. If not specified, the default is -1.
* Set this to -1 (or leave undefined) to retrieve ALL entries from the specified journal field.
* @param {function} [filterFunction=] - Optionally, specify a function that can be used to filter
* the results. This function will be called for each individual journal entry AFTER any other
* processing (such as HTML conversion). The function must accept one argument (the individual
* journal entry, as a string) and return the boolean value true if the journal entry passed
* into it matches your filter.
* For example:
* function checkJournalEntryContainsString(journalEntry) {
* return journalEntry.includes('SOME_STRING') || journalEntry.length < 100;
* }
* Passing the above checkJournalEntryContainsString function into getJournalEntries() as the last
* argument will cause getJournalEntries() to ONLY include journal entries that contain the
* text "SOME_STRING", *or* which are less than 100 characters in length. NOTE: When PASSING a
* function into another function as an argument, just pass it in by name. Do NOT include the
* "()" after the function name.
* If you DON'T want to filter the returned results, then simply do not specify this argument.
* @returns {String[]} - An array of journal entries from the specified journal field on the
* specified record, with the specified parsing done.
*
* @example
* var arrWorkNotes;
* var grInc = new GlideRecord('incident');
* grInc.setLimit(1);
* grInc.query();
* if (grInc.next()) {
* arrWorkNotes = getJournalEntries(
* grInc,
* 'work_notes',
* true,
* true,
* 2
* );
* gs.info(JSON.stringify(arrWorkNotes, null, 2));
* }
* //Output:
* //['2022-08-16 10:31:11 - Admin (Work notes)\nThis is an example journal entry <br />\n<br />\nwith a lot of<br />\nextraneous<br />\n<br />\n<br />\nline breaks.', '2022-08-16 10:30:58 - Admin (Work notes)\nThis is an example journal entry with <br />\nline breaks, and &#60;HTML wokkas that&#62; need to be converted to HTML.'];
*
* //Example 2:
* function myFilterFunction(strJournalEntry) {
* //Filter results: only include entries including this text, or less than 100 chars long
* return (
* strJournalEntry.includes('Some text to check for') ||
* strJournalEntry.length < 100
* );
* }
*
* var arrCommentsAndWorknotes = getJournalEntries(
* current,
* 'comments_and_work_notes',
* false,
* false,
* -1, //Get ALL journal entries
* myFilterFunction
* );
*/
function getJournalEntries(
current,
journalFieldName,
convertLineBreaksToHTML,
convertWokkasToHTML,
getLastNEntries,
filterFunction
) {
//Hoist declarations
var arrJournalEntries, strJournalEntries;
//Init default values for optional params
convertLineBreaksToHTML = (typeof convertLineBreaksToHTML == 'undefined') ? false : convertLineBreaksToHTML;
convertWokkasToHTML = (typeof convertWokkasToHTML == 'undefined') ? false : convertWokkasToHTML;
getLastNEntries = (typeof getLastNEntries == 'undefined') ? -1 : parseInt(getLastNEntries);
//Get all journal entries from the specified field as an array of strings.
strJournalEntries = current[journalFieldName] //Chain-call incoming...
//Arg -1 gets all journal entries in a string, delimited by "\n\n"
.getJournalEntry(getLastNEntries)
//Explicitly cast value to string to avoid unexpected type-coercion
.toString()
//Trim trailing whitespace (which would otherwise result is a trailing empty element)
.trim();
if (convertWokkasToHTML) {
if (strJournalEntries.includes('<') || strJournalEntries.includes('>')) {
strJournalEntries = strJournalEntries.replaceAll(
'<',
('&#' + '<'.charCodeAt(0) + ';')
);
strJournalEntries = strJournalEntries.replaceAll(
'>',
('&#' + '>'.charCodeAt(0) + ';')
);
}
}
if (convertLineBreaksToHTML) {
//Replace embedded returns in journal entry with HTML break if necessary
strJournalEntries = strJournalEntries.replaceAll(
'\r\n', (convertLineBreaksToHTML ? '<br />\n' : '\r\n')
);
}
//Split the string on double-new-line-character to get unique journal entries
arrJournalEntries = strJournalEntries.split('\n\n');
/*
NOTE: Splitting on the double-new-line-character may seem risky. What if a user creates
a journal entry with a double-return in it, for example? -- However, this is not the
case. New-lines inside of journal entries are converted to "\r\n" for each new line.
For example, the following journal entry:
"This
is a test"
Will be converted to:
"This\r\n\r\nis a test"
Note that each line-return in the original entry is converted to "\r\n". Therefore, when
splitting on "\n\n", we should expect no conflict.
*/
//Fail early: If no journal entries are found, bail out here.
if (!arrJournalEntries || arrJournalEntries.length < 1) {
return []; //No journal entries found. Halt and return, but throw no error or warning.
}
//If filterFunction arg is provided, filter journal entries using that function.
if (typeof filterFunction == 'function') {
arrJournalEntries = arrJournalEntries.filter(filterFunction);
}
return arrJournalEntries;
}

Usage

There are examples in the JSDoc above the function, which includes what it should output given a set of arguments, but I’ll reproduce them below so you can see them in-code:

//Example 1:

var arrWorkNotes;
var grInc = new GlideRecord('incident');
grInc.setLimit(1);
grInc.query();
if (grInc.next()) {
    arrWorkNotes = getJournalEntries(
        grInc,
        'work_notes',
        true,
        true,
        2
    );
    gs.info(JSON.stringify(arrWorkNotes, null, 2));
}
/* Output:
['2022-08-16 10:31:11 - Admin (Work notes)\nThis is an example journal entry <br />\n<br    />\nwith a lot of<br />\nextraneous<br />\n<br />\n<br />\nline breaks.', '2022-08-16    10:30:58 - Admin (Work notes)\nThis is an example journal entry with <br />\nline breaks,    and &#60;HTML wokkas that&#62; need to be converted to HTML.'];
*/

//Example 2:

function myFilterFunction(strJournalEntry) {
    //Filter results: only include entries including this text, or less than 100 chars long
    return (
        strJournalEntry.includes('Some text to check for') ||
        strJournalEntry.length < 100
    );
}

var arrCommentsAndWorknotes = getJournalEntries(
    current,
    'comments_and_work_notes',
    false,
    false,
    -1, //Get ALL journal entries
    myFilterFunction
);

“But what if I want the journal entries to show up in a table in the notification email/page/whatever?”

Okay sure fine; if you need an HTML table, you can pass the resulting array into this function:

function convertToTable(arrJournalEntries) {
    var i, strJournalEntry;
    var strHTMLTable = '<table><tbody>';
    var openTrTd = '<tr><td>';
    var closeTrTd = '</td></tr>';

    if (!arrWorkNotes || arrWorkNotes.length < 1) {
        return '<table></table>';
    }

    for (i = 0; i < arrJournalEntries.length; i++) {
        strJournalEntry = arrJournalEntries[i];
        strHTMLTable += openTrTd + strJournalEntry + closeTrTd;
    }

    strHTMLTable += '</tbody></table>';
    return strHTMLTable;
}

Installation

You can add this to your instance so that it can be called from any server-side code (such as Mail Scripts or Business Rules) by creating a Script Include called “getJournalEntries”, and replacing all of the auto-generated code with this code.
Once that’s done, you can simply call getJournalEntries() from anywhere, and pass in the necessary arguments as described at the top of the function. The only required arguments are the GlideRecord object you want to get the journal field values from, and a string with the name of the journal field you want to get the entries from.

Alternatively, you can simply import this XML file into your Script Includes table (sys_script_include).



Share