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('/(]*?\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('/]*\balt=)(?=[^>]*\bsrc=["\']data:)/i', ']*?\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[] = '' . $counts[$key] . ' ' . $word; } } return ucfirst(implode(', ', $parts) ?: 'nothing brought in') . ($counts['images'] ? '. Left out ' . $counts['images'] . ' image' . ($counts['images'] === 1 ? '' : 's') . " the file only pointed at, without carrying - too big, or gone where it came from" : '') . '.'; }