<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>migration – ./blog.sh</title>
    <link>https://blogsh.app/tag/migration/</link>
    <atom:link href="https://blogsh.app/tag/migration/rss.xml" rel="self" type="application/rss+xml" />
    <description>Posts tagged migration on Daniel Šnor&#39;s personal site.</description>
    <language>en</language>
    <lastBuildDate>Sat, 19 Sep 2026 17:32:40 +0200</lastBuildDate>
    <item>
  <title>How did I set up blog.sh on my shared hosting</title>
  <link>https://blogsh.app/posts/2026/pavel-moved-a-wordpress-blog-off-a-shared/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/pavel-moved-a-wordpress-blog-off-a-shared/</guid>
  <pubDate>Sat, 19 Sep 2026 17:32:40 +0200</pubDate>
  <description><![CDATA[<p class="link-block">pavelchcepsat.cz</p>
<p>Pavel moved a WordPress blog off a shared host and onto blog.sh, over plain FTP. The write-up goes step by step: the WXR export, the import, rclone, a dry run, the deploy.</p>
<p>Worth a read if your site lives on a host that gives you FTP and nothing else.</p>
<p>#blogsh #wordpress #staticsite</p>]]></description>
  <category>migration</category><category>deploy</category>
</item>
<item>
  <title>Posts nobody meant to publish</title>
  <link>https://blogsh.app/posts/2026/posts-nobody-meant-to-publish/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/posts-nobody-meant-to-publish/</guid>
  <pubDate>Tue, 01 Sep 2026 10:21:00 +0200</pubDate>
  <description><![CDATA[<p>An importer&#39;s worst failure is not losing a post. It is publishing one.</p>
<p>1.5 went through all eleven import adapters and the machinery underneath them looking for exactly that, and found it three times over, in three different files, made the same way each time.</p>
<h2 id="an-archive-is-the-whole-account">An archive is the whole account</h2>
<p>A Mastodon export is not your public timeline. The outbox holds your followers-only posts and your direct messages beside the public ones — and every standalone toot in it was being written as published. On the archive this was measured against that is 141 posts of 2,548, of which 132 are direct messages, each given its own page, its own sitemap entry and its own item in the feed. Nothing in the run&#39;s summary mentioned it.</p>
<p>The scope was in the export all along, exactly where ActivityPub puts it. Public in <code>to</code> is public. Public in <code>cc</code> is unlisted — a real address, out of the listings. Followers-only and direct become drafts: that keeps the author&#39;s words in the archive and off the site, which is the only reading of &quot;not public&quot; that cannot publish something by accident.</p>
<h2 id="gated-at-serve-time-is-not-gated">Gated at serve time is not gated</h2>
<p>Ghost ships the full body of every members-only and paid post in its export, because a Ghost site decides who you are when it serves the page. Read by anything that does not make that check, every word anybody paid for is plain text sitting in a file.</p>
<p>They arrived published, untagged and uncounted. They now arrive as drafts, tagged, and counted in the summary — the reversible half of the decision, and a number the person running the import can act on.</p>
<h2 id="a-comment-on-the-wrong-line">A comment on the wrong line</h2>
<p>Hugo&#39;s TOML front matter says <code>draft = true</code>. Written as <code>draft = true # nekdy pozdeji</code> — a note to yourself, on precisely the line where a person writes one — the value scanner took the comment as part of the value. That made the flag a truthy string rather than the boolean it looks like, and a truthy string is not <code>true</code>, so the post imported as published.</p>
<p>Somebody&#39;s unfinished writing went onto the open web because they annotated the flag that was supposed to hold it back.</p>
<h2 id="the-rule-underneath">The rule underneath</h2>
<p>All three are one mistake wearing different clothes: the importer treated anything it could not classify as public. The rule runs the other way now. Not being able to tell whether something is publishable is a reason not to publish it — and to say so in the summary, with a count.</p>
<p>An import you have to audit afterwards is one you cannot trust. The point of a number in the summary is that you should not have to go looking.</p>]]></description>
  <category>migration</category><category>philosophy</category>
</item>
<item>
  <title>My fixtures agreed with me</title>
  <link>https://blogsh.app/posts/2026/my-fixtures-agreed-with-me/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/my-fixtures-agreed-with-me/</guid>
  <pubDate>Mon, 24 Aug 2026 10:11:00 +0200</pubDate>
  <description><![CDATA[<p>The importers had a good test suite. Every source had fixtures, the fixtures were parsed, the results were asserted, and everything passed.</p>
<p>Then I ran the importers over three archives I had not made: a Ghost export of 118 posts and 419 images, a Hugo tree of 77 files, and a WordPress WXR of 194 items. Alongside them, a fourth that is entirely mine — this engine&#39;s own export, read straight back in — because that one is not asking whether the code survives a stranger, it is asking whether the two halves of a round trip still agree.</p>
<p>The three foreign ones were the point, though. Fixtures had agreed with the code because both ends of them are mine.</p>
<h2 id="the-picture-nobody-failed-to-download">The picture nobody failed to download</h2>
<p><code>{{&lt; figure &gt;}}</code> is how Hugo writes a picture, and the Liquid stripper ate it as a Liquid tag. Both of the ones in that archive pointed at files lying right next to the article in its page bundle: nothing to download, nothing that could fail, and they were gone without a line in the summary — because nobody had tried to fetch them, so nobody had failed.</p>
<p>That is the shape of every finding in this pass. Not crashes. Silence.</p>
<h2 id="the-promise-that-held-for-three-per-cent">The promise that held for three per cent</h2>
<p><i>Images come from the tree itself — no network.</i> True of a Jekyll site, and repeated as a general promise about markdown trees in four places: the docs, the README&#39;s comparison table, the importer&#39;s header, and the comment above the adapter.</p>
<p>Of 79 image references in that real Hugo archive, 72 were absolute URLs pointing back at the WordPress the site had been migrated from years earlier. The import made 72 requests, 66 answered 404, and 20 of the 74 posts arrived with no pictures at all.</p>
<p>The summary reported all of that honestly. The documentation had told the reader there was nothing to report, so there was no reason to read it. All four places now say which half needs the network, give the number from that archive, and tell you to import while the old host still answers.</p>
<h2 id="a-fifth-of-a-site-with-urls-in-its-headings">A fifth of a site with URLs in its headings</h2>
<p><code>[](url)</code> is what a WordPress-to-Markdown conversion leaves behind where a heading anchor used to be. The parser required at least one character of label, so it matched nothing and printed the raw markdown to the reader: 153 heading blocks across 14 of those 74 posts, square brackets and a full URL in the heading, five of them welded onto the end of the actual words. The table of contents linked to anchors named after github.com.</p>
<h2 id="and-a-few-more">And a few more</h2>
<p>A video uploaded to Ghost was lost and three lines of its player were published in its place. A percent-escaped filename was slugified character by character, minting permanent addresses beginning <code>ef-bf-bc</code>. An article that showed markdown inside a code block had its example eaten — and chasing that turned up something worse underneath: a paragraph ending in a picture came back a newline short, so the fence under it stuck to the text and every fence from there down was read inside-out, code as prose and prose as code. On that archive, 30 of its 72 images each did that to everything below them.</p>
<p>What the HTML converter has to drop — players, iframes, forms — was counted by exactly one of the eleven adapters that use it and thrown away by the other ten, so a WordPress import named its losses and a Jekyll import of the same body said &quot;Done.&quot;</p>
<h2 id="the-lesson-is-cheap-and-i-keep-re-learning-it">The lesson is cheap and I keep re-learning it</h2>
<p>A fixture I wrote encodes what I believed when I wrote it. A stranger&#39;s archive encodes what actually happened to their blog over fifteen years, on platforms that changed under them, exported by a tool that had its own bad day.</p>
<p>Only one of those two can tell me I am wrong.</p>]]></description>
  <category>migration</category><category>philosophy</category>
</item>
<item>
  <title>An import only ever adds</title>
  <link>https://blogsh.app/posts/2026/an-import-only-ever-adds/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/an-import-only-ever-adds/</guid>
  <pubDate>Fri, 21 Aug 2026 10:51:00 +0200</pubDate>
  <description><![CDATA[<p>Re-importing an archive used to be a thing you did carefully, at night, after a backup. The run went out and fetched every picture again — pictures the archive already held, byte for byte — and each of those downloads landed on top of a file that was already correct.</p>
<p>That is fixed, and the fix is one sentence: a media file&#39;s identity is the address it came from.</p>
<h2 id="the-archive-already-knows">The archive already knows</h2>
<p>Every media entry records its <code>src</code>. A re-import compares what the source offers against what the archive holds, and fetches only what is missing. Over a complete archive that is nothing at all — the second run of the same import downloads not one picture, and says so in the summary.</p>
<p>The source itself is still read, and that is the honest limit of the claim: an export sitting on your disk costs nothing, while a feed or an API is fetched every time, because there is no other way to find out whether anything changed.</p>
<p>Here is a real Ghost import run a second time over an archive that already holds it:</p>
<pre class="code-block"><code>Done. 118 post(s) written, 419 media file(s).
  419 of them are already in this archive and come from there, not
  from the source.
  18 post(s) were scheduled in Ghost -- imported as drafts; schedule
  them here when you&#39;re ready.</code></pre>
<p>Three seconds. Four hundred and nineteen pictures, not one of them fetched.</p>
<h2 id="never-replaced-not-by-a-flag-not-by-anything">Never replaced. Not by a flag, not by anything</h2>
<p>A file already in the archive is never overwritten. Not by a re-import, not by <code>REFETCH_MEDIA=1</code>, which only makes the run ask the source about every address again instead of trusting its own records — what lands on disk is still just the files that were missing.</p>
<p>The reasoning is that the bytes an import brings come from somewhere the engine cannot vouch for. A CDN that re-encodes, a platform that starts watermarking, an export that was built wrong: any of those, trusted once, silently replaces originals you cannot get back. The archive wins every disagreement.</p>
<p>When a download no longer matches the copy the archive keeps, the download is discarded and counted. That counter is the one honest signal that the source has drifted away from your archive — and it counts bytes from outside only, so a post that reuses a picture from another post is not mistaken for drift.</p>
<h2 id="a-picture-that-leaves-and-comes-back">A picture that leaves and comes back</h2>
<p>Sources edit themselves. A photo drops out of a post in March and reappears in June, and naïvely each of those is a new file: the picture leaves, the name is freed, the return mints a byte-identical duplicate under a fresh number.</p>
<p>Now the post&#39;s kept versions remember the address a file was stored under, so a returning picture takes back the name it had. Failing that, the file&#39;s own bytes answer — identity by content, checked always rather than only when a name collides. An entry that never carried an address at all is recognised the same way.</p>
<p>The point of all that machinery is one property: a drop-and-return cycle does not grow the post&#39;s directory, and a re-import a year from now writes the same tree it wrote today.</p>
<h2 id="and-the-post-describes-what-is-really-there">And the post describes what is really there</h2>
<p>Width and height are re-read from the archive&#39;s own copy once the media is in place, so a post cannot claim dimensions over a file that never arrived. A copy that cannot be measured leaves the previous record standing. An entry whose file is missing entirely is left saying exactly what it said — and <code>./blog.sh check</code> is the tool that finds it, because nothing an import does repairs a damaged file.</p>
<p>Importing should be boring. Run it again on Tuesday and find out that nothing happened: that is the feature.</p>]]></description>
  <category>migration</category>
</item>
<item>
  <title>Most of your Facebook isn&#39;t yours</title>
  <link>https://blogsh.app/posts/2026/most-of-your-facebook-isnt-yours/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/most-of-your-facebook-isnt-yours/</guid>
  <pubDate>Thu, 13 Aug 2026 16:42:00 +0200</pubDate>
  <description><![CDATA[<p>The Facebook importer was built against a real &quot;Download Your Information&quot; export. The first useful thing it told me was a number I did not expect.</p>
<p><b>On that export, 95 % of the posts were not written on Facebook.</b> They were mirrored in — from Twitter, from Posterous, from the whole crosspost era when every service politely offered to repost you everywhere else.</p>
<h2 id="they-are-skipped-and-counted">They are skipped, and counted</h2>
<p>Those posts do not import. Not because they are worthless, but because the platforms they actually came from have their own exports, and those carry the originals: the full text, the real timestamps, the media at the size it was uploaded. A Facebook mirror of a tweet is a worse copy of something you can import properly from the Twitter archive sitting in the same folder.</p>
<p>Import both and your archive doubles, with the lesser version winning half the time. So they are recognized and skipped with a count in the summary, and <code>FACEBOOK_CROSSPOSTS=1</code> overrides it if you disagree. Wordless check-ins and app stories are counted skips too.</p>
<p>That leaves your own posts, with the photos and videos from the archive itself.</p>
<h2 id="both-formats-read-the-same">Both formats read the same</h2>
<p>Meta lets you choose JSON or HTML on the way out, and people pick without knowing it matters. Here it doesn&#39;t: both are read, and they come out epoch-identical, down to the ids minted for re-import matching.</p>
<p>Getting there was the fiddly part. The JSON carries plain timestamps. The HTML prints a wall clock — with seconds, in the account&#39;s own timezone, daylight saving observed — which had to be measured against the JSON of the same account to be sure. And the HTML&#39;s dates arrive localized, so Czech and English are both understood, and a language the importer does not recognize skips what it cannot date and says so rather than guessing a month name.</p>
<h2 id="threads-the-twenty-second">Threads, the twenty-second</h2>
<p>Threads closes the social-network roster, and its export has the strangest shape of the family: every post is a media list even when there is no media, with the text riding in a title field. The HTML page is read back into that same odd shape so both formats walk one mapping.</p>
<p>Ask for JSON where you get the choice. Only the JSON marks replies — an HTML run ends by saying so every time, because it cannot know whether there was anything to miss — and only the JSON carries seconds. The HTML page prints minutes, in Meta&#39;s fixed no-daylight-saving Pacific clock, which converts back exact to the minute against the same account&#39;s JSON.</p>
<p>One flag in that export is a trap worth naming: <code>cross_post_source</code> looks like the crosspost marker, and on real exports it also marks posts written directly in the Threads app. It records where a post was shared <i>to</i>. Skipping on it would empty your archive.</p>
<h2 id="the-encoding-finally">The encoding, finally</h2>
<p>Meta&#39;s exports have mangled non-ASCII text for years — the bytes are UTF-8 read as Latin-1 and re-encoded, so every accented letter arrives as gibberish. The Instagram importer proved out a repair for it; it now lives in a shared module the whole Meta family uses.</p>
<p>Which means Czech, German, French and everyone else gets their own words back, instead of an archive of ÄŤ and Ã©.</p>
<h2 id="the-part-that-stayed-with-me">The part that stayed with me</h2>
<p>Nineteen out of twenty posts on that account were copies of things written somewhere else — somewhere that has usually since closed. The crosspost era felt like insurance at the time. It turned out to be the opposite: the copies outlived the originals, in a worse form, on a platform that will not hand them back in one piece.</p>
<p>Ask for the export. From all of them.</p>]]></description>
  <category>migration</category>
</item>
<item>
  <title>Rescuing a blog that died in 2013</title>
  <link>https://blogsh.app/posts/2026/rescuing-a-blog-that-died-in-2013/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/rescuing-a-blog-that-died-in-2013/</guid>
  <pubDate>Wed, 12 Aug 2026 16:32:00 +0200</pubDate>
  <description><![CDATA[<p>Every importer here reads an export file. That works right up until the platform is gone — and then there is no export to ask for, no admin to log into, and no account to recover. Posterous shut down in 2013. Blog.cz is gone. Your host is next, eventually.</p>
<p>For those blogs the twentieth import source is the Internet Archive.</p>
<h2 id="feed-mode-what-the-crawler-kept">Feed mode: what the crawler kept</h2>
<p>The Archive did something useful without meaning to. For years it crawled blogs&#39; RSS feeds — over and over, every few weeks, whenever a crawler happened past. Each capture is a snapshot of the last ten or twenty posts at that moment. Line them up oldest first and the overlaps reconstruct a history nobody saved.</p>
<p>That is what the importer does: reads the CDX index for the feed&#39;s address, deduplicates by digest so a hundred identical captures cost one fetch, pulls the original bytes through the <code>id_</code> endpoint so the Archive&#39;s own toolbar never lands in your posts, and merges the overlapping windows the same way a re-import merges anything else.</p>
<p>Images come back through the same time machine.</p>
<p>Verified by rescuing posts out of a Posterous blog that has been dead for thirteen years.</p>
<h2 id="page-mode-when-there-was-never-a-feed">Page mode: when there was never a feed</h2>
<p>Plenty of blogs the Archive crawled had their feed missed entirely. Those fall through to page mode: every archived post page, newest capture of each.</p>
<p>The hard part is that an archived page is just HTML from an era with no conventions. So platform packs say which paths are posts and how that platform&#39;s markup spells a title, a date and a body. <b>Blog.cz ships built in</b> — <code>/YYMM/slug</code> addresses, the article div, Czech long-form dates, and the windows-1250 encodings of the period converted on the way in. <b>B2evolution ships too</b>, built from a real 2008 skin rather than from documentation: the stock <code>bText</code> template that skins almost never replaced, <code>h3.bTitle</code> titles, tags behind their localized label, and the two-digit <code>y/m/d</code> dates read in the order the template source says — which no rendered page could have told you.</p>
<p>Without a pack, <code>POST_PATTERN</code> covers the shape by hand. With neither, the run refuses and prints sample archived paths to build a pattern from, rather than guessing.</p>
<h2 id="what-it-will-not-pretend">What it will not pretend</h2>
<p>This is the part I care about most, because a rescue tool that quietly invents things is worse than none.</p>
<p>What the crawler never met stays lost, and the run says so. Unreadable captures are counted. Pages that can only be dated by when they were captured are counted separately, not silently backdated. And a missing image — which the Archive serves as an HTML error page with a cheerful <code>200</code> — is caught by failing to measure as an image, counted as lost, rather than saved as a broken file that looks fine in a listing.</p>
<p>A busy Archive is waited out rather than read as a blog that was never there.</p>
<h2 id="what-you-get-back">What you get back</h2>
<p>Posts, with their dates, at permanent addresses, with whatever images survived, tagged with their origin, in a folder you own. Not a screenshot, not a PDF of a page — the same JSON files every other post on your site is made of, editable, searchable, exportable again.</p>
<p>The Archive kept a copy of your blog because it keeps a copy of everything. This just reads it back into something you can use.</p>]]></description>
  <category>migration</category>
</item>
<item>
  <title>A blog engine that became an archive</title>
  <link>https://blogsh.app/posts/2026/a-blog-engine-that-became-an-archive/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/a-blog-engine-that-became-an-archive/</guid>
  <pubDate>Tue, 11 Aug 2026 10:21:00 +0200</pubDate>
  <description><![CDATA[<p>The first post on this site is about a photo I put on Instagram in 2012. The link still works. The photo doesn&#39;t.</p>
<p>That post was about one archive — mine — and one rule: media comes home. This one is about what happened when that rule was applied to every platform I could find an export for.</p>
<h2 id="the-roster-is-closed">The roster is closed</h2>
<p>Twenty-two sources import now, in three groups.</p>
<p><b>Social networks:</b> Bluesky, Facebook, Instagram, Mastodon, Pixelfed, Threads, Twitter/X.</p>
<p><b>Blogs and publishing platforms:</b> beehiiv, Blogger, Ghost, Jekyll/Hugo and any markdown folder, LiveJournal, Medium, Movable Type and TypePad, podcasts (any feed with audio), Squarespace, Substack, Tumblr, Wix, WordPress and any RSS or Atom feed.</p>
<p><b>And dead sites:</b> the Wayback Machine, for blogs whose platform no longer exists at all.</p>
<figure><img src="https://blogsh.app/posts/2026/a-blog-engine-that-became-an-archive/01.png" width="1024" height="720" alt="The import wizard: three groups, with the blog platforms expanded" loading="lazy" decoding="async"><figcaption>Twenty-two sources behind twenty-one choices: WordPress and a plain feed share one</figcaption></figure>
<p>That last group is the one I did not expect to build, and it is the reason this post exists.</p>
<h2 id="what-changed-isn-t-the-count">What changed isn&#39;t the count</h2>
<p>An engine with eight importers is a blog engine that can read your old blog. An engine with twenty-two, covering every social network you are likely to have posted to, is something else: it is a place to put the things you wrote on other people&#39;s websites.</p>
<p>So here is the claim, plainly. <b>This is a blog. You can also use it as an archive of your social networks.</b> Point it at an export, and what comes out the other side is a folder of JSON files and the actual image and video and audio files, on your disk, with the origin recorded as a tag and every post reachable at a permanent address.</p>
<p>You do not have to put any of it on the web. The import writes the files and then asks whether to rebuild and deploy; answer no and the archive stays on your disk. An archive that only you read is still an archive.</p>
<h2 id="why-the-exports-and-not-the-apis">Why the exports and not the APIs</h2>
<p>Every importer here reads a file you asked the platform for, not a live API. That was a decision, and it holds up better every year: an export is a legal right in most of the world, it does not expire, it does not get repriced, and it does not need a developer account that a policy change can revoke. The three sources that talk to a network at all — Bluesky, Tumblr, LiveJournal — do it because those networks hand a public archive to anyone who asks, no token required.</p>
<p>The Facebook importer is built on Meta&#39;s own &quot;Download Your Information&quot;. The Threads one reads the same export. Instagram, Mastodon, Pixelfed, Twitter, all the same shape: a file, not a permission.</p>
<h2 id="the-uncomfortable-arithmetic">The uncomfortable arithmetic</h2>
<p>On the reference Facebook export used to build that importer, <b>95 % of the posts were mirrored in from somewhere else</b> — Twitter, Posterous, the whole crosspost era. They are recognized and skipped, with a count, because those platforms&#39; own exports carry the originals and importing both would double your archive with worse copies.</p>
<p>That number stayed with me. Nineteen out of twenty things on that account were not really written there. Whatever you think you have on any one platform, a good deal of it is a copy of something you put somewhere else — and the somewhere else has usually closed.</p>
<h2 id="the-point-again">The point, again</h2>
<p>An archive is only yours if the files are yours. Fourteen years was long enough to watch a few landlords disappear; twenty-two importers is what it took to stop renting.</p>
<p>Every source, and what each export does and doesn&#39;t contain, is in <a href="https://github.com/DanielSnor/blog.sh/blob/main/docs/importing.md">docs/importing.md</a>.</p>]]></description>
  <category>migration</category><category>philosophy</category><category>archive</category>
</item>
<item>
  <title>Fourteen years, and every image came home</title>
  <link>https://blogsh.app/posts/2026/fourteen-years-every-image-came-home/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/fourteen-years-every-image-came-home/</guid>
  <pubDate>Fri, 31 Jul 2026 22:17:35 +0000</pubDate>
  <description><![CDATA[<p>In 2012 I posted a photo to Instagram. The link still works. The photo doesn&#39;t.</p>
<p>If you kept a blog anywhere between 2010 and now, you know the drill. The platform got acquired, or pivoted, or &quot;sunset some legacy infrastructure&quot;, and the images you embedded from their CDN quietly turned into grey rectangles. My personal archive goes back fourteen years across Tumblr, Twitter, Mastodon and a couple of other places — and a depressing number of its images now live at addresses that answer with nothing at all.</p>
<p>Here&#39;s what I mean, from my own archive. On February 22, 2012, I published a blog post called <i>#53: Messengers of Spring?</i> and embedded its photo straight from Instagram&#39;s CDN, the way everyone did back then. This is that link today:</p>
<figure><img src="https://blogsh.app/posts/2026/fourteen-years-every-image-came-home/01.png" width="1280" height="720" alt="Browser error page saying the server IP address of distilleryimage1.instagram.com could not be found" loading="lazy" decoding="async"><figcaption>distilleryimage1.instagram.com, fourteen years later</figcaption></figure>
<p>Notice it&#39;s not even a polite 404. <code>distilleryimage1.instagram.com</code> doesn&#39;t resolve at all — the photo is gone along with the entire piece of infrastructure that used to serve it. <a href="https://www.sean.cz/posts/2012/messengers-of-spring/">The post itself is still alive</a>, imported into this engine, minus the one photo nobody can download anymore.</p>
<p>Here&#39;s the uncomfortable part: the post was mine. The photo was mine. The URL never was.</p>
<h2 id="what-the-importer-does-about-it">What the importer does about it</h2>
<p>./blog.sh ships importers for twenty-two sources, and they all follow one stubborn rule: <b>media comes home</b>. Every image, video and audio file gets downloaded next to its post, into <code>media.nosync/&lt;year&gt;/&lt;slug&gt;/</code>, and the post references the local copy from then on. No hotlinks, no third-party CDN, no &quot;this content is no longer available&quot;.</p>
<p>A few details I&#39;m fond of:</p>
<ul><li>Downloads are measured on arrival. The build refuses to render an image without known dimensions — so the importer simply never writes one.</li><li>Failed downloads get retried. And when a file genuinely can&#39;t be fetched anymore, because the source deleted it years ago, you lose that one image — not the post.</li><li>The origin becomes a tag. Import an old Tumblr blog and every post lands tagged <code>tumblr</code>. Your old blog turns into a browsable archive instead of a zip file in a drawer.</li></ul>
<h2 id="the-numbers">The numbers</h2>
<p>From a real migration of a real fourteen-year archive:</p>
<div class="table-wrap"><table><thead><tr><th>Source</th><th style="text-align:right">Items in the archive</th><th>Notes</th></tr></thead><tbody><tr><td>Mastodon</td><td style="text-align:right">6,591</td><td>2,984 replies and 1,059 boosts skipped on purpose</td></tr><tr><td>Pixelfed</td><td style="text-align:right">333</td><td>two accounts, 571 media attachments</td></tr><tr><td>Tumblr</td><td style="text-align:right">1,099</td><td>four separate blogs, importing 1,247 media files that came home with them</td></tr><tr><td>Twitter</td><td style="text-align:right">5,388</td><td>2008–2022, with 1,134 replies and 417 retweets in the pile — and 610 media files</td></tr></tbody></table></div>
<p>Here&#39;s a handful from that archive — photos that spent years on one platform or another, and now live as files next to this post:</p>
<div class="photo-grid"><figure><img src="https://blogsh.app/posts/2026/fourteen-years-every-image-came-home/02.jpg" width="640" height="640" alt="The Astronomical Clock in Prague at night" loading="lazy" decoding="async"><figcaption>The Astronomical Clock — Hipstamatic, 2019</figcaption></figure><figure><img src="https://blogsh.app/posts/2026/fourteen-years-every-image-came-home/03.jpg" width="640" height="640" alt="Prague Castle photographed through a tower filter" loading="lazy" decoding="async"><figcaption>Prague Castle — Hipstamatic, 2019</figcaption></figure><figure><img src="https://blogsh.app/posts/2026/fourteen-years-every-image-came-home/04.jpg" width="640" height="640" alt="The Emauzy monastery towers" loading="lazy" decoding="async"><figcaption>Emauzy — Hipstamatic, 2019</figcaption></figure><figure><img src="https://blogsh.app/posts/2026/fourteen-years-every-image-came-home/05.jpg" width="1280" height="1280" alt="A metal sculpture lit at night" loading="lazy" decoding="async"><figcaption>Spholio, a sculpture at night — 2022</figcaption></figure><figure class="span-2"><img src="https://blogsh.app/posts/2026/fourteen-years-every-image-came-home/06.jpg" width="1280" height="1707" alt="A photo taken on a Palm phone" loading="lazy" decoding="async"><figcaption>Shot on a Palm phone — 2019, because the archive keeps the weird experiments too</figcaption></figure></div>
<h2 id="the-point">The point</h2>
<p>None of this is clever engineering. Downloading a file and putting it in a folder is about as advanced as computing gets. The clever part was done by every platform that convinced us it wasn&#39;t necessary.</p>
<p>An archive is only yours if the files are yours. Everything else is a lease — and fourteen years is long enough to watch a few landlords disappear.</p>]]></description>
  <category>migration</category><category>philosophy</category>
</item>

  </channel>
</rss>
