|
#!/usr/bin/env php |
|
<?php |
|
/** |
|
* DokuWiki Statistics Plugin: MySQL → SQLite Migration |
|
* |
|
* Migrates historical statistics data from the old MySQL-based plugin |
|
* to the new SQLite-based schema (splitbrain/dokuwiki-plugin-statistics). |
|
* |
|
* Usage: |
|
* php migrate.php [--dry-run] [--verbose] [--config=path/to/config.php] |
|
* php migrate.php --help |
|
* |
|
* The old schema is denormalized (stats_access contains session, browser, |
|
* referrer, and pageview data in one row). The new schema normalizes this |
|
* across 14 tables with foreign keys. |
|
* |
|
* Migration phases (respecting FK dependencies): |
|
* Phase 1: Independent tables (iplocation, history, logins, users) |
|
* Phase 2: Sessions (depends on users) |
|
* Phase 3: Data tables (pageviews, referers, outlinks, search, edits, media) |
|
* Phase 4: Searchwords (depends on search) |
|
*/ |
|
|
|
namespace DokuWikiMigration; |
|
|
|
// ─── Bootstrap ─── |
|
|
|
if (php_sapi_name() !== 'cli') { |
|
die("This script must be run from the command line.\n"); |
|
} |
|
|
|
require_once __DIR__ . '/src/MysqlSource.php'; |
|
require_once __DIR__ . '/src/SqliteTarget.php'; |
|
require_once __DIR__ . '/src/RefererMapper.php'; |
|
require_once __DIR__ . '/src/SessionResolver.php'; |
|
|
|
// ─── CLI argument parsing ─── |
|
|
|
$options = getopt('', ['dry-run', 'verbose', 'config:', 'help', 'skip-phase:', 'phase:']); |
|
|
|
if (isset($options['help'])) { |
|
echo <<<HELP |
|
DokuWiki Statistics Plugin: MySQL → SQLite Migration |
|
|
|
Usage: |
|
php migrate.php [options] |
|
|
|
Options: |
|
--config=PATH Path to config.php (default: ./config.php) |
|
--dry-run Preview migration without writing data |
|
--verbose Show detailed progress output |
|
--phase=N Run only phase N (1-4) |
|
--skip-phase=N Skip phase N (can be specified multiple times) |
|
--help Show this help |
|
|
|
Phases: |
|
1 Independent tables (iplocation, history, logins, users) |
|
2 Sessions (depends on users) |
|
3 Data tables (pageviews, referers, outlinks, search, edits, media) |
|
4 Searchwords (depends on search) |
|
|
|
HELP; |
|
exit(0); |
|
} |
|
|
|
$configPath = $options['config'] ?? __DIR__ . '/config.php'; |
|
if (!file_exists($configPath)) { |
|
fwrite(STDERR, "Config file not found: $configPath\n"); |
|
fwrite(STDERR, "Copy config.example.php to config.php and adjust settings.\n"); |
|
exit(1); |
|
} |
|
|
|
$config = require $configPath; |
|
$dryRun = isset($options['dry-run']) || ($config['options']['dry_run'] ?? false); |
|
$verbose = isset($options['verbose']) || ($config['options']['verbose'] ?? false); |
|
$batchSize = $config['options']['batch_size'] ?? 5000; |
|
$clearTarget = $config['options']['clear_target'] ?? true; |
|
|
|
// Determine which phases to run |
|
$onlyPhase = isset($options['phase']) ? (int)$options['phase'] : null; |
|
$skipPhases = $config['options']['skip_phases'] ?? []; |
|
if (isset($options['skip-phase'])) { |
|
$sp = $options['skip-phase']; |
|
$skipPhases = array_merge($skipPhases, is_array($sp) ? $sp : [$sp]); |
|
} |
|
$skipPhases = array_map('intval', $skipPhases); |
|
|
|
// ─── Helper functions ─── |
|
|
|
function out(string $msg): void |
|
{ |
|
echo $msg . "\n"; |
|
} |
|
|
|
function info(string $msg): void |
|
{ |
|
global $verbose; |
|
if ($verbose) { |
|
echo " [INFO] $msg\n"; |
|
} |
|
} |
|
|
|
function progress(string $label, int $current, int $total): void |
|
{ |
|
global $verbose; |
|
if (!$verbose) return; |
|
$pct = $total > 0 ? round($current / $total * 100, 1) : 100; |
|
echo " [$label] $current / $total ($pct%)\r"; |
|
if ($current >= $total) echo "\n"; |
|
} |
|
|
|
function shouldRunPhase(int $phase): bool |
|
{ |
|
global $onlyPhase, $skipPhases; |
|
if ($onlyPhase !== null) return $phase === $onlyPhase; |
|
return !in_array($phase, $skipPhases, true); |
|
} |
|
|
|
function formatDuration(float $seconds): string |
|
{ |
|
if ($seconds < 60) return round($seconds, 1) . 's'; |
|
$min = floor($seconds / 60); |
|
$sec = round($seconds - $min * 60, 1); |
|
return "{$min}m {$sec}s"; |
|
} |
|
|
|
// ─── Start migration ─── |
|
|
|
$totalStart = microtime(true); |
|
|
|
out("╔══════════════════════════════════════════════════════════╗"); |
|
out("║ DokuWiki Statistics: MySQL → SQLite Migration ║"); |
|
out("╚══════════════════════════════════════════════════════════╝"); |
|
out(""); |
|
|
|
if ($dryRun) { |
|
out(" *** DRY RUN MODE - No data will be written ***"); |
|
out(""); |
|
} |
|
|
|
// Connect to databases |
|
out("[1/6] Connecting to databases..."); |
|
try { |
|
$mysql = new MysqlSource($config['mysql'], $batchSize); |
|
out(" MySQL: Connected to {$config['mysql']['host']}:{$config['mysql']['database']}"); |
|
} catch (\Exception $e) { |
|
fwrite(STDERR, "MySQL connection failed: " . $e->getMessage() . "\n"); |
|
exit(1); |
|
} |
|
|
|
try { |
|
$sqlite = new SqliteTarget($config['sqlite']['path'], $batchSize, $dryRun); |
|
out(" SQLite: Opened {$config['sqlite']['path']}"); |
|
} catch (\Exception $e) { |
|
fwrite(STDERR, "SQLite connection failed: " . $e->getMessage() . "\n"); |
|
$mysql->close(); |
|
exit(1); |
|
} |
|
|
|
$refMapper = new RefererMapper($sqlite); |
|
$sessionResolver = new SessionResolver($mysql, $sqlite); |
|
|
|
// ─── Source counts ─── |
|
|
|
out(""); |
|
out("[2/6] Reading source table counts..."); |
|
$sourceCounts = []; |
|
$sourceTableNames = [ |
|
'iplocation', 'history', 'logins', 'session', 'access', |
|
'outlinks', 'search', 'searchwords', 'edits', 'media', 'refseen', |
|
]; |
|
foreach ($sourceTableNames as $t) { |
|
try { |
|
$sourceCounts[$t] = $mysql->count($t); |
|
info("stats_$t: " . number_format($sourceCounts[$t]) . " rows"); |
|
} catch (\Exception $e) { |
|
$sourceCounts[$t] = -1; |
|
info("stats_$t: ERROR - " . $e->getMessage()); |
|
} |
|
} |
|
out(" Total source rows: " . number_format(array_sum(array_filter($sourceCounts, fn($v) => $v > 0)))); |
|
out(""); |
|
|
|
// ═══════════════════════════════════════════════════════════ |
|
// Phase 1: Independent tables |
|
// ═══════════════════════════════════════════════════════════ |
|
|
|
if (shouldRunPhase(1)) { |
|
out("[3/6] Phase 1: Independent tables (iplocation, history, logins, users)"); |
|
$phaseStart = microtime(true); |
|
|
|
// --- iplocation --- |
|
$count = $sourceCounts['iplocation']; |
|
out(" Migrating iplocation ($count rows)..."); |
|
if ($clearTarget) $sqlite->clearTable('iplocation'); |
|
$migrated = 0; |
|
foreach ($mysql->readIplocation() as $batch) { |
|
$sqlite->beginTransaction(); |
|
foreach ($batch as $row) { |
|
$sqlite->insertIplocation($row); |
|
$migrated++; |
|
} |
|
$sqlite->commit(); |
|
progress('iplocation', $migrated, $count); |
|
} |
|
out(" iplocation: $migrated rows migrated"); |
|
|
|
// --- history --- |
|
$count = $sourceCounts['history']; |
|
out(" Migrating history ($count rows)..."); |
|
if ($clearTarget) $sqlite->clearTable('history'); |
|
$migrated = 0; |
|
foreach ($mysql->readHistory() as $batch) { |
|
$sqlite->beginTransaction(); |
|
foreach ($batch as $row) { |
|
$sqlite->insertHistory($row); |
|
$migrated++; |
|
} |
|
$sqlite->commit(); |
|
progress('history', $migrated, $count); |
|
} |
|
out(" history: $migrated rows migrated"); |
|
|
|
// --- logins --- |
|
$count = $sourceCounts['logins']; |
|
out(" Migrating logins ($count rows)..."); |
|
if ($clearTarget) $sqlite->clearTable('logins'); |
|
$migrated = 0; |
|
foreach ($mysql->readLogins() as $batch) { |
|
$sqlite->beginTransaction(); |
|
foreach ($batch as $row) { |
|
$sqlite->insertLogin($row); |
|
$migrated++; |
|
} |
|
$sqlite->commit(); |
|
progress('logins', $migrated, $count); |
|
} |
|
out(" logins: $migrated rows migrated"); |
|
|
|
// --- users (aggregated) --- |
|
out(" Aggregating users from access/edits/logins..."); |
|
if ($clearTarget) $sqlite->clearTable('users'); |
|
$users = $mysql->readDistinctUsers(); |
|
$sqlite->beginTransaction(); |
|
foreach ($users as $u) { |
|
// Attempt to extract domain from user if it contains @ |
|
$domain = null; |
|
if (strpos($u['user'], '@') !== false) { |
|
$domain = substr($u['user'], strrpos($u['user'], '@') + 1); |
|
} |
|
$sqlite->insertUser($u['user'], $u['first_seen'], $domain); |
|
} |
|
$sqlite->commit(); |
|
out(" users: " . count($users) . " distinct users migrated"); |
|
|
|
$phaseDuration = microtime(true) - $phaseStart; |
|
out(" Phase 1 completed in " . formatDuration($phaseDuration)); |
|
out(""); |
|
} else { |
|
out("[3/6] Phase 1: SKIPPED"); |
|
out(""); |
|
} |
|
|
|
// ═══════════════════════════════════════════════════════════ |
|
// Phase 2: Sessions (depends on users) |
|
// ═══════════════════════════════════════════════════════════ |
|
|
|
if (shouldRunPhase(2)) { |
|
out("[4/6] Phase 2: Sessions"); |
|
$phaseStart = microtime(true); |
|
|
|
$count = $sourceCounts['session']; |
|
out(" Building browser info map from stats_access..."); |
|
$browserMap = $mysql->buildBrowserInfoMap(); |
|
info("Browser info loaded for " . count($browserMap) . " sessions"); |
|
|
|
out(" Migrating sessions ($count sessions)..."); |
|
if ($clearTarget) $sqlite->clearTable('sessions'); |
|
$migrated = 0; |
|
|
|
foreach ($mysql->readSessionsSimple() as $batch) { |
|
$sqlite->beginTransaction(); |
|
foreach ($batch as $row) { |
|
$sid = $row['session']; |
|
$bi = $browserMap[$sid] ?? []; |
|
$sqlite->insertSession([ |
|
'session' => $sid, |
|
'dt' => $row['dt'], |
|
'end' => $row['end'] ?? $row['dt'], |
|
'uid' => $row['uid'] ?? '', |
|
'user' => !empty($bi['user']) ? $bi['user'] : null, |
|
'ua' => $bi['ua'] ?? '', |
|
'ua_info' => $bi['ua_info'] ?? '', |
|
'ua_type' => $bi['ua_type'] ?? 'browser', |
|
'ua_ver' => $bi['ua_ver'] ?? '', |
|
'os' => $bi['os'] ?? '', |
|
]); |
|
$sessionResolver->markKnown($sid); |
|
$migrated++; |
|
} |
|
$sqlite->commit(); |
|
progress('sessions', $migrated, $count); |
|
} |
|
// Pass browser map to session resolver for orphan enrichment |
|
$sessionResolver->setBrowserMap($browserMap); |
|
unset($browserMap); // Free the local reference (resolver holds it now) |
|
out(" sessions: $migrated rows migrated"); |
|
|
|
// Resolve orphan sessions (uses browserMap, no per-session MySQL queries) |
|
out(" Resolving orphan sessions..."); |
|
$sqlite->beginTransaction(); |
|
$stubs = $sessionResolver->resolveOrphans(); |
|
$sqlite->commit(); |
|
out(" sessions: $stubs stub sessions created for orphans"); |
|
|
|
// Create placeholder session for search entries |
|
$sqlite->beginTransaction(); |
|
$sessionResolver->ensurePlaceholder(); |
|
$sqlite->commit(); |
|
out(" sessions: placeholder session created for search migration"); |
|
|
|
$phaseDuration = microtime(true) - $phaseStart; |
|
out(" Phase 2 completed in " . formatDuration($phaseDuration)); |
|
out(""); |
|
} else { |
|
out("[4/6] Phase 2: SKIPPED"); |
|
out(""); |
|
} |
|
|
|
// ═══════════════════════════════════════════════════════════ |
|
// Phase 3: Data tables (depends on sessions, referers) |
|
// ═══════════════════════════════════════════════════════════ |
|
|
|
if (shouldRunPhase(3)) { |
|
out("[5/6] Phase 3: Data tables (referers, pageviews, outlinks, search, edits, media)"); |
|
$phaseStart = microtime(true); |
|
|
|
// --- referers (build map first) --- |
|
out(" Building referrer map from stats_access..."); |
|
$referers = $mysql->readDistinctReferers(); |
|
$sqlite->beginTransaction(); |
|
$refMapper->buildFromMysqlData($referers); |
|
$sqlite->commit(); |
|
out(" referers: " . $refMapper->getMappedCount() . " distinct referrers mapped"); |
|
|
|
// --- pageviews (largest table) --- |
|
$count = $sourceCounts['access']; |
|
out(" Migrating pageviews from stats_access ($count rows)..."); |
|
if ($clearTarget) $sqlite->clearTable('pageviews'); |
|
$migrated = 0; |
|
|
|
foreach ($mysql->readAccess() as $batch) { |
|
$sqlite->beginTransaction(); |
|
foreach ($batch as $row) { |
|
// Resolve session |
|
$sessionId = $sessionResolver->ensure($row['session'] ?? ''); |
|
|
|
// Resolve referrer |
|
$refId = null; |
|
$refType = $row['ref_type'] ?? ''; |
|
$refUrl = $row['ref'] ?? ''; |
|
if (!empty($refUrl) && in_array($refType, ['external', 'search'], true)) { |
|
$refId = $refMapper->resolve($refUrl, $refType); |
|
} |
|
|
|
$sqlite->insertPageview([ |
|
'dt' => $row['dt'], |
|
'ip' => $row['ip'] ?? '', |
|
'session' => $sessionId, |
|
'page' => $row['page'] ?? '', |
|
'ref_id' => $refId, |
|
'screen_x' => $row['screen_x'] ?? 0, |
|
'screen_y' => $row['screen_y'] ?? 0, |
|
'view_x' => $row['view_x'] ?? 0, |
|
'view_y' => $row['view_y'] ?? 0, |
|
]); |
|
$migrated++; |
|
} |
|
$sqlite->commit(); |
|
progress('pageviews', $migrated, $count); |
|
} |
|
out(" pageviews: $migrated rows migrated"); |
|
|
|
// --- outlinks --- |
|
$count = $sourceCounts['outlinks']; |
|
out(" Migrating outlinks ($count rows)..."); |
|
if ($clearTarget) $sqlite->clearTable('outlinks'); |
|
$migrated = 0; |
|
foreach ($mysql->readOutlinks() as $batch) { |
|
$sqlite->beginTransaction(); |
|
foreach ($batch as $row) { |
|
$sessionId = $sessionResolver->ensure($row['session'] ?? ''); |
|
$sqlite->insertOutlink([ |
|
'dt' => $row['dt'], |
|
'session' => $sessionId, |
|
'link' => $row['link'] ?? '', |
|
'page' => $row['page'] ?? '', |
|
]); |
|
$migrated++; |
|
} |
|
$sqlite->commit(); |
|
progress('outlinks', $migrated, $count); |
|
} |
|
out(" outlinks: $migrated rows migrated"); |
|
|
|
// --- search --- |
|
// Old stats_search has no session/ip columns. Use placeholder session. |
|
$count = $sourceCounts['search']; |
|
out(" Migrating search ($count rows)..."); |
|
if ($clearTarget) { |
|
$sqlite->clearTable('searchwords'); // Clear dependent table first |
|
$sqlite->clearTable('search'); |
|
} |
|
$migrated = 0; |
|
|
|
// Build old→new ID map for searchwords phase |
|
$searchIdMap = []; // old MySQL id → new SQLite id |
|
|
|
foreach ($mysql->readSearch() as $batch) { |
|
$sqlite->beginTransaction(); |
|
foreach ($batch as $row) { |
|
$oldId = (int)$row['id']; |
|
$newId = $sqlite->insertSearch([ |
|
'dt' => $row['dt'], |
|
'ip' => '', // Not available in old schema |
|
'session' => SessionResolver::PLACEHOLDER_SESSION, |
|
'query' => $row['query'] ?? '', |
|
]); |
|
$searchIdMap[$oldId] = $newId; |
|
$migrated++; |
|
} |
|
$sqlite->commit(); |
|
progress('search', $migrated, $count); |
|
} |
|
out(" search: $migrated rows migrated (with placeholder session)"); |
|
|
|
// --- edits --- |
|
$count = $sourceCounts['edits']; |
|
out(" Migrating edits ($count rows)..."); |
|
if ($clearTarget) $sqlite->clearTable('edits'); |
|
$migrated = 0; |
|
foreach ($mysql->readEdits() as $batch) { |
|
$sqlite->beginTransaction(); |
|
foreach ($batch as $row) { |
|
$sessionId = $sessionResolver->ensure($row['session'] ?? ''); |
|
$sqlite->insertEdit([ |
|
'dt' => $row['dt'], |
|
'ip' => $row['ip'] ?? '', |
|
'session' => $sessionId, |
|
'page' => $row['page'] ?? '', |
|
'type' => $row['type'] ?? '', |
|
]); |
|
$migrated++; |
|
} |
|
$sqlite->commit(); |
|
progress('edits', $migrated, $count); |
|
} |
|
out(" edits: $migrated rows migrated"); |
|
|
|
// --- media --- |
|
$count = $sourceCounts['media']; |
|
out(" Migrating media ($count rows)..."); |
|
if ($clearTarget) $sqlite->clearTable('media'); |
|
$migrated = 0; |
|
foreach ($mysql->readMedia() as $batch) { |
|
$sqlite->beginTransaction(); |
|
foreach ($batch as $row) { |
|
$sessionId = $sessionResolver->ensure($row['session'] ?? ''); |
|
$sqlite->insertMedia([ |
|
'dt' => $row['dt'], |
|
'ip' => $row['ip'] ?? '', |
|
'session' => $sessionId, |
|
'media' => $row['media'] ?? '', |
|
'size' => $row['size'] ?? 0, |
|
'mime1' => $row['mime1'] ?? '', |
|
'mime2' => $row['mime2'] ?? '', |
|
'inline' => $row['inline'] ?? 0, |
|
]); |
|
$migrated++; |
|
} |
|
$sqlite->commit(); |
|
progress('media', $migrated, $count); |
|
} |
|
out(" media: $migrated rows migrated"); |
|
|
|
$phaseDuration = microtime(true) - $phaseStart; |
|
out(" Phase 3 completed in " . formatDuration($phaseDuration)); |
|
out(""); |
|
} else { |
|
out("[5/6] Phase 3: SKIPPED"); |
|
$searchIdMap = []; |
|
out(""); |
|
} |
|
|
|
// ═══════════════════════════════════════════════════════════ |
|
// Phase 4: Searchwords (depends on search) |
|
// ═══════════════════════════════════════════════════════════ |
|
|
|
if (shouldRunPhase(4)) { |
|
out("[6/6] Phase 4: Searchwords"); |
|
$phaseStart = microtime(true); |
|
|
|
$count = $sourceCounts['searchwords']; |
|
out(" Migrating searchwords ($count rows)..."); |
|
if (empty($searchIdMap) && !shouldRunPhase(3)) { |
|
out(" WARNING: Phase 3 was skipped - searchwords need the search ID map."); |
|
out(" Building search ID map from existing data..."); |
|
// Rebuild map: read old search IDs and match by query+dt |
|
out(" SKIPPED: Cannot map searchwords without Phase 3 data."); |
|
} else { |
|
if ($clearTarget && shouldRunPhase(3)) { |
|
// Already cleared in phase 3 |
|
} elseif ($clearTarget) { |
|
$sqlite->clearTable('searchwords'); |
|
} |
|
|
|
$migrated = 0; |
|
$skipped = 0; |
|
foreach ($mysql->readSearchwords() as $batch) { |
|
$sqlite->beginTransaction(); |
|
foreach ($batch as $row) { |
|
$oldSid = (int)$row['sid']; |
|
if (!isset($searchIdMap[$oldSid])) { |
|
$skipped++; |
|
continue; |
|
} |
|
$newSid = $searchIdMap[$oldSid]; |
|
$sqlite->insertSearchword($newSid, $row['word']); |
|
$migrated++; |
|
} |
|
$sqlite->commit(); |
|
progress('searchwords', $migrated + $skipped, $count); |
|
} |
|
out(" searchwords: $migrated rows migrated, $skipped skipped (orphan sid)"); |
|
} |
|
|
|
$phaseDuration = microtime(true) - $phaseStart; |
|
out(" Phase 4 completed in " . formatDuration($phaseDuration)); |
|
out(""); |
|
} else { |
|
out("[6/6] Phase 4: SKIPPED"); |
|
out(""); |
|
} |
|
|
|
// ═══════════════════════════════════════════════════════════ |
|
// Verification |
|
// ═══════════════════════════════════════════════════════════ |
|
|
|
out("═══════════════════════════════════════════════════════════"); |
|
out("Verification"); |
|
out("═══════════════════════════════════════════════════════════"); |
|
|
|
if (!$dryRun) { |
|
// Row counts |
|
$targetTables = [ |
|
'iplocation' => 'iplocation', |
|
'history' => 'history', |
|
'logins' => 'logins', |
|
'users' => null, // aggregated, no 1:1 source |
|
'sessions' => 'session', |
|
'referers' => null, // aggregated |
|
'pageviews' => 'access', |
|
'outlinks' => 'outlinks', |
|
'search' => 'search', |
|
'searchwords' => 'searchwords', |
|
'edits' => 'edits', |
|
'media' => 'media', |
|
]; |
|
|
|
out(""); |
|
out(" Target Table | Count | Source Count | Match"); |
|
out(" -----------------+-------------+-------------+------"); |
|
|
|
foreach ($targetTables as $target => $sourceKey) { |
|
$targetCount = $sqlite->count($target); |
|
$sourceCount = $sourceKey !== null ? ($sourceCounts[$sourceKey] ?? '?') : 'N/A'; |
|
|
|
$match = ''; |
|
if (is_int($sourceCount)) { |
|
if ($target === 'sessions') { |
|
// Sessions may have stubs, so target >= source |
|
$match = $targetCount >= $sourceCount ? 'OK' : 'WARN'; |
|
} else { |
|
$match = $targetCount === $sourceCount ? 'OK' : 'DIFF'; |
|
} |
|
} |
|
|
|
$targetFormatted = str_pad(number_format($targetCount), 11, ' ', STR_PAD_LEFT); |
|
$sourceFormatted = is_int($sourceCount) |
|
? str_pad(number_format($sourceCount), 11, ' ', STR_PAD_LEFT) |
|
: str_pad((string)$sourceCount, 11, ' ', STR_PAD_LEFT); |
|
|
|
out(sprintf(" %-17s| %s | %s | %s", $target, $targetFormatted, $sourceFormatted, $match)); |
|
} |
|
|
|
// FK integrity check |
|
out(""); |
|
out(" Foreign key integrity check..."); |
|
$fkViolations = $sqlite->checkForeignKeys(); |
|
if (empty($fkViolations)) { |
|
out(" FK Check: PASSED (no violations)"); |
|
} else { |
|
out(" FK Check: FAILED (" . count($fkViolations) . " violations)"); |
|
foreach (array_slice($fkViolations, 0, 10) as $v) { |
|
out(" Table: {$v['table']}, Row: {$v['rowid']}, Parent: {$v['parent']}, FK: {$v['fkid']}"); |
|
} |
|
if (count($fkViolations) > 10) { |
|
out(" ... and " . (count($fkViolations) - 10) . " more"); |
|
} |
|
} |
|
|
|
out(""); |
|
out(" Session resolver stats:"); |
|
out(" Known sessions: " . number_format($sessionResolver->getKnownCount())); |
|
out(" Stub sessions created: " . number_format($sessionResolver->getStubsCreated())); |
|
out(" Referrers mapped: " . number_format($refMapper->getMappedCount())); |
|
} else { |
|
out(" (Skipped in dry-run mode)"); |
|
} |
|
|
|
// ─── Cleanup ─── |
|
|
|
$sqlite->close(); |
|
$mysql->close(); |
|
|
|
$totalDuration = microtime(true) - $totalStart; |
|
out(""); |
|
out("═══════════════════════════════════════════════════════════"); |
|
if ($dryRun) { |
|
out(" DRY RUN completed in " . formatDuration($totalDuration)); |
|
} else { |
|
out(" Migration completed in " . formatDuration($totalDuration)); |
|
} |
|
out("═══════════════════════════════════════════════════════════"); |
|
out(""); |
|
out("Next steps:"); |
|
out(" 1. Verify row counts above match expectations"); |
|
out(" 2. Check FK integrity (should say PASSED)"); |
|
out(" 3. Set correct file permissions:"); |
|
out(" chown www-data:www-data {$config['sqlite']['path']}"); |
|
out(" chmod 664 {$config['sqlite']['path']}"); |
|
out(" 4. Verify schema version:"); |
|
out(" sqlite3 {$config['sqlite']['path']} \"SELECT val FROM opts WHERE opt = 'dbversion';\""); |
|
out(" 5. Test the Statistics admin page in DokuWiki"); |
|
out(""); |