Switch to Fizlog without losing your history

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.

From Beamer

Imports posts through Beamer's REST API.

Read the guide →

From Headway

Imports from your public changelog's RSS feed.

Read the guide →

From AnnounceKit

Imports from your RSS or Atom feed.

Read the guide →

From Canny

Imports changelog entries through Canny's API.

Read the guide →

From Featurebase

Imports from the changelog RSS feed, full text included.

Read the guide →

From LaunchNotes

Imports titles, dates and excerpts from RSS.

Read the guide →

The importer

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.

Download fizlog-import.php

Show the source
<?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); // &nbsp; 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.