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