Created
August 22, 2026 21:26
-
-
Save geoffreycrofte/41a68d162267b3814b3c09fc05d970fa to your computer and use it in GitHub Desktop.
PHP Script Migration of Dotclear 2.0 Export to a Dotclear 2.39.2 Import Compatible file
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| declare(strict_types=1); | |
| /** | |
| * Dotclear 2.0 (2008) full export -> Dotclear 2.39.2 SINGLE blog export | |
| * | |
| * Please, don't judge me, I had to do it :D | |
| * --------- | |
| * This follows FlatImportV2::importSingle() exactly: | |
| * - single imports need old IDs (cat_id, post_id, media_id) so FlatImportV2 | |
| * can populate its old_ids mapping. | |
| * - blog_id is NOT removed: the importer overwrites it with the target blog. | |
| * - category must precede post; post must precede meta/comment. | |
| * - current Dotclear requires post_position and post_firstpub. | |
| * - original CSV rows are preserved byte-for-byte, except two appended post | |
| * values. No use of str_getcsv(), fgetcsv(), fputcsv(), iconv, utf8_decode, | |
| * or any re-encoding. | |
| * - validates category/post/meta/comment relations before creating a download. | |
| * | |
| * Example of installation for a MAMP installation: | |
| * /Applications/MAMP/htdocs/dotclear-migration/index.php | |
| * http://localhost:8888/dotclear-migration/ | |
| */ | |
| const MAX_UPLOAD_BYTES = 30 * 1024 * 1024; | |
| function renderPage(?string $message = null, bool $error = false): void | |
| { | |
| $class = $error ? 'error' : 'success'; | |
| ?> | |
| <!doctype html> | |
| <html lang="fr"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Préparer un import Dotclear single blog</title> | |
| <style> | |
| :root { color-scheme: light dark; } | |
| body { font:16px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; margin:0; background:#f4f6f8; color:#17202a; } | |
| main { max-width:800px; margin:6vh auto; padding:2rem; background:#fff; border-radius:12px; box-shadow:0 8px 30px rgb(0 0 0 / 12%); } | |
| h1 { margin-top:0; line-height:1.15; } | |
| h2 { margin-top:2rem; font-size:1.15rem; } | |
| label { display:block; margin:1.4rem 0 .5rem; font-weight:700; } | |
| input[type=file] { width:100%; box-sizing:border-box; padding:.75rem; border:1px solid #aab4be; border-radius:8px; } | |
| button { margin-top:1.4rem; padding:.8rem 1rem; border:0; border-radius:8px; color:#fff; background:#0969da; font:inherit; font-weight:700; cursor:pointer; } | |
| button:hover { background:#0759ba; } | |
| .notice { margin:1rem 0; padding:1rem; border-radius:8px; white-space:pre-wrap; } | |
| .error { background:#ffe8e8; color:#8a1111; border:1px solid #e6a7a7; } | |
| .success { background:#e8f6ed; color:#155f32; border:1px solid #9ed2ae; } | |
| code { background:#eef1f4; border-radius:4px; padding:.15rem .3rem; } | |
| small { color:#4f5b66; } | |
| @media (prefers-color-scheme:dark) { body { background:#12171d; color:#e5edf5; } main { background:#1d2630; } input[type=file] { border-color:#667789; } code { background:#2b3745; } small { color:#bdc9d4; } .error { background:#4c2020; color:#ffd6d6; border-color:#9b4b4b; } .success { background:#173d27; color:#c8f7d7; border-color:#427e56; } } | |
| </style> | |
| </head> | |
| <body> | |
| <main> | |
| <h1>Préparer un import Dotclear <em>single blog</em></h1> | |
| <p>Cette version vérifie les relations catégories → billets → tags/commentaires avant de générer le téléchargement.</p> | |
| <?php if ($message !== null): ?> | |
| <p class="notice <?= $class ?>"><?= htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></p> | |
| <?php endif; ?> | |
| <form method="post" enctype="multipart/form-data"> | |
| <label for="backup">Export Dotclear original (<code>.txt</code>)</label> | |
| <input id="backup" name="backup" type="file" accept=".txt,text/plain" required> | |
| <small>Taille maximale : <?= (int) (MAX_UPLOAD_BYTES / 1024 / 1024) ?> Mo. Le fichier n’est jamais stocké.</small> | |
| <button type="submit">Vérifier, préparer et télécharger</button> | |
| </form> | |
| <h2>À faire ensuite</h2> | |
| <ol> | |
| <li>Dans Dotclear, sélectionne le blog de destination.</li> | |
| <li>Va dans <strong>Plugins → Import/Export → Single blog import</strong>.</li> | |
| <li>Choisis le fichier généré par cette page.</li> | |
| <li>Copie ensuite l’ancien répertoire <code>public/</code> pour restaurer les fichiers médias.</li> | |
| </ol> | |
| </main> | |
| </body> | |
| </html> | |
| <?php | |
| } | |
| function fail(string $message, int $status = 400): never | |
| { | |
| http_response_code($status); | |
| renderPage($message, true); | |
| exit; | |
| } | |
| /** | |
| * Parses a Dotclear flat-export CSV row without decoding it. Returned values | |
| * include their original surrounding quotes, keeping every byte untouched. | |
| * | |
| * @return list<string> | |
| */ | |
| function splitRawDotclearCsv(string $line): array | |
| { | |
| $fields = []; | |
| $length = strlen($line); | |
| $offset = 0; | |
| while ($offset < $length) { | |
| if ($line[$offset] !== '"') { | |
| throw new RuntimeException('CSV invalide : champ non encadré par des guillemets.'); | |
| } | |
| $start = $offset; | |
| $offset++; | |
| $escaped = false; | |
| $closed = false; | |
| while ($offset < $length) { | |
| $char = $line[$offset]; | |
| if ($escaped) { | |
| $escaped = false; | |
| $offset++; | |
| continue; | |
| } | |
| if ($char === '\\') { | |
| $escaped = true; | |
| $offset++; | |
| continue; | |
| } | |
| if ($char === '"') { | |
| $offset++; | |
| $closed = true; | |
| break; | |
| } | |
| $offset++; | |
| } | |
| if (!$closed) { | |
| throw new RuntimeException('CSV invalide : guillemet fermant absent.'); | |
| } | |
| $fields[] = substr($line, $start, $offset - $start); | |
| if ($offset === $length) { | |
| break; | |
| } | |
| if ($line[$offset] !== ',') { | |
| throw new RuntimeException('CSV invalide : séparateur virgule attendu.'); | |
| } | |
| $offset++; | |
| } | |
| return $fields; | |
| } | |
| function decodeRawField(string $raw): string | |
| { | |
| if (strlen($raw) < 2 || $raw[0] !== '"' || substr($raw, -1) !== '"') { | |
| throw new RuntimeException('Champ CSV invalide.'); | |
| } | |
| return stripcslashes(substr($raw, 1, -1)); | |
| } | |
| /** | |
| * @return array<string,int> | |
| */ | |
| function headerIndexes(string $header, string $expectedTable): array | |
| { | |
| $quotedTable = preg_quote($expectedTable, '/'); | |
| if (!preg_match('/^\[' . $quotedTable . '\s+(.+)\]$/i', trim($header), $matches)) { | |
| throw new RuntimeException("En-tête [{$expectedTable}] invalide."); | |
| } | |
| $indexes = []; | |
| foreach (array_map('trim', explode(',', $matches[1])) as $index => $name) { | |
| $indexes[strtolower($name)] = $index; | |
| } | |
| return $indexes; | |
| } | |
| function countDataRows(array $section): int | |
| { | |
| return count(array_filter(array_slice($section, 1), static fn (string $line): bool => trim($line) !== '')); | |
| } | |
| /** | |
| * Adds post_position and post_firstpub only when missing. All legacy field | |
| * data remains untouched. The importer accesses these keys unconditionally. | |
| */ | |
| function addRequiredPostColumns(array $section): array | |
| { | |
| $header = $section[0] ?? ''; | |
| $indexes = headerIndexes($header, 'post'); | |
| $required = ['post_position', 'post_firstpub']; | |
| $missing = array_values(array_filter($required, static fn (string $field): bool => !isset($indexes[$field]))); | |
| if ($missing === []) { | |
| return $section; | |
| } | |
| if (!preg_match('/^\[post\s+(.+)\]$/i', trim($header), $matches)) { | |
| throw new RuntimeException('En-tête [post] invalide.'); | |
| } | |
| $oldColumns = array_map('trim', explode(',', $matches[1])); | |
| $newColumns = array_merge($oldColumns, $missing); | |
| $expectedOldFieldCount = count($oldColumns); | |
| $result = ['[post ' . implode(',', $newColumns) . ']']; | |
| foreach (array_slice($section, 1) as $line) { | |
| if (trim($line) === '') { | |
| continue; | |
| } | |
| $fields = splitRawDotclearCsv($line); | |
| if (count($fields) !== $expectedOldFieldCount) { | |
| throw new RuntimeException('Nombre de champs inattendu dans [post]. Attendu : ' . $expectedOldFieldCount . ', trouvé : ' . count($fields) . '.'); | |
| } | |
| foreach ($missing as $field) { | |
| // Legacy positions are not exported; firstpub should preserve a | |
| // published-state marker. Values are only used as compatible defaults. | |
| $fields[] = $field === 'post_position' ? '"0"' : '"1"'; | |
| } | |
| $result[] = implode(',', $fields); | |
| } | |
| return $result; | |
| } | |
| /** | |
| * Verifies exactly what FlatImportV2::insertPostSingle() and | |
| * insertMetaSingle()/insertCommentSingle() need. | |
| */ | |
| function validateRelations(array $sections): array | |
| { | |
| $errors = []; | |
| $categoryIndexes = headerIndexes($sections['category'][0], 'category'); | |
| $postIndexes = headerIndexes($sections['post'][0], 'post'); | |
| foreach (['cat_id'] as $field) { | |
| if (!isset($categoryIndexes[$field])) { | |
| $errors[] = "La colonne {$field} manque dans [category]."; | |
| } | |
| } | |
| foreach (['post_id', 'cat_id', 'post_title'] as $field) { | |
| if (!isset($postIndexes[$field])) { | |
| $errors[] = "La colonne {$field} manque dans [post]."; | |
| } | |
| } | |
| if ($errors !== []) { | |
| return $errors; | |
| } | |
| $categoryIds = []; | |
| foreach (array_slice($sections['category'], 1) as $lineNumber => $line) { | |
| if (trim($line) === '') { | |
| continue; | |
| } | |
| $fields = splitRawDotclearCsv($line); | |
| $id = decodeRawField($fields[$categoryIndexes['cat_id']] ?? ''); | |
| if ($id === '') { | |
| $errors[] = 'Une catégorie sans cat_id a été trouvée.'; | |
| continue; | |
| } | |
| $categoryIds[$id] = true; | |
| } | |
| $postIds = []; | |
| $postSource = []; | |
| foreach (array_slice($sections['post'], 1) as $lineNumber => $line) { | |
| if (trim($line) === '') { | |
| continue; | |
| } | |
| $fields = splitRawDotclearCsv($line); | |
| $postId = decodeRawField($fields[$postIndexes['post_id']] ?? ''); | |
| $categoryId = decodeRawField($fields[$postIndexes['cat_id']] ?? ''); | |
| $title = decodeRawField($fields[$postIndexes['post_title']] ?? ''); | |
| if ($postId === '') { | |
| $errors[] = 'Un billet sans post_id a été trouvé.'; | |
| continue; | |
| } | |
| if (isset($postIds[$postId])) { | |
| $errors[] = "post_id dupliqué : {$postId}."; | |
| } | |
| $postIds[$postId] = true; | |
| $postSource[$postId] = $title; | |
| // FlatImportV2 accepts 0 / empty as no category. | |
| if ($categoryId !== '' && $categoryId !== '0' && !isset($categoryIds[$categoryId])) { | |
| $errors[] = "Le post_id {$postId} (« {$title} ») référence la catégorie inexistante cat_id {$categoryId}."; | |
| } | |
| } | |
| foreach (['meta', 'comment', 'post_media', 'ping'] as $table) { | |
| if (!isset($sections[$table])) { | |
| continue; | |
| } | |
| $indexes = headerIndexes($sections[$table][0], $table); | |
| if (!isset($indexes['post_id'])) { | |
| $errors[] = "La colonne post_id manque dans [{$table}]."; | |
| continue; | |
| } | |
| foreach (array_slice($sections[$table], 1) as $line) { | |
| if (trim($line) === '') { | |
| continue; | |
| } | |
| $fields = splitRawDotclearCsv($line); | |
| $postId = decodeRawField($fields[$indexes['post_id']] ?? ''); | |
| if ($postId !== '' && $postId !== '0' && !isset($postIds[$postId])) { | |
| $errors[] = "La table [{$table}] référence le post_id inexistant {$postId}."; | |
| } | |
| } | |
| } | |
| return array_values(array_unique($errors)); | |
| } | |
| function prepareSingleBlogImport(string $content): array | |
| { | |
| $content = preg_replace('/^\xEF\xBB\xBF/', '', $content) ?? $content; | |
| $lines = preg_split('/\R/', $content); | |
| if ($lines === false || $lines === []) { | |
| throw new RuntimeException('Le fichier est vide ou illisible.'); | |
| } | |
| $firstNonEmpty = null; | |
| foreach ($lines as $line) { | |
| if (trim($line) !== '') { | |
| $firstNonEmpty = trim($line); | |
| break; | |
| } | |
| } | |
| if ($firstNonEmpty === null || !preg_match('/^\/\/\/DOTCLEAR\|[^|]+\|full$/', $firstNonEmpty)) { | |
| throw new RuntimeException('Export complet attendu : en-tête ///DOTCLEAR|…|full introuvable.'); | |
| } | |
| $allowedTables = array_fill_keys([ | |
| 'category', | |
| 'link', | |
| 'post', | |
| 'meta', | |
| 'media', | |
| 'post_media', | |
| 'ping', | |
| 'comment', | |
| ], true); | |
| // This is the relationship order expected by FlatImportV2::importSingle(). | |
| $outputOrder = ['category', 'link', 'post', 'meta', 'media', 'post_media', 'ping', 'comment']; | |
| $sections = []; | |
| $currentTable = null; | |
| $currentLines = []; | |
| $headerRead = false; | |
| $flush = static function () use (&$sections, &$currentTable, &$currentLines, $allowedTables): void { | |
| if ($currentTable !== null && isset($allowedTables[$currentTable])) { | |
| $sections[$currentTable] = $currentLines; | |
| } | |
| }; | |
| foreach ($lines as $line) { | |
| if (!$headerRead) { | |
| if (trim($line) === '') { | |
| continue; | |
| } | |
| $headerRead = true; | |
| continue; | |
| } | |
| if (preg_match('/^\[([a-z_]+)\s+.+\]$/i', trim($line), $matches)) { | |
| $flush(); | |
| $currentTable = strtolower($matches[1]); | |
| $currentLines = [$line]; | |
| continue; | |
| } | |
| if ($currentTable !== null && isset($allowedTables[$currentTable])) { | |
| $currentLines[] = $line; | |
| } | |
| } | |
| $flush(); | |
| foreach (['category', 'post'] as $required) { | |
| if (!isset($sections[$required])) { | |
| throw new RuntimeException("Section obligatoire [{$required}] absente."); | |
| } | |
| } | |
| $sections['post'] = addRequiredPostColumns($sections['post']); | |
| $relationshipErrors = validateRelations($sections); | |
| if ($relationshipErrors !== []) { | |
| $sample = array_slice($relationshipErrors, 0, 20); | |
| $suffix = count($relationshipErrors) > 20 ? "\n… et " . (count($relationshipErrors) - 20) . ' autre(s) erreur(s).' : ''; | |
| throw new RuntimeException("Le fichier source présente des relations non importables :\n- " . implode("\n- ", $sample) . $suffix); | |
| } | |
| $output = ["///DOTCLEAR|2.39.2|single", '']; | |
| $counts = []; | |
| foreach ($outputOrder as $table) { | |
| if (!isset($sections[$table])) { | |
| continue; | |
| } | |
| foreach ($sections[$table] as $line) { | |
| if ($line !== '') { | |
| $output[] = $line; | |
| } | |
| } | |
| $output[] = ''; | |
| $counts[$table] = countDataRows($sections[$table]); | |
| } | |
| return [implode("\n", $output) . "\n", $counts]; | |
| } | |
| if ($_SERVER['REQUEST_METHOD'] !== 'POST') { | |
| renderPage(); | |
| exit; | |
| } | |
| if (!isset($_FILES['backup']) || !is_array($_FILES['backup'])) { | |
| fail('Aucun fichier n’a été reçu.'); | |
| } | |
| $upload = $_FILES['backup']; | |
| $error = $upload['error'] ?? UPLOAD_ERR_NO_FILE; | |
| if ($error !== UPLOAD_ERR_OK) { | |
| $messages = [ | |
| UPLOAD_ERR_INI_SIZE => 'Le fichier dépasse upload_max_filesize dans php.ini.', | |
| UPLOAD_ERR_FORM_SIZE => 'Le fichier dépasse la limite du formulaire.', | |
| UPLOAD_ERR_PARTIAL => 'Le fichier n’a été envoyé que partiellement.', | |
| UPLOAD_ERR_NO_FILE => 'Aucun fichier n’a été envoyé.', | |
| UPLOAD_ERR_NO_TMP_DIR => 'Le dossier temporaire PHP est indisponible.', | |
| UPLOAD_ERR_CANT_WRITE => 'PHP n’a pas pu écrire le fichier temporaire.', | |
| UPLOAD_ERR_EXTENSION => 'L’envoi a été bloqué par une extension PHP.', | |
| ]; | |
| fail($messages[$error] ?? 'Erreur inconnue pendant l’upload.'); | |
| } | |
| if (!is_uploaded_file((string) $upload['tmp_name'])) { | |
| fail('Le fichier reçu n’est pas un upload HTTP valide.'); | |
| } | |
| if ((int) $upload['size'] > MAX_UPLOAD_BYTES) { | |
| fail('Le fichier dépasse la taille maximale autorisée.'); | |
| } | |
| $originalName = (string) ($upload['name'] ?? 'dotclear-export.txt'); | |
| if (!preg_match('/\.txt$/i', $originalName)) { | |
| fail('Merci de sélectionner un fichier texte .txt.'); | |
| } | |
| $content = file_get_contents((string) $upload['tmp_name']); | |
| if ($content === false) { | |
| fail('Impossible de lire le fichier envoyé.'); | |
| } | |
| try { | |
| [$prepared, $counts] = prepareSingleBlogImport($content); | |
| } catch (RuntimeException $exception) { | |
| fail($exception->getMessage()); | |
| } | |
| $baseName = pathinfo($originalName, PATHINFO_FILENAME); | |
| $baseName = preg_replace('/[^A-Za-z0-9._-]+/', '-', $baseName) ?: 'dotclear-export'; | |
| $downloadName = $baseName . '-single-blog-2.39.2-v5.txt'; | |
| header('Content-Type: text/plain; charset=UTF-8'); | |
| header('Content-Disposition: attachment; filename="' . $downloadName . '"'); | |
| header('Content-Length: ' . strlen($prepared)); | |
| header('X-Content-Type-Options: nosniff'); | |
| header('Cache-Control: no-store, max-age=0'); | |
| header('Pragma: no-cache'); | |
| header('X-Dotclear-Records-Kept: ' . array_sum($counts)); | |
| echo $prepared; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment