Canned Responses exports them
* (export_canned_responses) and imports them (import_canned_responses); the
* MSP starter pack on Maintenance > Starter Content is loaded the same way.
*
* The file is JSON: {"format": "itflow-canned-responses", "version": 1,
* "exported_at", "responses": [...]}. Each response carries its name, what it
* says, the ticket categories it is offered on (by name, with their colour,
* icon and description, made on the way in when missing - none for every
* ticket), what picking it sets up (reply_type internal, public or email;
* status by name; minutes) and, when exported with them, its files (name and
* data, base64). Only shared ones are imported, as shared ones - an agent's
* own belong to the install it came from.
*/
const CANNED_RESPONSE_FILE_FORMAT = 'itflow-canned-responses';
const CANNED_RESPONSE_FILE_VERSION = 1;
/** The largest file that goes into an export, in bytes - bigger ones stay behind. */
const CANNED_RESPONSE_TRANSFER_FILE_MAX = 10 * 1024 * 1024;
/** Who a reply is for, as a file has it - CANNED_RESPONSE_REPLY_TYPES by word. */
const CANNED_RESPONSE_FILE_REPLY_TYPES = [0 => 'internal', 1 => 'public', 2 => 'email'];
/*
* Responses as a file's contents (an array, for json_encode()) - the ones in
* $ids, or every current shared one when $ids is empty, and the archived
* shared ones too with $archived. Their files go with them with $with_files,
* each up to CANNED_RESPONSE_TRANSFER_FILE_MAX.
*/
function cannedResponsesExport(array $ids = [], $archived = false, $with_files = true) {
global $mysqli;
$ids = array_values(array_filter(array_map('intval', $ids)));
$where = $ids ? 'canned_response_id IN (' . implode(',', $ids) . ')'
: 'canned_response_user_id = 0' . ($archived ? '' : ' AND canned_response_archived_at IS NULL');
$rows = [];
$sql = mysqli_query($mysqli, "SELECT canned_response_id, canned_response_name, canned_response_body, canned_response_reply_type, canned_response_ticket_status, canned_response_minutes, ticket_status_name
FROM canned_responses LEFT JOIN ticket_statuses ON ticket_status_id = canned_response_ticket_status
WHERE $where ORDER BY canned_response_name ASC, canned_response_id ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$rows[intval($row['canned_response_id'])] = $row;
}
$categories = [];
if ($rows) {
$sql = mysqli_query($mysqli, "SELECT canned_response_id, category_name, category_color, category_icon, category_description FROM canned_response_categories
JOIN categories ON categories.category_id = canned_response_categories.category_id
WHERE canned_response_id IN (" . implode(',', array_keys($rows)) . ") ORDER BY category_name");
while ($row = mysqli_fetch_assoc($sql)) {
$categories[intval($row['canned_response_id'])][] = ['name' => (string) $row['category_name'], 'color' => (string) $row['category_color'],
'icon' => (string) $row['category_icon'], 'description' => (string) $row['category_description']];
}
}
$files = $with_files ? cannedResponseFilesFor(array_keys($rows)) : [];
$responses = [];
foreach ($rows as $id => $row) {
$response = [
'name' => (string) $row['canned_response_name'],
'body' => (string) $row['canned_response_body'],
'categories' => $categories[$id] ?? [],
'reply_type' => $row['canned_response_reply_type'] === null ? null : (CANNED_RESPONSE_FILE_REPLY_TYPES[intval($row['canned_response_reply_type'])] ?? null),
'status' => $row['canned_response_ticket_status'] !== null && $row['ticket_status_name'] !== null ? (string) $row['ticket_status_name'] : null,
'minutes' => intval($row['canned_response_minutes']),
];
if ($with_files) {
$response['files'] = [];
foreach ($files[$id] ?? [] as $file) {
$path = cannedResponseFileDir($id) . basename($file['canned_response_file_reference_name']);
if (is_file($path) && filesize($path) <= CANNED_RESPONSE_TRANSFER_FILE_MAX) {
$response['files'][] = ['name' => (string) $file['canned_response_file_name'], 'data' => base64_encode((string) file_get_contents($path))];
}
}
}
$responses[] = $response;
}
return ['format' => CANNED_RESPONSE_FILE_FORMAT, 'version' => CANNED_RESPONSE_FILE_VERSION, 'exported_at' => date('c'), 'responses' => $responses];
}
/** A file name for an export - its one response's name, or the date. */
function cannedResponsesExportFileName(array $export) {
$responses = $export['responses'] ?? [];
if (count($responses) === 1) {
$slug = trim(preg_replace('/[^a-z0-9]+/', '-', strtolower($responses[0]['name'])), '-');
return 'canned-response-' . ($slug !== '' ? substr($slug, 0, 60) : 'export') . '.json';
}
return 'canned-responses-' . date('Y-m-d') . '.json';
}
/* One line of text from a file, tidied - no markup, no runs of spaces, no longer than $max. */
function cannedResponseFileText($value, $max) {
return is_scalar($value) ? trim(mb_substr(preg_replace('/\s+/u', ' ', strip_tags((string) $value)), 0, $max)) : '';
}
/*
* A file's responses, read and checked - from what export wrote, or the
* starter pack. Each made safe: text tidied, what it says through the HTML
* purifier, a reply type or status that isn't one dropped, the time worked
* kept to a day, and its files to the kinds a ticket takes, each decoded and
* no bigger than CANNED_RESPONSE_TRANSFER_FILE_MAX, up to
* CANNED_RESPONSE_MAX_FILES. 'files' is null when the file carried none for
* it (exported without them), and 'files_refused' names the ones left out.
* Null with $error saying why the file can't be used.
*/
function cannedResponsesImportRead($contents, &$error = '') {
$data = is_array($contents) ? $contents : json_decode((string) $contents, true);
if (!is_array($data) || ($data['format'] ?? '') !== CANNED_RESPONSE_FILE_FORMAT || !isset($data['responses']) || !is_array($data['responses'])) {
$error = "That isn't a canned responses file - export one from Admin > Canned Responses.";
return null;
}
if (intval($data['version'] ?? 0) > CANNED_RESPONSE_FILE_VERSION) {
$error = 'That file was made by a newer version of ITFlow - update this one first.';
return null;
}
if (count($data['responses']) > 1000) {
$error = 'That file holds more than 1000 canned responses - split it up.';
return null;
}
$statuses = [];
foreach (cannedResponseStatuses() as $status_id => $status) {
$statuses[mb_strtolower($status['name'])] = $status_id;
}
$reply_types = array_flip(CANNED_RESPONSE_FILE_REPLY_TYPES);
$responses = [];
foreach ($data['responses'] as $r) {
$name = is_array($r) ? cannedResponseFileText($r['name'] ?? '', 200) : '';
if ($name === '') {
continue;
}
$categories = [];
foreach (array_slice(is_array($r['categories'] ?? null) ? $r['categories'] : [], 0, 50) as $category) {
$category_name = is_array($category) ? cannedResponseFileText($category['name'] ?? '', 200) : cannedResponseFileText($category, 200);
if ($category_name !== '' && !isset($categories[mb_strtolower($category_name)])) {
$categories[mb_strtolower($category_name)] = ['name' => $category_name, 'color' => is_array($category) ? (string) ($category['color'] ?? '') : '',
'icon' => is_array($category) ? (string) ($category['icon'] ?? '') : '', 'description' => is_array($category) ? cannedResponseFileText($category['description'] ?? '', 255) : ''];
}
}
$files = null;
$refused = [];
if (is_array($r['files'] ?? null)) {
$files = [];
foreach ($r['files'] as $file) {
$file_name = is_array($file) ? trim(mb_substr(basename(str_replace('\\', '/', preg_replace('/[\x00-\x1F\x7F]/u', '', (string) ($file['name'] ?? '')))), 0, 255)) : '';
$bytes = is_array($file) && is_string($file['data'] ?? null) ? base64_decode($file['data'], true) : false;
$extension = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
if ($file_name === '' || $bytes === false || $bytes === '' || strlen($bytes) > CANNED_RESPONSE_TRANSFER_FILE_MAX
|| !in_array($extension, TICKET_ATTACHMENT_EXTENSIONS, true) || count($files) >= CANNED_RESPONSE_MAX_FILES) {
$refused[] = $file_name !== '' ? $file_name : 'a file with no name';
continue;
}
$files[] = ['name' => $file_name, 'data' => $bytes];
}
}
$reply_type = is_string($r['reply_type'] ?? null) ? ($reply_types[strtolower($r['reply_type'])] ?? null) : null;
$status = is_string($r['status'] ?? null) ? ($statuses[mb_strtolower(trim($r['status']))] ?? null) : null;
$responses[] = [
'name' => $name,
'body' => is_scalar($r['body'] ?? null) && trim((string) $r['body']) !== '' ? documentHtmlPurify((string) $r['body']) : '',
'categories' => array_values($categories),
'reply_type' => $reply_type,
'ticket_status' => $status,
'minutes' => max(0, min(CANNED_RESPONSE_MAX_MINUTES, intval($r['minutes'] ?? 0))),
'files' => $files,
'files_refused' => $refused,
];
}
if (!$responses) {
$error = 'There are no canned responses in that file.';
return null;
}
return $responses;
}
/* A file's ticket category by name - this install's of that name, or one made from it. 0 when there is only an archived one. */
function cannedResponseImportCategory(array $category, array &$counts) {
global $mysqli;
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT category_id, category_archived_at FROM categories
WHERE category_type = 'Ticket' AND category_name = '" . escapeSql($category['name']) . "' ORDER BY category_archived_at IS NOT NULL LIMIT 1"));
if ($row) {
return $row['category_archived_at'] ? 0 : intval($row['category_id']);
}
[$fields] = categoryFromInput(['type' => 'Ticket', 'name' => $category['name'], 'description' => $category['description'], 'color' => $category['color'] ?: categoryNextColor('Ticket'), 'icon' => $category['icon']]);
if (!$fields) {
return 0;
}
$counts['categories']++;
return categoryCreate($fields);
}
/* Keep a file's bytes with a response - the import's. Whether it was kept. */
function cannedResponseAddFileData($canned_response_id, $name, $bytes) {
global $mysqli;
$canned_response_id = intval($canned_response_id);
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (!$canned_response_id || $bytes === '' || !in_array($extension, TICKET_ATTACHMENT_EXTENSIONS, true) || count(cannedResponseFiles($canned_response_id)) >= CANNED_RESPONSE_MAX_FILES) {
return false;
}
$dir = cannedResponseFileDir($canned_response_id);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$reference = randomString(32) . '.' . $extension;
if (file_put_contents($dir . $reference, $bytes) === false) {
return false;
}
mysqli_query($mysqli, "INSERT INTO canned_response_files SET canned_response_file_canned_response_id = $canned_response_id, canned_response_file_name = '" . escapeSql($name) . "',
canned_response_file_reference_name = '$reference', canned_response_file_size = " . strlen($bytes));
return true;
}
/*
* Bring in responses read by cannedResponsesImportRead(), as shared ones. One
* whose name a shared one here already has is skipped ($existing 'skip'),
* added under a name of its own ('copy') or has everything in the file put in
* its place ('replace') - what it says, where it is offered and what it sets
* up, and its files when the file carries them. Its record of use and whether
* it is archived stay. $only_existing: bring in only the ones already here
* (to update them). ['added', 'replaced', 'skipped', 'categories' made,
* 'files' kept, 'files_refused' (names), 'ids' of those added or replaced].
*/
function cannedResponsesImport(array $responses, $existing = 'skip', $only_existing = false) {
global $mysqli;
$counts = ['added' => 0, 'replaced' => 0, 'skipped' => 0, 'categories' => 0, 'files' => 0, 'files_refused' => [], 'ids' => []];
$here = [];
$sql = mysqli_query($mysqli, "SELECT canned_response_id, canned_response_name FROM canned_responses WHERE canned_response_user_id = 0 ORDER BY canned_response_id");
while ($row = mysqli_fetch_assoc($sql)) {
$here[mb_strtolower($row['canned_response_name'])] = $here[mb_strtolower($row['canned_response_name'])] ?? intval($row['canned_response_id']);
}
foreach ($responses as $r) {
$id = $here[mb_strtolower($r['name'])] ?? 0;
if (($only_existing && !$id) || ($id && $existing === 'skip')) {
$counts['skipped']++;
continue;
}
$name = $r['name'];
if ($id && $existing === 'copy') {
for ($n = 2; isset($here[mb_strtolower($name)]); $n++) {
$name = mb_substr($r['name'], 0, 190) . " ($n)";
}
$id = 0;
}
$replacing = $id > 0;
$category_ids = [];
foreach ($r['categories'] as $category) {
$category_id = cannedResponseImportCategory($category, $counts);
if ($category_id) {
$category_ids[] = $category_id;
}
}
$saved = cannedResponseSave($id, ['name' => $name, 'body' => $r['body'], 'category_ids' => $category_ids,
'reply_type' => $r['reply_type'], 'ticket_status' => $r['ticket_status'], 'minutes' => $r['minutes']], 0);
$here[mb_strtolower($name)] = $saved;
// Its files are the file's, when it carries them
if ($r['files'] !== null) {
if ($replacing) {
cannedResponseRemoveFiles($saved, array_column(cannedResponseFiles($saved), 'canned_response_file_id'));
}
foreach ($r['files'] as $file) {
if (cannedResponseAddFileData($saved, $file['name'], $file['data'])) {
$counts['files']++;
} else {
$counts['files_refused'][] = $file['name'];
}
}
}
$counts['files_refused'] = array_merge($counts['files_refused'], $r['files_refused']);
$counts[$replacing ? 'replaced' : 'added']++;
$counts['ids'][] = $saved;
}
return $counts;
}
/* What an import did, in words - for its message. HTML. */
function cannedResponsesImportSummary(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;
}
}
$made = [];
if ($counts['categories']) {
$made[] = 'Made ' . $counts['categories'] . ' ticket categor' . ($counts['categories'] === 1 ? 'y' : 'ies') . ' they are offered on';
}
if ($counts['files']) {
$made[] = 'Brought in ' . $counts['files'] . ' file' . ($counts['files'] === 1 ? '' : 's') . ' that go with them';
}
return ucfirst(implode(', ', $parts) ?: 'nothing brought in')
. ($made ? '. ' . implode('. ', $made) : '')
. ($counts['files_refused'] ? '. Left out ' . escapeHtml(implode(', ', array_slice($counts['files_refused'], 0, 10))) . ''
. (count($counts['files_refused']) > 10 ? ' and ' . (count($counts['files_refused']) - 10) . ' more' : '') . " - a kind a ticket doesn't take, too big or more than " . CANNED_RESPONSE_MAX_FILES . ' on one' : '')
. '.';
}