#!/usr/bin/env php PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]); $dst = new PDO(TARGET_DSN, DB_USER, DB_PASS, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]); // Disable foreign key checks and autocommit for speed $dst->exec('SET FOREIGN_KEY_CHECKS = 0'); $dst->exec('SET AUTOCOMMIT = 0'); // ── Helpers ───────────────────────────────────────────────────────────────── function progress(string $label, int $done, int $total): void { $pct = $total > 0 ? round($done / $total * 100, 1) : 100.0; printf("\r %s: %d / %d (%s%%)", str_pad($label, 20), $done, $total, $pct); } function done(string $label, int $count, float $elapsed): void { printf("\r %s: %d rows (%.2f s)\n", str_pad($label, 20), $count, $elapsed); } function batchInsert(PDO $dst, string $table, array $columns, array $rows, int $batchSize = BATCH_SIZE): int { if (empty($rows)) { return 0; } $total = 0; $placeholders = '(' . implode(',', array_fill(0, count($columns), '?')) . ')'; $sql = sprintf( 'INSERT INTO `%s` (`%s`) VALUES %s', $table, implode('`, `', $columns), implode(',', array_fill(0, min($batchSize, count($rows)), $placeholders)) ); foreach (array_chunk($rows, $batchSize) as $chunk) { // Rebuild SQL if chunk is smaller than batchSize (last chunk) if (count($chunk) < $batchSize) { $sql = sprintf( 'INSERT INTO `%s` (`%s`) VALUES %s', $table, implode('`, `', $columns), implode(',', array_fill(0, count($chunk), $placeholders)) ); } $flat = []; foreach ($chunk as $row) { foreach ($row as $val) { $flat[] = $val; } } $stmt = $dst->prepare($sql); $stmt->execute($flat); $total += count($chunk); } return $total; } function safeString(?string $val, int $maxLen = 255): ?string { if ($val === null || $val === '') { return null; } $val = trim($val); if ($val === '') { return null; } return mb_substr($val, 0, $maxLen); } function timestampToDateTime(int $ts): ?string { if ($ts <= 0) { return null; } return date('Y-m-d H:i:s', $ts); } // ── Truncate target tables first ──────────────────────────────────────────── echo "Clearing target tables...\n"; $tables = [ 'kimai2_timesheet_tags', 'kimai2_timesheet_meta', 'kimai2_timesheet', 'kimai2_activities_rates', 'kimai2_activities_teams', 'kimai2_activities_meta', 'kimai2_activities', 'kimai2_projects_rates', 'kimai2_projects_teams', 'kimai2_projects_comments', 'kimai2_projects_meta', 'kimai2_projects', 'kimai2_customers_rates', 'kimai2_customers_teams', 'kimai2_customers_comments', 'kimai2_customers_meta', 'kimai2_customers', 'kimai2_users_teams', 'kimai2_user_preferences', 'kimai2_users', 'kimai2_teams', ]; foreach ($tables as $table) { try { $dst->exec("DELETE FROM `$table`"); echo " Cleared: $table\n"; } catch (PDOException $e) { echo " Skipped (doesn't exist): $table\n"; } } $dst->exec('COMMIT'); echo "\n"; // ── Migration order: master data first, then transactions ─────────────────── // ╔══════════════════════════════════════════════════════════════════════════════╗ // ║ 1. Groups → Teams ║ // ╚══════════════════════════════════════════════════════════════════════════════╝ echo "--- Migrating Groups → Teams ---\n"; $t0 = microtime(true); $groups = $src->query('SELECT grp_ID, grp_name FROM kimai15_grp WHERE grp_trash = 0')->fetchAll(); $rows = []; foreach ($groups as $g) { $rows[] = [ 'id' => (int) $g['grp_ID'], 'name' => safeString($g['grp_name'], 100) ?? '', 'color' => null, ]; } $count = batchInsert($dst, 'kimai2_teams', ['id', 'name', 'color'], $rows); $dst->exec('COMMIT'); done('Teams', $count, microtime(true) - $t0); // ╔══════════════════════════════════════════════════════════════════════════════╗ // ║ 2. Customers ║ // ╚══════════════════════════════════════════════════════════════════════════════╝ echo "--- Migrating Customers ---\n"; $t0 = microtime(true); $customers = $src->query('SELECT * FROM kimai15_knd WHERE knd_trash = 0')->fetchAll(); $total = count($customers); $count = 0; $rows = []; foreach ($customers as $c) { $rows[] = [ 'id' => (int) $c['knd_ID'], 'name' => safeString($c['knd_name'], 150) ?: '', 'number' => null, 'comment' => $c['knd_comment'] ?: null, 'visible' => (int) $c['knd_visible'], 'company' => safeString($c['knd_company'], 100), 'contact' => safeString($c['knd_contact'], 100), 'address' => $c['knd_street'] ?: null, 'country' => DEFAULT_COUNTRY, 'currency' => DEFAULT_CURRENCY, 'phone' => safeString($c['knd_tel'], 30), 'fax' => safeString($c['knd_fax'], 30), 'mobile' => safeString($c['knd_mobile'], 30), 'email' => safeString($c['knd_mail'], 75), 'homepage' => safeString($c['knd_homepage'], 100), 'timezone' => DEFAULT_TIMEZONE, 'color' => null, 'time_budget' => 0, 'budget' => 0.0, 'vat_id' => safeString($c['knd_vat'], 50), 'budget_type' => null, 'billable' => 1, 'invoice_template_id' => null, 'invoice_text' => null, 'created_at' => date('Y-m-d H:i:s'), 'address_line1' => safeString($c['knd_street'], 150), 'address_line2' => null, 'address_line3' => null, 'postcode' => safeString($c['knd_zipcode'], 20), 'city' => safeString($c['knd_city'], 50), 'buyer_reference' => null, ]; $count++; if ($count % 100 === 0) { progress('Customers', $count, $total); } } $imported = batchInsert($dst, 'kimai2_customers', [ 'id', 'name', 'number', 'comment', 'visible', 'company', 'contact', 'address', 'country', 'currency', 'phone', 'fax', 'mobile', 'email', 'homepage', 'timezone', 'color', 'time_budget', 'budget', 'vat_id', 'budget_type', 'billable', 'invoice_template_id', 'invoice_text', 'created_at', 'address_line1', 'address_line2', 'address_line3', 'postcode', 'city', 'buyer_reference', ], $rows); $dst->exec('COMMIT'); done('Customers', $imported, microtime(true) - $t0); // Reset auto-increment after inserting with explicit IDs $dst->exec("ALTER TABLE kimai2_customers AUTO_INCREMENT = " . ((int) $src->query('SELECT MAX(knd_ID) FROM kimai15_knd')->fetchColumn() + 1)); // ╔══════════════════════════════════════════════════════════════════════════════╗ // ║ 3. Projects ║ // ╚══════════════════════════════════════════════════════════════════════════════╝ echo "--- Migrating Projects ---\n"; $t0 = microtime(true); $projects = $src->query('SELECT * FROM kimai15_pct WHERE pct_trash = 0')->fetchAll(); $total = count($projects); $count = 0; $rows = []; foreach ($projects as $p) { $rows[] = [ 'id' => (int) $p['pct_ID'], 'customer_id' => (int) $p['pct_kndID'], 'name' => safeString($p['pct_name'], 150) ?: '', 'order_number'=> null, 'comment' => $p['pct_comment'] ?: null, 'visible' => (int) $p['pct_visible'], 'budget' => (float) $p['pct_budget'], 'color' => null, 'time_budget' => 0, 'order_date' => null, 'start' => null, 'end' => null, 'timezone' => null, 'budget_type' => null, 'billable' => (int) $p['pct_billable'], 'invoice_text'=> null, 'global_activities' => 1, 'number' => null, 'created_at' => date('Y-m-d H:i:s'), ]; $count++; if ($count % 100 === 0) { progress('Projects', $count, $total); } } $imported = batchInsert($dst, 'kimai2_projects', [ 'id', 'customer_id', 'name', 'order_number', 'comment', 'visible', 'budget', 'color', 'time_budget', 'order_date', 'start', 'end', 'timezone', 'budget_type', 'billable', 'invoice_text', 'global_activities', 'number', 'created_at', ], $rows); $dst->exec('COMMIT'); done('Projects', $imported, microtime(true) - $t0); $dst->exec("ALTER TABLE kimai2_projects AUTO_INCREMENT = " . ((int) $src->query('SELECT MAX(pct_ID) FROM kimai15_pct')->fetchColumn() + 1)); // ╔══════════════════════════════════════════════════════════════════════════════╗ // ║ 4. Activities (global) ║ // ╚══════════════════════════════════════════════════════════════════════════════╝ echo "--- Migrating Activities ---\n"; $t0 = microtime(true); $activities = $src->query('SELECT * FROM kimai15_evt WHERE evt_trash = 0')->fetchAll(); $total = count($activities); $count = 0; $rows = []; foreach ($activities as $a) { $rows[] = [ 'id' => (int) $a['evt_ID'], 'project_id' => null, // global activities 'name' => safeString($a['evt_name'], 150) ?: '', 'comment' => $a['evt_comment'] ?: null, 'visible' => (int) $a['evt_visible'], 'color' => null, 'time_budget'=> 0, 'budget' => 0.0, 'budget_type'=> null, 'billable' => 1, 'invoice_text'=> null, 'number' => null, 'created_at' => date('Y-m-d H:i:s'), ]; $count++; if ($count % 100 === 0) { progress('Activities', $count, $total); } } $imported = batchInsert($dst, 'kimai2_activities', [ 'id', 'project_id', 'name', 'comment', 'visible', 'color', 'time_budget', 'budget', 'budget_type', 'billable', 'invoice_text', 'number', 'created_at', ], $rows); $dst->exec('COMMIT'); done('Activities', $imported, microtime(true) - $t0); $dst->exec("ALTER TABLE kimai2_activities AUTO_INCREMENT = " . ((int) $src->query('SELECT MAX(evt_ID) FROM kimai15_evt')->fetchColumn() + 1)); // ╔══════════════════════════════════════════════════════════════════════════════╗ // ║ 5. Users ║ // ╚══════════════════════════════════════════════════════════════════════════════╝ echo "--- Migrating Users ---\n"; $t0 = microtime(true); $users = $src->query('SELECT * FROM kimai15_usr ORDER BY usr_ID')->fetchAll(); $total = count($users); $count = 0; $now = date('Y-m-d H:i:s'); $passwordHash = password_hash(DEFAULT_PASSWORD, PASSWORD_BCRYPT); $userRows = []; $prefRows = []; $teamRows = []; $seenEmails = []; $seenUsernames = []; foreach ($users as $u) { $userId = (int) $u['usr_ID']; $username = safeString($u['usr_name'], 180) ?: 'user_' . $userId; $alias = safeString($u['usr_alias'], 60); $email = safeString($u['usr_mail'], 180) ?: ($username . '@migrated.local'); $enabled = (int) $u['usr_active']; $v1group = (int) $u['usr_grp']; // Deduplicate username (case-insensitive) $origUsername = $username; $suffix = 1; while (isset($seenUsernames[mb_strtolower($username)])) { $username = mb_substr($origUsername, 0, 170) . '_' . $suffix; $suffix++; } $seenUsernames[mb_strtolower($username)] = true; // Deduplicate email $origEmail = $email; $suffix = 1; $emailLower = mb_strtolower($email); while (isset($seenEmails[$emailLower])) { $atPos = strpos($origEmail, '@'); if ($atPos !== false) { $email = mb_substr($origEmail, 0, min($atPos, 170)) . '+' . $suffix . mb_substr($origEmail, $atPos); } else { $email = mb_substr($origEmail, 0, 170) . '+' . $suffix . '@migrated.local'; } $email = mb_substr($email, 0, 180); $emailLower = mb_strtolower($email); $suffix++; } $seenEmails[$emailLower] = true; // Role mapping: admin user gets SUPER_ADMIN, others get USER if ($origUsername === 'admin' || $origUsername === 'administrator') { $roles = serialize(['ROLE_SUPER_ADMIN']); } else { $roles = serialize(['ROLE_USER']); } $userRows[] = [ 'id' => $userId, 'username' => $username, 'email' => $email, 'password' => $passwordHash, 'alias' => $alias, 'enabled' => $enabled, 'registration_date' => $now, 'title' => null, 'avatar' => null, 'roles' => $roles, 'last_login' => null, 'confirmation_token' => null, 'password_requested_at' => null, 'api_token' => null, 'auth' => 'kimai', 'color' => null, 'account' => null, 'totp_secret' => null, 'totp_enabled' => 0, 'system_account' => 0, 'supervisor_id' => null, 'signature_date' => null, ]; // User preferences: hourly_rate $prefRows[] = [ 'user_id' => $userId, 'name' => 'hourly_rate', 'value' => '0', ]; $prefRows[] = [ 'user_id' => $userId, 'name' => 'timezone', 'value' => DEFAULT_TIMEZONE, ]; // Team membership if ($v1group > 0) { $teamRows[] = [ 'user_id' => $userId, 'team_id' => $v1group, 'teamlead' => 0, ]; } $count++; if ($count % 100 === 0) { progress('Users', $count, $total); } } // Verify no duplicate emails before insert $emailCheck = []; $dupFound = false; foreach ($userRows as $r) { $em = mb_strtolower($r['email']); if (isset($emailCheck[$em])) { echo "\n *** DUPLICATE EMAIL: $em (IDs: {$emailCheck[$em]} and {$r['id']})\n"; $dupFound = true; } $emailCheck[$em] = $r['id']; } echo " Verified: " . count($userRows) . " users, " . count($emailCheck) . " unique emails\n"; if ($dupFound) { echo " ERROR: Duplicate emails detected, aborting.\n"; exit(1); } $imported = batchInsert($dst, 'kimai2_users', [ 'id', 'username', 'email', 'password', 'alias', 'enabled', 'registration_date', 'title', 'avatar', 'roles', 'last_login', 'confirmation_token', 'password_requested_at', 'api_token', 'auth', 'color', 'account', 'totp_secret', 'totp_enabled', 'system_account', 'supervisor_id', 'signature_date', ], $userRows); $dst->exec('COMMIT'); done('Users', $imported, microtime(true) - $t0); $dst->exec("ALTER TABLE kimai2_users AUTO_INCREMENT = " . ((int) $src->query('SELECT MAX(usr_ID) FROM kimai15_usr')->fetchColumn() + 1)); // User preferences echo "--- Migrating User Preferences ---\n"; $t0 = microtime(true); $imported = batchInsert($dst, 'kimai2_user_preferences', ['user_id', 'name', 'value'], $prefRows); $dst->exec('COMMIT'); done('User Preferences', $imported, microtime(true) - $t0); // User-Team mappings echo "--- Migrating User-Team Mappings ---\n"; $t0 = microtime(true); $imported = batchInsert($dst, 'kimai2_users_teams', ['user_id', 'team_id', 'teamlead'], $teamRows); $dst->exec('COMMIT'); done('User-Team Mappings', $imported, microtime(true) - $t0); // ╔══════════════════════════════════════════════════════════════════════════════╗ // ║ 6. Rates ║ // ╚══════════════════════════════════════════════════════════════════════════════╝ echo "--- Migrating Rates ---\n"; $t0 = microtime(true); $rates = $src->query('SELECT * FROM kimai15_rates')->fetchAll(); $projectRates = []; $activityRates = []; foreach ($rates as $r) { if (!empty($r['project_id'])) { $projectRates[] = [ 'user_id' => $r['user_id'] ? (int) $r['user_id'] : null, 'project_id' => (int) $r['project_id'], 'rate' => (float) $r['rate'], 'fixed' => 0, 'internal_rate' => null, ]; } if (!empty($r['event_id'])) { $activityRates[] = [ 'user_id' => $r['user_id'] ? (int) $r['user_id'] : null, 'activity_id' => (int) $r['event_id'], 'rate' => (float) $r['rate'], 'fixed' => 0, 'internal_rate' => null, ]; } } $count1 = batchInsert($dst, 'kimai2_projects_rates', ['user_id', 'project_id', 'rate', 'fixed', 'internal_rate'], $projectRates); $count2 = batchInsert($dst, 'kimai2_activities_rates', ['user_id', 'activity_id', 'rate', 'fixed', 'internal_rate'], $activityRates); $dst->exec('COMMIT'); done('Rates (projects)', $count1, microtime(true) - $t0); done('Rates (activities)', $count2, microtime(true) - $t0); // ╔══════════════════════════════════════════════════════════════════════════════╗ // ║ 7. Timesheets (the big one: ~908K rows) ║ // ╚══════════════════════════════════════════════════════════════════════════════╝ echo "--- Migrating Timesheets (~908K rows) ---\n"; $t0 = microtime(true); // Count total $total = (int) $src->query('SELECT COUNT(*) FROM kimai15_zef')->fetchColumn(); $count = 0; // Stream with unbuffered query $stmt = $src->prepare('SELECT zef_ID, zef_in, zef_out, zef_time, zef_usrID, zef_pctID, zef_evtID, zef_comment, zef_rate FROM kimai15_zef ORDER BY zef_ID'); $stmt->execute(); $rows = []; while ($row = $stmt->fetch()) { $startTime = timestampToDateTime((int) $row['zef_in']); // Skip entries with no valid start time if ($startTime === null) { $count++; continue; } $endTime = timestampToDateTime((int) $row['zef_out']); $duration = (int) $row['zef_time']; // seconds // date_tz: extract date portion from start_time $dateTz = $startTime ? substr($startTime, 0, 10) : null; $rows[] = [ 'id' => (int) $row['zef_ID'], 'user' => (int) $row['zef_usrID'], 'activity_id' => (int) $row['zef_evtID'], 'project_id' => (int) $row['zef_pctID'], 'start_time' => $startTime, 'end_time' => $endTime, 'duration' => $duration > 0 ? $duration : null, 'description' => $row['zef_comment'] ?: null, 'rate' => (float) $row['zef_rate'], 'fixed_rate' => null, 'hourly_rate' => null, 'exported' => 0, 'timezone' => DEFAULT_TIMEZONE, 'internal_rate'=> null, 'billable' => 1, 'category' => 'work', 'modified_at' => null, 'date_tz' => $dateTz, 'break' => null, ]; $count++; if ($count % BATCH_SIZE === 0) { batchInsert($dst, 'kimai2_timesheet', [ 'id', 'user', 'activity_id', 'project_id', 'start_time', 'end_time', 'duration', 'description', 'rate', 'fixed_rate', 'hourly_rate', 'exported', 'timezone', 'internal_rate', 'billable', 'category', 'modified_at', 'date_tz', 'break', ], $rows, BATCH_SIZE); $dst->exec('COMMIT'); $rows = []; progress('Timesheets', $count, $total); } } // Insert final batch if (!empty($rows)) { batchInsert($dst, 'kimai2_timesheet', [ 'id', 'user', 'activity_id', 'project_id', 'start_time', 'end_time', 'duration', 'description', 'rate', 'fixed_rate', 'hourly_rate', 'exported', 'timezone', 'internal_rate', 'billable', 'category', 'modified_at', 'date_tz', 'break', ], $rows, BATCH_SIZE); $dst->exec('COMMIT'); } done('Timesheets', $count, microtime(true) - $t0); $dst->exec("ALTER TABLE kimai2_timesheet AUTO_INCREMENT = " . ((int) $src->query('SELECT MAX(zef_ID) FROM kimai15_zef')->fetchColumn() + 1)); // ╔══════════════════════════════════════════════════════════════════════════════╗ // ║ 8. Expenses (optional) ║ // ╚══════════════════════════════════════════════════════════════════════════════╝ echo "--- Migrating Expenses ---\n"; $t0 = microtime(true); $expenses = $src->query('SELECT * FROM kimai15_exp')->fetchAll(); $total = count($expenses); // Expenses are stored as timesheet entries with category='expense' in Kimai2 // Expenses: need a fallback activity_id since Kimai2 requires it NOT NULL $fallbackActivityId = (int) ($dst->query('SELECT MIN(id) FROM kimai2_activities')->fetchColumn() ?: 1); $rows = []; foreach ($expenses as $e) { $startTime = timestampToDateTime((int) $e['exp_timestamp']); $dateTz = $startTime ? substr($startTime, 0, 10) : null; $rows[] = [ 'user' => (int) $e['exp_usrID'], 'activity_id' => $fallbackActivityId, 'project_id' => (int) $e['exp_pctID'], 'start_time' => $startTime, 'end_time' => $startTime, // expenses: same as start 'duration' => null, 'description' => ($e['exp_designation'] ?: '') . ($e['exp_comment'] ? ' — ' . $e['exp_comment'] : ''), 'rate' => (float) $e['exp_value'], 'fixed_rate' => null, 'hourly_rate' => null, 'exported' => (int) $e['exp_cleared'], 'timezone' => DEFAULT_TIMEZONE, 'internal_rate'=> null, 'billable' => (int) $e['exp_refundable'], 'category' => 'expense', 'modified_at' => null, 'date_tz' => $dateTz, 'break' => null, ]; } $imported = batchInsert($dst, 'kimai2_timesheet', [ 'user', 'activity_id', 'project_id', 'start_time', 'end_time', 'duration', 'description', 'rate', 'fixed_rate', 'hourly_rate', 'exported', 'timezone', 'internal_rate', 'billable', 'category', 'modified_at', 'date_tz', 'break', ], $rows); $dst->exec('COMMIT'); done('Expenses', $imported, microtime(true) - $t0); // ╔══════════════════════════════════════════════════════════════════════════════╗ // ║ Re-enable foreign keys ║ // ╚══════════════════════════════════════════════════════════════════════════════╝ $dst->exec('SET FOREIGN_KEY_CHECKS = 1'); $dst->exec('SET AUTOCOMMIT = 1'); // ╔══════════════════════════════════════════════════════════════════════════════╗ // ║ Summary ║ // ╚══════════════════════════════════════════════════════════════════════════════╝ echo "\n"; echo str_repeat('=', 60) . "\n"; echo "MIGRATION SUMMARY\n"; echo str_repeat('=', 60) . "\n"; $summaryTables = [ 'Teams' => 'kimai2_teams', 'Customers' => 'kimai2_customers', 'Projects' => 'kimai2_projects', 'Activities' => 'kimai2_activities', 'Users' => 'kimai2_users', 'User Preferences' => 'kimai2_user_preferences', 'User-Team Mappings'=> 'kimai2_users_teams', 'Timesheets' => 'kimai2_timesheet', ]; foreach ($summaryTables as $label => $table) { $cnt = $dst->query("SELECT COUNT(*) FROM `$table`")->fetchColumn(); printf(" %-20s → %s: %d rows\n", $label, $table, $cnt); } echo "\nDefault password for all users: " . DEFAULT_PASSWORD . "\n"; echo "Password hash algorithm: BCRYPT\n"; echo "\nDone.\n";