/** * Moves favicon-shaped values out of `rich_content.thumbnailUrl` into the new * `faviconUrl` field. * * The extraction cascade used to end at the page's icon and then at a guessed * `${origin}/favicon.ico`, so `thumbnailUrl` was almost never empty — it just * held a 16×16 icon (or a 404) that the UI then cover-cropped into a 128×72 * box. The cascade now stops at real artwork, and an absent `thumbnailUrl` * means "no artwork", which is what lets the frontend draw a placeholder. * This migration gives rows written before that change the same meaning. * * Purely local — it classifies the already-stored URL and makes no network * calls, so `accentColor` is deliberately not backfilled: the frontend derives * a stable hue from the hostname whenever one is missing, and * `refreshDumpMetadata` fetches the real color on demand. * * Idempotent: rows that already carry a `faviconUrl` are skipped, so a fresh * database built from schema.sql is a no-op. * * @module */ import type { DatabaseSync } from "node:sqlite"; /** * Checks whether a stored thumbnail URL is really a site icon. * * Deliberately loose. A false positive (a genuine cover image living under * `/assets/icons/`) renders contained on a tinted field instead of * cover-cropped — mildly wrong, never broken — so chasing them isn't worth the * extra rules. */ function isIconUrl(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); } /** Applies this migration to `db`. */ 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 patched = 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 } if (rich.faviconUrl || !rich.thumbnailUrl) continue; if (!isIconUrl(rich.thumbnailUrl)) continue; const { thumbnailUrl: _dropped, ...rest } = rich; update.run( JSON.stringify({ ...rest, faviconUrl: rich.thumbnailUrl }), row.id, ); patched++; } if (patched > 0) { console.log( `[migrate] 0011: reclassified ${patched} favicon thumbnail(s)`, ); } }