pdfetch
Version:
Node.js application to download KB articles from an arbitrary ServiceNow instance in PDF format.
488 lines (460 loc) • 14.5 kB
JavaScript
const path = require("path");
const fs = require("fs");
const { removeFolderContents } = require("../core/fs_tools");
const {
getArticlesList,
fetchArticles,
getKBChanges,
} = require("../core/sn_tools");
/**
* Removes the content of the workspace directory while keeping
* the session file intact, if any.
* @param {String} folderPath
* Absolute path to a folder where downloaded PDFs and related files
* are to be placed.
*
* @param {Function} [monitoringFn=null]
* Optional function to receive real-time monitoring information.
* Expected signature/arguments structure is:
* onMonitoringInfo ({
* type:"info|warn|error",
* message:"<any>"[, data : {}]
* });
*/
async function resetWorkspace(folderPath, monitoringFn = null) {
const $m = monitoringFn || function () {};
try {
await removeFolderContents(
folderPath,
["PDFs", "*.json*", "*.pdf", "*.zip"],
$m
);
$m({
type: "info",
message: `Workspace at "${folderPath}" has been reset.`,
});
} catch (error) {
$m({
type: "error",
message: `Failed to reset workspace. Details: "${error.message}"`,
data: { error },
});
}
}
/**
* Resets the workspace and downloads a clean JSON list with the KB articles
* available in ServiceNow.
*
* @param {String} folderPath
* Absolute path to a folder where downloaded PDFs and related files
* are to be placed.
*
* @param {String} targetFileName
* Name of a JSON file inside `folderPath` where the resulting list of
* articles is to be stored.
*
* @param {String} instanceName
* Name of the ServiceNow instance to connect to, e.g., "acme" in "acme.
* service-now.com".
*
* @param {String} userName
* Name of a local ITIL user to log in with.
*
* @param {String} userPass
* Password to use when logging in.
*
* @param {String} [filter=null]
* Optional ServiceNow encoded query to filter the results.
*
* @param {Function} [monitoringFn=null]
* Optional function to receive real-time monitoring information.
* Expected signature/arguments structure is:
* onMonitoringInfo ({
* type:"info|warn|error",
* message:"<any>"[, data : {}]
* });
*
* @returns {Promise<Array<Object>>}
* Promise resolving to an array of objects, each containing the KB
* article details.
*/
async function doListingOnly(
folderPath,
targetFileName,
instanceName,
userName,
userPass,
filter = null,
monitoringFn = null
) {
const $m = monitoringFn || function () {};
try {
await resetWorkspace(folderPath, $m);
const listFilePath = path.resolve(folderPath, `${targetFileName}.json`);
const articles = await getArticlesList(
instanceName,
userName,
userPass,
listFilePath,
filter,
$m
);
$m({
type: "info",
message: `Articles list has been stored at "${listFilePath}".`,
});
return articles;
} catch (error) {
$m({
type: "error",
message: `Failed to list articles. Details: "${error.message}"`,
data: { error },
});
throw error;
}
}
/**
* DOES NOT reset the workspace, and downloads a (new) JSON list with the
* latest KB articles, also keeping the old one if available. Extracts the
* changes between the two (e.g., what articles have been added, updated or
* retired/deleted) and saves those to a JSON file as well.
* @param {String} folderPath
* Absolute path to a folder where downloaded PDFs and related files
* are to be placed.
*
* @param {String} listFileName
* Name of a JSON file inside `folderPath` where resulting list of
* articles is stored.
*
* @param {String} changesFileName
* Name of a JSON file inside `folderPath` where extracted changes
* are to be placed.
*
* @param {String} instanceName
* Name of ServiceNow instance to connect to, e.g., "acme" in "acme.
* service-now.com".
*
* @param {String} userName
* Name of a local ITIL user to log in with.
*
* @param {String} userPass
* Password to use when logging in.
* @param {String} [filter=null]
* Optional ServiceNow encoded query to filter the results.
*
* @param {Function} [monitoringFn=null]
* Optional function to receive real-time monitoring information.
* Expected signature/arguments structure is:
* onMonitoringInfo ({
* type:"info|warn|error",
* message:"<any>"[, data : {}]
* });
*
* @returns {Object} The content written to the `changesFileName`. This will
* be an Object resembling to the following:
* {
* "last_updated_on": "2024/07/09 14:52:03",
* "changes": {
* "removed": ["KB001234", "KB004567"],
* "added": ["KB8888", "KB9999"],
* "updated": ["KB74125", "KB89652"]
* }
* }
* Returns `null` in case no `listFileName` was found, because no
* changes can be extracted in lack of an older articles list file to
* use as base for comparison.
*
*/
async function doListingWithChanges(
folderPath,
listFileName,
changesFileName,
instanceName,
userName,
userPass,
filter = null,
monitoringFn = null
) {
const $m = monitoringFn || function () {};
try {
const listFilePath = path.resolve(folderPath, `${listFileName}.json`);
// Check if the list file exists
if (!fs.existsSync(listFilePath)) {
$m({
type: "warn",
message: `Cannot extract changes. File not found: "${listFilePath}". Try other "operation_mode" first.`,
});
return null;
}
// Rename the old list file
const oldListFilePath = `${listFilePath}.old`;
fs.renameSync(listFilePath, oldListFilePath);
// Download a new articles list
await getArticlesList(
instanceName,
userName,
userPass,
listFilePath,
filter,
$m
);
// Extract changes using the two lists
const changesFilePath = path.resolve(folderPath, `${changesFileName}.json`);
const changes = await getKBChanges(
listFilePath,
oldListFilePath,
changesFilePath,
$m
);
$m({
type: "debug",
message: `Changes extracted and saved to ${changesFilePath}.`,
data: changes,
});
return changes;
} catch (error) {
$m({
type: "error",
message: `Error extracting changes. Details: ${error.message}`,
data: { error },
});
return null;
}
}
/**
* Same as `doListingOnly`, except listed articles are actually downloaded
* and exported to PDF format.
* Note: Resulting files are placed under the "PDFs" subfolder of the given
* `folderPath`.
*
* @param {String} folderPath
* Absolute path to a folder where downloaded PDFs and related files
* are to be placed.
*
* @param {String} targetFileName
* Name of a JSON file inside `folderPath` where resulting list of
* articles is to be stored.
*
* @param {String} instanceName
* Name of ServiceNow instance to connect to, e.g., "acme" in "acme.
* service-now.com".
*
* @param {String} userName
* Name of a local ITIL user to log in with.
*
* @param {String} userPass
* Password to use when logging in.
*
* @param {String} [filter=null]
* Optional ServiceNow encoded query to filter the results.
*
* @param {Function} [monitoringFn=null]
* Optional function to receive real-time monitoring information.
* Expected signature/arguments structure is:
* onMonitoringInfo ({
* type:"info|warn|error",
* message:"<any>"[, data : {}]
* });
*/
async function doListingWithFiles(
folderPath,
targetFileName,
instanceName,
userName,
userPass,
filter = null,
monitoringFn = null
) {
const $m = monitoringFn || function () {};
try {
// Step 1: List articles
const articlesList = await doListingOnly(
folderPath,
targetFileName,
instanceName,
userName,
userPass,
filter,
$m
);
// Step 2: Prepare PDF home directory
const pdfHome = path.join(folderPath, "PDFs");
if (!fs.existsSync(pdfHome)) {
fs.mkdirSync(pdfHome, { recursive: true });
$m({ type: "debug", message: `PDFs directory created at ${pdfHome}.` });
}
// Step 3: Fetch articles as PDFs
const articleNumbers = articlesList.map((listItem) => listItem.number);
await fetchArticles(
instanceName,
userName,
userPass,
articleNumbers,
pdfHome,
false,
$m
);
$m({
type: "info",
message: `Articles have been fetched and saved as PDFs in "${pdfHome}".`,
});
} catch (error) {
$m({
type: "error",
message: `Error during doListingWithFiles. Details: ${error.message}`,
data: { error },
});
}
}
/**
* Same as `doListingWithChanges`, except it also acts upon an existing
* collection of previously downloaded articles, by selectively deleting and
* (re)downloading them in order to keep the collection up to date.
*
* Unlike `doListingWithChanges`, if no `listFileName` was found (meaning that
* there was no earlier execution that has ever produced a list of articles),
* then this function does not simply exit, but executes `doListingWithFiles`
* first. That's because its ultimate goal is to provide an up to date
* collection of articles in PDF format.
*
* NOTE, however, that this function does not ensure that the files in the
* `PDFs` subfolder correctly match to the ones in the JSON list.
*
* @param {String} folderPath
* Absolute path to a folder where downloaded PDFs and related files
* are to be placed.
*
* @param {String} listFileName
* Name of a JSON file inside `folderPath` where resulting list of
* articles is stored.
*
* @param {String} changesFileName
* Name of a JSON file inside `folderPath` where extracted changes
* are to be placed.
*
* @param {String} instanceName
* Name of ServiceNow instance to connect to, e.g., "acme" in "acme.
* service-now.com".
*
* @param {String} userName
* Name of a local ITIL user to log in with.
*
* @param {String} userPass
* Password to use when logging in.
*
* @param {String} [filter=null]
* Optional ServiceNow encoded query to filter the results.
*
* @param {Function} [monitoringFn=null]
* Optional function to receive real-time monitoring information.
* Expected signature/arguments structure is:
* onMonitoringInfo ({
* type:"info|warn|error",
* message:"<any>"[, data : {}]
* });
*
* @param {Boolean} [newerOnly=false]
* Optional. If `true`, only articles that were added or updated
* since the previous call will be maintained in the `PDFs` subfolder.
* This is meant to meet a specific integration scenario, where the client code
* blindly consumes everything `pdFetch` downloads to its `PDFs` subfolder.
* This effectively means that folder will be empty most of the time,
* and only populate for a short period of time, between two subsequent
* calls, when articles get added and/or updated in ServiceNow. The client code
* can still respond to deletions on ServiceNow side by using information
* from the changes JSON file.
*/
async function doChangesWithFiles(
folderPath,
listFileName,
changesFileName,
instanceName,
userName,
userPass,
filter = null,
monitoringFn = null,
newerOnly = false
) {
const $m = monitoringFn || function () {};
try {
const listFilePath = path.resolve(folderPath, `${listFileName}.json`);
// If the list file is missing, execute `doListingWithFiles` and then exit.
if (!fs.existsSync(listFilePath)) {
$m({
type: "warn",
message: `Cannot extract changes. File not found: "${listFilePath}". Will download articles then exit.`,
});
await doListingWithFiles(
folderPath,
listFileName,
instanceName,
userName,
userPass,
filter,
$m
);
return;
}
// Execute `doListingWithChanges` and effect extracted changes.
const changesReport = await doListingWithChanges(
folderPath,
listFileName,
changesFileName,
instanceName,
userName,
userPass,
filter,
$m
);
if (changesReport) {
const pdfHome = path.join(folderPath, "PDFs");
// Handle removals and updates by deleting current versions of the respective articles.
if (!newerOnly) {
const articlesToRemove = [].concat(
changesReport.changes.removed || [],
changesReport.changes.updated || []
);
if (articlesToRemove.length > 0) {
const removals = articlesToRemove.map((articleNumber) =>
path.join(pdfHome, `${articleNumber}.pdf`)
);
await removeFolderContents(folderPath, removals, $m);
}
}
// Handle additions and updates by downloading (again) the respective articles.
const articlesToDownload = [].concat(
changesReport.changes.updated || [],
changesReport.changes.added || []
);
if (articlesToDownload.length > 0) {
await fetchArticles(
instanceName,
userName,
userPass,
articlesToDownload,
pdfHome,
newerOnly,
$m
);
}
$m({
type: "info",
message: `Changes applied and articles updated as needed.`,
data: changesReport,
});
}
} catch (error) {
$m({
type: "error",
message: `Error applying changes and updating articles. Details: ${error.message}`,
data: { error },
});
}
}
module.exports = {
resetWorkspace,
doListingOnly,
doListingWithChanges,
doListingWithFiles,
doChangesWithFiles,
};