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)`, ); } }