// One-time migration from the pre-multi-site layout (a single global // settings.json holding branding + channel groups + social links, with each // channel carrying a `group` field, and a root chart-templates.json) into the // multi-site layout (sites//site.json + per-site chart-templates.json, with // settings.json reduced to operational config). Idempotent: a no-op once any // site exists. import fs from "node:fs"; import path from "node:path"; import type { Paths } from "../lib/paths"; import { getPaths } from "../lib/paths"; import { listSiteIds, writeSite, siteChartTemplatesFile, type Site } from "../lib/site"; import { parseChannelGroups, resolveChannelGroupId, resolveDefaultGroupId, FALLBACK_GROUP, type ChannelGroup, } from "../lib/channelGroups"; import { parseSocialLinks, writeSettings, getSettings, DEFAULT_ADMIN_TITLE, } from "../lib/settings"; export type MigrateToSitesOptions = { paths?: Paths; // Target site id for the migrated config. Defaults to a slug derived from the // old siteTitle, falling back to "default". siteId?: string; onLog?: (msg: string) => void; }; export type MigrateToSitesResult = | { migrated: false; reason: string } | { migrated: true; siteId: string; channelCount: number }; function slugify(input: string): string { const s = input .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); return s || "default"; } function readRawSettings(file: string): Record { try { const parsed = JSON.parse(fs.readFileSync(file, "utf8")); return parsed && typeof parsed === "object" ? (parsed as Record) : {}; } catch { return {}; } } export async function migrateToSites( opts: MigrateToSitesOptions = {}, ): Promise { const paths = opts.paths ?? getPaths(); const log = opts.onLog ?? (() => {}); if (listSiteIds(paths).length > 0) { return { migrated: false, reason: "Sites already configured" }; } const raw = readRawSettings(paths.settingsFile); const siteTitle = typeof raw.siteTitle === "string" && raw.siteTitle.trim() ? raw.siteTitle.trim() : "Transcript Browser"; const headerTitle = typeof raw.headerTitle === "string" && raw.headerTitle.trim() ? raw.headerTitle.trim() : siteTitle; const siteDescription = typeof raw.siteDescription === "string" ? raw.siteDescription : ""; const homeTagline = typeof raw.homeTagline === "string" ? raw.homeTagline : ""; const parsedGroups = parseChannelGroups(raw.groups); const groups: ChannelGroup[] = parsedGroups.length > 0 ? parsedGroups : [{ ...FALLBACK_GROUP }]; const defaultGroupId = resolveDefaultGroupId(raw.defaultGroupId, groups); const socialLinks = parseSocialLinks(raw.socialLinks); const siteId = opts.siteId ?? slugify(siteTitle); // Build channel membership from each channel's old `group` field (read raw, // since parseChannelConfig no longer surfaces it). const channels: Site["channels"] = []; let channelEntries: fs.Dirent[] = []; try { channelEntries = fs.readdirSync(paths.channelsDir, { withFileTypes: true }); } catch { channelEntries = []; } for (const e of channelEntries.sort((a, b) => a.name.localeCompare(b.name))) { if (!e.isDirectory()) continue; const cfgPath = path.join(paths.channelsDir, e.name, "config.json"); let cfgRaw: Record = {}; try { cfgRaw = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch { continue; // no valid config -> not a real channel } const oldGroup = typeof cfgRaw.group === "string" ? cfgRaw.group : undefined; const groupId = resolveChannelGroupId(oldGroup, groups, defaultGroupId); channels.push({ slug: e.name, groupId }); } const site: Site = { siteId, siteTitle, siteDescription, headerTitle, homeTagline, socialLinks, groups, defaultGroupId, channels, }; await writeSite(site, paths); log(`Wrote site "${siteId}" with ${channels.length} channels.`); // Move the global chart-templates.json into the site, if present. try { if (fs.existsSync(paths.chartsConfigFile)) { const dest = siteChartTemplatesFile(paths, siteId); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(paths.chartsConfigFile, dest); fs.rmSync(paths.chartsConfigFile, { force: true }); log(`Moved chart-templates.json into site "${siteId}".`); } } catch (err) { log(`Chart templates migration skipped: ${String(err)}`); } // Reduce settings.json to operational config (+ adminTitle). getSettings() // already ignores the removed fields; writeSettings persists only the new // operational shape. const current = getSettings(); await writeSettings({ ...current, adminTitle: current.adminTitle && current.adminTitle !== DEFAULT_ADMIN_TITLE ? current.adminTitle : `${headerTitle} Admin`, }); log("Reduced settings.json to operational config."); return { migrated: true, siteId, channelCount: channels.length }; }