/** * @copyright Sister Software * @license AGPL-3.0 * @author Teffen Ellis, et al. * * Download + alphabetize libpostal's `resources/dictionaries` — the per-language abbreviation, * street-type, and synonym tables the normalizer expands against. Shallow-clones * {@link https://github.com/openvenues/libpostal openvenues/libpostal}, sorts each dictionary file * in place, and copies the `dictionaries/` tree next to this script. * * Replaces the bash `resources-download.sh`. `git clone` runs through zx's `$` (no clean native * equivalent); everything else is `node:fs` / `node:os`. Sorting is done in-process with a plain * code-point `Array.sort()`, which matches `LC_ALL=C sort` byte order — deterministic and free of * the shell `sort`'s locale dependency (the original relied on the ambient locale). * * ## Usage * * ```sh * mailwoman dev download libpostal-resources [--force] * ``` * * ## Flags * * - `--force` — delete an existing `./dictionaries` directory instead of erroring out */ import { cp, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { isDirectory } from "#fs" import { CommandError } from "#scripting/command" import { resourceDictionaryPath } from "#utils" const REPO_URL = "https://github.com/openvenues/libpostal.git" const DICTIONARIES_DIR = resourceDictionaryPath("libpostal") /** * Sort a single dictionary file in place by code point (matching `LC_ALL=C sort`). Blank lines sort to the top, exactly * as `sort` orders empty strings; a trailing newline is preserved. */ async function sortFileInPlace(path: string): Promise { const text = await readFile(path, "utf8") const hadTrailingNewline = text.endsWith("\n") // oxlint-disable-next-line mailwoman/prefer-spliterator -- Sorting needs every line resident; the largest libpostal dictionary is 409 KB. const lines = text.split("\n") // Drop the empty element produced by a trailing newline so it isn't re-sorted as a blank line. if (hadTrailingNewline) { lines.pop() } lines.sort() await writeFile(path, lines.join("\n") + (hadTrailingNewline ? "\n" : "")) } /** * Shallow-clone libpostal, alphabetize each dictionary file, and install the tree at the checked-in * `core/data/libpostal/dictionaries`. Refuses to clobber an existing tree unless `force`. zx is lazy-imported * (dev-grade dependency — the pipeline convention). */ export async function downloadLibpostalResources( options: { force?: boolean } = {}, report?: (line: string) => void ): Promise { // Guard the destination exactly as the bash version did: refuse to clobber unless --force. if (await isDirectory(DICTIONARIES_DIR)) { if (options.force) { report?.("Warning: The dictionaries directory already exists. Deleting it due to --force flag.") await rm(DICTIONARIES_DIR, { recursive: true, force: true }) } else { throw new CommandError("The dictionaries directory already exists. Remove it first or pass --force.") } } const { $ } = await import("zx") const tempDir = await mkdtemp(join(tmpdir(), "libpostal-")) try { const cloneDir = join(tempDir, "libpostal") await $`git clone --depth 1 ${REPO_URL} ${cloneDir}` const sourceDicts = join(cloneDir, "resources", "dictionaries") // Alphabetize the contents of each dictionary file in place. for (const entry of await readdir(sourceDicts, { withFileTypes: true })) { if (entry.isFile()) { await sortFileInPlace(join(sourceDicts, entry.name)) } } // Copy the (now-sorted) dictionaries tree into the checked-in data home. await cp(sourceDicts, DICTIONARIES_DIR, { recursive: true }) } finally { await rm(tempDir, { recursive: true, force: true }) } }