diff --git a/scripts/export-story.ts b/scripts/export-story.ts index d410e0e..8108a0a 100644 --- a/scripts/export-story.ts +++ b/scripts/export-story.ts @@ -1,5 +1,5 @@ import { type ChildProcess, exec, execSync } from "node:child_process"; -import { readdir, mkdir, mkdtemp, writeFile, readFile } from "node:fs/promises"; +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"; @@ -26,7 +26,34 @@ function getRTFStyles(rtfSource: string) { 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("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[]; @@ -41,17 +68,31 @@ async function exportStory(slug: string, options: { outputDir: string }) { process.exit(1); } /* Check if Astro development server needs to be spawned */ - const healthcheckURL = `http://localhost:4321/healthcheck`; + const healthcheckURL = `http://localhost:4321/api/healthcheck`; let devServerProcess: ChildProcess | null = null; try { - await fetchRetry(healthcheckURL, { retries: 3, retryDelay: 1000 }); + 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 { - await fetchRetry(healthcheckURL, { retries: 5, retryDelay: 2000 }); + 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("Astro dev server didn't respond in time!"); devServerProcess && devServerProcess.kill("SIGINT"); @@ -64,35 +105,35 @@ async function exportStory(slug: string, options: { outputDir: string }) { 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}`; - + 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( - ["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"; + Object.entries(data.description).map(async ([website, description]) => { return await writeFile( - pathJoin(outputDir, `description_${website}.${descriptionExt}`), - await description.text(), + pathJoin(outputDir, `description_${website}.${website === "weasyl" ? "md" : "txt"}`), + description, ); }), ); - const thumbnail = await fetch(exportThumbnailURL); - if (!thumbnail.ok) { - throw new Error("Failed to get thumbnail"); + 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())); + } } - 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(); + storyText = data.story; writeFile(pathJoin(outputDir, `${slug}.txt`), storyText); } finally { if (devServerProcess) { diff --git a/src/pages/stories/export/description/[website]/[...slug].ts b/src/pages/api/export-story/[...slug].ts similarity index 61% rename from src/pages/stories/export/description/[website]/[...slug].ts rename to src/pages/api/export-story/[...slug].ts index f60d5a7..5bea164 100644 --- a/src/pages/stories/export/description/[website]/[...slug].ts +++ b/src/pages/api/export-story/[...slug].ts @@ -2,11 +2,19 @@ import { type APIRoute, type GetStaticPaths } from "astro"; import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content"; import { marked, type RendererApi } from "marked"; import { decode as tinyDecode } from "tiny-decode"; -import { type Website } from "../../../../../content/config"; +import { type Lang, type Website } from "../../../content/config"; -const WEBSITE_LIST = ["eka", "furaffinity", "inkbunny", "sofurry", "weasyl"] as const satisfies Website[]; +type DescriptionFormat = "bbcode" | "markdown"; -type ExportWebsite = typeof WEBSITE_LIST extends ReadonlyArray<infer K> ? K : never; +const WEBSITE_LIST = [ + ["eka", "bbcode"], + ["furaffinity", "bbcode"], + ["inkbunny", "bbcode"], + ["sofurry", "bbcode"], + ["weasyl", "markdown"], +] as const satisfies [Website, DescriptionFormat][]; + +type ExportWebsite = typeof WEBSITE_LIST extends ReadonlyArray<[infer K, DescriptionFormat]> ? K : never; const bbcodeRenderer: RendererApi = { strong: (text) => `[b]${text}[/b]`, @@ -186,12 +194,18 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsite): throw new Error(`No "${website}" link for user "${user.id}" (consider setting preferredLink)`); } +function getNameForUser(user: CollectionEntry<"users">, anonymousUser: CollectionEntry<"users">, lang: Lang): string { + if (user.data.isAnonymous) { + return anonymousUser.data.nameLang[lang] || anonymousUser.data.name; + } + return user.data.nameLang[lang] || user.data.name; +} + type Props = { story: CollectionEntry<"stories">; }; type Params = { - website: ExportWebsite; slug: CollectionEntry<"stories">["slug"]; }; @@ -199,19 +213,14 @@ export const getStaticPaths: GetStaticPaths = async () => { if (import.meta.env.PROD) { return []; } - return (await getCollection("stories")) - .map((story) => - WEBSITE_LIST.map((website) => ({ - params: { website, slug: story.slug } satisfies Params, - props: { story } satisfies Props, - })), - ) - .flat(); + return (await getCollection("stories")).map((story) => ({ + params: { slug: story.slug } satisfies Params, + props: { story } satisfies Props, + })); }; -export const GET: APIRoute<Props, Params> = async ({ props: { story }, params: { website }, site }) => { - const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website); - +export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) => { + const { lang } = story.data; if ( story.data.copyrightedCharacters && "" in story.data.copyrightedCharacters && @@ -236,45 +245,103 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, params: { >, ); - let storyDescription = ( - [ - story.data.description, - `*Word count: ${story.data.wordCount}. ${story.data.contentWarning.trim()}*`, - "Writing: " + (await getEntries([story.data.authors].flat())).map((author) => u(author)).join(" , "), - story.data.requester && "Request for: " + u(await getEntry(story.data.requester)), - story.data.commissioner && "Commissioned by: " + u(await getEntry(story.data.commissioner)), - ...(await Promise.all( - (Object.keys(charactersPerUser) as CollectionEntry<"users">["id"][]).map(async (id) => { - const user = u(await getEntry("users", id)); - const characterList = charactersPerUser[id]; - if (characterList[0] == "") { - return `All characters are © ${user}`; - } else if (characterList.length > 2) { - return `${characterList.slice(0, characterList.length - 1).join(", ")}, and ${characterList[characterList.length - 1]} are © ${user}`; - } else if (characterList.length > 1) { - return `${characterList[0]} and ${characterList[1]} are © ${user}`; - } - return `${characterList[0]} is © ${user}`; - }), - )), - ].filter((data) => data) as string[] - ) - .join("\n\n") - .replaceAll( - /\[([^\]]+)\]\((\.[^\)]+)\)/g, - (_, group1, group2) => `[${group1}](${new URL(group2, new URL(`/stories/${story.slug}`, site)).toString()})`, - ); - const headers = { "Content-Type": "text/markdown; charset=utf-8" }; - const bbcodeExports: ReadonlyArray<ExportWebsite> = ["eka", "furaffinity", "inkbunny", "sofurry"] as const; - const markdownExport: ReadonlyArray<ExportWebsite> = ["weasyl"] as const; - // BBCode exports - if (bbcodeExports.includes(website)) { - storyDescription = tinyDecode(await marked.use({ renderer: bbcodeRenderer }).parse(storyDescription)); - headers["Content-Type"] = "text/plain; charset=utf-8"; - // Markdown exports (no-op) - } else if (!markdownExport.includes(website)) { - console.log(`Unrecognized ExportWebsite "${website}"`); - return new Response(null, { status: 404 }); + const description: Record<ExportWebsite, string> = Object.fromEntries( + await Promise.all( + WEBSITE_LIST.map(async ([website, exportFormat]) => { + const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website); + const storyDescription = ( + [ + story.data.description, + `*Word count: ${story.data.wordCount}. ${story.data.contentWarning.trim()}*`, + "Writing: " + (await getEntries([story.data.authors].flat())).map((author) => u(author)).join(" , "), + story.data.requester && "Request for: " + u(await getEntry(story.data.requester)), + story.data.commissioner && "Commissioned by: " + u(await getEntry(story.data.commissioner)), + ...(await Promise.all( + (Object.keys(charactersPerUser) as CollectionEntry<"users">["id"][]).map(async (id) => { + const user = u(await getEntry("users", id)); + const characterList = charactersPerUser[id]; + if (characterList[0] == "") { + return `All characters are © ${user}`; + } else if (characterList.length > 2) { + return `${characterList.slice(0, characterList.length - 1).join(", ")}, and ${characterList[characterList.length - 1]} are © ${user}`; + } else if (characterList.length > 1) { + return `${characterList[0]} and ${characterList[1]} are © ${user}`; + } + return `${characterList[0]} is © ${user}`; + }), + )), + ].filter((data) => data) as string[] + ) + .join("\n\n") + .replaceAll( + /\[([^\]]+)\]\((\.[^\)]+)\)/g, + (_, group1, group2) => + `[${group1}](${new URL(group2, new URL(`/stories/${story.slug}`, site)).toString()})`, + ); + if (exportFormat === "bbcode") { + return [ + website, + tinyDecode(await marked.use({ renderer: bbcodeRenderer }).parse(storyDescription)) + .replaceAll(/\n\n\n+/g, "\n\n") + .trim(), + ]; + } + if (exportFormat === "markdown") { + return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()]; + } + throw new Error(`Unknown exportFormat "${exportFormat}"`); + }), + ), + ); + + const anonymousUser = await getEntry("users", "anonymous"); + const authorsNames = (await getEntries([story.data.authors].flat())).map((author) => + getNameForUser(author, anonymousUser, lang), + ); + const commissioner = story.data.commissioner && (await getEntry(story.data.commissioner)); + const requester = story.data.requester && (await getEntry(story.data.requester)); + + let storyHeader = `${story.data.title}\n`; + if (lang === "eng") { + let authorsString = `by ${authorsNames[0]}`; + if (authorsNames.length > 2) { + authorsString += `, ${authorsNames.slice(1, authorsNames.length - 1).join(", ")}, and ${authorsNames[authorsNames.length - 1]}`; + } else if (authorsNames.length == 2) { + authorsString += ` and ${authorsNames[1]}`; + } + storyHeader += + `${authorsString}\n` + + (commissioner ? `Commissioned by ${getNameForUser(commissioner, anonymousUser, lang)}\n` : "") + + (requester ? `Requested by ${getNameForUser(requester, anonymousUser, lang)}\n` : ""); + } else if (lang === "tok") { + let authorsString = "lipu ni li tan "; + if (authorsNames.length > 1) { + authorsString += `jan ni: ${authorsNames.join(" en ")}`; + } else { + authorsString += authorsNames[0]; + } + if (commissioner) { + throw new Error(`No "commissioner" handler for language "tok"`); + } + if (requester) { + throw new Error(`No "requester" handler for language "tok"`); + } + storyHeader += `${authorsString}\n`; + } else { + throw new Error(`Unknown language "${lang}"`); } - return new Response(`${storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()}\n`, { headers }); + + const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll("\\*", "*").replaceAll("\\=", "=")}` + .replaceAll(/\n\n\n+/g, "\n\n") + .trim(); + + const headers = { "Content-Type": "application/json; charset=utf-8" }; + return new Response( + JSON.stringify({ + story: storyText, + description, + thumbnail: story.data.thumbnail ? story.data.thumbnail.src : null, + }), + { headers }, + ); }; diff --git a/src/pages/healthcheck.ts b/src/pages/api/healthcheck.ts similarity index 100% rename from src/pages/healthcheck.ts rename to src/pages/api/healthcheck.ts diff --git a/src/pages/stories/export/story/[...slug].ts b/src/pages/stories/export/story/[...slug].ts deleted file mode 100644 index e98a993..0000000 --- a/src/pages/stories/export/story/[...slug].ts +++ /dev/null @@ -1,72 +0,0 @@ -import { type APIRoute, type GetStaticPaths } from "astro"; -import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content"; -import { type Lang } from "../../../../content/config"; - -type Props = { - story: CollectionEntry<"stories">; -}; - -type Params = { - slug: CollectionEntry<"stories">["slug"]; -}; - -export const getStaticPaths: GetStaticPaths = async () => { - if (import.meta.env.PROD) { - return []; - } - return (await getCollection("stories")).map((story) => ({ - params: { slug: story.slug } satisfies Params, - props: { story } satisfies Props, - })); -}; - -function getNameForUser(user: CollectionEntry<"users">, anonymousUser: CollectionEntry<"users">, lang: Lang): string { - if (user.data.isAnonymous) { - return anonymousUser.data.nameLang[lang] || anonymousUser.data.name; - } - return user.data.nameLang[lang] || user.data.name; -} - -export const GET: APIRoute<Props, Params> = async ({ props: { story } }) => { - const { lang } = story.data; - const anonymousUser = await getEntry("users", "anonymous"); - const authorsNames = (await getEntries([story.data.authors].flat())).map((author) => - getNameForUser(author, anonymousUser, lang), - ); - const commissioner = story.data.commissioner && (await getEntry(story.data.commissioner)); - const requester = story.data.requester && (await getEntry(story.data.requester)); - - let storyHeader = `${story.data.title}\n`; - if (lang === "eng") { - let authorsString = `by ${authorsNames[0]}`; - if (authorsNames.length > 2) { - authorsString += `, ${authorsNames.slice(1, authorsNames.length - 1).join(", ")}, and ${authorsNames[authorsNames.length - 1]}`; - } else if (authorsNames.length == 2) { - authorsString += ` and ${authorsNames[1]}`; - } - storyHeader += - `${authorsString}\n` + - (commissioner ? `Commissioned by ${getNameForUser(commissioner, anonymousUser, lang)}\n` : "") + - (requester ? `Requested by ${getNameForUser(requester, anonymousUser, lang)}\n` : ""); - } else if (lang === "tok") { - let authorsString = "lipu ni li tan "; - if (authorsNames.length > 1) { - authorsString += `jan ni: ${authorsNames.join(" en ")}`; - } else { - authorsString += authorsNames[0]; - } - if (commissioner) { - throw new Error(`No "commissioner" handler for language "tok"`); - } - if (requester) { - throw new Error(`No "requester" handler for language "tok"`); - } - storyHeader += `${authorsString}\n`; - } else { - throw new Error(`Unknown language "${lang}"`); - } - - const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll("\\*", "*").replaceAll("\\=", "=")}`; - const headers = { "Content-Type": "text/plain; charset=utf-8" }; - return new Response(`${storyText.replaceAll(/\n\n\n+/g, "\n\n").trim()}\n`, { headers }); -}; diff --git a/src/pages/stories/export/thumbnail/[...slug].ts b/src/pages/stories/export/thumbnail/[...slug].ts deleted file mode 100644 index 0c82b11..0000000 --- a/src/pages/stories/export/thumbnail/[...slug].ts +++ /dev/null @@ -1,27 +0,0 @@ -import { type APIRoute, type GetStaticPaths } from "astro"; -import { getCollection, type CollectionEntry } from "astro:content"; - -type Props = { - story: CollectionEntry<"stories">; -}; - -type Params = { - slug: CollectionEntry<"stories">["slug"]; -}; - -export const getStaticPaths: GetStaticPaths = async () => { - if (import.meta.env.PROD) { - return []; - } - return (await getCollection("stories")).map((story) => ({ - params: { slug: story.slug } satisfies Params, - props: { story } satisfies Props, - })); -}; - -export const GET: APIRoute<Props, Params> = async ({ props: { story }, redirect }) => { - if (!story.data.thumbnail) { - return new Response(null, { status: 404 }); - } - return redirect(story.data.thumbnail.src); -};