Skip to content

Instantly share code, notes, and snippets.

@geoffreycrofte
Created August 22, 2026 21:26
Show Gist options
  • Select an option

  • Save geoffreycrofte/41a68d162267b3814b3c09fc05d970fa to your computer and use it in GitHub Desktop.

Select an option

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
<?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