import { type ChildProcess, exec, execSync } from "node:child_process"; import { readdir, mkdir, mkdtemp, writeFile, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join as pathJoin, normalize } from "node:path"; import { setTimeout } from "node:timers/promises"; import { program } from "commander"; import fetchRetryWrapper from "fetch-retry"; function getRTFStyles(rtfSource: string) { const matches = rtfSource.matchAll( /\\s(\d+)(?:\\sbasedon\d+)?\\snext\d+((?:\\[a-z0-9]+ ?)+)(?: ([A-Z][a-zA-Z ]*));/g, ); let hasMatches = false; const rtfStyles: Record = {}; for (const [_, styleNumber, partialRTFStyle, styleName] of matches) { 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); async function exportStory(slug: string, options: { outputDir: string }) { /* 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 > 1) { console.error(`Directory ${outputDir} is not empty!`); process.exit(1); } /* Check if Astro development server needs to be spawned */ const healthcheckURL = `http://localhost:4321/healthcheck`; let devServerProcess: ChildProcess | null = null; try { await fetchRetry(healthcheckURL, { retries: 3, retryDelay: 1000 }); } catch { /* Spawn Astro dev server */ console.log("Starting Astro development server..."); devServerProcess = exec("npm run dev"); await setTimeout(2000); try { await fetchRetry(healthcheckURL, { retries: 5, retryDelay: 2000 }); } catch { console.error("Astro dev server didn't respond in time!"); devServerProcess && devServerProcess.kill("SIGINT"); devServerProcess = null; process.exit(1); } } /* Get data (story, thumbnail, descriptions) from Astro development server */ let storyText = ""; try { console.log("Getting data from Astro..."); const exportStoryURL = `http://localhost:4321/stories/export/story/${slug}`; const exportThumbnailURL = `http://localhost:4321/stories/export/thumbnail/${slug}`; const exportDescriptionURLs = (website: string) => `http://localhost:4321/stories/export/description/${website}/${slug}`; await Promise.all( ["eka", "furaffinity", "inkbunny", "sofurry", "weasyl"].map(async (website) => { const description = await fetch(exportDescriptionURLs(website)); if (!description.ok) { throw new Error(`Failed to get description for "${website}"`); } const descriptionExt = description.headers.get("Content-Type")?.startsWith("text/markdown") ? "md" : "txt"; return await writeFile( pathJoin(outputDir, `description_${website}.${descriptionExt}`), await description.text(), ); }), ); const thumbnail = await fetch(exportThumbnailURL); if (!thumbnail.ok) { throw new Error("Failed to get thumbnail"); } const thumbnailExt = thumbnail.headers.get("Content-Type")?.startsWith("image/png") ? "png" : "jpg"; writeFile(pathJoin(outputDir, `thumbnail.${thumbnailExt}`), Buffer.from(await thumbnail.arrayBuffer())); const story = await fetch(exportStoryURL); if (!story.ok) { throw new Error("Failed to get story"); } storyText = await story.text(); writeFile(pathJoin(outputDir, `${slug}.txt`), storyText); } finally { if (devServerProcess) { console.log("Shutting down the Astro development server..."); if (!devServerProcess.kill("SIGINT")) { console.error("Unable to shut down Astro dev server!"); } devServerProcess = null; } } /* Parse story into output formats */ console.log("Parsing story into output formats..."); await writeFile(pathJoin(outputDir, `${slug}.md`), storyText.replaceAll(/=(?==)/g, "= ").replaceAll("*", "\\*")); const tempDir = await mkdtemp(pathJoin(tmpdir(), "export-story-")); await writeFile(pathJoin(tempDir, "temp.txt"), storyText.replaceAll(/\n\n+/g, "\n")); execSync(`libreoffice --convert-to "rtf:Rich Text Format" --outdir ${tempDir} ${pathJoin(tempDir, "temp.txt")}`); const rtfText = await readFile(pathJoin(tempDir, "temp.rtf"), "utf-8"); const rtfStyles = getRTFStyles(rtfText); await writeFile( pathJoin(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("", `Slug portion of the story's URL (eg. "the-lost-of-the-marshes/chapter-1")`) .option("-o, --output-dir ", `Empty or inexistent directory path to export files to`) .action(exportStory) .parseAsync();