diff --git a/servicenow-kb-toc.user.js b/servicenow-kb-toc.user.js index 4c4c42d..8178bc1 100644 --- a/servicenow-kb-toc.user.js +++ b/servicenow-kb-toc.user.js @@ -1,433 +1,432 @@ // ==UserScript== // @name ServiceNow - Table of Contents // @namespace it.epfl.ch // @version 0.4 // @description Runs when a knowledge article is edited. Add a button in the Wysiwyg editor (TinyMCE) "Create or update table of contents". When pressed, creates unique ids on titles contained in the text editor and then insert a table of content with anchor links. // @author Laurent Indermühle, Julien Grondier, Frederik Künstner // @match https://it.epfl.ch/backoffice/kb_knowledge.do* // @match https://it-test.epfl.ch/backoffice/kb_knowledge.do* // @grant none // ==/UserScript== /** * COMPATIBILITY * * V0.4 : ServiceNow Fuji */ /** * LOG * * 2016.08.09 L.Indermühle: Bump to version 0.4 * 2016.08.09 L.Indermühle: Bump to version 0.3 * 2016.08.09 L.Indermühle: Bump to version 0.2 * 2016.08.09 L.Indermühle: Published on C4Science.ch * 2016.08.08 L.Indermühle: Initial version */ (function() { 'use strict'; /** * Window = kb_knowledge.do (because of the @match in head of this script) * * The wysiwyg editor's iframe container has the id #kb_knowledge.text_ifr * ServiceNow Fuji (early 2016) : window.frames[1] * This script is in the scope of a parent iframe called "gsft_main". */ function getDocument() { var frame_id = 'kb_knowledge.text_ifr'; for (var i = 0; i < window.frames.length; i++) { if (window.frames[i].frameElement.id == frame_id) { return window.frames[i].document; } } } /** * @return string 'fr' or 'en' */ function getLang() { return document.getElementById('kb_knowledge.language').value; } /** * tocTitle prints the header of the navigation bloc * @return {string} [description] */ function tocTitle() { if (getLang() == 'fr') { return 'Sommaire'; } else if (getLang() == 'en') { return 'Table of contents'; } } /** * Generate a unique hash */ function generateUID() { return ( "0000" + (Math.random()*Math.pow(36,4) << 0).toString(36)).slice(-4); } /** * Count <H1> */ function countH1(nodelist) { var h1count = 0; for (var i of nodelist) { if (i.tagName === 'H1') { h1count += 1; } } return h1count; } /** * Get all titles html element * @param {NodeList} body * @return {NodeList or null} */ function getTitles(body) { var b = body.querySelectorAll("h1, h2, h3, h4, h5, h6"); if (b.length === 0) { throw new Error("Titles not found."); } else { if (countH1(b) === 0) { throw new Error("No H1 title found."); } else if (countH1(b) > 1) { throw new Error("More than one H1 title found."); } else { return b; } } } /** * Generate ids based on the title text. Example: * Mon premier titre => monpremiertitre-4gu2 * Also strip specials characters: * Ça va être-épique ! => cavaetreepique-9u1o */ function generateId(title) { title = title.replace(/[ë|é|è|ê]/gi, 'e'); title = title.replace(/[ï|í|ì|î]/gi, 'i'); title = title.replace(/[ü|ú|ù|û]/gi, 'u'); title = title.replace(/[ä|á|à|â]/gi, 'a'); title = title.replace(/[ç|Ç]/g, 'c'); title = title.replace(/\s|\'|\|\.|\;|\:"/g, ''); // \s = space title = title.replace(/^([^a-z])/i, 'a'); // first char must be alpha title = title.replace(/[^a-z|^0-9]/gi, ''); return title + "-" + generateUID(); } /* TinyMCE add a br for every element that you add. They do that * because Firefox don't let you move the caret on empty elements in * a text field text field. * That's fine but the problem is that this behavior also trigger when * we add an id. I you look in the source-code provided by TinyMCE, * the one you see when pressing the <> button, you'll see: * <h1 id="MyId<brdata-mce-bogus="1">">... * And if you console.log(title.id): * MyId<brdata-mce-bogus="1"> * So an HTML entitized function happen. We now know what to search for. */ function removeMceBogus(string) { return string.replace('<brdata-mce-bogus="1">', ''); } /** * If the title already has an id, we keep it. * So if external referers still works. * In regards to duplicates ids, we had a unique hash at the end, so if * duplicates ids already exists in the article, it's not our fault. */ function insertTitlesIds(titles) { for (var title of titles) { if (title.id === "") { title.id = generateId(title.textContent); } else { title.id = removeMceBogus(title.id); } } } /** * Table of Contents looks like that: * - * <nav role="navigation" id="psdn_toc_container"> + * <div id="psdn_toc_container"> * <p style="font-weight:bold;">[Sommaire|Table of contents]</p> * <ul id="psdn_toc_top_ul"> * <li><a href="#title1a">Title 1</a></li> * <li><a href="#title1b">Title 1</a> * <ul> * <li><a href="#title2a">Title 2</a></li> * <ul> * </li> * </ul> - * </nav> + * </div> */ function buildToC() { - var n = getDocument().createElement('nav'); - n.setAttribute('role', 'navigation'); + var n = getDocument().createElement('div'); n.id = 'psdn_toc_container'; var t = getDocument().createElement('p'); t.setAttribute('style', 'font-weight:bold;'); t.textContent = tocTitle(); n.appendChild(t); var u = getDocument().createElement('ul'); u.id = 'psdn_toc_top_ul'; n.appendChild(u); return n; } /** * <h2> ==> 2 */ function extractLevel(html_tag) { return parseInt(html_tag.replace(/[^\d]/i, "")); } /** * higher level means smallest digit, because <h1> is top level * * This will serve to construct the hierarchy, especially in the case * the first title is not a top level one : * <h3>Titre</h3> * <h2>Un autre titre</h2> * <h3>Un autre sous titre</h3> * In this case, the loop will begin with a title level 3, then receive a * level 2 but can't shift left because we should have add an * extra <ul> in the previous loop */ function higherLevel(lst){ var higher_level = 9; for (var i of lst) { var level = extractLevel(i.tagName); // H1 is ignored if (level < higher_level && level != 1) { higher_level = level; } } return higher_level; } /** * InsertToC finds the position where to insert the Toc */ function insertToC() { var childN; // Childs Nodes of the document var new_pos; // New position where to insert the ToC var old_pos; // Old position where the ToC was var titlestags; // List of HTML titles tag to match - var toc; // the <nav> html element in the DOM + var toc; // the <div> html element in the DOM which serves the role of a <nav> tag toc = getDocument().getElementById("psdn_toc_container"); if (toc) { old_pos = toc.nextSibling; - toc.outerHTML = ''; // Remove the <nav> from the DOM + toc.outerHTML = ''; // Remove the nav from the DOM // Insert the ToC in the same place getDocument().body.insertBefore(buildToC(), old_pos); return; } else { childN = getDocument().body.childNodes; // DISABLED: Some articles begin with hidden stuff, like // <p><style type="text/css"><!-- a { font-weight: bold[...] // so this test was too restrictive // // if (childN[0].tagName !== 'H1') { // throw new Error('The article should begin with a H1 title'); // } titlestags = ['H2', 'H3', 'H4', 'H5', 'H6']; // Insert the navigation above the first title found for (var child of childN) { if (titlestags.indexOf(child.tagName) > -1) { new_pos = child; break; } } if (new_pos) { getDocument().body.insertBefore(buildToC(), new_pos); } else { throw new Error('Trying to insert the table above first title that is not <H1>. Failed because no title found.'); } } } /** * Generate the <ul><li> bloc with the <a> links */ function generateToC(titles_lst) { var hl = higherLevel(titles_lst); var toc_html = []; var prev_level = 0; var curr_level = 0; var new_gap = 0; var first_iteration = true; for (var title of titles_lst) { curr_level = extractLevel(title.tagName); // We want to ignore H1 titles if (curr_level == 1) { continue; } // In case the first title is not the higher level if (first_iteration) { if (curr_level > hl) { // UL can only contain li, not another ul var open_tag = '<li style="list-style-type:none"><ul>'; toc_html.push(open_tag.repeat(curr_level - hl)); } first_iteration = false; } else { if (curr_level > prev_level) { new_gap = curr_level - prev_level; toc_html.push('<ul>'.repeat(new_gap)); } else if (curr_level < prev_level) { new_gap = prev_level - curr_level; toc_html.push('</ul></li>'.repeat(new_gap)); } } toc_html.push('<li><a href="#'+title.id+'">'+title.textContent+'</a>'); prev_level = curr_level; } return toc_html.join('\n'); } /** * If TinyMCE isn't loaded yet, the toolbar doesn't exists * * Here document is equivalent to window.document */ function toolbarExists() { return document.getElementsByClassName('mce-toolbar').length !== 0; } function buildButton() { var zNode = document.createElement ('div'); zNode.innerHTML = '<button id="psdn_create_toc_button"' + 'role="presentation" type="button">' + 'Create or update table of Contents</button>'; zNode.id = 'psdn_toc_button_container'; zNode.setAttribute('class', 'mce-widget mce-btn mce-btn-small'); zNode.setAttribute('aria-label', 'Table of contents'); zNode.setAttribute('aria-labelledby', 'psdn_toc_button_container'); return zNode; } /** * Triggered when the button is pressed */ function ButtonClickAction() { var err = []; // array of all errors catchs var titles_lst; // All titles founds in the document var tocTopUl; // Top <ul> element (The one with our ID) try { titles_lst = getTitles(getDocument()); } catch (e) { err.push("Error while getting titles: " + e); } if (titles_lst) { try { insertTitlesIds(titles_lst); // Create the ids } catch (e) { err.push("Error while generating IDs: " + e); } try { insertToC(); } catch (e) { err.push("Error while inserting the ToC: " + e); } try { tocTopUl = getDocument().getElementById("psdn_toc_top_ul"); } catch (e) { err.push("Error while searching top UL elements: " + e); } try { tocTopUl.innerHTML = generateToC(titles_lst); } catch (e) { err.push("Error while generating the ToC: " + e); } } if (err.length > 0) { alert(err.join('\n')); } } /** * Add our script to the DOM */ function activateButton() { document.getElementById("psdn_create_toc_button").addEventListener ( "click", ButtonClickAction, false ); } /** * We crawl through the DOM and seek for the last button on the supposed * last group of buttons. It's less hardcoded this way than matching ids. */ function insertButton() { var a = document.getElementsByClassName('mce-toolbar'); var b = a.item(0); var c = b.childNodes; var d = c.item(0); var e = d.childNodes; var f = e.item(e.length - 1); var g = f.childNodes; var h = g.item(0); //Append the element in page (in span). h.appendChild(buildButton()); } /** * Our script runs before TinyMCE has finished loading * This timer loop every seconds and insert the button when if founds * The TinyMCE's toolbar */ (function waitForTinyMCE() { if (!toolbarExists()) { setTimeout(waitForTinyMCE, 1000); } else { insertButton(); activateButton(); } })(); })();