diff --git a/composer.json b/composer.json index 6ec74d1b..eeed6dae 100644 --- a/composer.json +++ b/composer.json @@ -105,6 +105,11 @@ "symfony/web-profiler-bundle": "^6.0" }, "repositories": [ + { + "name": "import-plugin", + "type": "vcs", + "url": "https://github.com/kevinpapst/ImportBundle.git" + }, { "type": "artifact", "url": "var/packages/" diff --git a/migrate.php b/migrate.php new file mode 100644 index 00000000..f6eab7fd --- /dev/null +++ b/migrate.php @@ -0,0 +1,656 @@ +#!/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";