From 7303509b6fa6d0ebfbc7c5109891768bbc76b416 Mon Sep 17 00:00:00 2001 From: khannurien Date: Tue, 11 Aug 2026 19:51:49 +0000 Subject: [PATCH] v3: fix migration 0011 mistaking real og:images for favicons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0011 tested for "favicon" anywhere in the path, so mirtitles.org's declared og:image — /wp-content/uploads/2022/04/mir-logo-favicon.png — was moved into faviconUrl and its thumbnailUrl cleared. Without a thumbnail, hasThumbnail() is false and the journal mosaic demoted the dump from an image card to a pull-quote. A blanket .svg match did the same to real artwork (tanibis.net's header.svg). 0012 judges an icon by its filename rather than the whole path: named favicon…/apple-touch-icon…/icon…, living in an icons/ directory, or ending in .ico. That spares mir-logo-favicon.png and also catches icon_SEARCH.png, which 0011 missed. It runs in both directions, and is deliberately narrow when reversing — a value is only promoted back to thumbnailUrl if it matched 0011's rule but not this one, so rows written by a normal fetch aren't disturbed. On the dev database: 4 thumbnails restored, 1 icon reclassified, real favicons left alone, and a second pass rewrites nothing. Co-Authored-By: Claude Opus 5 --- api/db/migrate.ts | 5 + .../0012_fix_favicon_reclassification.ts | 100 ++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 api/db/migrations/0012_fix_favicon_reclassification.ts diff --git a/api/db/migrate.ts b/api/db/migrate.ts index b3b04cf..dd84913 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -10,6 +10,7 @@ import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts"; import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts"; import { up as up0010YoutubeEmbedStart } from "./migrations/0010_youtube_embed_start.ts"; import { up as up0011SplitFaviconThumbnail } from "./migrations/0011_split_favicon_thumbnail.ts"; +import { up as up0012FixFaviconReclassification } from "./migrations/0012_fix_favicon_reclassification.ts"; interface Migration { name: string; @@ -31,6 +32,10 @@ const MIGRATIONS: Migration[] = [ { name: "0009_chat_reply", up: up0009ChatReply }, { name: "0010_youtube_embed_start", up: up0010YoutubeEmbedStart }, { name: "0011_split_favicon_thumbnail", up: up0011SplitFaviconThumbnail }, + { + name: "0012_fix_favicon_reclassification", + up: up0012FixFaviconReclassification, + }, ]; export function runMigrations(db: DatabaseSync): void { diff --git a/api/db/migrations/0012_fix_favicon_reclassification.ts b/api/db/migrations/0012_fix_favicon_reclassification.ts new file mode 100644 index 0000000..438cdf6 --- /dev/null +++ b/api/db/migrations/0012_fix_favicon_reclassification.ts @@ -0,0 +1,100 @@ +import type { DatabaseSync } from "node:sqlite"; + +// Repairs 0011, which was too eager about what counts as a site icon. +// +// 0011 tested for "favicon" anywhere in the path, so a legitimate `og:image` +// like mirtitles.org's `/wp-content/uploads/2022/04/mir-logo-favicon.png` was +// moved into `faviconUrl` and its `thumbnailUrl` cleared — which also demoted +// the dump from an image card to a pull-quote in the journal mosaic. A blanket +// `.svg` match had the same effect on real artwork. +// +// `isIconUrl` here looks at the *filename* rather than the whole path: an icon +// is named `favicon…`, `apple-touch-icon…` or `icon…`, lives in an `icons/` +// directory, or ends in `.ico`. That both spares `mir-logo-favicon.png` and +// catches `icon_SEARCH.png`, which 0011 missed. +// +// Applied in both directions, and deliberately narrow when reversing: a value +// is only promoted back to `thumbnailUrl` if it matches 0011's rule but not +// this one, so rows written by a normal fetch aren't disturbed. + +/** 0011's rule, kept verbatim so its false positives can be identified. */ +function wasIconUrl0011(raw: string): boolean { + let pathname: string; + try { + pathname = new URL(raw).pathname; + } catch { + return false; + } + return /favicon|apple-touch-icon|\/icons?\//i.test(pathname) || + /\.(ico|svg)$/i.test(pathname); +} + +/** Whether a URL names a site icon, judged on its filename and directory. */ +function isIconUrl(raw: string): boolean { + let pathname: string; + try { + pathname = new URL(raw).pathname; + } catch { + return false; + } + const segments = pathname.split("/").filter(Boolean); + const filename = segments.pop() ?? ""; + if (/\.ico$/i.test(filename)) return true; + if (segments.some((s) => /^(favicons?|icons?)$/i.test(s))) return true; + const basename = filename.replace(/\.[^.]+$/, ""); + return /^(favicon|apple-touch-icon|icon)(?=$|[-_.\d])/i.test(basename); +} + +export function up(db: DatabaseSync): void { + const rows = db.prepare( + `SELECT id, rich_content FROM dumps + WHERE kind = 'url' AND rich_content IS NOT NULL;`, + ).all() as { id: string; rich_content: string }[]; + + const update = db.prepare( + `UPDATE dumps SET rich_content = ? WHERE id = ?;`, + ); + + let restored = 0; + let reclassified = 0; + for (const row of rows) { + let rich: { thumbnailUrl?: string; faviconUrl?: string }; + try { + rich = JSON.parse(row.rich_content); + } catch { + continue; // malformed payload — leave it untouched + } + + // Artwork 0011 mistook for an icon: put it back. + if ( + rich.faviconUrl && !rich.thumbnailUrl && + wasIconUrl0011(rich.faviconUrl) && !isIconUrl(rich.faviconUrl) + ) { + const { faviconUrl: _dropped, ...rest } = rich; + update.run( + JSON.stringify({ ...rest, thumbnailUrl: rich.faviconUrl }), + row.id, + ); + restored++; + continue; + } + + // An icon 0011's rule didn't recognize: move it now. + if ( + rich.thumbnailUrl && !rich.faviconUrl && isIconUrl(rich.thumbnailUrl) + ) { + const { thumbnailUrl: _dropped, ...rest } = rich; + update.run( + JSON.stringify({ ...rest, faviconUrl: rich.thumbnailUrl }), + row.id, + ); + reclassified++; + } + } + + if (restored > 0 || reclassified > 0) { + console.log( + `[migrate] 0012: restored ${restored} thumbnail(s), reclassified ${reclassified} icon(s)`, + ); + } +}