- Post History
- Subscribe to RSS Feed
- Mark as New
- Mark as Read
- Bookmark
- Subscribe
- Printer Friendly Page
- Report Inappropriate Content
02-23-2022 02:27 AM - edited 08-02-2024 12:11 AM
This post has been updated to reflect multiple feature requests I've had since I originally posted this.
The artifact now searches through multiple different aspects of a portal - Taxonomies, Topics, Menu's, Content Experiences,
Themes, Footers & Headers, Web Applications, Quick Links, Service Catalogs and their Categories as well as
KB Categories, widgets called from other widgets, custom Angular templates, if a variable is sent to getMessage it will check if it's in
a defined variable or if it's being sent from a function it will check for those function calls to find the variables in the calls and more...
A massive thank you to everyone who is providing feedback and suggestions, please keep them coming as you are all
helping to make this better for everyone - it is greatly appreciated by many!
Also, we are working on a better way to release this. Originally it was just a PoC and one to teach people how to make really powerful
artifacts. Now it's turned into something quite a bit more - watch this space...
So it seems I caused a bit of a stir with my last webinar at the Employee Center Academy. In that, it's become the single most asked question I now get - "how did you do it?" (referring to localizing the portal into Japanese).
If you haven't seen the session I highly recommend you check it out:
In this post I'm going to go through some concepts, some sneak peaks behind the scenes, how I rationalise table hierarchies for solution architecture and even share my prototype Localization Framework artifact script to get you started.
This one is going to be quite technical and long so make sure you have a coffee at the ready and some snacks on hand because this is going to be a long one!
I have a portal, it needs to be in other languages
If we look at the new /esc portal, imagine we want to go from pure English:
To also have another Language (imagine Japanese in my example);
In this new portal, we now need to consider the "Mega Menu", "Topics" and even "Quick Links" (the widget title is in English as it's a pre-release version and will be fixed by the time you have access to it).
These new components will be super important, as we'll need to understand what tables they are in and therefore what their respective relationships are in respect to the Portal they are shown on.
As a recap, you should remember from our training course (available here) that for custom widgets etc, we need to follow certain coding standards to ensure all end-user facing strings are properly externalised. Here's a quick recap slide to show what I mean, which details how text is calling the Message API based on it's string type:
To test if everything is indeed "externalised" as needed, if we remember from our training, we can leverage the "i18n debugger" which should show us a Prefix for each UI string representing that the system would expect a translation for it in one of the 5 tables;
If you're a regular reader of my blog, you'll know that whilst I'm a big proponent of the Localization Framework (possibly because I helped design it) I've also shown how you can write your own Artifacts, for example Portal Announcements and Surveys.
Tip, if you haven't yet read those posts, please read them both before continuing, because they will act as foundational knowledge for the following aspects.
This then means, did I achieve my objective (in under 2 days I need for prep) by leveraging the Localization Framework? The simple answer is yes. I wrote a prototype Artifact to go and identify everything I could within that given portal - which required mapping out the table hierarchies of the types of records used in this new portal (the way I think about it is, it's a pyramid of data, so knowing how that pyramid is structured is the objective).
How did I do it?
For sake of time, lets assume we've built to coding standards (meaning everything is correctly externalised), let's consider making our new Artifact. The first consideration we would need to think about is where does a portal's hierarchy start?
The tip would be the [sp_portal] table, then we need to think about all of the known pages directly related to it, then the page route map, then all of the widgets (and their respective instances) in those given pages, as well as the new "mega menu" and it's entries, the new "topics" and their sub-values, as well as the new "footers".
This therefore means, we need to follow the relationships to see if we can create suitable sub-queries from the [sp_portal]. In the most part we can, but due to the unique nature of how Service Portals are built, we can't necessarily obtain everything this way. And so, in my prototype it's not 100%, but it's pretty close.
As we're working on the Rome release, this artifact will be a "Processor Script" (aka a Script Include) and the UI action will be defined on the [sp_portal] table.
- To learn all of the necessary steps make sure you read the two blog posts above and make sure you read the Product Documentation here (for what to do) and here (to understand what functions you have available).
Remembering this is a prototype, there's for sure many areas of improvement (optimization and efficiency), however this should cover the vast majority of scenarios. The idea here, is to show you that if you understand the table hierarchy of that proverbial thing you want to translate, then you too can write your own artifact should you need to.
[Update]
I thought it might be helpful to show how a portal is typically structured to explain the logic I applied to designing this POC artifact:
this is a simplification of what a Portal's structure looks like
So here it is (it's quite a long one, you've been warned),
The artifact is called;
Important note - as with any Script Include make sure that the name of it is the exact same as the first declared object, in this case
"LF_PortalProcessor"
With the processor script looking like this;
If you're revisiting this post, you might notice my code example is longer than before. That's because in a recent call with a Customer, they asked if I could also check for the associated Theme, hence the changes.
Note - this artifact will pick up a lot of strings. If you are translating into a net-new language, it may be more performant to use the Page level artifact further down below, but please be patient once you've selected your language at the request stage as it may take a couple of minutes to generate the task (even if it doesn't look like it's doing anything).
var LF_PortalProcessor = Class.create();
LF_PortalProcessor.prototype = Object.extendsObject(global.LFArtifactProcessorSNC, {
category: 'localization_framework', // DO NOT REMOVE THIS LINE!
/**********
* Extracts the translatable content for the artifact record
*
* @Param params.tableName The table name of the artifact record
* @Param params.sysId The sys_id of the artifact record
* @Param params.language Language into which the artifact has to be translated (Target language)
* @return LFDocumentContent object
**********/
getTranslatableContent: function(params) {
/**********
* Use LFDocumentContentBuilder to build the LFDocumentContent object
* Use the build() to return the LFDocumentContent object
**********/
var tableName = params.tableName;
var sysId = params.sysId;
var language = params.language;
var lfDocumentContentBuilder = new global.LFDocumentContentBuilder("v1", language, sysId, tableName);
var pageArr = []; // for later
var widgetArr = []; // we will need this later
// what portal are we looking at?
var portalCheck = new GlideRecord('sp_portal');
portalCheck.addQuery('sys_id', sysId);
portalCheck.query();
if (portalCheck.next()) {
// processString will look for the (portalCheck.getDisplayValue()) in sys_ui_message table and translated value too, if present
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(portalCheck, 'Portal', 'Name');
// we need to do some specific AI search checks
if (portalCheck.enable_ais == true) {
var getSearchTabs = new GlideRecord('sys_search_filter');
getSearchTabs.addQuery('active=true^search_context_config=' + portalCheck.search_application.sys_id);
getSearchTabs.query();
while (getSearchTabs.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getSearchTabs, "Search", "Name");
}
// we also need to do some other filters checks
var getSearchSorts = new GlideRecord('sys_search_sort_option');
getSearchSorts.addEncodedQuery('active=true^search_context_config=' + portalCheck.search_application.sys_id);
getSearchSorts.query();
while (getSearchSorts.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getSearchSorts, "Search", "Name");
}
// we also need to check some suggestion reader groups
var getSuggestions = new GlideRecord('sys_suggestion_reader_group');
getSuggestions.addEncodedQuery('context_config_id=' + portalCheck.search_application.sys_id);
getSuggestions.orderBy('order');
getSuggestions.query();
while (getSuggestions.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getSuggestions, "Search", "name");
}
// we also need to check the search facets
var getSearchFacets = new GlideRecord('sys_search_facet');
getSearchFacets.addEncodedQuery('active=true^search_context_config=' + portalCheck.search_application.sys_id);
getSearchFacets.orderBy('order');
getSearchFacets.query();
while (getSearchFacets.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getSearchFacets, "Search", "Name");
}
// exceptions
lfDocumentContentBuilder.processString("Most relevant", "Search", "Name");
lfDocumentContentBuilder.processString("Was this suggestion helpful?", "Search", "Name");
lfDocumentContentBuilder.processString("Top result", "Search", "Name");
lfDocumentContentBuilder.processString("Yes", "Search", "Name");
lfDocumentContentBuilder.processString("No", "Search", "Name");
}
// we need to check through the Theme Header and Footer of this portal
var themeHeader = '';
var themeFooter = '';
if (portalCheck.theme.header) {
var getHeader = new GlideRecord('sp_header_footer');
getHeader.addQuery('sys_id', portalCheck.theme.header.sys_id);
getHeader.query();
if (getHeader.next()) {
themeHeader = getHeader;
// let's now get the specifics in the theme's header
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(themeHeader, "Theme", 'Header');
lfDocumentContentBuilder.processScript(themeHeader.template, "Theme", "Header - Template");
_getMessages(themeHeader.template.toString(), "Theme", themeHeader.getDisplayValue());
// we need to do a quick check to see if the "user_profile" widget is being used
if (themeHeader.template.includes('user_profile')) {
var getUserProfWid = new GlideRecord('sp_widget');
getUserProfWid.addQuery('id', 'user-profile');
getUserProfWid.query();
if (getUserProfWid.next()) {
lfDocumentContentBuilder.processScript(getUserProfWid.template, "User Profile", "HTML");
_getMessages(getUserProfWid.template.toString(), "User Profile", "HTML");
lfDocumentContentBuilder.processScript(getUserProfWid.script, "User Profile", "Server Script");
lfDocumentContentBuilder.processScript(getUserProfWid.client_script, "User Profile", "Client Controller");
}
}
lfDocumentContentBuilder.processScript(themeHeader.css, "Theme", "Header - CSS");
_getMessages(themeHeader.css.toString(), "Theme", themeHeader.getDisplayValue());
lfDocumentContentBuilder.processScript(themeHeader.script, "Theme", "Header - Script");
_getMessages(themeHeader.script.toString(), "Theme", themeHeader.getDisplayValue());
lfDocumentContentBuilder.processScript(themeHeader.client_script, "Theme", "Header - Client Script");
_getMessages(themeHeader.client_script.toString(), "Theme", themeHeader.getDisplayValue());
lfDocumentContentBuilder.processScript(themeHeader.link, "Theme", "Header - Link");
_getMessages(themeHeader.link.toString(), "Theme", themeHeader.getDisplayValue());
}
}
if (portalCheck.theme.footer) {
var getFooter = new GlideRecord('sp_header_footer');
getFooter.addQuery('sys_id', portalCheck.theme.footer.sys_id);
getFooter.query();
if (getFooter.next()) {
themeFooter = getFooter;
// we have some specific /esc footer checks to make
if (getFooter.id == "employee-center-footer") {
// we need to double-check to see if the EC or EC pro is installed first
var checkEC = new GlideRecord('sys_package');
checkEC.addEncodedQuery('source=sn_ex_sp^ORsource=sn_ex_sp_pro');
checkEC.query();
if (checkEC.hasNext()) {
// now we need to go through the footer
var footerCheck = new GlideRecord('sn_ex_sp_footer');
footerCheck.addEncodedQuery('portalLIKE' + portalCheck.sys_id);
footerCheck.addQuery('active', 'true');
footerCheck.query();
if (footerCheck.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(footerCheck, 'Footer', 'Footer');
// now we need to find the menus for the footer
var footerMenuCheck = new GlideRecord('sn_ex_sp_footer_menu');
footerMenuCheck.addQuery('footer', footerCheck.sys_id);
footerMenuCheck.orderBy('order');
footerMenuCheck.query();
while (footerMenuCheck.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(footerMenuCheck, 'Footer Menu', 'Footer Menu');
// now we need to find the items in the menus
var footerMenuItem = new GlideRecord('sp_instance_menu');
footerMenuItem.addQuery('sys_id', footerMenuCheck.menu.sys_id);
footerMenuItem.query();
if (footerMenuItem.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(footerMenuItem, 'Footer Menu Item', 'Footer Menu Item');
// now we need to check each sub-item
var footerMenuSubItem = new GlideRecord('sp_rectangle_menu_item');
footerMenuSubItem.addEncodedQuery('active=true');
footerMenuSubItem.addQuery('sp_rectangle_menu', footerMenuItem.sys_id);
footerMenuSubItem.orderBy('order');
footerMenuSubItem.query();
while (footerMenuSubItem.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(footerMenuSubItem, 'Footer Menu Sub-Item', 'Footer Menu Sub-Item');
}
}
}
}
}
}
// let's now get the specifics in the theme's footer
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(themeFooter, "Theme", 'Footer');
lfDocumentContentBuilder.processScript(themeFooter.template, "Theme", "Footer - Template");
_getMessages(themeFooter.template.toString(), "Theme", themeFooter.getDisplayValue());
lfDocumentContentBuilder.processScript(themeFooter.css, "Theme", "Footer - CSS");
_getMessages(themeFooter.css.toString(), "Theme", themeFooter.getDisplayValue());
lfDocumentContentBuilder.processScript(themeFooter.script, "Theme", "Footer - Script");
_getMessages(themeFooter.script.toString(), "Theme", themeFooter.getDisplayValue());
lfDocumentContentBuilder.processScript(themeFooter.client_script, "Theme", "Footer - Client Script");
_getMessages(themeFooter.client_script.toString(), "Theme", themeFooter.getDisplayValue());
lfDocumentContentBuilder.processScript(themeFooter.link, "Theme", "Footer - Link");
_getMessages(themeFooter.link.toString(), "Theme", themeFooter.getDisplayValue());
}
}
// we need to check if a cart widget is used for this portal and if so which
var cartProp = gs.getProperty('glide.sc.portal.use_cart_v2_header');
var cartId = '';
if (cartProp) {
cartId = 'sc-shopping-cart-v2';
} else {
cartId = 'sc-shopping-cart';
}
var getCartWidget = new GlideRecord('sp_widget');
getCartWidget.addQuery('id', cartId);
getCartWidget.query();
if (getCartWidget.next()) {
_getMessages(getCartWidget.template, "Shopping Cart", 'Cart Widget');
_getMessages(getCartWidget.script, "Shopping Cart", 'Cart Widget');
_getMessages(getCartWidget.client_script, "Shopping Cart", 'Cart Widget');
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getCartWidget, "Shopping Cart", "Shopping Cart");
}
// we need to check for any Taxonomies used on this portal
var taxPkg = new GlideRecord('sys_package');
taxPkg.addEncodedQuery('source=com.snc.taxonomy');
taxPkg.query();
if (taxPkg.hasNext()) {
// only progress if the Taxonomy plugins are installed
var taxCheck = new GlideRecord('m2m_sp_portal_taxonomy');
taxCheck.addNotNullQuery('active');
taxCheck.addEncodedQuery('active=true');
taxCheck.addQuery('sp_portal', portalCheck.sys_id);
taxCheck.query();
while (taxCheck.next()) {
// we have to use a while incase there is more than one taxonomy associated
// now we need to follow the taxonomy's path
var getTax = new GlideRecord('taxonomy');
getTax.addNotNullQuery('sys_id');
getTax.addQuery('sys_id', taxCheck.taxonomy);
getTax.query();
if (getTax.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getTax, "Taxonomy", "Name");
}
var taxTopic = new GlideRecord('topic');
taxTopic.addNotNullQuery('taxonomy');
taxTopic.addQuery('taxonomy', getTax.sys_id);
taxTopic.query();
while (taxTopic.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(taxTopic, "Topic", "Name");
// Now we need to see what's connected
var conTopic = new GlideRecord('m2m_connected_content');
conTopic.addNotNullQuery('topic');
conTopic.addQuery('topic', taxTopic.sys_id);
conTopic.addEncodedQuery('content_type=07f4b6bfe75430104cda66ef11e8a9a9'); // we're looking for quick links
conTopic.query();
while (conTopic.next()) {
// we must have found a quick link
qls = true;
var qlRec = new GlideRecord('sp_page');
qlRec.addNotNullQuery('sys_id');
qlRec.addQuery('sys_id', conTopic.quick_link.page); // this is the page from the quick link
qlRec.query();
if (qlRec.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(qlRec, qlRec.sys_class_name.getDisplayValue(), qlRec.getDisplayName());
_getPage(qlRec.sys_id);
}
}
}
}
}
// now we need to find the menu in this portal
var menuCheck = new GlideRecord('sp_instance_menu');
menuCheck.addNotNullQuery('sys_id');
menuCheck.addQuery('sys_id', portalCheck.sp_rectangle_menu);
menuCheck.query();
if (menuCheck.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(menuCheck, "Menu - " + menuCheck.getDisplayValue(), 'Menu');
// now we have the menu, we need the options in it
var menuItemCheck = new GlideRecord('sp_rectangle_menu_item');
menuItemCheck.addNotNullQuery('sp_rectangle_menu');
menuItemCheck.orderBy('label');
menuItemCheck.addQuery('sp_rectangle_menu', menuCheck.sys_id);
menuItemCheck.query();
while (menuItemCheck.next()) {
if (menuItemCheck.label) {
// This will look for the (menuItemCheck.label)'s value in sys_ui_message table.
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(menuItemCheck, menuCheck.getDisplayValue(), 'Menu Item');
// we need to check if we have "communities" installed or being used
if (menuItemCheck.label == "Employee Forums" || menuItemCheck.label == "Community" || menuItemCheck.label == "Forums") {
// we now need to loop through some specific records for the Communities actions and notification types
var comMessages = new GlideRecord("sn_actsub_activity_type");
comMessages.addEncodedQuery('activity_nl_string.messageISNOTEMPTY');
comMessages.query();
while (comMessages.next()) {
// now we need to get the specific message
var getComMessage = new GlideRecord('sys_ui_message');
getComMessage.addQuery('sys_id', comMessages.activity_nl_string.sys_id);
getComMessage.query();
if (getComMessage.next()) {
lfDocumentContentBuilder.processString(getComMessage.message.toString(), "Activity Message", "Name");
}
}
}
// now we need to process a page if there is one
if (menuItemCheck.sp_page.sys_id) {
_getPage(menuItemCheck.sp_page.sys_id);
}
// lets check if there's a sub-menu also
var subMenuItemCheck = new GlideRecord('sp_rectangle_menu_item');
subMenuItemCheck.addNotNullQuery('sp_rectangle_menu_item');
subMenuItemCheck.orderBy('label');
subMenuItemCheck.addQuery('sp_rectangle_menu_item', menuItemCheck.sys_id);
subMenuItemCheck.query();
while (subMenuItemCheck.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(subMenuItemCheck, menuCheck.getDisplayValue() + ' - ' + menuItemCheck.getDisplayValue(), 'Sub-Menu Item');
// now we need to process a page if there is one
if (subMenuItemCheck.sp_page.sys_id) {
_getPage(subMenuItemCheck.sp_page.sys_id);
}
// lets check if there's a sub-sub-menu also
var subsubMenuItemCheck = new GlideRecord('sp_rectangle_menu_item');
subsubMenuItemCheck.addNotNullQuery('sp_rectangle_menu_item');
subsubMenuItemCheck.orderBy('label');
subsubMenuItemCheck.addQuery('sp_rectangle_menu_item', subMenuItemCheck.sys_id);
subsubMenuItemCheck.query();
while (subsubMenuItemCheck.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(subsubMenuItemCheck, menuCheck.getDisplayValue() + ' - ' + menuItemCheck.getDisplayValue(), 'Sub-Sub-Menu Item');
// now we need to process a page if there is one
if (subsubMenuItemCheck.sp_page.sys_id) {
_getPage(subsubMenuItemCheck.sp_page.sys_id);
}
}
}
}
}
}
// lets trawl the page route map before we figure out which pages are in the portal
var porPageMap = new GlideRecord('sp_page_route_map');
porPageMap.addNotNullQuery('portals');
porPageMap.addEncodedQuery('portals=' + portalCheck.sys_id); // updated this query to not include route entries not associated to a portal due to higher chance of unecessary results
porPageMap.query();
while (porPageMap.next()) {
var pages = [];
pages.push(porPageMap.route_from_page.sys_id); // we need to process the from pages
pages.push(porPageMap.route_to_page.sys_id); // we also need to process the to pages as well
pages.sort();
// we need to make the pages unique
var cleanPages = new ArrayUtil();
cleanPages = cleanPages.unique(pages);
for (var i = 0; i < cleanPages.length; i++) {
// now we can process each unique page
_getPage(cleanPages[i]);
// now we need to check for any Request Filters
var reqFil = new GlideRecord('request_filter');
reqFil.addQuery('portal_page', cleanPages[i]);
reqFil.query();
while (reqFil.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(reqFil, reqFil.sys_class_name.getDisplayValue(), reqFil.getDisplayValue());
}
}
}
// lets check for any categories
var checkCatCats = new GlideRecord('m2m_sp_portal_catalog');
checkCatCats.addEncodedQuery('sp_portal=' + portalCheck.sys_id.toString());
checkCatCats.addQuery('active', 'true');
checkCatCats.query();
while (checkCatCats.next()) {
// we might have more than one catalog associated to the portal, so we need to loop through each of them
var getCatalog = new GlideRecord('sc_catalog');
getCatalog.addQuery('sys_id', checkCatCats.sc_catalog.sys_id);
getCatalog.addQuery('active', 'true');
getCatalog.query();
if (getCatalog.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getCatalog, "Service Catalog", "Name");
}
// now we need to get the Categories in the Catalog, but we don't need the items in the Catalog (there's a seperate artifact for that)
var getCatCat = new GlideRecord('sc_category');
getCatCat.addEncodedQuery('sc_catalog=' + checkCatCats.sc_catalog.sys_id.toString());
getCatCat.addQuery('active', 'true');
getCatCat.orderBy('title');
getCatCat.query();
while (getCatCat.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getCatCat, checkCatCats.sc_catalog.getDisplayValue() + " - Service Catalog Categories", "Category Name");
}
}
// we also need to check for the KB categories associated to this portal
var checkKBcats = new GlideRecord('m2m_sp_portal_knowledge_base');
checkKBcats.addEncodedQuery('sp_portal=' + portalCheck.sys_id.toString());
checkKBcats.query();
while (checkKBcats.next()) {
// we now need to loop through each KB for it's categories
var getKBcats = new GlideRecord('kb_category');
getKBcats.addQuery('parent_id', checkKBcats.kb_knowledge_base.sys_id);
getKBcats.addQuery('active', 'true');
getKBcats.query();
while (getKBcats.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getKBcats, checkKBcats.kb_knowledge_base.getDisplayValue() + " - KB Categories", "Category Name");
}
}
// now we need to check the widgets on the defined pages for the portal in question, rather than the ones with the routing
if (portalCheck.homepage) {
// if we have a homepage value
_getPage(portalCheck.homepage.sys_id);
}
if (portalCheck.kb_knowledge_page) {
// if we have a kb page value
_getPage(portalCheck.kb_knowledge_page.sys_id);
}
if (portalCheck.login_page) {
// if we have a login page value
_getPage(portalCheck.login_page.sys_id);
}
if (portalCheck.notfound_page) {
// if we have a 404 page value
_getPage(portalCheck.notfound_page.sys_id);
}
if (portalCheck.sc_catalog_page) {
// if we have a catalog page value
_getPage(portalCheck.sc_catalog_page.sys_id);
}
if (portalCheck.sc_category_page) {
// if we have a Catalog category home page value
_getPage(portalCheck.sc_category_page.sys_id);
}
if (portalCheck.sp_rectangle_menu) {
// we need to fetch the widget record
var getHeaderWid = new GlideRecord('sp_widget');
getHeaderWid.addQuery('sys_id', portalCheck.sp_rectangle_menu.sp_widget.sys_id);
getHeaderWid.query();
if (getHeaderWid.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getHeaderWid, "Header Menu", "Name");
lfDocumentContentBuilder.processScript(getHeaderWid.template, "Header Menu", "Body HTML Template");
lfDocumentContentBuilder.processScript(getHeaderWid.script, "Header Menu", "Server Script");
lfDocumentContentBuilder.processScript(getHeaderWid.client_script, "Header Menu", "Client Controller");
lfDocumentContentBuilder.processScript(getHeaderWid.link, "Header Menu", "Link");
}
// we also need to check if there's any ${} calls
_getMessages(portalCheck.sp_rectangle_menu.sp_widget.template.toString(), "Header Menu", "Body HTML Template");
_getMessages(portalCheck.sp_rectangle_menu.sp_widget.script.toString(), "Header Menu", "Server Script");
_getMessages(portalCheck.sp_rectangle_menu.sp_widget.client_script.toString(), "Header Menu", "Client Controller");
_getMessages(portalCheck.sp_rectangle_menu.sp_widget.link.toString(), "Header Menu", "Link");
}
// we need to check portal content categories in this way because otherwise it causes duplicates at the page level
var checkCDcats = new GlideRecord('sn_cd_content_category');
checkCDcats.addEncodedQuery('active=true');
checkCDcats.orderBy('order');
checkCDcats.query();
while (checkCDcats.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(checkCDcats, "Portal Content Experience", "Category");
}
// there is a scenario where we may have widgets with no ID's which can't be captured unless we specifically look for them but this will likely include widgets not in the scope of this portal, there is no otherway :(
var getNoIDWid = new GlideRecord('sp_widget');
getNoIDWid.addEncodedQuery('id=NULL');
getNoIDWid.orderBy('name');
getNoIDWid.query();
while (getNoIDWid.next()) {
_getWidgets(getNoIDWid);
}
}
function _getPage(pageSYS) {
try {
if (pageSYS) {
// we need to check if this page has been already processed
if (!pageArr.toString().includes(pageSYS.toString())) {
// we need to pick up the Content Experience elements if this is an ESC Pro portal
// need to check if "Content Publishing" is installed first
var checkCD = new GlideRecord('sys_package');
checkCD.addEncodedQuery('sourceSTARTSWITHsn_cd');
checkCD.query();
if (checkCD.hasNext()) {
// we only need to know it's installed, so we don't need the full record return
// let's now check for what Content is set up
var getContExp = new GlideRecord('sn_cd_content_visibility');
getContExp.addEncodedQuery('sp_page=' + pageSYS + '^active=true');
getContExp.query();
while (getContExp.next()) {
// we need to process the page directly related to this record
if (getContExp.sp_page.sys_id != pageSYS) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getContExp, "Portal Content Experience", getContExp.title);
_getPage(getContExp.sp_page.sys_id); // we need to send the page associated to the content, which would call this very function again... but we need to know...
}
// now we need the actual content record
var contBase = new GlideRecord('sn_cd_content_portal'); // this should represent the various sub-classes of content
contBase.addNotNullQuery('sys_id');
contBase.addQuery('sys_id', getContExp.content.sys_id);
contBase.query();
if (contBase.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(contBase, "Portal Content Experience", getContExp.title);
lfDocumentContentBuilder.processString(contBase.button_text.toString(), "Portal Content Experience", "Button Text");
}
}
}
var pageVal = instCheck.sp_column.sp_row.sp_container.sp_page.getDisplayValue().toString();
if (!pageVal) {
pageVal = "No Page Associated";
}
// if we have the page sys_id then we need to loop from the instances
var instCheck = new GlideRecord('sp_instance');
instCheck.addNotNullQuery('sp_column');
instCheck.addEncodedQuery('sp_column.sp_row.sp_container.sp_page=' + pageSYS);
instCheck.addQuery('active', 'true');
instCheck.query();
while (instCheck.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(instCheck, "Widget Instance - " + instCheck.sys_class_name.getDisplayValue(), "Page - " + pageVal + " > Widget - " + instCheck.sp_widget.getDisplayName());
_getWidgets(instCheck, pageSYS);
}
pageArr.push(pageSYS.toString()); // we need to feed the page just processed into the Page Array
}
}
} catch (err) {
//gs.log('Error in _getPage - ' + err.name + ' - ' + err.message);
}
}
function _getWidgets(instCheck, pageSYS) {
try {
// we need to check if this widget has already been processed
var widgetCheck = '';
if (instCheck) {
if (instCheck.sp_widget) {
// this covers instances, there are multiple types
widgetCheck = instCheck.sp_widget.sys_id.toString();
} else {
// this covers widget widgets
widgetCheck = instCheck.sys_id.toString();
}
// widget duplication check
if (!widgetArr.toString().includes(instCheck.toString())) {
widgetArr.push(instCheck.toString()); // now we need to push the widget processed so we can de-dedupe check it for each widget we go through
}
} else {
return
}
if (widgetCheck == 'cf1a5153cb21020000f8d856634c9c3c') {
// this is the ootb carousel widget we now need to check the carousel slides to see if they are related to this portal
var checkCarousel = new GlideRecord('sp_carousel_slide');
checkCarousel.addEncodedQuery('carousel.sp_widget=' + widgetCheck);
checkCarousel.query();
while (checkCarousel.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(checkCarousel, "Carousel Slides", "Name");
}
}
if (!widgetArr.toString().includes(widgetCheck.toString()) && pageSYS != '') {
var pageVal = '';
var pageName = new GlideRecord('sp_page');
pageName.addQuery('sys_id', pageSYS);
pageName.query();
if (!pageName.hasNext()) {
if (instCheck.sp_column.sp_row.sp_container.sp_page.getDisplayValue().toString() != '') {
// we need to be super defensive
pageVal = instCheck.sp_column.sp_row.sp_container.sp_page.getDisplayValue().toString();
}
}
if (pageName.next()) {
pageVal = pageName.getDisplayValue();
}
if (!pageVal) {
pageVal = "No Page Associated";
}
// now we need to check through widgets
var widCheck = new GlideRecord('sp_widget');
widCheck.addEncodedQuery('sys_id=' + widgetCheck + '^ORsp_widget=' + widgetCheck);
widCheck.query();
while (widCheck.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(widCheck, widCheck.sys_class_name.getDisplayValue(), "Page - " + pageVal + " > Widget - " + widCheck.getDisplayValue());
// in some very rare scenarios we also need to check for other titles of a widget instance
var getOtherWidInst = new GlideRecord('sp_instance');
getOtherWidInst.addEncodedQuery('active=true^title!=NULL^sp_widget=' + widCheck.sys_id);
getOtherWidInst.orderBy('order');
getOtherWidInst.query();
if (getOtherWidInst.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getOtherWidInst, widCheck.sys_class_name.getDisplayValue(), "Page - " + pageVal + " > Widget - " + widCheck.getDisplayValue());
}
// we need to see if this widget is the GetSupport widget
if (widCheck.id == 'ec-get-support') {
// this is if it's the new EC "get support" widget, then we need to go and query the sn_ex_sp_get_support for supporting elements
var getSupportCheck = new GlideRecord('sn_ex_sp_get_support');
getSupportCheck.query();
while (getSupportCheck.next()) {
// now we need to go to each record
var checkSupport = new GlideRecord(getSupportCheck.table.toString()); // this should be holding the table value
checkSupport.addQuery('sys_id', getSupportCheck.content.toString()); // this should be holding the sys_id of the linked record
checkSupport.query();
if (checkSupport.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(checkSupport, "Support items", checkSupport.title.toString());
}
}
}
// we need to check the Angular provider as well
var checkAng = new GlideRecord('sp_ng_template');
checkAng.addQuery('sp_widget', widCheck.sys_id);
checkAng.query();
while (checkAng.next()) {
lfDocumentContentBuilder.processScript(checkAng.template, widCheck.sys_class_name.getDisplayValue(), "Page - " + pageVal + " > Angular Provider - " + checkAng.getDisplayValue());
_getMessages(checkAng.template.toString(), widCheck.sys_class_name.getDisplayValue(), "Page - " + pageVal + " > Angular Provider - " + checkAng.getDisplayValue());
}
// template field
lfDocumentContentBuilder.processScript(widCheck.template, widCheck.sys_class_name.getDisplayValue(), "Page - " + pageVal + " > Widget - " + widCheck.getDisplayValue());
_getMessages(widCheck.template.toString(), widCheck.sys_class_name.getDisplayValue(), "Page - " + pageVal + " > Widget - " + widCheck.getDisplayValue());
// Server Script
lfDocumentContentBuilder.processScript(widCheck.script, widCheck.sys_class_name.getDisplayValue(), "Page - " + pageVal + " > Widget - " + widCheck.getDisplayValue());
_getMessages(widCheck.script.toString(), widCheck.sys_class_name.getDisplayValue(), "Page - " + pageVal + " > widget - " + widCheck.getDisplayValue());
// we need to see if there's widget being loaded by script
if (widCheck.script.match(/(\$sp.getWidget)(\(\'|\").*?(\"|\')/gi)) {
var widLine = widCheck.script.match(/(\$sp.getWidget)(\(\'|\").*?(\"|\')/gi);
// we might have an array
var servWid = widLine.toString().split(',');
for (var widS = 0; widS < servWid.length; widS++) {
if (servWid[widS].toString() != '') {
servWid[widS] = servWid[widS].match(/(?:['"].*?['"])/gi); // we want to strip any quotes to get a clean value
// now we need to remove the quotes if there are any
var cleanWid = servWid[widS].toString().replace(/['"]/gi, ''); // with nothing
// now we need to get the widget before we process it
var getWidID = new GlideRecord('sp_widget');
getWidID.addQuery('id', cleanWid.toString());
getWidID.query();
if (getWidID.next()) {
// process this widget
if (getWidID.sys_id != instCheck.sys_id && (!widgetArr.toString().includes(getWidID.sys_id))) {
_getWidgets(getWidID, instCheck.sp_column.sp_row.sp_container.sp_page.sys_id);
}
}
}
}
}
// Client Script
lfDocumentContentBuilder.processScript(widCheck.client_script, widCheck.sys_class_name.getDisplayValue(), "Page - " + pageVal + " > Widget - " + widCheck.getDisplayValue());
_getMessages(widCheck.client_script.toString(), widCheck.sys_class_name.getDisplayValue(), "Page - " + pageVal + " > Widget - " + widCheck.getDisplayValue());
// we need to see if there's widgets being loaded by script
if (widCheck.client_script.match(/(spUtil.get\(\'|\").*?(\'|\")/gi)) {
var widCLid = widCheck.client_script.match(/(spUtil.get\(\'|\").*?(\'|\")/gi);
// we might have an array
var clWid = widCLid.toString().split(',');
for (var widCL = 0; widCL < clWid.length; widCL++) {
if (clWid[widCL].toString() != '') {
// now we need to remove the quote if there are any
var cleanWidCL = clWid[widCL].toString().replace(/'|"/g, ''); // we want to strip any quotes to get a clean value
// now we need to get the widget before we process it
var CLgetWidID = new GlideRecord('sp_widget');
CLgetWidID.addQuery('id', cleanWidCL.toString());
CLgetWidID.query();
if (CLgetWidID.next()) {
// process the widget
if (CLgetWidID.sys_id != instCheck.sys_id && (!widgetArr.toString().includes(CLgetWidID))) {
_getWidgets(CLgetWidID);
}
}
}
}
}
}
// now we need to check instance specifics
// the JSON of the instance
var pageArr = [];
if (instCheck.widget_parameters != '') {
var valueObj = instCheck.widget_parameters.toString();
// we need to process the "widget_parameters" incase there's a message call in it
if (valueObj != "" || valueObj != " ") {
_getMessages(valueObj.toString(), instCheck.sys_class_name.getDisplayValue(), "Page - " + pageVal + " > Widget - " + instCheck.sp_widget.getDisplayValue());
}
// now we need to see if there's a page in the "widget_parameters"
if (valueObj.includes("page") || valueObj.includes("displayValue")) {
// we only need to look into the JSON values, if there is a mention of pages otherwise it could be completely unrelated to our needs.
var valueCheck = JSON.parse(valueObj, function(key, val) {
if (key == 'displayValue') {
// we need this page's sys_id but we need to make sure we don't reprocess the same page we started with
if ((val != pageSYS.id || !valueObj.toString().includes(pageSYS.id)) && val.toString().match(/[a-z0-9]{32}/g)) {
// we don't want to cause a query with a null value and only for a sys_id
var getPageID = new GlideRecord('sp_page');
getPageID.addQuery('id', val.toString());
getPageID.query();
if (!getPageID.hasNext()) {
/*
// we might not need this after all
// we might need to factor in a header
if (val.toString() != 'true' && val.toString().match(/(?:^\{)|(^['"].*?['"])/gim) && val.toString() != 'false' && val.toString() != '' && val.toString() != ' ' && val.toString() != "" && val.toString() != 'number') {
lfDocumentContentBuilder.processString(val.toString(), instCheck.sys_class_name.getDisplayValue(), "Page - " + pageVal + " > Widget - " + instCheck.sp_widget.getDisplayValue());
}
*/
}
if (getPageID.next()) {
pageArr.push(getPageID.sys_id.toString());
}
}
/*
// we might not need this after all
else if (val != '[object Object]' && val.toString().match(/(?:^\{)|(^['"].*?['"])/gim) && val.toString() != 'true' && val.toString() != 'false' && val.toString() != '' && val.toString() != ' ' && val.toString() != "" && val.toString() != 'number') {
if (val.toString() != "" || val.toString() != " ") {
lfDocumentContentBuilder.processString(val.toString(), instCheck.sys_class_name.getDisplayValue(), "Page - " + pageVal + " > Widget - " + instCheck.sp_widget.getDisplayValue());
}
}
*/
}
});
}
}
var pageIDArr = new ArrayUtil();
pageIDArr.unqiue(pageArr);
for (var iD = 0; iD < pageIDArr.length; iD++) {
_getPage(pageIDArr[iD]);
}
// now we need to do some specific option_schema checks
var optMsg = [];
if (instCheck.sp_widget.option_schema != '') {
var widString = instCheck.sp_widget.option_schema.toString();
if (widString != '') {
var widJ = JSON.parse(widString, function(key, value) {
// if we send these to the array, we can clear out duplicates
if (key == "displayValue" && (value != '' && value != ' ')) {
optMsg.push(value);
}
if (key == "label" && (value != '' && value != ' ')) {
optMsg.push(value);
}
if (key == "hint" && (value != '' && value != ' ')) {
optMsg.push(value);
}
if (key == "section" && (value != '' && value != ' ')) {
optMsg.push(value);
}
if (key == "default_value" && value.toString().match(/(^\b).*(\b)/gim) && (value.toString() != 'true' && value.toString() != 'false')) {
optMsg.push(value);
}
});
}
// we need to strip the option_schema array of any duplicates before we process it
optMsg.sort();
var optSchArr = new ArrayUtil();
optSchArr = optSchArr.unique(optMsg);
lfDocumentContentBuilder.processStringArray(optSchArr, instCheck.sys_class_name.getDisplayValue(), "Page - " + pageVal + " > Widget - " + instCheck.sp_widget.getDisplayValue());
optMsg = [];
}
// we need to check to see if the EC or EC pro is installed first
var checkEC = new GlideRecord('sys_package');
checkEC.addEncodedQuery('source=sn_ex_sp^ORsource=sn_ex_sp_pro');
checkEC.query();
if (checkEC.hasNext()) {
// for the /esc portal, we also need to loop through any "web applications"
if (instCheck.sp_widget.id == 'web_applications' || instCheck.sp_widget.id == "app-launcher") {
var webApp = new GlideRecord('sn_ex_sp_pro_web_application');
webApp.addNotNullQuery('active');
webApp.addEncodedQuery('active=true'); // we only need active items
webApp.query();
while (webApp.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(webApp, "Web Applications", webApp.name);
}
}
// now we need the quick links
if (instCheck.sp_widget.id == "quick-links" || instCheck.sp_widget.id == "cd-quick-links" || instCheck.sp_widget.id == "quick_links_on_topic_page") {
var quickLinks = new GlideRecord('sn_ex_sp_quick_link');
quickLinks.addNotNullQuery('active');
quickLinks.addEncodedQuery('active=true'); // these will pick up everything connected via the m2m_connected_content table and any others associated
quickLinks.query();
while (quickLinks.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(quickLinks, "Quick Links", "Name");
}
}
// we need to get the Activity items of task records
if (instCheck.sp_widget.id == 'my-items') {
var activeItems = new GlideRecord('sn_ex_sp_activity_configuration');
activeItems.addNotNullQuery('active');
activeItems.addEncodedQuery('active=true');
activeItems.query();
while (activeItems.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(activeItems, "Active Items", "Name");
}
// we also need to manually add some due time messages for these tasks
lfDocumentContentBuilder.processString('overdue', "Active Items", "Name");
lfDocumentContentBuilder.processString('overdue {0} day', "Active Items", "Name");
lfDocumentContentBuilder.processString('overdue {0} days', "Active Items", "Name");
lfDocumentContentBuilder.processString('due today', "Active Items", "Name");
lfDocumentContentBuilder.processString('due in {0} days', "Active Items", "Name");
lfDocumentContentBuilder.processString('Quick tasks', "Active Items", "Name");
}
}
// now that we've processed this page, let's now process any others we've previously found
var cleanPage = new ArrayUtil();
cleanPage = cleanPage.unique(pageArr);
if (cleanPage.length > 0) {
for (var p = 0; p < cleanPage.length; p++) {
var pageID = new GlideRecord('sp_page');
pageID.addNotNullQuery('id');
pageID.addQuery('id', cleanPage[p]);
pageID.addQuery('sys_id', '!=', sysId); // just to ensure we're not re-looping
pageID.query();
if (pageID.next()) {
_getPage(pageID.sys_id); // call this function again with the new page
}
}
pageArr = []; // resetting the array
cleanPage = []; // resetting the array
}
}
} catch (err) {
//gs.log('Error in _getWidgets - ' + err.name + ' - ' + err.message);
}
}
function _getMessages(recField, name, label) {
try {
// ${} checks
var MsgCheck = /\${(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*}|(?:^\$\{)(.*)(\})/gim; // this is a lighter version but is designed to find ${something} under multiple scenarios
var pgClean = /(^\$\{|^\{)|(\}$)/gi; // this is for cleaning out the flanking { } in the identified string
var msgArr = [];
var fieldCheck = '';
fieldCheck = recField.toString().match(MsgCheck);
if (fieldCheck) {
var fieldClean = fieldCheck.toString().split(',$');
for (var i = 0; i < fieldClean.length; i++) {
if (!fieldClean[i].toString().includes('getMessage')) {
// this is a defensive check to avoid unnecessary duplicates
if (fieldClean[i].toString() != '' && (!fieldClean[i].toString().includes('{{item.label}}'))) {
// this is a defensive check because there are some sometimes some MessageAPI calls that contain nothing
var cleanStr = fieldClean[i].toString().replace(pgClean, '');
if (cleanStr) {
// we need to make sure we're not inadvertantly pushing a blank space
msgArr.push(cleanStr); // send to the array to clean unnecessary duplicates
}
}
} else if (fieldClean[i].toString().includes('getMessage')) {
// this might not ever be needed due to use of .processScript() method used on the widget records
var msgStringReg = /(['"])(.*)(['"])/gi;
var msgString = fieldClean[i].toString().match(msgStringReg);
// now we need to move the flanking quotes (single or double)
var getMclean = /(\B['"])|(['"]\s)/gi;
var cleanGetM = msgString.replace(getMclean, '');
msgArr.push(cleanGetM); // send to the array to clean unnecessary duplicates
}
}
}
// getMessage(variable) checks
var checkMsgReg = /(?:getMessage\()(?!['"])(.*)(?!['"])(\))/gi;
var callCheck = recField.toString().match(checkMsgReg);
if (callCheck) {
// we need to cleanup the call
var callValReg = /([^()]*)/gi;
var callValCheck = callCheck.toString().match(callValReg);
var varToCheck = '';
for (var cvi = 0; cvi < callValCheck.length; cvi++) {
if (!callValCheck[cvi].toString().includes('getMessage') && callValCheck[cvi].toString() != '') {
varToCheck = callValCheck[cvi].toString();
}
}
// now we need to see if there is a variable of this with a string associated to it
// the regex being built is - /(?:(var\s)(something).*(\=)).*(['"].*["';])/gi
var checkIfVarReg1 = "(?:(var\s)(";
var checkIfVarReg2 = ").*(\=)).*(['";
var checkIfVarReg3 = '"].*[';
var checkIfVarReg4 = '"';
var checkIfVarReg5 = "';])";
var buildCheckVarReg = checkIfVarReg1 + varToCheck + checkIfVarReg2 + checkIfVarReg3 + checkIfVarReg4 + checkIfVarReg5;
var VarRegChecker = new RegExp(buildCheckVarReg, "gim");
var VarRegCheck = recField.toString().match(VarRegChecker);
if (VarRegCheck) {
// this is for a variable with the string - which should find something like "var something = 'this is my text' or to that effect"
// now we need to identify just the pure string, our findings could be in an array
for (vriC = 0; vriC < VarRegCheck.length; vriC++) {
var VarStrCheck = /(['"].*["'])/gim;
var VarRegChecker = VarRegCheck[vri].toString().match(VarStrCheck);
if (VarRegChecker) {
// now we need to clean the flanking quotes (single or double);
var VarRegClStr = /(^['"])|(['"]$)/gim;
var VarRegCleanedStr = VarRegChecker.toString().replace(VarRegClStr, '');
msgArr.push(VarRegCleanedStr);
}
}
} else {
// so if there's no variable is there a function?
var checkIfFuncReg = "(?:function ).*?(\()(" + varToCheck + ")(\))";
var checkIfFuncChecker = new RegExp(checkIfFuncReg, "gi");
var checkIfFunc = recField.toString().match(checkIfFuncChecker);
if (checkIfFunc) {
// we have a valid find for a function call, now we need to see each call to this function
var funcCheckReg = /(?!\bfunction\s+)(?:(\w+)\s*)(?=\()/gi;
var nameOfFunc = checkIfFunc.toString().match(funcCheckReg);
// now we need to find every time the function is called and pull out it's string
// (error)(\(['"]).*(['"])
var nameOfFuncCheck = "(" + nameOfFunc + ")(\\(['" + '"]).*([' + "'" + '"])';
var nameOfFuncChecker = new RegExp(nameOfFuncCheck, "gi");
// this will give us -> func = 'something' so we need to clean it up
var FuncNameChecker = recField.toString().match(nameOfFuncChecker);
// this will make an array of -> functionname('something')
if (FuncNameChecker) {
for (fni = 0; fni < FuncNameChecker.length; fni++) {
// lets extract the clean text
var cleanFNI = FuncNameChecker[fni].toString().match(/(['"]).*(['"])/gi);
if (cleanFNI) {
// now we need to clean the flanking quotes
var cleanedFNI = cleanFNI.toString().replace(/(^['"])|(['"]$)/gim, '');
if (cleanedFNI) {
msgArr.push(cleanedFNI);
}
}
}
}
}
}
}
msgArr.sort();
var cleanMsg = new ArrayUtil();
cleanMsg = cleanMsg.unique(msgArr);
if (cleanMsg.length > 0 && cleanMsg != ' ') {
lfDocumentContentBuilder.processStringArray(cleanMsg, name, label);
}
} catch (err) {
//gs.log('Error in _getMessages - ' + err.name + ' - ' + err.message + " - " + label);
}
}
return lfDocumentContentBuilder.build();
},
/**********
* Uncomment the saveTranslatedContent function to override the default behavior of saving translations
*
* @Param documentContent LFDocumentContent object
* @return
**********/
/**********
saveTranslatedContent: function(documentContent) {},
**********/
type: 'LF_PortalProcessor'
});
If for what-ever reason, the above doesn't pick something up on a specific page (this might happen when a page is in a forced href for example), I've also created a Page specific Prototype artifact that you can run on that particular page (I called it "LF_PortalPageProcessor", so be mindful of the Script Include name you'll need to make, and the Artifact name for the UI action):
var LF_PortalPageProcessor = Class.create();
LF_PortalPageProcessor.prototype = Object.extendsObject(global.LFArtifactProcessorSNC, {
category: 'localization_framework', // DO NOT REMOVE THIS LINE!
/**********
* Extracts the translatable content for the artifact record
*
* params.tableName The table name of the artifact record
* params.sysId The sys_id of the artifact record
* params.language Language into which the artifact has to be translated (Target language)
* @return LFDocumentContent object
**********/
getTranslatableContent: function(params) {
/**********
* Use LFDocumentContentBuilder to build the LFDocumentContent object
* Use the build() to return the LFDocumentContent object
**********/
// we need to trawl through this page for all widgets and widget instances
var tableName = params.tableName;
var sysId = params.sysId;
var language = params.language;
var lfDocumentContentBuilder = new global.LFDocumentContentBuilder("v1", language, sysId, tableName);
var pageArr = [];
var spPage = new GlideRecord('sp_page');
spPage.addQuery('sys_id', sysId); // this is the page from the UIaction
spPage.query();
if (spPage.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(spPage, "Page Name", "Name");
// now we need to process the page accordingly
_getPage(spPage.sys_id);
}
function _getPage(pageSYS) {
var msgArr = []; // for later
// if we have the page sys_id then we need to loop from the instances
var instCheck = new GlideRecord('sp_instance');
instCheck.addEncodedQuery('sp_column.sp_row.sp_container.sp_page=' + pageSYS);
instCheck.addQuery('active', 'true');
instCheck.query();
while (instCheck.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(instCheck, instCheck.sys_class_name.getDisplayValue(), instCheck.sp_widget.getDisplayName());
// now we need to check through widgets
var widCheck = new GlideRecord('sp_widget');
widCheck.addQuery('sys_id', instCheck.sp_widget.sys_id);
widCheck.query();
while (widCheck.next()) {
lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(widCheck, widCheck.sys_class_name.getDisplayValue(), widCheck.getDisplayValue());
// template field
lfDocumentContentBuilder.processScript(widCheck.template, widCheck.sys_class_name.getDisplayValue(), widCheck.getDisplayValue());
_getMessages(widCheck.template, widCheck.sys_class_name.getDisplayValue(), widCheck.getDisplayValue());
// Server Script
lfDocumentContentBuilder.processScript(widCheck.script, widCheck.sys_class_name.getDisplayValue(), widCheck.getDisplayValue());
_getMessages(widCheck.script, widCheck.sys_class_name.getDisplayValue(), widCheck.getDisplayValue());
// Client Script
lfDocumentContentBuilder.processScript(widCheck.client_script, widCheck.sys_class_name.getDisplayValue(), widCheck.getDisplayValue());
_getMessages(widCheck.client_script, widCheck.sys_class_name.getDisplayValue(), widCheck.getDisplayValue());
}
// now we need to check the JSON of the instance
if (instCheck.widget_parameters != '') {
var valueObj = instCheck.widget_parameters.toString();
if (valueObj.includes("page")) {
// we only need to look into the JSON values, if there is a mention of pages otherwise it could be completely unrelated to our needs.
valueCheck = JSON.parse(valueObj, function(key, val) {
if (key == 'displayValue') {
// we need this page's sys_id but we need to make sure we don't reprocess the same page we started with
if (val != pageSYS.id || !valueObj.toString().includes(pageSYS.id) || val != '') {
pageArr.push(val);
}
}
});
}
}
if (instCheck.sp_widget.option_schema != '') {
var widString = instCheck.sp_widget.option_schema.toString();
var widJ = JSON.parse(widString, function(key, value) {
// if we send these to the array, we can clear out duplicates
if (key == "displayValue") {
msgArr.push(value);
}
if (key == "label") {
msgArr.push(value);
}
if (key == "hint") {
msgArr.push(value);
}
if (key == "section") {
msgArr.push(value);
}
if (key == "default_value") {
msgArr.push(value);
}
});
}
// message array duplicate check
msgArr.sort();
var arrayUtil = new ArrayUtil();
arrayUtil = arrayUtil.unique(msgArr);
lfDocumentContentBuilder.processStringArray(arrayUtil, instCheck.sys_class_name.getDisplayValue(), instCheck.sp_widget.getDisplayValue());
msgArr = []; // we need to reset the array
}
function _getMessages(recField, name, label) {
// ${} checks
var MsgCheck = /\${(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*}|(?:^\$\{)(.*)(\})/gim; // this is a lighter version but is designed to find ${something} under multiple scenarios
var pgClean = /(^\$\{|^\{)|(\}$)/gi; // this is for cleaning out the flanking { } in the identified string
var msgArr = [];
var fieldCheck = '';
fieldCheck = recField.toString().match(MsgCheck);
if (fieldCheck) {
var fieldClean = fieldCheck.toString().split(',$');
for (var i = 0; i < fieldClean.length; i++) {
if (!fieldClean[i].toString().includes('getMessage')) {
// this is a defensive check to avoid unnecessary duplicates
if (fieldClean[i].toString() != '' && (!fieldClean[i].toString().includes('{{item.label}}'))) {
// this is a defensive check because there are some sometimes some MessageAPI calls that contain nothing
var cleanStr = fieldClean[i].toString().replace(pgClean, '');
if (cleanStr != '') {
// we need to make sure we're not inadvertantly pushing a blank space
msgArr.push(cleanStr); // send to the array to clean unnecessary duplicates
}
}
} else if (fieldClean[i].toString().includes('getMessage')) {
// this might not ever be needed due to use of .processScript() method used on the widget records
var msgStringReg = /(['"])(.*)(['"])/gi;
var msgString = fieldClean[i].toString().match(msgStringReg);
// now we need to move the flanking quotes (single or double)
var getMclean = /(\B['"])|(['"]\s)/gi;
var cleanGetM = msgString.replace(getMclean, '');
msgArr.push(cleanGetM); // send to the array to clean unnecessary duplicates
}
}
}
// getMessage(variable) checks
var checkMsgReg = /(?:getMessage\()(?!['"])(.*)(?!['"])(\))/gi;
var callCheck = recField.toString().match(checkMsgReg);
if (callCheck) {
// we need to cleanup the call
var callValReg = /([^()]*)/gi;
var callValCheck = callCheck.toString().match(callValReg);
var varToCheck = '';
for (var cvi = 0; cvi < callValCheck.length; cvi++) {
if (!callValCheck[cvi].toString().includes('getMessage') && callValCheck[cvi].toString() != '') {
varToCheck = callValCheck[cvi].toString();
}
}
// now we need to see if there is a variable of this with a string associated to it
// the regex being built is - /(?:(var\s)(something).*(\=)).*(['"].*["';])/gi
var checkIfVarReg1 = "(?:(var\s)(";
var checkIfVarReg2 = ").*(\=)).*(['";
var checkIfVarReg3 = '"].*[';
var checkIfVarReg4 = '"';
var checkIfVarReg5 = "';])";
var buildCheckVarReg = checkIfVarReg1 + varToCheck + checkIfVarReg2 + checkIfVarReg3 + checkIfVarReg4 + checkIfVarReg5;
var VarRegChecker = new RegExp(buildCheckVarReg, "gim");
var VarRegCheck = recField.toString().match(VarRegChecker);
if (VarRegCheck) {
// this is for a variable with the string - which should find something like "var something = 'this is my text' or to that effect"
// now we need to identify just the pure string, our findings could be in an array
for (vriC = 0; vriC < VarRegCheck.length; vriC++) {
var VarStrCheck = /(['"].*["'])/gim;
var VarRegChecker = VarRegCheck[vri].toString().match(VarStrCheck);
if (VarRegChecker) {
// now we need to clean the flanking quotes (single or double);
var VarRegClStr = /(^['"])|(['"]$)/gim;
var VarRegCleanedStr = VarRegChecker.toString().replace(VarRegClStr, '');
msgArr.push(VarRegCleanedStr);
}
}
} else {
// so if there's no variable is there a function?
var checkIfFuncReg = "(?:function ).*?(\()(" + varToCheck + ")(\))";
var checkIfFuncChecker = new RegExp(checkIfFuncReg, "gi");
var checkIfFunc = recField.toString().match(checkIfFuncChecker);
if (checkIfFunc) {
// we have a valid find for a function call, now we need to see each call to this function
var funcCheckReg = /(?!\bfunction\s+)(?:(\w+)\s*)(?=\()/gi;
var nameOfFunc = checkIfFunc.toString().match(funcCheckReg);
// now we need to find every time the function is called and pull out it's string
// (error)(\(['"]).*(['"])
var nameOfFuncCheck = "(" + nameOfFunc + ")(\\(['" + '"]).*([' + "'" + '"])';
var nameOfFuncChecker = new RegExp(nameOfFuncCheck, "gi");
// this will give us -> func = 'something' so we need to clean it up
var FuncNameChecker = recField.toString().match(nameOfFuncChecker);
// this will make an array of -> functionname('something')
for (fni = 0; fni < FuncNameChecker.length; fni++) {
// lets extract the clean text
var cleanFNI = FuncNameChecker[fni].toString().match(/(['"]).*(['"])/gi);
if (cleanFNI) {
// now we need to clean the flanking quotes
var cleanedFNI = cleanFNI.toString().replace(/(^['"])|(['"]$)/gim, '');
if (cleanedFNI) {
msgArr.push(cleanedFNI);
}
}
}
}
}
}
msgArr.sort();
var arrayUtil = new ArrayUtil();
arrayUtil = arrayUtil.unique(msgArr);
if (arrayUtil.length > 0 && arrayUtil != ' ') {
lfDocumentContentBuilder.processStringArray(arrayUtil, name, label);
}
}
}
// now that we've processed this page, let's now process any others we've previously found
var cleanPage = new ArrayUtil();
cleanPage = cleanPage.unique(pageArr);
if (cleanPage.length > 0) {
for (var p = 0; p < cleanPage.length; p++) {
var pageID = new GlideRecord('sp_page');
pageID.addQuery('id', cleanPage[p]);
pageID.addQuery('sys_id', '!=', sysId); // just to ensure we're not re-looping
pageID.query();
if (pageID.next()) {
_getPage(pageID.sys_id); // call this function again with the new page
}
}
pageArr = []; // resetting the array
cleanPage = []; // resetting the array
}
return lfDocumentContentBuilder.build();
},
/**********
* Uncomment the saveTranslatedContent function to override the default behavior of saving translations
*
* documentContent LFDocumentContent object
* @return
**********/
/**********
saveTranslatedContent: function(documentContent) {},
**********/
type: 'LF_PortalPageProcessor'
});
For those who want to learn, have a look at some of my comments in the code to get an idea of my reasoning for certain concepts and the way I structured my queries. Which also serves as a good example of Coding Standards and ensuring you comment your code.
With regards to the UIaction (for the portal level artifact) it should be defined on the [sp_portal] table, and should look like this:
* NOTE - in the "condition" of the UI Action, we need to call the "internal name" of the artifact, your's may be different to my example, ultimately if nothing happens when you click the UI Action, it will be because the call isn't recognising your artifact's "internal name".
When it all comes together, and you're able to request a translation of the portal, the comparison UI will break out the components into the following sub-sections (there could be more or less, depending on your portal):
Where-by you will be able to interact with the translations (just like you do with Catalog Items) like this:
Summary
What have we learned? Well, providing we understand how the 5 tables work and providing we understand the hierarchy of the "thing" we want to translate and therefore localize, it's very possible to make the method of actioning simpler. This method does not require a single spreadsheet or very complicated exports and imports.
Over time I can imagine some very clever Artifacts being made out there, but at least the 3 I've provided here will serve as a good starting point.
So, when it's all set-up you should be able to achieve this:
Feel free to tweak, modify, optimize this one as I only wrote it in a very short period of time so as to show the art of the possible, as well as help explain to others how to demystify the seemingly complex world of Localizing a Portal.
As always, if you liked this, please consider subscribing and like and share if you want to see more as it always helps
- 23,739 Views
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
I get an Error Message - "Error in requesting translations" when trying this
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Great stuff.
I actually build something quite similiar to this, but without using the Localization Framework and the nice UI. All the translations goes on in the backend through Machine Translation (Dynamic translation api), but have similiar scripts etc. I lacked the know-how (and time) to build it up as nice as this!
Very useful stuff here. I've created for other tables that are translated automatically as well (chat, surveys etc.), so I will take a closer look at this, and see how I can transform it to real Artifacts! Again, thanks!
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Fully agree. This is a gold nugget.
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Hi Mr.
I am trying to translate the Help Text of a variable in Service Catalog. I was able to translate it in the sys_translated_text however, the link in ENGLISH language is missing when translated to SPANISH language (see screenshot below)
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
This is truely awesome! Thanks!
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Alex,
Thanks for sharing! This is amazing and makes my life so much easier.
One thing I am curious about is the RegEx you're using for your ${} selector - (\$\{)(.[^\{\{\'\"]*\}). If there is a single or double quotation in the phrase, for example ${you didn't set up your security questions}, the RegEx fails to pick up this phrase because of the quotation mark. Was this by design?
For our use case, I modified the Regex to be /(\$\{)(.[^\{\{]*\})/g instead, which seems to work just fine.
Mainly checking to see if there is a use case that I'm not familiar with that would break because we modified the RegEx to allow for inclusion of quotation marks now.
Thanks!
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Great spot and thank you for sharing (I'll update the code snippet). This is exactly why I shared it with the community like this for that wider type of testing considerations,
Many thanks,
kind regards
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
I've just done some testing, and sadly it will make a noisy match;
So something to be mindful of, because it could be down to the script using tabs or spaces, which makes regex tougher to use,
Many thanks,
Kind regards
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
I've updated the regex to:
/(\$\{)([^\{\{\'\"\s].*?)(\})/g
Which seems to cover your scenario;
So a minor tweak, thanks for sharing, as it's greatly appreciated for the benefit of the community,
I'll do some more tests though, as I have a hunch there could be a better way. Regex will sadly never be perfect due to the nature of regex,
Many thanks,
Kind regards
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Thanks a bunch for the follow up on this! I'll update my regex to this for now.
For my own knowledge, what is this code in your screenshot checking? I've been trying to figure out why you're including the ' and " in the regex at all.
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
No worries at all,
I'm testing it against the latest version of the /esc portal with all of the demo stuff added to it. I have also spotted the regex isn't working for strings like:
${service will be unavailable {{outage.begin}} to {{outage.end}}}
So be prepared for another update when I've got a viable option for it. Of course if you want to post an idea I'm more than happy to test it, as it's all for the community,
Many thanks,
kind regards
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Thanks to your feedback, I managed to find a better regex (I'm beginning to think I might convert it into a property at this rate):
Due to the nature of REGEX, it'll sadly never be perfect. However this version is for sure better than before. I've tested it on numerous fully set up portals and it seems to pick up everything I would expect it to. One small caveat, is it might pick up something passed into {{something.object}}
, but that's a small and acceptable trade off I think (considering the alternative),
Please keep the feedback coming,
Many thanks,
Kind regards

- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Everything went smoothly with these instructions until I got this message on the portal record.. any idea what could be the issue?
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@Alex Edmiston1,
Double check the portal artifact script is exactly correct (in that you didn't accidentally also include the page level script as well), and double check the "internal_name" of your artifact vs what is called in the UI action,
Most of the time it's when the script isn't correct, or the call is wrong,
- It might also be worth checking "Script Log Statements" as it might say if a variable is having an issue,
Many thanks,
Kind regards

- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
What is the best approach for dividing these? I was trying to isolate the Mega Menu, Taxonomy, and Topic to only pull these, but when I edited the script and tried to request translations, I am getting an error "Failed to request translations"
I pretty much commented out everything I did not need.
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@Alessandro Idro,
It's probably failing because it's expecting to be able to find the taxonomy and topics associated to the sys_id of the portal record it's being called from, which if you've commented them out wouldn't be able to complete correctly and thus error,
So, for your specific scenario, you could lift the Taxonomy + Topic queries and put them into a new artifact defined for the taxonomy table [taxonomy] to then loop through each of the topic's [topic] of that taxonomy (because it's basically a parent/child record relationship),
- You would need to define a new artifact, have the correct code in a processor script and add a new UI action all against the [taxonomy] table, but your primary query could absolutely come from this one.
Many thanks,
Kind regards

- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Great Article.
I made works based you article. Thank you very much Alex.
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Can we use dynamic translations to translate our portal in different languages? or this is the only method?
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@Alex Coope - SNCan we use dynamic translation to translate our portal in different languages? or this is the only way?
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@AtharvaLahurika,
Indeed you can through the Localization Framework. This is because the Localization Framework when used in this way identifies the source strings to translate (providing everything has been built correctly and according to coding standards) then in the task you can either translate manually in the task, via Dynamic Translation or via a TMS. Which ever method you use in the task, when you hit "publish" it will populate the necessary translation tables so that it only needs to be done once,
Also it's worth saying, that our best practice (especially if using a CloudMT provider to perform the translations) is to have native speakers review the outputs to ensure:
* They make sense
* They are contextually correct
* They are the correct and consistent translations,
We have a workbook available here on our CSC page,
Many thanks,
kind regards
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Very good article Alex with a lot of good information - very helpful. There is still one thing I am wondering to the example in the video about HR and IT portals having different translation requirements. Let us say if we want to turn on multi-language for one portal while having the other portal in English only, a user navigating between the two portals will still see the other portal partially translated for those labels with system provided OOTB translations. There does not seem to be a good way to have part of the platform translated while the other part not (due to the OOTB translation in place for label, etc). Could you please comment or confirm? Thank you.
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@Joanna15,
Good question. In that scenario, you could run this artifact on both portals to ensure their UI's are translated as much as possible.
If you were to go for absolute consistency, then you would leverage as-many of our other artifacts (VA, Catalog Items etc) as necessary to ensure all User Journeys are covered,
Many thanks,
kind regards
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Hi @Alex Coope - SN , I created the Portal artifact as well as the UI action and currently using LMS to translate in the setting.
This works fine in the sense that when UI action is clicked its opens the modal with list of languages, successfully creates the LRITM and LTASK. On LFTASK when I click on Translate and provide actual translations in my language and submit for approval. After its approved when I navigate to EC portal and change my language I don't see those Topics translated. Am I missing something here?
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@MohammedU did you "publish" the translations, which is typically after an approval stage if that's the workflow you've used in your "setting",
Assuming you have "published" the translations, if you've previously browsed to the portal in the language prior to the translations existing you may need to do a "cache.do" to force your browser to pull the strings from the server.
If they still don't show for you, change your language then fully log out and log back into the instance. If this happens, you may need to upgrade your version of the /esc portal from the store app version as there were multiple browser performance improvements introduced after Tokyo and the later versions of the portal app which impact this,
Many thanks,
Kind regards
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@Alex Coope - SN , Amazing write up, ive followed the guide in my PDI and was successfully able see the results of my translations reflected in the employee center portal. However after upgrading from Utah to Washington, im now running into an issue where it takes 2 clicks on the ui action "submit for approval". After submitting the translations, and approving the translations, they are no longer being reflected in the portal. Ive tried clearing the cache, to no avail. It seems the only way to get this configuration to work, is to click the UI action 'Save as Draft" before submitting for approval. Doing so, fixes the double click issue on the UI Action and then allows the translations to be applied on the portal. Have you seen this behavior in your own testing?
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@JamesRu, it's probably down to the sheer volume of strings (in the thousands) so save what's there to the task just simplifies it. The "double click" you mention is probably down to being too quick, in that in my testing if I wait for a moment or two before clicking it works fine - also bear in mind the performance difference with a PDI instance vs a full customer grade instance,
Many thanks,
Kind regards
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@Alex Coope - SN, Unfortunately I've had this issue persist in both my PDI and a Clients instance. I've tried waiting upwards of 10 minutes before clicking the UI Action 'submit for approval', and after clicking it the first time, with the same result. Wasn't sure if there was an issue with the newer upgrades not supporting this feature.
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Hi @JamesRu,
How many strings are in the task? is it more than 10,000? There shouldn't be any issue with newer upgrades because it was originally prototyped in the Rome release and I did a demo of it just yesterday on Washington Patch 1,
It could be the flow being initiated could be getting stuck due to a possible malformed string or an empty identified source string. Would you be able to have a quick double check that there aren't any spurious source strings identified as well as the quantity of strings. For example, in my fully loaded demo ESC portal it's a smidge under 6000 source strings in the Washington Release,
Many thanks,
Kind regards
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@Alex Coope - SN ,
In my PDI there are a total of 2,826 strings, all of which are OOB. In the clients instance there are a total of 4,735 strings. As far as the spurious strings, is there anything in particular I should look out for? As everything looks correct to me.
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@JamesRu,
Just so I fully understand you didn't need to double-click prior to the Washington release, only after the upgrade?
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@Alex Coope - SN Yes, for context, the clients instance was running into the same double click issue, so I did the same set up in my PDI. In the beginning there were no issues. We were trying to troubleshoot why the PDI's config worked but not the clients. It wasn't until I upgraded to Washington did we see the same problems in my PDI.
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Long time no speak (from my Sysco Foods days). I am now with another company and we share our ServiceNow internally with a bunch of other groups. Just a quick question about the Portal translations... Can you have more than one Localization Framework "Setting" so that one group who wants to manually translate stuff on their portal can use the Translate -> Publish option, and my group can use the Auto-Translate -> Publish option? From what I am seeing, it will not allow us to have two using the same processor, and you can only have one process per table (sp_portal). Do we have any workaround for this?
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Wanted to add something to the discussion: The Template HTML cannot be parsed using standard regex as e.g. html like ${this is a message {{::but.this.is.a.angular.data.binding}}}
This can be parsed using the following script however:
_processHTML: function (html) {
// in-place translations via ${ MESSAGE {{DATA-BINDING}} } in 'Body HTML template'
let i = html.indexOf('${');
let pcount = 0;
let start = 0;
const messages = [];
while (i < html.length && i != -1) {
if (html[i] == '$' && html[i + 1] == '{' && (++pcount == 1)) {
// beginning of message
// Note: This won't throw if (i + 1) is out of bounds
i += 2;
start = i;
} else if (html[i] == '{' && pcount > 0) {
// regular open brace (only registered when we've started parsing)
pcount++;
i++;
} else if (html[i] == '}' && pcount > 0 && (--pcount == 0)) {
// if all braces closed, message is fully parsed
messages.push(html.substring(start, i));
i = html.indexOf('${', i);
} else {
i++;
}
}
return [...new Set(messages)];
},
I'd also like to point out my "Localization Framework+ Service Portal" App which allows you to keep the overview of all translated artifacts by enabling the insight generation (this is not possible ootb, because several service portal artifacts do not have a "true" field which is required for insights generation - luckily I've found a workaround for this):
https://github.com/kr4uzi/ServiceNow-Localization-Framework-Service-Portal
As you can see, it simply integrates into the OOTB Localization Framework Dashboard, in addition, if you want Taxonomy translations covered, check out my Taxonomy Translations Scoped App which you can find on my github as well.
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Hi @Alex Coope - SN ,
this is very useful, thank you very much. I have a little question, in catalog items we have two buttons "Edit translations" and "Request translations", but in portals we only have "Request translations". Is ther any plan to add the "Edit translations" button to the portal?
Regards,
Pablo Espinar
ServiceNow technical lead at Holcim
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@pespinar,
Currently the "Edit Translations" is a specific option only for Catalog Items. It was originally intended for use with Citizen Developers, where a translation needed to be modified / updated on a Prod instance as it doesn't generate an LFtask,
Many thanks,
kind regards
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@Timothy Onyskin,
That is a really good question (apologies I only just got the alert for your question),
It's actually more of a question to do with LF because we don't want to promote bad practices / mixed language experiences. Currently the per language settings are defined at the "artifact" level which is super important when factoring a UI experience like the Portal.
So, is it possible to say for the Portal artifact Language A routes this way and Language B for the Portal artifact routes that way - yes, I'm afraid it can't be split by which portal which is true for any LF "artifact" as that's not currently the design. My suggestion would be to raise it as an "IDEA" on the IDEA portal as I can see merits for doing that because it might be that Facilities have a portal that needs to be in different languages, or Customer Services need completely different languages to internal HR needs,
Many thanks,
kind regards
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Thank you for the information. I have one more question/issue. I am looking at a custom widget (made by NewRocket) and it has the same internationalization as the OOTB "breadcrumbs" widget however it does not work (haven't checked the OOTB one since we don't use it). The part that is not working is the {{item.label}}. I added some items for the pages to the Translated Text table, but I am not sure if that is the correct place. I tried turning on debugging, and it does show that the prefix is TRT. The header right under the breadcrumb is not translating either. I do have some pages in which this works, but I cannot figure out why a couple are not working. Any help would be great.
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@Tonyskin You need a custom script for this, as the ootb methods rely on regex and the strings like "${hello {{::data.something}} world}" cannot be possibly parsed with a regex.
The script can be found my previous reply in this thread:
https://www.servicenow.com/community/international-localization/need-to-translate-a-portal-check-thi...
Also depending on the need of the approach you can checkout the following app which enables the "Edit Translations" UI Actions on any application file that has any translation relevant record in it:
https://github.com/kr4uzi/ServiceNow-Localization-Framework-Scoped-Apps
https://github.com/kr4uzi/ServiceNow-Localization-Framework-Scoped-Apps/wiki
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@Markus Kraus,
A custom script shouldn't be needed, because in theory (and in my testing) the PoC artifact is designed to find the entirety of:
${hello {{::data.something}} world}
Then, the next step is to remove the flanking "${" and "}" to produce the following string:
hello {{::data.something}} world
This would then be the "key" in the [sys_ui_message] table for this example's translation. The challenge (which I absolutely accept) is finding the object source of "data.something" which in reality could be anywhere such as records in a table parsed to it or even another function in the same or another script etc (which may or may not be translatable at source). However, this kind of translation string (referred to as a "concatenated string" is not considered as a good practice) so advice is to avoid writing code like this as much as possible because of such challenges - it also wouldn't correctly translate in some languages. You're better off having a key like "Hello {0} world", where the param is a data object and it can be moved in to different areas of the translation,
- It's also important to note, that good / bad practice concept has nothing to do with SN, it's software in general 🙂
If there's enough demand for it, I might do a future blog post on the challenges of concatenated strings to compliment the HardCoded strings blog post we did a little while ago,
Many thanks,
Kind regards
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@Alex Coope - SN Thats the difficult part: The computation logic (script, regex, ...) would need to know when this key ends, and if you have nested braces, you'd have to count opening and closing braces. The regex used in this thread is the following:
var MsgCheck = /\$\{((?:\s*(?:[^{}\s]|\{|\{\{(?:[^{}]|(?:))*\}||\}\})+s*|(?:))*)\}/gim;
But this cannot detect '${hello {{::data.messages{data.dynamicKey}}} {{::data.messages.staticKey}}}'.
It is impossible to create a regex which is able to do this - the proof for this claim can be deduced from the so called "pumping-lemma".
But as you said, those concatenated strings should be considered bad practice. However it shouldn't fool the LF to look for the wrong keys - in this example the key produced by the regex is: 'hello {{::data.messages{data.dynamicKey}}'.
A little bit different, difficult (if not outright impossible) to detect:
var MESSAGE_KEY_FAILURE = "FAILURE: Expected state of component '{0}' to be {1} but was {2}";
var messageMap = new GwtMessage().getMessages([MESSAGE_KEY_FAILURE]);
This code can be found a lot of (ootb) ATF Step Configs, and I'm not sure if it is possible to create a reasonable test/script for this (backtracking one level should be possible, but this is pretty much the same problematic as with the regex: either make it work in all situations - or just outright consider it bad practice and do not support it at all).
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@Markus Kraus,
I can definitely write a function to find the string in quotes against a variable when a variable / object is sent into getMessage or ${}, the problem is that introduces a performance penalty when requesting translations,
Also, it's not impossible to find the type of string you are suggesting, it would actually be a much simpler regex at that starting point, with something like:
/\$(\{).*(\})/gim
The problem then is the sheer number of potential false positives it finds, which means multiple layers of clean-up,
I'll have a think - watch this space...
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@Markus Kraus,
Nothing is impossible good sir 🙂
If it proves reliable in my testing I'll update the code above in the post,
As always thank you for the feedback - this will help a lot of people out in the community
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Hehe, I appreciate your persistence, the regex is already getting quite complicated, but please try this regex with "${hello {{breaking}}} ${world}" now.
It would match the whole string instead of extracting the two individual keys "hello {{breaking}}" and "world".
As a reference I can recommend the example of the pumping-lemma for regular languages (standard regular expressions *are* regular [language] - this is why the lemma applies):
https://en.wikipedia.org/wiki/Pumping_lemma_for_regular_languages
This is also the reason why I didn't use any regex for this in my code: Simply because it is "mathematically" proven to be impossible.
It would be possible with regex extensions though: https://xregexp.com/api/#matchRecursive
Some other programming languages either have built-in recursive regex (e.g. PHP) support or they have "depth counter" (.NET has this) which can achieve this.
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@Alex Coope - SN
${hello {{data{member}}}} ${world}
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@Markus Kraus,
it's not going to be infallible due to the nature of how REGEX works, but for now it covers the most common scenarios and examples - I have some pretty grand plans for the longer term future,
When I've finished prototyping the function to find strings in declared variables we'll look to publish the next version if it tests well,
As always, thanks for the useful info and feedback 👍
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
@Alex Coope - SN - Hoping you can help me identify why I am getting the 'Failed to request translations' error.
I've double checked the script include, artifact, settings and UI action against your article and everything looks correct. We have the base Employee Center installed.
Checking the logs I am seeing a warning with 100 rows of 'Invalid query detected'. That is followed by several more errors for each language selected.
Query warning examples:
Invalid query detected, please check logs for details [Unknown field sys_created_on in table sn_imt_tracing_exposed_contact]
Invalid query detected, please check logs for details [Unknown field display_to_subject_person in table sn_hr_le_activity_set]
Invalid query detected, please check logs for details [Unknown field activity_set.le_type.active in table sn_hr_le_activity]
Invalid query detected, please check logs for details [Unknown field sys_created_on in table sn_imt_ppe_ppe_assignment_log]
Invalid query detected, please check logs for details [Unknown field active in table sn_imt_quarantine_crisis_task]
Invalid query detected, please check logs for details [Unknown field recently_collaborated in table sn_imt_diagnosis_impacted_users]
Invalid query detected, please check logs for details [Unknown field ppe_model.product_model.cmdb_model_category in table sn_imt_ppe_ppe_stock_log]
Invalid query detected, please check logs for details [Unknown field acknowledgement_status in table sn_imt_checkin_check_in_acknowledgement]
Invalid query detected, please check logs for details [Unknown field sys_updated_on in table sn_imt_core_health_and_safety_user]
Invalid query detected, please check logs for details [Unknown field sys_created_on in table sn_hr_er_interview]
Errors
Localization Framework: Error occurred in LFRequestedItemUtils. Error Occurred while creating LF Requested Item for sp_portalwith sys_id 70cd9f3b734b13001fdae9c54cf6a72ffor language fr. java.lang.NullPointerException: Cannot invoke "com.glide.db.meta.Query.addQueryString(String, boolean)" because "this.fQuery" is null
*** Script: Localization Framework: Error occurred in LFRequestedItemUtils. Error Occurred while creating LF Requested Item for sp_portalwith sys_id 70cd9f3b734b13001fdae9c54cf6a72ffor language fr. java.lang.NullPointerException: no thrown error
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Hi @Brent Caudill, a couple of things come to mind:
- Was the artifact made in the "global" scope and run in the "global" scope?
- Are there any "Restricted Cross-Scope" permissions being asked for read access?
Many thanks,
kind regards
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Hi @Alex Coope - SN , thank you for the speedy reply!
- The artifact was made and run in the Global scope. The LF_PortalProcessor script include is also in Global.
- I didn't get any prompts, or see any errors related to the 'Restricted Cross-Scope' read access. I looked at the sys_scope_privilege table and didn't see any new entries (not sure if this is the appropriate place to look).
- Mark as Read
- Mark as New
- Bookmark
- Permalink
- Report Inappropriate Content
Hi @Brent Caudill,
Best place to look would be here:
For any where the status is "requested" and review them carefully,
It looks like it making queries to some HR used tables (due to widgets), so it's possible that you might have "delegated" admin enabled, so in that case you may need to run the page level artifact specifically in the HR scope to identify those strings if you have permission within the admin delegation.
- For note, "Delegated Permissions" is often used when certain dev activities are to be solely performed by "admins" within and only within a scope such as HR. This could be due to PII security reasons / company policy etc and not allowing standard Sys Admins from seeing such sensitive data. More info can be found here.
Many thanks,
kind regards