/** * @copyright Sister Software * @license AGPL-3.0 * @author Teffen Ellis, et al. * @file Reads and writes corpus-source manifests. */ import { pathExists, readLocalTextFile } from "@mailwoman/core/fs/readers" import { openWriteStream, pipeline, Readable } from "@mailwoman/core/fs/streams" import { movePath, writeLocalJSONFile } from "@mailwoman/core/fs/writers" import { tryParsingJSON } from "@mailwoman/core/json" import type { PathBuilderLike } from "path-ts" import { HTTPStatusError, type SourceManifest } from "#tools/fetch/download/network" /** * Read a manifest.json; `null` when missing or corrupt (callers re-fetch from scratch). */ export async function readManifest(path: PathBuilderLike): Promise { if (!(await pathExists(path))) return null // A read failure (e.g. The file vanished after the existsSync probe) maps to null like // corrupt JSON does. tryParsingJSON returns null for the non-string sentinel. const text = await readLocalTextFile(path).catch(() => null) return tryParsingJSON(text) } /** * Load manifest entries into a map so untouched keys survive a partial re-fetch. */ export async function loadManifestEntries( path: PathBuilderLike, key: (entry: T) => string ): Promise> { const entries = new Map() const parsed = await readManifest(path) for (const entry of parsed ?? []) { entries.set(key(entry), entry) } return entries } /** * Write a manifest.json in the house shape: pretty-printed, trailing newline. */ export async function writeManifest(path: PathBuilderLike, manifest: unknown): Promise { await writeLocalJSONFile(manifest, path) } /** * The sibling `manifest.json` shape for a source that is a collection of files behind one * portal (a monthly register published per region, per industry, or per first letter). * * It records what a trained artifact has to be able to cite later: the license the portal labels * the data with, the attribution wording it requires and one {@link SourceManifest} per file. */ export interface SourceCollectionManifest { source: string source_url: string license: string attribution: string downloaded_at: string files: SourceManifest[] } /** * Pipe a response body to `dest` and answer the byte count. * * The one primitive the portal fetchers share when the request is not a bare GET. * A session cookie, a csrf header, or a form post stands between the listing * and the file, so {@link streamDownload}'s URL-only interface does not fit * and each module builds its own `Response` first. * * Writes a `.tmp` sibling and renames, so an interrupted transfer never lands * at the final path looking complete. */ export async function streamBodyToFile(res: Response, dest: PathBuilderLike): Promise { if (!res.body) throw new HTTPStatusError(res.status, `HTTP ${res.status} with no body — ${res.url}`) let bytes = 0 const counter = new TransformStream({ transform(chunk, controller) { bytes += chunk.byteLength controller.enqueue(chunk) }, }) const tmp = dest + ".tmp" await pipeline(Readable.fromWeb(res.body.pipeThrough(counter)), openWriteStream(tmp)) await movePath(tmp, dest) return bytes } /** * The per-file entries of a {@link SourceCollectionManifest} keyed by file name, * or an empty map when the manifest is missing or not in the collection shape, * so a re-run after an interruption fetches only what is missing. * * The single-file modules' `loadManifestEntries` reads a bare array and is not this. */ export async function loadCollectionFiles(path: PathBuilderLike): Promise> { const parsed = await readManifest(path) const entries = new Map() for (const entry of Array.isArray(parsed?.files) ? parsed.files : []) { entries.set(entry.filename, entry) } return entries }