Files
deskhub/DeskHub-Webseite/includes/osticket_import_passes.php
T
2026-10-16 19:12:18 +00:00

423 lines
23 KiB
PHP

<?php
declare(strict_types=1);
/**
* osTicket-Import, zweiter Teil: Übernahme der analysierten Daten in DeskHub (Durchgänge users, structure, tickets,
* threads, attachments). Siehe osticket_import.php für Analyse und Zustand.
*/
require_once __DIR__ . '/osticket_import.php';
const OSTICKET_CHUNK = 200;
function osticket_fallback_name(string $email, ?string $poster = null): string
{
$poster = trim((string) $poster);
if ($poster !== '') return $poster;
$email = trim($email);
if ($email !== '' && str_contains($email, '@')) return trim((string) strtok($email, '@')) ?: $email;
return $email !== '' ? $email : 'Kunde';
}
function osticket_html_to_text(string $body, string $format = 'html'): string
{
$body = trim($body);
if ($body === '') return '';
if (strtolower($format) !== 'html') {
$body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$body = preg_replace("/\r\n?|\n/u", "\n", $body);
$body = preg_replace('/[ \t]+/u', ' ', $body);
return preg_replace("/\n{3,}/u", "\n\n", trim($body));
}
$body = preg_replace('/<blockquote\b[^>]*>.*?<\/blockquote>/is', "\n", $body);
$body = preg_replace('/<(br|\/p|\/div|\/li|\/tr|\/h[1-6])\b[^>]*>/i', "\n", $body);
$body = preg_replace('/<(p|div|li|tr|table|ul|ol|h[1-6])\b[^>]*>/i', "\n", $body);
$body = strip_tags($body);
$body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$body = preg_replace('/\r\n?|\n/u', "\n", $body);
$body = preg_replace('/[ \t]+/u', ' ', $body);
return preg_replace('/\n{3,}/u', "\n\n", trim($body));
}
function normalize_osticket_body(string $body, string $format, string $authorType): string
{
$body = trim($body);
if ($body === '') return '';
$format = strtolower(trim($format));
if ($authorType === 'staff' || $authorType === 'system') {
if ($format === 'html') return sanitize_staff_html($body);
$parts = [];
foreach (preg_split('/\R{2,}/', preg_replace('/\r\n?/', "\n", $body)) ?: [$body] as $segment) {
$segment = trim($segment);
if ($segment !== '') $parts[] = '<p>' . nl2br(e($segment)) . '</p>';
}
return implode('', $parts) ?: nl2br(e($body));
}
if ($format === 'html') return osticket_html_to_text($body);
return preg_replace('/\n{3,}/', "\n\n", trim(preg_replace('/\r\n?/', "\n", $body)));
}
/** Datum aus dem Dump prüfen; sonst Ersatzwert. */
function osticket_date(?string $d, ?string $fallback = null): ?string
{
return $d !== null && preg_match('/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?/', $d) && !str_starts_with($d, '0000') ? $d : $fallback;
}
// ─── Zuordnungen (Abteilungen, Themen, Mitarbeiter, Prioritäten) ─────────────
/**
* Baut die Zuordnung osTicket-ID -> DeskHub-ID. Mit $create legt sie fehlende Abteilungen und Hilfethemen an, sonst
* werden nur vorhandene (gleicher Name) gefunden. Das Ergebnis wird im Zustand gespeichert.
*
* @return array{dept:array, topic:array, staff:array, prio:array, created:array{departments:int, topics:int}}
*/
function osticket_maps(PDO $pdo, array &$st, bool $create): array
{
$maps = ['dept' => [], 'topic' => [], 'staff' => [], 'prio' => [], 'created' => ['departments' => 0, 'topics' => 0]];
$defaultDept = (int) (get_setting('default_department_id') ?: 0);
if (!$defaultDept) $defaultDept = (int) $pdo->query('SELECT id FROM departments ORDER BY is_default DESC, id LIMIT 1')->fetchColumn();
$maps['default_dept'] = $defaultDept;
// Prioritäten: erst über den Namen, sonst über die Rangfolge (dringendste zuerst)
$dh = $pdo->query('SELECT id, name, resolution_hours FROM priorities ORDER BY resolution_hours, id')->fetchAll();
$maps['default_prio'] = (int) ($pdo->query("SELECT id FROM priorities WHERE LOWER(name) IN ('normal','mittel') ORDER BY id LIMIT 1")->fetchColumn() ?: ($dh[(int) floor((count($dh) - 1) / 2)]['id'] ?? 1));
$ost = $st['prios'];
uasort($ost, fn($a, $b) => ($a['u'] ?: 99) <=> ($b['u'] ?: 99));
$rank = 0;
$n = max(1, count($ost));
foreach ($ost as $id => $p) {
$match = null;
foreach ($dh as $d) if (strcasecmp($d['name'], $p['d']) === 0 || strcasecmp($d['name'], $p['n']) === 0) { $match = (int) $d['id']; break; }
$maps['prio'][(int) $id] = $match ?? (int) ($dh[(int) round($rank * (count($dh) - 1) / max(1, $n - 1))]['id'] ?? $maps['default_prio']);
$rank++;
}
// Abteilungen
$byName = [];
foreach ($pdo->query('SELECT id, name FROM departments')->fetchAll() as $d) $byName[mb_strtolower($d['name'])] = (int) $d['id'];
foreach ($st['departments'] as $id => $name) {
$key = mb_strtolower(trim((string) $name));
if ($key === '') continue;
if (isset($byName[$key])) { $maps['dept'][(int) $id] = $byName[$key]; continue; }
if ($create) {
$new = department_save($pdo, 0, ['name' => trim((string) $name)]);
$byName[$key] = $new;
$maps['dept'][(int) $id] = $new;
$maps['created']['departments']++;
}
}
// Hilfethemen
$topicByName = [];
foreach ($pdo->query('SELECT id, name FROM help_topics')->fetchAll() as $t) $topicByName[mb_strtolower($t['name'])] = (int) $t['id'];
foreach ($st['topics'] as $id => $t) {
$key = mb_strtolower(trim($t['n']));
if ($key === '') continue;
if (isset($topicByName[$key])) { $maps['topic'][(int) $id] = $topicByName[$key]; continue; }
if ($create) {
$new = help_topic_save($pdo, 0, ['name' => trim($t['n']), 'department_id' => $maps['dept'][$t['d']] ?? $defaultDept, 'priority_id' => $maps['prio'][$t['p']] ?? $maps['default_prio'],
'is_active' => $t['on'] ? 1 : 0, 'is_public' => $t['pub'] ? 1 : 0]);
$topicByName[$key] = $new;
$maps['topic'][(int) $id] = $new;
$maps['created']['topics']++;
}
}
// Mitarbeiter über die E-Mail-Adresse
$staffByEmail = [];
foreach ($pdo->query('SELECT id, email FROM staff')->fetchAll() as $s) $staffByEmail[strtolower((string) $s['email'])] = (int) $s['id'];
foreach ($st['staff'] as $id => $s) if ($s['e'] !== '' && isset($staffByEmail[$s['e']])) $maps['staff'][(int) $id] = $staffByEmail[$s['e']];
$st['maps'] = $maps;
return $maps;
}
// ─── Durchgänge ──────────────────────────────────────────────────────────────
/**
* Führt einen Schritt eines Durchgangs aus.
*
* @return array{inserted:int, skipped:int, processed:int, next_offset:int, done:bool, notes:array}
*/
function osticket_import_step(PDO $pdo, string $dir, string $pass, int $offset, string $sqlPath = ''): array
{
@set_time_limit(300);
$st = osticket_state_load($dir);
$r = ['inserted' => 0, 'skipped' => 0, 'processed' => 0, 'next_offset' => $offset, 'done' => false, 'notes' => []];
switch ($pass) {
case 'users': osticket_pass_users($pdo, $st, $offset, $r); break;
case 'structure':
$maps = osticket_maps($pdo, $st, true);
$r['inserted'] = $maps['created']['departments'] + $maps['created']['topics'];
$r['notes'][] = "{$maps['created']['departments']} Abteilung(en) und {$maps['created']['topics']} Hilfethema(en) angelegt, vorhandene gleichen Namens wiederverwendet.";
$r['done'] = true;
break;
case 'tickets': osticket_pass_tickets($pdo, $st, $offset, $r); break;
case 'threads': osticket_pass_threads($pdo, $st, $dir, $offset, $r); break;
case 'attachments': osticket_pass_attachments($pdo, $st, $dir, $sqlPath, $offset, $r); break;
default: throw new InvalidArgumentException('Unbekannter Durchgang.');
}
osticket_state_save($dir, $st);
return $r;
}
function osticket_pass_users(PDO $pdo, array &$st, int $offset, array &$r): void
{
$contacts = osticket_contacts($st);
$ids = array_keys($contacts);
$slice = array_slice($ids, $offset, OSTICKET_CHUNK);
$pw = password_hash(bin2hex(random_bytes(8)), PASSWORD_DEFAULT);
$chk = $pdo->prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)');
$ins = $pdo->prepare('INSERT INTO users (name, email, password_hash, created_at) VALUES (?,?,?,?)');
foreach ($slice as $uid) {
$c = $contacts[$uid];
if ($c['email'] === '' || !str_contains($c['email'], '@')) { $r['skipped']++; continue; }
$chk->execute([$c['email']]);
if ($chk->fetchColumn()) { $r['skipped']++; continue; }
$ins->execute([mb_substr(osticket_fallback_name($c['email'], $c['name']), 0, 120), mb_substr($c['email'], 0, 150), $pw, db_now()]);
$r['inserted']++;
}
$r['processed'] = count($slice);
$r['next_offset'] = $offset + count($slice);
$r['done'] = count($slice) < OSTICKET_CHUNK;
}
function osticket_pass_tickets(PDO $pdo, array &$st, int $offset, array &$r): void
{
$maps = $st['maps'] ?? osticket_maps($pdo, $st, false);
$contacts = osticket_contacts($st);
$prios = [];
foreach ($pdo->query('SELECT * FROM priorities')->fetchAll() as $p) $prios[(int) $p['id']] = $p;
$keys = array_keys($st['tickets']);
$slice = array_slice($keys, $offset, OSTICKET_CHUNK);
$chk = $pdo->prepare('SELECT id FROM tickets WHERE ticket_number = ?');
$getUser = $pdo->prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)');
$ins = $pdo->prepare('INSERT INTO tickets (ticket_number, subject, user_id, requester_name, requester_email, department_id, help_topic_id, assigned_staff_id, priority_id, status, source, requester_ip,
first_response_due_at, first_responded_at, resolution_due_at, resolved_at, closed_at, is_overdue, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)');
$sources = ['web' => 'web', 'email' => 'email', 'phone' => 'phone', 'api' => 'api'];
foreach ($slice as $tid) {
$t = $st['tickets'][$tid];
$status = osticket_status($st, $t['s']);
if ($status === null) { $r['skipped']++; $r['notes']['deleted'] = 'Gelöschte osTicket-Tickets wurden nicht importiert.'; continue; }
if ($t['n'] === '') { $r['skipped']++; continue; }
$chk->execute([$t['n']]);
if ($chk->fetchColumn()) { $r['skipped']++; $r['notes']['existing'] = 'Bereits vorhandene Ticketnummern wurden übersprungen.'; continue; }
$contact = $contacts[$t['u']] ?? ['name' => '', 'email' => ''];
$email = $contact['email'];
$name = mb_substr(osticket_fallback_name($email, $contact['name']), 0, 120);
$userId = null;
if ($email !== '') { $getUser->execute([$email]); $userId = $getUser->fetchColumn() ?: null; }
$cdata = $st['cdata'][$tid] ?? ['', 0];
$subject = mb_substr(trim($cdata[0]) !== '' ? trim($cdata[0]) : 'Ticket #' . $t['n'], 0, 255);
$deptId = $maps['dept'][$t['d']] ?? $maps['default_dept'];
$topicId = $maps['topic'][$t['t']] ?? null;
$staffId = $maps['staff'][$t['a']] ?? null;
if ($t['a'] > 0 && !$staffId) $r['unassigned'] = ($r['unassigned'] ?? 0) + 1;
$prioId = $maps['prio'][$cdata[1]] ?? $maps['default_prio'];
$created = osticket_date($t['c'], db_now());
$updated = osticket_date($t['lu'], $created);
$closedAt = in_array($status, ['resolved', 'closed'], true) ? osticket_date($t['cl'], $updated) : null;
$threadId = $st['ticket_thread'][$tid] ?? null;
$firstResp = $threadId !== null ? osticket_date($st['first_resp'][(string) $threadId] ?? null) : null;
$firstDue = $resDue = null;
if (!in_array($status, ['resolved', 'closed'], true) && isset($prios[$prioId])) {
$sla = calc_sla_dates($prios[$prioId], $created, $deptId, $topicId);
$firstDue = $firstResp ? null : $sla['first_response_due_at'];
$resDue = $sla['resolution_due_at'];
}
$ins->execute([$t['n'], $subject, $userId, $name, mb_substr($email, 0, 150), $deptId, $topicId, $staffId, $prioId, $status, $sources[strtolower($t['src'])] ?? 'web',
$t['ip'] !== '' ? mb_substr($t['ip'], 0, 45) : null, $firstDue, $firstResp, $resDue, $closedAt, $closedAt, ($t['od'] && $closedAt === null) ? 1 : 0, $created, $updated]);
$r['inserted']++;
}
if (!empty($r['unassigned'])) $r['notes']['unassigned'] = $r['unassigned'] . ' Ticket(s) waren Mitarbeitern zugewiesen, die es in DeskHub nicht gibt (Zuordnung über gleiche E-Mail-Adresse). Sie sind jetzt unzugewiesen.';
unset($r['unassigned']);
$r['processed'] = count($slice);
$r['next_offset'] = $offset + count($slice);
$r['done'] = count($slice) < OSTICKET_CHUNK;
}
function osticket_pass_threads(PDO $pdo, array &$st, string $dir, int $offset, array &$r): void
{
$maps = $st['maps'] ?? osticket_maps($pdo, $st, false);
$contacts = osticket_contacts($st);
$file = $dir . '/entries.jsonl';
$fh = is_file($file) ? fopen($file, 'rb') : null;
$lines = 0;
$end = true;
$ticketByNumber = $pdo->prepare('SELECT id FROM tickets WHERE ticket_number = ?');
$getUser = $pdo->prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)');
$exists = $pdo->prepare('SELECT id FROM ticket_threads WHERE ticket_id = ? AND created_at = ? AND author_type = ? AND message = ? LIMIT 1');
$ins = $pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, staff_id, user_id, message, is_internal_note, created_at) VALUES (?,?,?,?,?,?,?)');
$entryMap = $fh ? fopen($dir . '/entry_map.jsonl', 'ab') : null;
$ticketCache = [];
$lastTouched = [];
if ($fh) {
fseek($fh, $offset);
while ($lines < OSTICKET_CHUNK && ($line = fgets($fh)) !== false) {
$lines++;
$e = json_decode($line, true);
if (!is_array($e)) { $r['skipped']++; continue; }
$osTid = $st['thread_ticket'][(string) $e['th']] ?? null;
$number = $osTid !== null ? ($st['tickets'][(string) $osTid]['n'] ?? null) : null;
if ($number === null || $number === '') { $r['skipped']++; continue; }
if (!array_key_exists($number, $ticketCache)) { $ticketByNumber->execute([$number]); $ticketCache[$number] = (int) $ticketByNumber->fetchColumn() ?: null; }
$dhTid = $ticketCache[$number];
if (!$dhTid) { $r['skipped']++; $r['notes']['no_ticket'] = 'Nachrichten zu nicht importierten Tickets wurden übersprungen.'; continue; }
$type = strtoupper($e['ty']);
$staffId = $e['st'] > 0 ? ($maps['staff'][$e['st']] ?? null) : null;
$userId = null;
$internal = 0;
if ($type === 'N') { $author = $staffId ? 'staff' : 'system'; $internal = 1; }
elseif ($e['st'] > 0 || $type === 'R') { $author = 'staff'; }
else {
$author = 'customer';
$email = $contacts[$e['us']]['email'] ?? '';
if ($email !== '') { $getUser->execute([$email]); $userId = $getUser->fetchColumn() ?: null; }
}
if (($e['st'] > 0 || $type === 'R') && !$staffId) $r['notes']['staff'] = 'Antworten von Mitarbeitern, die es in DeskHub nicht gibt (gleiche E-Mail-Adresse nötig), wurden ohne Mitarbeiter übernommen.';
$hasAtt = !empty($st['att'][(string) $e['id']]);
$body = normalize_osticket_body($e['b'], $e['f'], $author);
if (trim(strip_tags($body)) === '') {
if (!$hasAtt) { $r['skipped']++; continue; }
$body = $author === 'customer' ? '(Nachricht ohne Text, siehe Anhang)' : '<p>(Nachricht ohne Text, siehe Anhang)</p>';
}
$created = osticket_date($e['c'], db_now());
$exists->execute([$dhTid, $created, $author, $body]);
$threadId = $exists->fetchColumn();
if (!$threadId) {
$ins->execute([$dhTid, $author, $staffId, $userId, $body, $internal, $created]);
$threadId = (int) $pdo->lastInsertId();
$r['inserted']++;
} else {
$r['skipped']++;
}
if ($hasAtt && $entryMap) fwrite($entryMap, json_encode([(int) $e['id'], (int) $threadId]) . "\n");
}
$r['next_offset'] = ftell($fh);
$end = feof($fh) || fgets($fh) === false;
fclose($fh);
if ($entryMap) fclose($entryMap);
}
$r['processed'] = $lines;
$r['done'] = $end;
if ($end) { // zum Schluss: Mitlesende (Collaborators)
$addedCollab = 0;
$existsC = $pdo->prepare('SELECT COUNT(*) FROM ticket_collaborators WHERE ticket_id = ? AND LOWER(email) = LOWER(?)');
$insC = $pdo->prepare('INSERT INTO ticket_collaborators (ticket_id, email, name, created_at) VALUES (?,?,?,?)');
foreach ($st['collab'] as $threadId => $uids) {
$osTid = $st['thread_ticket'][(string) $threadId] ?? null;
$number = $osTid !== null ? ($st['tickets'][(string) $osTid]['n'] ?? null) : null;
if (!$number) continue;
$ticketByNumber->execute([$number]);
$dhTid = (int) $ticketByNumber->fetchColumn();
if (!$dhTid) continue;
foreach (array_unique($uids) as $uid) {
$c = $contacts[$uid] ?? null;
if (!$c || $c['email'] === '') continue;
$existsC->execute([$dhTid, $c['email']]);
if ((int) $existsC->fetchColumn() > 0) continue;
$insC->execute([$dhTid, $c['email'], mb_substr(osticket_fallback_name($c['email'], $c['name']), 0, 120), db_now()]);
$addedCollab++;
}
}
if ($addedCollab) $r['notes']['collab'] = "{$addedCollab} Mitlesende übernommen.";
}
}
/** Dateiendung für gespeicherte Anhänge: nur bekannte, ungefährliche Typen, alles andere wird .bin. */
function osticket_safe_extension(string $name): string
{
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
$safe = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'pdf', 'txt', 'log', 'csv', 'rtf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'zip', 'eml', 'msg', 'mp3', 'mp4', 'mov', 'heic'];
return in_array($ext, $safe, true) ? $ext : 'bin';
}
function osticket_pass_attachments(PDO $pdo, array &$st, string $dir, string $sqlPath, int $offset, array &$r): void
{
// Welche Dateien werden gebraucht? (nur Anhänge von Nachrichten, die importiert wurden und nicht zu groß sind)
$wanted = [];
foreach ($st['att'] as $entryId => $list) foreach ($list as [$fileId]) if (isset($st['files'][$fileId]) && $st['files'][$fileId]['s'] <= OSTICKET_ATTACHMENT_MAX_BYTES) $wanted[$fileId] = true;
$parts = $dir . '/files';
if (!is_dir($parts)) @mkdir($parts, 0700, true);
if (!$wanted || $st['chunk_start'] === null || $sqlPath === '' || !is_file($sqlPath)) {
$r['notes'][] = $wanted ? 'In der Datei wurden keine Anhangsdaten gefunden.' : 'Keine Anhänge zum Übernehmen.';
$r['done'] = true;
return;
}
$start = $offset > 0 ? $offset : (int) $st['chunk_start'];
$fh = fopen($sqlPath, 'rb');
fseek($fh, $start);
$read = 0;
$ended = false;
$inChunks = $offset > 0;
while (!feof($fh) && $read < 16 * 1024 * 1024) {
$line = fgets($fh, 64 * 1024 * 1024);
if ($line === false) break;
$read += strlen($line);
$text = null;
if ($line[0] === '(') {
$text = $line;
} elseif (str_starts_with($line, 'INSERT')) {
$h = osticket_parse_header(rtrim($line));
if (!$h || osticket_table_key($h[0]) !== 'file_chunk') { $ended = true; break; }
$inChunks = true;
$text = $h[2]; // mysqldump: die Datensätze stehen in derselben Zeile wie INSERT
}
if ($text !== null && $text !== '') {
foreach (osticket_rows($text) as $row) {
if (count($row) < 3) continue;
$fileId = (int) $row[0];
if (!isset($wanted[$fileId])) continue;
$raw = (string) $row[2];
$data = str_starts_with($raw, '0x') ? (strlen($raw) > 2 ? (string) hex2bin(strlen($raw) % 2 ? '0' . substr($raw, 2) : substr($raw, 2)) : '') : $raw;
file_put_contents($parts . '/' . $fileId . '.part', $data, FILE_APPEND);
}
continue;
}
if (str_starts_with($line, 'CREATE') || str_starts_with($line, 'ALTER') || str_starts_with($line, 'COMMIT')) { $ended = $inChunks || $ended || true; break; }
}
$next = $start + $read;
$atEof = feof($fh);
fclose($fh);
$r['processed'] = 1;
$r['next_offset'] = $next;
if (!$ended && !$atEof) return;
// Fertig mit dem Lesen: Dateien in den Upload-Ordner legen und mit den Nachrichten verknüpfen
$entryThread = [];
$mapFile = $dir . '/entry_map.jsonl';
if (is_file($mapFile)) foreach (file($mapFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $l) { $p = json_decode($l, true); if (is_array($p)) $entryThread[$p[0]] = $p[1]; }
$uploads = rtrim((string) config()['uploads']['path'], '/' . chr(92));
if (!is_dir($uploads)) @mkdir($uploads, 0755, true);
$ins = $pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)');
$dup = $pdo->prepare('SELECT id FROM ticket_attachments WHERE thread_id = ? AND original_name = ? AND filesize = ? LIMIT 1');
$broken = 0; $tooBig = 0; $noThread = 0;
foreach ($st['att'] as $entryId => $list) {
$threadId = $entryThread[(int) $entryId] ?? null;
foreach ($list as [$fileId, $attName]) {
$meta = $st['files'][$fileId] ?? null;
if (!$meta) continue;
if ($meta['s'] > OSTICKET_ATTACHMENT_MAX_BYTES) { $tooBig++; continue; }
if (!$threadId) { $noThread++; continue; }
$part = $parts . '/' . $fileId . '.part';
if (!is_file($part) || filesize($part) !== $meta['s']) { $broken++; continue; }
$orig = mb_substr(trim(basename(str_replace(chr(92), '/', ($attName !== null && $attName !== '' ? $attName : $meta['n']) ?: 'Anhang'))), 0, 255) ?: 'Anhang';
$dup->execute([$threadId, $orig, $meta['s']]);
if ($dup->fetchColumn()) { $r['skipped']++; continue; }
$stored = bin2hex(random_bytes(16)) . '.' . osticket_safe_extension($orig);
if (!copy($part, $uploads . '/' . $stored)) { $broken++; continue; }
$ins->execute([$threadId, $orig, $stored, $meta['s'], db_now()]);
$r['inserted']++;
}
}
foreach (glob($parts . '/*.part') ?: [] as $f) @unlink($f);
if ($broken) $r['notes']['broken'] = "{$broken} Anhang/Anhänge unvollständig oder nicht lesbar und übersprungen.";
if ($tooBig) $r['notes']['big'] = "{$tooBig} Anhang/Anhänge größer als " . (OSTICKET_ATTACHMENT_MAX_BYTES / 1048576) . ' MB übersprungen.';
if ($noThread) $r['notes']['nothread'] = "{$noThread} Anhang/Anhänge gehören zu Nachrichten, die nicht importiert wurden (Nachrichten zuerst importieren).";
$r['done'] = true;
}