import { spawn, spawnSync } from "node:child_process"; import { readdir, mkdir, mkdtemp, writeFile, readFile, copyFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, normalize } from "node:path"; import { createInterface } from "node:readline"; import { program } from "commander"; import fetchRetryWrapper from "fetch-retry"; function getRTFStyles(rtfSource: string) { const matches = rtfSource.matchAll( /\\s(?<styleNumber>\d+)(?:\\sbasedon\d+)?\\snext\d+(?<partialRTFStyle>(?:\\[a-z0-9]+ ?)+)(?: (?<styleName>[A-Z][a-zA-Z ]*));/g, ); let hasMatches = false; const rtfStyles: Record<number | string, string> = {}; for (const match of matches) { const { styleNumber, partialRTFStyle, styleName } = match.groups as { styleNumber: string; partialRTFStyle: string; styleName: string; }; hasMatches = true; const rtfStyle = `\\s${styleNumber}${partialRTFStyle}`; rtfStyles[Number.parseInt(styleNumber)] = rtfStyle; rtfStyles[styleName] = rtfStyle; } if (!hasMatches) { throw new Error("Couldn't find valid RTF styles!"); } return rtfStyles; } const fetchRetry = fetchRetryWrapper(global.fetch); const isLibreOfficeRunning = async () => new Promise<boolean>((res) => { spawn("ps", ["-ax"], {stdio: 'pipe'}); const lines = createInterface({ input: spawn("ps", ["-ax"], {stdio: 'pipe'}).stdout }); lines.on("line", (line) => { if (line.includes("libreoffice") && line.includes("--writer")) { res(true); } }); lines.on("close", () => res(false)); }); async function exportStory(slug: string, options: { outputDir: string }) { /* Check that LibreOffice is not running */ if (await isLibreOfficeRunning()) { console.error("ERROR: LibreOffice cannot be open while this command is running!"); process.exit(1); } /* Check that outputDir is valid */ const outputDir = normalize(options.outputDir); let files: string[]; try { files = await readdir(outputDir); } catch { files = []; console.log(`Created directory at ${await mkdir(outputDir, { recursive: true })}`); } if (files.length > 0) { console.error(`ERROR: Directory ${outputDir} is not empty!`); process.exit(1); } /* Spawn Astro dev server */ console.log("Starting Astro development server..."); const devServerProcess = spawn("./node_modules/.bin/astro", ["dev"], { stdio: 'pipe' }); let astroURL: string | null = null; try { astroURL = await new Promise<string>((resolve, reject) => { const localServerRegex = /Local\s+(http:\/\/\S+)/ const lines = createInterface({ input: devServerProcess.stdout }); lines.on("line", (line) => { const match = localServerRegex.exec(line); if (match && match[1]) { resolve(match[1]); } }); lines.on("close", reject); }); console.log(`Astro listening on ${astroURL}`); const response = await fetchRetry(new URL(`/api/healthcheck`, astroURL), { retries: 5, retryDelay: 2000 }); if (!response.ok) { throw new Error(response.statusText); } const healthcheck: { isAlive: boolean } = await response.json(); if (healthcheck.isAlive !== true) { throw new Error(JSON.stringify(healthcheck)); } } catch { console.error("ERROR: Astro dev server didn't respond in time!"); if (!devServerProcess.kill("SIGTERM")) { console.error("WARNING: Unable to shut down Astro dev server!"); } process.exit(1); } /* Get data (story, thumbnail, descriptions) from Astro development server */ let storyText = ""; try { console.log("Getting data from Astro..."); const response = await fetch(new URL(`/api/export-story/${slug}`, astroURL)); if (!response.ok) { throw new Error(`Failed to reach API (status code ${response.status})`); } const data: { story: string, description: Record<string, string>, thumbnail: string | null } = await response.json(); await Promise.all( Object.entries(data.description).map(async ([filename, description]) => { return await writeFile( join(outputDir, filename), description, ); }), ); if (data.thumbnail) { if (data.thumbnail.startsWith("/@fs/")) { const thumbnailPath = data.thumbnail .replace(/^\/@fs/, "") .replace(/\?(&?[a-z][a-zA-Z0-9_-]+=[a-zA-Z0-9_-]*)*$/, ""); await copyFile(thumbnailPath, join(outputDir, `thumbnail${thumbnailPath.match(/\.[^.]+$/)![0]}`)); } else { const thumbnail = await fetch(data.thumbnail); if (!thumbnail.ok) { throw new Error("Failed to get thumbnail"); } const thumbnailExt = thumbnail.headers.get("Content-Type")!.startsWith("image/png") ? "png" : "jpg"; await writeFile(join(outputDir, `thumbnail.${thumbnailExt}`), Buffer.from(await thumbnail.arrayBuffer())); } } storyText = data.story; writeFile(join(outputDir, `${slug}.txt`), storyText); } finally { if (devServerProcess) { console.log("Shutting down the Astro development server..."); if (!devServerProcess.kill("SIGTERM")) { console.error("WARNING: Unable to shut down Astro dev server!"); } } } /* Parse story into output formats */ console.log("Parsing story into output formats..."); await writeFile(join(outputDir, `${slug}.md`), storyText.replaceAll(/=(?==)/g, "= ").replaceAll("*", "\\*")); const tempDir = await mkdtemp(join(tmpdir(), "export-story-")); await writeFile(join(tempDir, "temp.txt"), storyText.replaceAll(/\n\n+/g, "\n")); spawnSync( "libreoffice", ["--convert-to", "rtf:Rich Text Format", "--outdir", tempDir, join(tempDir, "temp.txt")], { stdio: "ignore" }, ); const rtfText = await readFile(join(tempDir, "temp.rtf"), "utf-8"); const rtfStyles = getRTFStyles(rtfText); await writeFile( join(outputDir, `${slug}.rtf`), rtfText.replaceAll(rtfStyles["Preformatted Text"], rtfStyles["Normal"]), ); console.log("Success!"); process.exit(0); } await program .name("export-story") .description("Generate and export formatted upload files for a story") .argument("<story-slug>", `Slug portion of the story's URL (eg. "the-lost-of-the-marshes/chapter-1")`) .option("-o, --output-dir <directory>", `Empty or inexistent directory path to export files to`) .action(exportStory) .parseAsync();