Files
itflow/functions/document_template_transfer.php
johnnyq 5a3f4ba6f5 Document templates stage 4, export, import, starter pack and report: Admin > Document Templates gets a menu beside New with Import, Export, the report and the starter pack, and each template page gets an Export link; the file carries each template's summary, its text with images embedded, its fill-ins and review interval, leaving who should have one to each install; importing a template already there by name can leave it, add the file's copy alongside or replace it (keeping its documents and who should have one), new ones come in required of no one, content goes through the HTML purifier, and images that only point at the other install's uploads are left out with the message saying how many; the MSP starter pack is rebuilt as 14 templates (Asset Inventory and Vendors and Escalation new) that fill in from the client, pull in its sites, networks, equipment, servers, domains, licenses, vendors and contacts as tables, ask for the rest with typed fill-ins, set 6 or 12 month reviews on the ones that go stale, and are required of no one out of the box; Update on Starter Content now covers document templates, bringing older starter templates up to the new versions while keeping their documents and who should have one; new Document Templates report under Clients & Documentation by template (documents made in the period against the period before, current documents, clients with one, coverage of the clients it is required of, documents due a review) and by client (what each should have, has and is missing, and documents due), filtered by client or by the agent who made them, each table exportable as CSV, and client-restricted users seeing only their clients; no database change; changelog updated
2026-10-18 19:50:42 -04:00

223 lines
12 KiB
PHP

