// Which umtool report-video project is an article's video. // // Two ways, in order: // // 1. The manifest says so: a top-level `"article": "/"` in // video.manifest.json. build-video.mjs never reads top-level keys it does // not know (it reads `generatedBy` no more than this), so the key costs // the render nothing. A manifest that names an article is linked to it // and to nothing else. // 2. The slug matches: the manifest's `slug` (else the project directory's // name) is the report id, `polemic-`, or the id without `polemic-` -- // and the project lives in the same workspace as the article's draft // (lib/articles/sources.mjs). A UNIQUE match is linked; two or more are // only "possible", and the page says so rather than picking one. // // Plain ESM, so `umtool notes` can name an article's video too. import { readFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { REPORTS_ROOT } from "../paths.mjs"; import { projectRefs } from "../projects/core.mjs"; import { kindLinksArticles } from "../projects/kinds.mjs"; import { idKeys } from "./sources.mjs"; const CACHE_MS = 30_000; /** @type {Map }>} */ const cache = new Map(); /** `~/x` back to an absolute path. */ export function untildify(p) { if (typeof p !== "string") return p; return p === "~" || p.startsWith("~/") ? path.join(/* turbopackIgnore: true */ os.homedir(), p.slice(2)) : p; } /** * Every report-video project with what linking needs from its manifest: * `{ id, dir, name, slug, article, generatedBy, title }`. * * @param {string} [reportsRoot] */ export function videoProjects(reportsRoot = REPORTS_ROOT) { const hit = cache.get(reportsRoot); if (hit && Date.now() - hit.at < CACHE_MS) return hit.value; const value = (async () => { const out = []; for (const p of await projectRefs(reportsRoot)) { if (!kindLinksArticles(p.kind)) continue; let m = {}; try { m = JSON.parse(await readFile(/* turbopackIgnore: true */ path.join(/* turbopackIgnore: true */ p.dir, "video.manifest.json"), "utf8")); } catch { // a project with no readable manifest still links by its directory name } out.push({ id: p.id, dir: p.dir, name: p.name, slug: typeof m.slug === "string" && m.slug ? m.slug : p.name, article: typeof m.article === "string" ? m.article : null, generatedBy: typeof m.generatedBy === "string" ? m.generatedBy : null, title: typeof m.title === "string" ? m.title : p.name, }); } return out.sort((a, b) => a.id.localeCompare(b.id)); })(); cache.set(reportsRoot, { at: Date.now(), value }); value.catch(() => cache.delete(reportsRoot)); return value; } export function clearLinksCache() { cache.clear(); } const inside = (root, p) => p === root || p.startsWith(root + path.sep); /** * The projects linked to one article: `{ linked, possible, how }`. * * @param {string} siteId * @param {string} reportId * @param {{ reportsRoot?: string, workspace?: string | null, projects?: any[] }} [opts] * `workspace` is the article's workspace dir (sourceFor's, `~` allowed); without * one, slug matches are only ever "possible". */ export async function linkedProjects(siteId, reportId, { reportsRoot = REPORTS_ROOT, workspace = null, projects } = {}) { const all = projects ?? (await videoProjects(reportsRoot)); const key = `${siteId}/${reportId}`; const declared = all.filter((p) => p.article === key); if (declared.length) return { linked: declared, possible: [], how: "manifest names the article" }; const keys = idKeys(reportId); // A manifest that names a DIFFERENT article is never a slug match. const bySlug = all.filter((p) => !p.article && keys.has(p.slug)); const ws = workspace ? untildify(workspace) : null; const inWs = ws ? bySlug.filter((p) => inside(ws, p.dir)) : []; if (inWs.length === 1) return { linked: inWs, possible: [], how: `slug ${inWs[0].slug} in the article's workspace` }; const possible = inWs.length > 1 ? inWs : bySlug; return { linked: [], possible, how: possible.length ? `${possible.length} project(s) share the slug` : "no project" }; }