179 lines
6.3 KiB
TypeScript
179 lines
6.3 KiB
TypeScript
import { type ChildProcess, exec, spawnSync } from "node:child_process";
|
|
import { readdir, mkdir, mkdtemp, writeFile, readFile, copyFile } 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(?<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);
|
|
|
|
interface AstroApiResponse {
|
|
story: string;
|
|
description: Record<string, string>;
|
|
thumbnail: string | null;
|
|
}
|
|
|
|
const isLibreOfficeRunning = async () =>
|
|
new Promise<boolean>((res, rej) => {
|
|
exec("ps -ax", (err, stdout) => {
|
|
if (err) {
|
|
rej(err);
|
|
return;
|
|
}
|
|
res(
|
|
stdout
|
|
.toLowerCase()
|
|
.split("\n")
|
|
.some((line) => line.includes("libreoffice") && line.includes("--writer")),
|
|
);
|
|
});
|
|
});
|
|
|
|
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);
|
|
}
|
|
/* Check if Astro development server needs to be spawned */
|
|
const healthcheckURL = `http://localhost:4321/api/healthcheck`;
|
|
let devServerProcess: ChildProcess | null = null;
|
|
try {
|
|
const response = await fetchRetry(healthcheckURL, { retries: 3, retryDelay: 1000 });
|
|
if (!response.ok) {
|
|
throw new Error();
|
|
}
|
|
const healthcheck = await response.json();
|
|
if (!healthcheck.isAlive) {
|
|
throw new Error();
|
|
}
|
|
} catch {
|
|
/* Spawn Astro dev server */
|
|
console.log("Starting Astro development server...");
|
|
devServerProcess = exec("./node_modules/.bin/astro dev");
|
|
await setTimeout(2000);
|
|
try {
|
|
const response = await fetchRetry(healthcheckURL, { retries: 5, retryDelay: 2000 });
|
|
if (!response.ok) {
|
|
throw new Error();
|
|
}
|
|
const healthcheck = await response.json();
|
|
if (!healthcheck.isAlive) {
|
|
throw new Error();
|
|
}
|
|
} catch {
|
|
console.error("ERROR: Astro dev server didn't respond in time!");
|
|
devServerProcess && devServerProcess.kill();
|
|
devServerProcess = null;
|
|
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(`http://localhost:4321/api/export-story/${slug}`);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to reach API (status code ${response.status})`);
|
|
}
|
|
const data: AstroApiResponse = await response.json();
|
|
await Promise.all(
|
|
Object.entries(data.description).map(async ([website, description]) => {
|
|
return await writeFile(
|
|
pathJoin(outputDir, `description_${website}.${website === "weasyl" ? "md" : "txt"}`),
|
|
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, pathJoin(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(pathJoin(outputDir, `thumbnail.${thumbnailExt}`), Buffer.from(await thumbnail.arrayBuffer()));
|
|
}
|
|
}
|
|
storyText = data.story;
|
|
writeFile(pathJoin(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!");
|
|
}
|
|
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"));
|
|
spawnSync(
|
|
"libreoffice",
|
|
["--convert-to", "rtf:Rich Text Format", "--outdir", tempDir, pathJoin(tempDir, "temp.txt")],
|
|
{ stdio: "ignore" },
|
|
);
|
|
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("<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();
|