<?php
/*
* Document templates to and from a file - to copy a library between ITFlow
* installs, keep a copy, or share one. Admin > Document Templates exports them
* (export_document_templates) and imports them (import_document_templates);
* the MSP starter pack on Maintenance > Starter Content is loaded the same way.
*
* The file is JSON: {"format": "itflow-document-templates", "version": 1,
* "exported_at", "templates": [...]}. Each template carries its name, summary,
* what it says - its images in it, as data - the fill-ins set up on it, and
* how often the documents made from it are reviewed. Not who should have one:
* that is the call of the install it goes into - a new one comes in required
* of no one, and one replaced keeps its own.
*/
const DOCUMENT_TEMPLATE_FILE_FORMAT = 'itflow-document-templates';
const DOCUMENT_TEMPLATE_FILE_VERSION = 1;
/** The largest image that goes into an export, in bytes - bigger ones stay behind. */
const DOCUMENT_TEMPLATE_TRANSFER_IMAGE_MAX = 5 * 1024 * 1024;
/* A template's HTML with its own images in it as data - for a file. How many were too big or gone, in $left_out. */
function documentTemplateInlineImages($document_template_id, $html, &$left_out = 0) {
$document_template_id = intval($document_template_id);
$dir = documentTemplateDir($document_template_id);
return preg_replace_callback('/(<img\b[^>]*?\bsrc=)(["\'])\/?uploads\/document_templates\/' . $document_template_id . '\/([^"\'\/]+)\2/i', function ($match) use ($dir, &$left_out) {
$path = $dir . '/' . basename($match[3]);
$info = is_file($path) && filesize($path) <= DOCUMENT_TEMPLATE_TRANSFER_IMAGE_MAX ? @getimagesize($path) : false;
if (!$info || !in_array($info['mime'], ['image/png', 'image/jpeg', 'image/gif', 'image/webp'], true)) {
$left_out++;
return $match[0];
}
return $match[1] . $match[2] . 'data:' . $info['mime'] . ';base64,' . base64_encode((string) file_get_contents($path)) . $match[2];
}, (string) $html);
}
/*
* Templates as a file's contents (an array, for json_encode()) - the ones in
* $ids, or every current one when $ids is empty, and the archived ones too
* with $archived.
*/
function documentTemplatesExport(array $ids = [], $archived = false) {
global $mysqli;
$ids = array_values(array_filter(array_map('intval', $ids)));
$where = $ids ? 'document_template_id IN (' . implode(',', $ids) . ')' : ($archived ? '1 = 1' : 'document_template_archived_at IS NULL');
$rows = [];
$sql = mysqli_query($mysqli, "SELECT document_template_id, document_template_name, document_template_description, document_template_content, document_template_review_months
FROM document_templates WHERE $where ORDER BY document_template_name ASC, document_template_id ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$rows[intval($row['document_template_id'])] = $row;
}
$fill_ins = documentTemplateFillInRowsFor(array_keys($rows));
$templates = [];
foreach ($rows as $id => $row) {
$templates[] = [
'name' => (string) $row['document_template_name'],
'description' => (string) $row['document_template_description'],
'content' => documentTemplateInlineImages($id, $row['document_template_content']),
'fill_ins' => array_map(function ($fill_in) {
return ['name' => $fill_in['name'], 'type' => $fill_in['type'], 'options' => $fill_in['options'], 'required' => $fill_in['required'], 'help' => $fill_in['help']];
}, $fill_ins[$id] ?? []),
'review_months' => intval($row['document_template_review_months']),
];
}
return ['format' => DOCUMENT_TEMPLATE_FILE_FORMAT, 'version' => DOCUMENT_TEMPLATE_FILE_VERSION, 'exported_at' => date('c'), 'templates' => $templates];
}
/** A file name for an export - its one template's name, or the date. */
function documentTemplatesExportFileName(array $export) {
$templates = $export['templates'] ?? [];
if (count($templates) === 1) {
$slug = trim(preg_replace('/[^a-z0-9]+/', '-', strtolower($templates[0]['name'])), '-');
return 'document-template-' . ($slug !== '' ? substr($slug, 0, 60) : 'export') . '.json';
}
return 'document-templates-' . date('Y-m-d') . '.json';
}
/* One line of text from a file, tidied - no markup, no runs of spaces, no longer than $max. */
function documentTemplateFileText($value, $max) {
return is_scalar($value) ? trim(mb_substr(preg_replace('/\s+/u', ' ', strip_tags((string) $value)), 0, $max)) : '';
}
/*
* A file's templates, read and checked - from what export wrote, or the
* starter pack. Each made safe: text tidied, what it says through the HTML
* purifier (images in it as data kept when they are images; ones pointing at
* another install's uploads taken out, counted in 'images_dropped'), fill-ins
* with a name that can be one, a type that is one and a list with two
* choices (else plain text), each once, and a review period that is one.
* Null with $error saying why the file can't be used.
*/
function documentTemplatesImportRead($contents, &$error = '') {
$data = is_array($contents) ? $contents : json_decode((string) $contents, true);
if (!is_array($data) || ($data['format'] ?? '') !== DOCUMENT_TEMPLATE_FILE_FORMAT || !isset($data['templates']) || !is_array($data['templates'])) {
$error = "That isn't a document templates file - export one from Admin > Document Templates.";
return null;
}
if (intval($data['version'] ?? 0) > DOCUMENT_TEMPLATE_FILE_VERSION) {
$error = 'That file was made by a newer version of ITFlow - update this one first.';
return null;
}
if (count($data['templates']) > 500) {
$error = 'That file holds more than 500 document templates - split it up.';
return null;
}
$templates = [];
foreach ($data['templates'] as $t) {
$name = is_array($t) ? documentTemplateFileText($t['name'] ?? '', 200) : '';
if ($name === '') {
continue;
}
// An image carried as data with no alt would get one made of its data
$content = is_scalar($t['content'] ?? null) ? preg_replace('/<img\b(?![^>]*\balt=)(?=[^>]*\bsrc=["\']data:)/i', '<img alt=""', (string) $t['content']) : '';
$content = trim($content) !== '' ? documentHtmlPurify($content) : '';
// An image still pointing at the uploads of the install it came from is no image here
$dropped = 0;
$content = preg_replace('/<img\b[^>]*?\bsrc=(["\'])\/?uploads\/[^"\']*\1[^>]*>/i', '', $content, -1, $dropped);
$fill_ins = [];
foreach (array_slice(is_array($t['fill_ins'] ?? null) ? $t['fill_ins'] : [], 0, 100) as $f) {
$fill_in_name = is_array($f) ? documentFillInName(documentTemplateFileText($f['name'] ?? '', 50)) : '';
if ($fill_in_name === '' || isset($fill_ins[strtolower($fill_in_name)])) {
continue;
}
$type = is_string($f['type'] ?? null) && isset(TICKET_FILL_IN_TYPES[$f['type']]) ? $f['type'] : 'text';
$options = $type === 'list' ? ticketFillInOptions(is_array($f['options'] ?? null) ? implode("\n", array_filter($f['options'], 'is_scalar')) : (string) ($f['options'] ?? '')) : [];
if ($type === 'list' && count($options) < 2) {
$type = 'text';
$options = [];
}
$fill_ins[strtolower($fill_in_name)] = ['name' => $fill_in_name, 'type' => $type, 'options' => $options, 'required' => !empty($f['required']),
'help' => documentTemplateFileText($f['help'] ?? '', 255)];
}
$review_months = intval($t['review_months'] ?? 0);
$templates[] = [
'name' => $name,
'description' => documentTemplateFileText($t['description'] ?? '', 500),
'content' => $content,
'fill_ins' => array_values($fill_ins),
'review_months' => isset(DOCUMENT_TEMPLATE_REVIEW_MONTHS[$review_months]) ? $review_months : 0,
'images_dropped' => $dropped,
];
}
if (!$templates) {
$error = 'There are no document templates in that file.';
return null;
}
return $templates;
}
/*
* Bring in templates read by documentTemplatesImportRead(). One whose name is
* already here is skipped ($existing 'skip'), added under a name of its own
* ('copy') or has everything in the file put in its place ('replace') - its
* name, summary, what it says and its images, its fill-ins and how often its
* documents are reviewed. Its documents stay made from it, and who should
* have one stays as it was; a new one is required of no one. $only_existing:
* bring in only the ones already here (to update them). ['added', 'replaced',
* 'skipped', 'images' left out, 'ids' of those added or replaced].
*/
function documentTemplatesImport(array $templates, $existing = 'skip', $only_existing = false) {
global $mysqli;
$counts = ['added' => 0, 'replaced' => 0, 'skipped' => 0, 'images' => 0, 'ids' => []];
$here = [];
$sql = mysqli_query($mysqli, "SELECT document_template_id, document_template_name FROM document_templates ORDER BY document_template_id");
while ($row = mysqli_fetch_assoc($sql)) {
$here[mb_strtolower($row['document_template_name'])] = $here[mb_strtolower($row['document_template_name'])] ?? intval($row['document_template_id']);
}
foreach ($templates as $t) {
$id = $here[mb_strtolower($t['name'])] ?? 0;
if (($only_existing && !$id) || ($id && $existing === 'skip')) {
$counts['skipped']++;
continue;
}
$name = $t['name'];
if ($id && $existing === 'copy') {
for ($n = 2; isset($here[mb_strtolower($name)]); $n++) {
$name = mb_substr($t['name'], 0, 190) . " ($n)";
}
$id = 0;
}
$replacing = $id > 0;
// What it says, its images kept in its own folder
$saved = documentTemplateSave($id, ['name' => $name, 'description' => $t['description'], 'content' => $t['content']]);
$here[mb_strtolower($name)] = $saved;
// Its fill-ins are the file's
mysqli_query($mysqli, "DELETE FROM document_template_fill_ins WHERE document_fill_in_document_template_id = $saved");
foreach (array_values($t['fill_ins']) as $order => $f) {
mysqli_query($mysqli, "INSERT INTO document_template_fill_ins SET document_fill_in_document_template_id = $saved, document_fill_in_name = '" . escapeSql($f['name']) . "',
document_fill_in_type = '" . escapeSql($f['type']) . "', document_fill_in_options = " . ($f['options'] ? "'" . escapeSql(implode("\n", $f['options'])) . "'" : 'NULL') . ",
document_fill_in_required = " . ($f['required'] ? 1 : 0) . ", document_fill_in_help = " . ($f['help'] !== '' ? "'" . escapeSql($f['help']) . "'" : 'NULL') . ",
document_fill_in_order = " . ($order + 1));
}
mysqli_query($mysqli, "UPDATE document_templates SET document_template_review_months = " . intval($t['review_months']) . ", document_template_updated_at = document_template_updated_at
WHERE document_template_id = $saved");
$counts['images'] += intval($t['images_dropped']);
$counts[$replacing ? 'replaced' : 'added']++;
$counts['ids'][] = $saved;
}
return $counts;
}
/* What an import did, in words - for its message. HTML. */
function documentTemplatesImportSummary(array $counts) {
$parts = [];
foreach (['added' => 'added', 'replaced' => 'replaced', 'skipped' => 'already here, left as they were'] as $key => $word) {
if ($counts[$key]) {
$parts[] = '<strong>' . $counts[$key] . '</strong> ' . $word;
}
}
return ucfirst(implode(', ', $parts) ?: 'nothing brought in')
. ($counts['images'] ? '. Left out <strong>' . $counts['images'] . '</strong> image' . ($counts['images'] === 1 ? '' : 's') . " the file only pointed at, without carrying - too big, or gone where it came from" : '')
. '.';
}