One small importer moves your existing changelog into Fizlog — every entry keeps its original date, and nobody gets pinged about old posts. Pick where you're coming from.
Imports posts through Beamer's REST API.
Read the guide →Imports from your public changelog's RSS feed.
Read the guide →Imports from your RSS or Atom feed.
Read the guide →Imports changelog entries through Canny's API.
Read the guide →Imports from the changelog RSS feed, full text included.
Read the guide →Imports titles, dates and excerpts from RSS.
Read the guide →A single PHP file (PHP 7.4+, no dependencies). It reads an RSS/Atom feed, Canny's or Beamer's API, or a JSON file, converts the content to Fizlog's Markdown, and publishes each entry through the Fizlog API with "silent": true and the original date. Nothing else is installed or stored.
<?php
// fizlog-import.php — copies an existing changelog into Fizlog through the public API.
// Needs PHP 7.4+ on the command line, nothing else. Save as fizlog-import.php, then:
//
// export FIZLOG_API_KEY=your_project_api_key # Fizlog → your project → API access
// php fizlog-import.php rss https://example.com/changelog/rss --dry-run
// php fizlog-import.php rss ./exported-feed.xml
// php fizlog-import.php canny YOUR_CANNY_API_KEY
// php fizlog-import.php beamer YOUR_BEAMER_API_KEY
// php fizlog-import.php json ./entries.json # [{"title","body","date","type"}, ...]
//
// --dry-run prints what would be imported without creating anything. Every entry keeps its
// original date and is sent with "silent": true, so no Slack/Discord webhook or subscriber
// email fires for old posts. Images are not imported (Fizlog entries are text + links).
$FIZLOG_URL = rtrim(getenv('FIZLOG_URL') ?: 'https://fizlog.com', '/');
$FIZLOG_KEY = getenv('FIZLOG_API_KEY') ?: '';
$args = array_values(array_filter(array_slice($argv, 1), fn($a) => $a !== '--dry-run'));
$dryRun = in_array('--dry-run', $argv, true);
[$source, $target] = $args + [null, null];
if (!$source || !$target) {
fwrite(STDERR, "Usage: php fizlog-import.php <rss|canny|beamer|json> <url|file|api-key> [--dry-run]\n");
exit(1);
}
if (!$dryRun && $FIZLOG_KEY === '') {
fwrite(STDERR, "Set FIZLOG_API_KEY first (or add --dry-run to preview).\n");
exit(1);
}
function http(string $method, string $url, array $headers = [], ?string $body = null): array {
$ctx = stream_context_create(['http' => [
'method' => $method, 'header' => implode("\r\n", $headers),
'content' => $body ?? '', 'ignore_errors' => true, 'timeout' => 30,
]]);
$res = @file_get_contents($url, false, $ctx);
$status = isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m) ? (int) $m[1] : 0;
if ($res === false) {
throw new RuntimeException("Request failed: $method $url");
}
return [$status, $res];
}
// Fizlog renders a small Markdown subset, so imported HTML is reduced to exactly that.
function html_to_markdown(string $html): string {
if (strpos($html, '<') === false) {
$md = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim(preg_replace('/^#{1,6}\s+(.+)$/m', '**$1**', $md)); // headings -> bold
}
$md = preg_replace('#<(script|style)[^>]*>.*?</\1>#is', '', $html);
$md = preg_replace('#<h[1-6][^>]*>(.*?)</h[1-6]>#is', "\n**$1**\n", $md);
$md = preg_replace('#<a [^>]*href="([^"]+)"[^>]*>(.*?)</a>#is', '[$2]($1)', $md);
$md = preg_replace('#<(strong|b)>(.*?)</\1>#is', '**$2**', $md);
$md = preg_replace('#<(em|i)>(.*?)</\1>#is', '*$2*', $md);
$md = preg_replace('#<code>(.*?)</code>#is', '`$1`', $md);
$md = preg_replace('#<li[^>]*>#i', "\n- ", $md);
$md = preg_replace('#<br\s*/?>|</(p|div|h[1-6]|ul|ol)>#i', "\n", $md);
$md = html_entity_decode(strip_tags($md), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$md = str_replace("\u{00A0}", ' ', $md); // spacer paragraphs
$md = preg_replace('/^[ \t]+|[ \t]+$/m', '', $md); // indentation left over from the HTML
return trim(preg_replace("/\n{3,}/", "\n\n", $md));
}
// Maps a vendor's label ("Fix", "Improvement", "New feature", ...) to new / improved / fixed.
function entry_type(string $label): string {
$l = strtolower($label);
if (strpos($l, 'fix') !== false || strpos($l, 'bug') !== false) return 'fixed';
if (strpos($l, 'improv') !== false || strpos($l, 'update') !== false) return 'improved';
return 'new';
}
function from_rss(string $src): array {
$xml = preg_match('#^https?://#', $src) ? http('GET', $src)[1] : file_get_contents($src);
$feed = simplexml_load_string($xml);
if (!$feed) throw new RuntimeException('Not a valid RSS/Atom feed.');
$out = [];
foreach ($feed->channel->item ?? [] as $it) { // RSS 2.0
$full = (string) ($it->children('http://purl.org/rss/1.0/modules/content/')->encoded ?? '');
$out[] = ['title' => (string) $it->title, 'body' => $full !== '' ? $full : (string) $it->description,
'date' => (string) $it->pubDate, 'type' => (string) ($it->category ?? '')];
}
foreach ($feed->entry ?? [] as $it) { // Atom
$out[] = ['title' => (string) $it->title, 'body' => (string) ($it->content ?: $it->summary),
'date' => (string) ($it->published ?: $it->updated),
'type' => isset($it->category) ? (string) $it->category['term'] : ''];
}
return $out;
}
function from_canny(string $apiKey): array {
$out = [];
for ($skip = 0; ; $skip += 100) {
[, $res] = http('POST', 'https://canny.io/api/v1/entries/list',
['Content-Type: application/x-www-form-urlencoded'],
http_build_query(['apiKey' => $apiKey, 'limit' => 100, 'skip' => $skip]));
$data = json_decode($res, true);
if (!isset($data['entries'])) throw new RuntimeException('Canny API error: ' . $res);
foreach ($data['entries'] as $e) {
if (empty($e['publishedAt'])) continue; // skip drafts
$out[] = ['title' => $e['title'], 'body' => $e['markdownDetails'] ?? '',
'date' => $e['publishedAt'], 'type' => $e['types'][0] ?? 'new'];
}
if (empty($data['hasMore'])) return $out;
}
}
function from_beamer(string $apiKey): array {
$out = [];
$seen = [];
for ($page = 1; $page <= 200; $page++) {
[$status, $res] = http('GET', 'https://api.getbeamer.com/v0/posts?page=' . $page, ['Beamer-Api-Key: ' . $apiKey]);
$posts = json_decode($res, true);
if ($status >= 400 || !is_array($posts)) throw new RuntimeException('Beamer API error: ' . $res);
if (!$posts) break;
foreach ($posts as $p) {
if (isset($p['id'], $seen[$p['id']])) break 2; // API ignored ?page — stop, don't loop forever
$seen[$p['id'] ?? count($seen)] = true;
$t = $p['translations'][0] ?? $p; // first language of the post
$out[] = ['title' => (string) ($t['title'] ?? ''), 'body' => (string) ($t['contentHtml'] ?? $t['content'] ?? ''),
'date' => (string) ($p['date'] ?? ''), 'type' => (string) ($p['category'] ?? $t['category'] ?? '')];
}
}
return $out;
}
try {
switch ($source) {
case 'rss': $entries = from_rss($target); break;
case 'canny': $entries = from_canny($target); break;
case 'beamer': $entries = from_beamer($target); break;
case 'json': $entries = json_decode(file_get_contents($target), true) ?: []; break;
default: throw new RuntimeException("Unknown source '$source'.");
}
} catch (Throwable $e) {
fwrite(STDERR, $e->getMessage() . "\n");
exit(1);
}
// Oldest first, so the import reads like the original history.
usort($entries, fn($a, $b) => strtotime($a['date'] ?? 'now') <=> strtotime($b['date'] ?? 'now'));
printf("%d entries found%s.\n", count($entries), $dryRun ? ' (dry run)' : '');
$ok = 0;
foreach ($entries as $e) {
$title = mb_substr(trim(html_entity_decode(strip_tags($e['title'] ?? ''), ENT_QUOTES, 'UTF-8')), 0, 160);
if ($title === '') continue;
$ts = strtotime($e['date'] ?? '') ?: time();
$payload = [
'type' => entry_type($e['type'] ?? ''), 'title' => $title,
'body' => html_to_markdown($e['body'] ?? ''),
'published_at' => gmdate('c', $ts), 'is_published' => true, 'silent' => true,
];
if ($dryRun) {
printf(" %s [%s] %s\n", gmdate('Y-m-d', $ts), $payload['type'], $title);
continue;
}
[$status, $res] = http('POST', $FIZLOG_URL . '/api/entries.php',
['Authorization: Bearer ' . $FIZLOG_KEY, 'Content-Type: application/json'], json_encode($payload));
if ($status === 201) {
$ok++;
printf(" ✓ %s\n", $title);
} else {
printf(" ✗ %s — HTTP %d %s\n", $title, $status, $res);
}
usleep(150000);
}
if (!$dryRun) printf("Imported %d of %d entries.\n", $ok, count($entries));
Coming from something else? Save your entries as JSON — [{"title": "...", "body": "...", "date": "2025-03-01", "type": "new"}] — and run php fizlog-import.php json entries.json.