gallery.badmanners.xyz/scripts/export-story.ts
2024-12-05 21:43:04 -03:00

193 lines
7.2 KiB
TypeScript

import { spawn, spawnSync } from "node:child_process";
import { readdirSync, mkdirSync } from "node:fs";
import { 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";
import type { HealthcheckResponse } from "../src/pages/api/healthcheck";
import type { ExportStoryResponse } from "../src/pages/api/export-story/[...id]";
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(id: 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 = readdirSync(outputDir);
} catch {
files = [];
console.log(`Created directory at ${mkdirSync(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: HealthcheckResponse = 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/${id}`, astroURL));
if (!response.ok) {
throw new Error(`Failed to reach export-story API (status code ${response.status})`);
}
const data: ExportStoryResponse = await response.json();
// Process response fields in parallel
await Promise.all(
[
// Story
(async () => {
storyText = data.story;
await writeFile(join(outputDir, `${id}.txt`), storyText);
})(),
// Descriptions
Object.entries(data.description).map(
async ([filename, description]) => await writeFile(join(outputDir, filename), description),
),
// Thumbnail
(async () => {
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 fetchRetry(data.thumbnail, { retries: 2, retryDelay: 10000 });
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()));
}
}
})(),
].flat(),
);
} 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...");
// Process output files in parallel
await Promise.all([
// ${id}.md
writeFile(join(outputDir, `${id}.md`), storyText.replaceAll(/=(?==)/g, "= ").replaceAll("*", "\\*")),
// ${id}.rtf
(async () => {
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);
if (!rtfStyles["Preformatted Text"]) {
console.warn(`Missing RTF style "Preformatted Text"! Skipping RTF file generation.`);
} else if (!rtfStyles["Normal"]) {
console.warn(`Missing RTF style "Normal"! Skipping RTF file generation.`);
} else {
await writeFile(
join(outputDir, `${id}.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-id>", `ID 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();